264 lines
10 KiB
Rust
264 lines
10 KiB
Rust
// People watching and helping on the bots' computer: VNC, carried to them as a stream.
|
|
//
|
|
// The box already runs a VNC server (x11vnc, inside the container, with a password). A person's
|
|
// viewer (noVNC in a browser: the phone, or any computer through the account server) speaks VNC
|
|
// to this proxy, which reaches the box's server through one `docker exec` (a few lines of
|
|
// Python in the box joining its stdin/stdout to 127.0.0.1:5900). VNC sends only what changed on
|
|
// the screen and every mouse move and key as it happens, so watching is live and helping is
|
|
// like sitting at it — unlike pictures sent one by one.
|
|
//
|
|
// The proxy signs in to the box with its password itself and offers the viewer "no password",
|
|
// so the password never leaves this AnyBot; whoever reaches the proxy was already let in (an
|
|
// account session or one-time ticket at the account server, or the phone's password here).
|
|
// While the person clicks or types, the bot's own steps wait (HELD_BY_PERSON), and go on a
|
|
// little after they stop (person_active).
|
|
|
|
use std::io::{Read, Write};
|
|
use std::process::{Child, ChildStdin};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// The bot waits this long after the person's last click or key before it goes on.
|
|
const HELD_AFTER_INPUT: Duration = Duration::from_secs(8);
|
|
|
|
/// Joins the box's VNC server to stdin/stdout, and ends when either side does (a loop left in
|
|
/// the box would hold the connection open).
|
|
const BRIDGE: &str = r#"import os,select,socket
|
|
s=socket.create_connection(('127.0.0.1',5900))
|
|
while True:
|
|
r=select.select([s,0],[],[])[0]
|
|
if s in r:
|
|
d=s.recv(65536)
|
|
if not d: break
|
|
os.write(1,d)
|
|
if 0 in r:
|
|
d=os.read(0,65536)
|
|
if not d: break
|
|
s.sendall(d)
|
|
"#;
|
|
|
|
/// Viewers connected now (the box stays awake while there is one).
|
|
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
|
/// When the person last clicked or typed through a viewer.
|
|
static LAST_INPUT: Mutex<Option<Instant>> = Mutex::new(None);
|
|
|
|
/// VNC authentication: the 16-byte challenge encrypted with DES, the key being the password's
|
|
/// first 8 bytes with each byte's bits reversed (RFC 6143 §7.2.2, as every VNC server does it).
|
|
pub fn vnc_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
|
|
use des::cipher::{BlockEncrypt, KeyInit};
|
|
let mut key = [0u8; 8];
|
|
for (k, b) in key.iter_mut().zip(password.bytes()) {
|
|
*k = b.reverse_bits();
|
|
}
|
|
let c = des::Des::new_from_slice(&key).expect("8-byte key");
|
|
let mut out = *challenge;
|
|
for block in out.chunks_mut(8) {
|
|
c.encrypt_block(des::cipher::generic_array::GenericArray::from_mut_slice(block));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// This proxy's first words to a viewer, as a VNC server: version 3.8 (then "no password"; see
|
|
/// Session::feed).
|
|
pub const GREETING: &[u8] = b"RFB 003.008\n";
|
|
|
|
/// One person's viewer joined to the box.
|
|
pub struct Session {
|
|
child: Child,
|
|
stdin: ChildStdin,
|
|
/// The viewer's side of the handshake still to read: its version (12 bytes) and its choice
|
|
/// of security type (1 byte).
|
|
greeting: usize,
|
|
/// Viewer → box messages not yet complete (to notice clicks and keys).
|
|
pending: Vec<u8>,
|
|
sniffing: bool,
|
|
/// The viewer's ClientInit (one byte) is still to come.
|
|
init: bool,
|
|
}
|
|
|
|
/// Open a viewer's way to the box: sign in to its VNC server, and start sending what the server
|
|
/// says to `out` (from a thread of its own; `out` returns false when the viewer is gone, and
|
|
/// gets an empty slice when the box's side ended). The caller then sends the viewer GREETING
|
|
/// (once it is ready for the viewer's answer).
|
|
pub fn open(out: impl Fn(&[u8]) -> bool + Send + 'static) -> Result<Session, String> {
|
|
crate::cloudbox::ensure_running()?;
|
|
let mut child = crate::cloudbox::exec_piped(&["python3", "-c", BRIDGE])?;
|
|
let mut stdin = child.stdin.take().ok_or("連不上這台電腦的畫面")?;
|
|
let mut stdout = child.stdout.take().ok_or("連不上這台電腦的畫面")?;
|
|
let fail = |child: &mut Child, why: &str| -> String {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
format!("連不上這台電腦的畫面:{why}")
|
|
};
|
|
let mut read = |n: usize| -> Result<Vec<u8>, String> {
|
|
let mut b = vec![0u8; n];
|
|
stdout.read_exact(&mut b).map(|_| b).map_err(|_| "畫面伺服器沒有回應".to_string())
|
|
};
|
|
// The box's side, as a VNC client: version 3.8, then its password.
|
|
let signed_in = (|| -> Result<(), String> {
|
|
let v = read(12)?;
|
|
if !v.starts_with(b"RFB 003.") {
|
|
return Err("不是 VNC 伺服器".into());
|
|
}
|
|
stdin.write_all(b"RFB 003.008\n").map_err(|e| e.to_string())?;
|
|
let n = read(1)?[0] as usize;
|
|
if n == 0 {
|
|
return Err("畫面伺服器拒絕連線".into());
|
|
}
|
|
let types = read(n)?;
|
|
if types.contains(&2) {
|
|
stdin.write_all(&[2]).map_err(|e| e.to_string())?;
|
|
let c: [u8; 16] = read(16)?.try_into().map_err(|_| "認證失敗".to_string())?;
|
|
stdin.write_all(&vnc_response(&crate::cloudbox::box_vnc_password(), &c)).map_err(|e| e.to_string())?;
|
|
} else if types.contains(&1) {
|
|
stdin.write_all(&[1]).map_err(|e| e.to_string())?;
|
|
} else {
|
|
return Err("畫面伺服器要的登入方式不支援".into());
|
|
}
|
|
if read(4)? != [0, 0, 0, 0] {
|
|
return Err("畫面伺服器的密碼不對".into());
|
|
}
|
|
Ok(())
|
|
})();
|
|
if let Err(e) = signed_in {
|
|
return Err(fail(&mut child, &e));
|
|
}
|
|
OPEN.fetch_add(1, Ordering::SeqCst);
|
|
crate::cloudbox::set_viewer_open(true);
|
|
std::thread::spawn(move || {
|
|
let mut buf = vec![0u8; 64 * 1024];
|
|
loop {
|
|
match stdout.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
if !out(&buf[..n]) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The box's side ended: an empty send says so.
|
|
out(&[]);
|
|
});
|
|
Ok(Session { child, stdin, greeting: 13, pending: Vec::new(), sniffing: true, init: true })
|
|
}
|
|
|
|
impl Session {
|
|
/// Bytes from the viewer. False when the box's side is gone.
|
|
pub fn feed(&mut self, mut b: &[u8], reply: impl Fn(&[u8]) -> bool) -> bool {
|
|
if self.greeting > 0 {
|
|
let take = self.greeting.min(b.len());
|
|
let before = self.greeting;
|
|
self.greeting -= take;
|
|
b = &b[take..];
|
|
// After the version (12 bytes): the one security type, "None".
|
|
if before > 1 && self.greeting <= 1 && !reply(&[1, 1]) {
|
|
return false;
|
|
}
|
|
// After the choice: OK.
|
|
if self.greeting == 0 && !reply(&[0, 0, 0, 0]) {
|
|
return false;
|
|
}
|
|
if b.is_empty() {
|
|
return true;
|
|
}
|
|
}
|
|
self.notice_input(b);
|
|
self.stdin.write_all(b).and_then(|_| self.stdin.flush()).is_ok()
|
|
}
|
|
|
|
/// Follow the viewer's messages (RFC 6143 §7.5) to see clicks and keys. Anything it doesn't
|
|
/// know stops the watching, never the forwarding.
|
|
fn notice_input(&mut self, b: &[u8]) {
|
|
if !self.sniffing {
|
|
return;
|
|
}
|
|
self.pending.extend_from_slice(b);
|
|
// The first byte after the handshake is ClientInit (the shared flag).
|
|
if self.init && !self.pending.is_empty() {
|
|
self.pending.remove(0);
|
|
self.init = false;
|
|
}
|
|
let mut at = 0;
|
|
loop {
|
|
let rest = &self.pending[at..];
|
|
let Some(&kind) = rest.first() else { break };
|
|
let len = match kind {
|
|
0 => 20,
|
|
2 if rest.len() >= 4 => 4 + 4 * u16::from_be_bytes([rest[2], rest[3]]) as usize,
|
|
3 => 10,
|
|
4 => 8,
|
|
5 => 6,
|
|
6 if rest.len() >= 8 => 8 + u32::from_be_bytes([rest[4], rest[5], rest[6], rest[7]]) as usize,
|
|
2 | 6 => break,
|
|
_ => {
|
|
self.sniffing = false;
|
|
self.pending.clear();
|
|
return;
|
|
}
|
|
};
|
|
if rest.len() < len {
|
|
break;
|
|
}
|
|
match kind {
|
|
4 if rest[1] == 1 => person_acted(),
|
|
5 if rest[1] != 0 => person_acted(),
|
|
_ => {}
|
|
}
|
|
at += len;
|
|
}
|
|
self.pending.drain(..at);
|
|
}
|
|
}
|
|
|
|
impl Drop for Session {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
if OPEN.fetch_sub(1, Ordering::SeqCst) == 1 {
|
|
crate::cloudbox::set_viewer_open(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The person clicked or typed: the bot's steps wait until a little after they stop.
|
|
pub fn person_acted() {
|
|
*LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
|
}
|
|
|
|
/// The person is using the box through a viewer right now (input in the last few seconds).
|
|
pub fn person_active() -> bool {
|
|
LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < HELD_AFTER_INPUT).unwrap_or(false)
|
|
}
|
|
|
|
/// Text the person typed in the viewer's own box (any language): pasted into the box.
|
|
pub fn person_typed(text: &str) {
|
|
if !text.is_empty() {
|
|
person_acted();
|
|
crate::cloudbox::viewer_text(text);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_bridge_keeps_its_indentation() {
|
|
// Python needs its indentation (a Rust "\" line continuation would strip it).
|
|
assert!(BRIDGE.contains("\n r=select") && BRIDGE.contains("\n if not d: break"));
|
|
}
|
|
|
|
#[test]
|
|
fn vnc_password_answer_matches_the_standard() {
|
|
// Password "password", challenge 00..0f: DES-ECB with the bit-reversed key 0e86ceceeef64e26
|
|
// (worked out with `openssl enc -des-ecb -nopad`).
|
|
let c: [u8; 16] = core::array::from_fn(|i| i as u8);
|
|
let want = [0xb8, 0x66, 0x92, 0x41, 0x25, 0xc8, 0xee, 0xbb, 0x9d, 0xeb, 0xc1, 0xdb, 0x61, 0xc5, 0x38, 0xe2];
|
|
assert_eq!(vnc_response("password", &c), want);
|
|
// Only the first 8 bytes of a password count.
|
|
assert_eq!(vnc_response("password-and-more", &c), want);
|
|
}
|
|
}
|