AnyBot/src/vnc.rs

264 lines
10 KiB
Rust
Raw Normal View History

People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
// 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);
}
}