remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
src/main.rs (48K)
1 //! cce-remote — use a phone as a trackpad + keyboard for the cce desktop.
2 //!
3 //! One small server, no GUI: it serves the embedded `index.html` (a touch
4 //! trackpad + keyboard page) over HTTP on the tailnet (see `reachable`), accepts a WebSocket at
5 //! `/ws`, and translates the page's compact input events into the
6 //! compositor's line-oriented control socket (`/tmp/cce-{WAYLAND_DISPLAY}.sock`
7 //! — the same channel `ccectl` uses, so injection goes through the real
8 //! compositor input path: `pointer-move-by`, `pointer-scroll`,
9 //! `pointer-press/release/click`, `keypress`, `key-down`/`key-up`).
10 //!
11 //! Wire protocol (WS text frames, space-separated, one event per frame):
12 //! m <dx> <dy> relative pointer move (logical px)
13 //! s <dy> <dx> scroll (wayland axis units)
14 //! b <btn> <down|up|click> btn = left|right|middle
15 //! k <keycode> tap an evdev keycode
16 //! kd <keycode> / ku <keycode> hold / release (modifiers)
17 //!
18 //! Security model: pairing PIN. A persistent 6-digit PIN (generated on first
19 //! run, stored 0600 under ~/.config/cce/cce-remote.pin, printed at startup)
20 //! must arrive as the FIRST WebSocket frame (`auth <pin>`) before any input
21 //! event is accepted; anything else closes the connection. The page remembers
22 //! the PIN in localStorage after the first pairing.
23
24 use std::io::{Read, Write};
25 use std::net::{TcpListener, TcpStream};
26 use std::os::unix::net::UnixStream;
27 use std::time::Duration;
28
29 mod screencopy;
30 mod stream;
31 mod winstream;
32
33 const INDEX_HTML: &str = include_str!("../index.html");
34
35 /// Identity of the embedded page (FNV-1a). Sent to the page after auth so it
36 /// can notice that a restarted server is serving a NEWER page than the one it
37 /// is running, and reload itself — the page is baked into the binary, so
38 /// every UI change otherwise needs a manual refresh on the phone.
39 fn page_version() -> &'static str {
40 static V: std::sync::OnceLock<String> = std::sync::OnceLock::new();
41 V.get_or_init(|| {
42 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
43 for b in INDEX_HTML.bytes() {
44 h ^= b as u64;
45 h = h.wrapping_mul(0x0000_0100_0000_01b3);
46 }
47 format!("{h:016x}")
48 })
49 }
50 const DEFAULT_PORT: u16 = 17017;
51
52 fn control_socket_path() -> String {
53 let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
54 format!("/tmp/cce-{display}.sock")
55 }
56
57 fn pin_path() -> std::path::PathBuf {
58 let base = std::env::var("XDG_CONFIG_HOME")
59 .map(std::path::PathBuf::from)
60 .unwrap_or_else(|_| {
61 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
62 std::path::PathBuf::from(home).join(".config")
63 });
64 base.join("cce").join("cce-remote.pin")
65 }
66
67 /// The pairing PIN: read from disk, or generated (6 digits from /dev/urandom)
68 /// and stored 0600 on first run.
69 fn load_or_create_pin() -> std::io::Result<String> {
70 let path = pin_path();
71 if let Ok(existing) = std::fs::read_to_string(&path) {
72 let trimmed = existing.trim().to_string();
73 if !trimmed.is_empty() {
74 return Ok(trimmed);
75 }
76 }
77 let mut bytes = [0u8; 4];
78 std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
79 let pin = format!("{:06}", u32::from_le_bytes(bytes) % 1_000_000);
80 if let Some(dir) = path.parent() {
81 std::fs::create_dir_all(dir)?;
82 }
83 {
84 use std::os::unix::fs::OpenOptionsExt;
85 let mut f = std::fs::OpenOptions::new()
86 .write(true)
87 .create(true)
88 .truncate(true)
89 .mode(0o600)
90 .open(&path)?;
91 writeln!(f, "{pin}")?;
92 }
93 Ok(pin)
94 }
95
96 /// One control-socket command, one connection: the compositor's IPC server is
97 /// one-shot (read → reply → close), so a fresh connect per command is the
98 /// correct framing — the reply is everything until EOF (commands like
99 /// `windows --json` reply with multiple lines).
100 fn control_command(cmd: &str) -> std::io::Result<String> {
101 let mut s = UnixStream::connect(control_socket_path())?;
102 s.write_all(cmd.as_bytes())?;
103 s.write_all(b"\n")?;
104 let mut reply = String::new();
105 s.read_to_string(&mut reply)?;
106 Ok(reply)
107 }
108
109 /// True for tokens safe to splice into a control command (window queries:
110 /// numeric ids or app_ids). The WS payload is untrusted — nothing unvalidated
111 /// reaches the compositor.
112 fn safe_token(t: &str) -> bool {
113 !t.is_empty() && t.len() <= 128
114 && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
115 }
116
117 /// Whether `candidate` is the pairing PIN.
118 ///
119 /// An empty PIN never authorizes anything. `load_or_create_pin` cannot produce
120 /// one — it regenerates on an empty file — but that is a property of a
121 /// different function, and if it ever stopped holding, a bare `X-Pin:` header
122 /// or an `auth ` frame with nothing after it would authenticate every request.
123 /// Gate on the dangerous state here rather than trusting the caller.
124 fn pin_matches(candidate: &str, pin: &str) -> bool {
125 !pin.is_empty() && candidate == pin
126 }
127
128 /// The WebSocket auth gate: the FIRST frame must be `auth <pin>`. Everything
129 /// else — a wrong PIN, a different verb, an input event sent before pairing —
130 /// closes the connection, so no input can be injected unauthenticated.
131 fn auth_frame_ok(frame: &str, pin: &str) -> bool {
132 frame
133 .strip_prefix("auth ")
134 .is_some_and(|candidate| pin_matches(candidate.trim(), pin))
135 }
136
137 /// PIN carried by an `X-Pin` header. The header name is matched
138 /// case-insensitively (HTTP field names are), the value is not.
139 fn header_pin_ok(request_head: &str, pin: &str) -> bool {
140 request_head.lines().any(|line| {
141 line.to_ascii_lowercase()
142 .starts_with("x-pin:")
143 .then(|| line["x-pin:".len()..].trim())
144 .is_some_and(|candidate| pin_matches(candidate, pin))
145 })
146 }
147
148 /// PIN carried as a `?pin=` query parameter — needed because an `<img src>`
149 /// cannot send headers, so `/stream` has no other way to authenticate.
150 ///
151 /// Parsed as an actual parameter rather than searched for as a substring: the
152 /// old `target.contains("pin=<pin>")` also accepted `?notpin=<pin>` and
153 /// `?pin=<pin>trailing-garbage`. Neither is exploitable without already knowing
154 /// the PIN, but "close enough to the right string" is not a check.
155 fn query_pin_ok(request_head: &str, pin: &str) -> bool {
156 request_head
157 .split_whitespace()
158 .nth(1)
159 .and_then(|target| target.split_once('?'))
160 .is_some_and(|(_, query)| {
161 query
162 .split('&')
163 .any(|kv| kv.strip_prefix("pin=").is_some_and(|c| pin_matches(c, pin)))
164 })
165 }
166
167 /// Loopback, or an address Tailscale hands out: 100.64.0.0/10 and
168 /// fd7a:115c:a1e0::/48.
169 fn tailnet_or_loopback(ip: std::net::IpAddr) -> bool {
170 match ip.to_canonical() {
171 std::net::IpAddr::V4(v4) => {
172 let o = v4.octets();
173 v4.is_loopback() || (o[0] == 100 && (o[1] & 0xc0) == 64)
174 }
175 std::net::IpAddr::V6(v6) => {
176 let s = v6.segments();
177 v6.is_loopback() || (s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0)
178 }
179 }
180 }
181
182 /// Whether a connection may be served at all, before any PIN is asked for.
183 ///
184 /// The PIN crosses the wire in clear — HTTP, a WS frame, a `/stream` URL — so
185 /// on a network someone else can watch, anyone there who sees one pairing
186 /// owns the keyboard. By default, then, only the tailnet is served: WireGuard
187 /// carries it encrypted end to end, and loopback never leaves the machine.
188 /// Both ends are checked. The PEER must be a tailnet (or loopback) address,
189 /// and so must the LOCAL address it reached: Linux will accept a packet for
190 /// the tailnet address arriving on the Wi-Fi interface, and a LAN peer
191 /// spoofing a 100.x source cannot finish the handshake, since the reply is
192 /// routed into the tunnel. `lan` (`--lan` / `CCE_REMOTE_LAN=1`) serves every
193 /// interface, the old behaviour, for a network you trust.
194 fn reachable(peer: std::net::IpAddr, local: std::net::IpAddr, lan: bool) -> bool {
195 lan || (tailnet_or_loopback(peer) && tailnet_or_loopback(local))
196 }
197
198 /// Failed PIN attempts allowed back-to-back from one peer before it must wait.
199 /// Sized for a human mistyping a PIN, not for a client retry loop.
200 const PIN_ATTEMPT_BURST: f64 = 5.0;
201 /// Sustained rate a peer recovers attempts at: one per 30s. That caps a
202 /// brute-force at ~2/min, so walking a 6-digit space takes on the order of a
203 /// year rather than the couple of hours an unthrottled LAN socket allows.
204 const PIN_ATTEMPT_REFILL_PER_SEC: f64 = 1.0 / 30.0;
205 /// Backstop on the tracking table so the limiter cannot itself be turned into
206 /// a memory-exhaustion vector by cycling source addresses.
207 const PIN_MAX_TRACKED_PEERS: usize = 4096;
208 /// Failed attempts allowed back-to-back across ALL peers. The per-peer budget
209 /// alone is per address, and anyone who can claim many addresses (a /22 LAN
210 /// has a thousand) gets a budget for each — which brought walking the PIN
211 /// space down from a year to hours. Room for a few devices mistyping at once.
212 const PIN_GLOBAL_BURST: f64 = 20.0;
213 /// What every peer together recovers: the same one per 30s a single peer
214 /// does, so the year-long walk holds however many addresses do the guessing.
215 const PIN_GLOBAL_REFILL_PER_SEC: f64 = 1.0 / 30.0;
216
217 #[derive(Clone, Copy, Debug)]
218 struct Bucket {
219 tokens: f64,
220 last: std::time::Instant,
221 }
222
223 /// Per-peer token bucket over failed PIN attempts.
224 ///
225 /// The PIN is ~20 bits and every gate compares it with `==`, so the thing that
226 /// actually makes it a credential is that an attacker cannot try often. Only
227 /// FAILURES consume tokens — a paired client reconnecting its stream, which
228 /// the page does on every hiccup, must never be throttled — and a success
229 /// clears the peer's record entirely.
230 ///
231 /// `now` is a parameter rather than read inside, so the behavior is testable
232 /// without sleeping.
233 ///
234 /// Behind the per-peer buckets sits one GLOBAL bucket every failure also
235 /// drains, so guessing from many addresses buys no more attempts than
236 /// guessing from one. The price: whoever drains it locks out everyone,
237 /// a paired phone included, until it refills. That is the trade — a denial
238 /// of a trackpad for a while, against keyboard control of the desktop — and
239 /// with the default reach (loopback and the tailnet, see `reachable`) only
240 /// your own devices can drain it.
241 struct RateLimiter {
242 peers: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, Bucket>>,
243 global: std::sync::Mutex<Bucket>,
244 }
245
246 impl RateLimiter {
247 fn new() -> Self {
248 Self {
249 peers: std::sync::Mutex::new(std::collections::HashMap::new()),
250 global: std::sync::Mutex::new(Bucket {
251 tokens: PIN_GLOBAL_BURST,
252 last: std::time::Instant::now(),
253 }),
254 }
255 }
256
257 fn refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
258 Self::refilled_at(bucket, now, PIN_ATTEMPT_REFILL_PER_SEC, PIN_ATTEMPT_BURST)
259 }
260
261 fn refilled_at(bucket: Bucket, now: std::time::Instant, rate: f64, burst: f64) -> Bucket {
262 let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
263 Bucket {
264 tokens: (bucket.tokens + elapsed * rate).min(burst),
265 last: now,
266 }
267 }
268
269 fn global_refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
270 Self::refilled_at(bucket, now, PIN_GLOBAL_REFILL_PER_SEC, PIN_GLOBAL_BURST)
271 }
272
273 /// May this peer attempt a PIN right now? Does not consume anything —
274 /// a correct PIN costs nothing.
275 fn allow(&self, ip: std::net::IpAddr, now: std::time::Instant) -> bool {
276 if Self::global_refilled(*self.global.lock().unwrap(), now).tokens < 1.0 {
277 return false;
278 }
279 let peers = self.peers.lock().unwrap();
280 match peers.get(&ip) {
281 Some(&b) => Self::refilled(b, now).tokens >= 1.0,
282 None => true,
283 }
284 }
285
286 /// Charge this peer — and the global budget — for a wrong PIN.
287 fn record_failure(&self, ip: std::net::IpAddr, now: std::time::Instant) {
288 {
289 let mut g = self.global.lock().unwrap();
290 let mut b = Self::global_refilled(*g, now);
291 b.tokens = (b.tokens - 1.0).max(0.0);
292 *g = b;
293 }
294 let mut peers = self.peers.lock().unwrap();
295 // A fully refilled bucket is indistinguishable from an absent one, so
296 // dropping those keeps the table proportional to peers currently being
297 // penalized rather than to every peer ever seen.
298 peers.retain(|_, b| Self::refilled(*b, now).tokens < PIN_ATTEMPT_BURST);
299 if peers.len() >= PIN_MAX_TRACKED_PEERS && !peers.contains_key(&ip) {
300 // At capacity: evict whoever is closest to having recovered.
301 if let Some(&victim) = peers
302 .iter()
303 .max_by(|a, b| a.1.tokens.total_cmp(&b.1.tokens))
304 .map(|(k, _)| k)
305 {
306 peers.remove(&victim);
307 }
308 }
309 let entry = peers
310 .entry(ip)
311 .or_insert(Bucket { tokens: PIN_ATTEMPT_BURST, last: now });
312 let mut b = Self::refilled(*entry, now);
313 b.tokens = (b.tokens - 1.0).max(0.0);
314 *entry = b;
315 }
316
317 /// A correct PIN clears the peer's record.
318 fn record_success(&self, ip: std::net::IpAddr) {
319 self.peers.lock().unwrap().remove(&ip);
320 }
321 }
322
323 /// `f64::from_str` accepts "NaN" / "inf" / "infinity", and `{:.2}` formats them
324 /// straight back out, so without this a frame of `m NaN NaN` would reach the
325 /// compositor's pointer math verbatim. Reject rather than clamp: no legitimate
326 /// frame from the page contains one.
327 fn finite(v: f64) -> Option<f64> {
328 v.is_finite().then_some(v)
329 }
330
331 /// Translate one WS frame into the control-socket commands it means. Returns
332 /// None for frames that don't parse — they're dropped, never forwarded raw
333 /// (the WS payload is untrusted; only these fixed shapes reach the
334 /// compositor). A list rather than one command so a verb can expand to
335 /// several commands without the expansion living inline at the call site (the
336 /// retired view-mode tap did: absolute move, then click); keeping every
337 /// expansion here is what makes this the single place input is validated.
338 fn translate(frame: &str) -> Option<Vec<String>> {
339 let mut it = frame.split_ascii_whitespace();
340 let cmd = match it.next()? {
341 "m" => {
342 let dx = finite(it.next()?.parse().ok()?)?;
343 let dy = finite(it.next()?.parse().ok()?)?;
344 format!("pointer-move-by {dx:.2} {dy:.2}")
345 }
346 "s" => {
347 let dy = finite(it.next()?.parse().ok()?)?;
348 let dx = finite(it.next().unwrap_or("0").parse().ok()?)?;
349 format!("pointer-scroll {dy:.3} {dx:.3}")
350 }
351 "b" => {
352 let btn = match it.next()? {
353 b @ ("left" | "right" | "middle") => b,
354 _ => return None,
355 };
356 match it.next()? {
357 "down" => format!("pointer-press {btn}"),
358 "up" => format!("pointer-release {btn}"),
359 "click" => format!("pointer-click {btn}"),
360 _ => return None,
361 }
362 }
363 "k" => format!("keypress {}", it.next()?.parse::<u32>().ok()?),
364 "kd" => format!("key-down {}", it.next()?.parse::<u32>().ok()?),
365 "ku" => format!("key-up {}", it.next()?.parse::<u32>().ok()?),
366 "wf" => {
367 let target = it.next()?;
368 if !safe_token(target) {
369 return None;
370 }
371 format!("focus-window {target}")
372 }
373 // Named commands, individually whitelisted — never pass-through.
374 "cmd" => match it.next()? {
375 "restart-compositor" => "restart-compositor".to_string(),
376 _ => return None,
377 },
378 _ => return None,
379 };
380 Some(vec![cmd])
381 }
382
383 /// The `windows --json` reply (one JSON object per line) as a JSON array
384 /// for the page's switcher.
385 fn window_list_json() -> String {
386 let reply = control_command("windows --json").unwrap_or_default();
387 let objs: Vec<&str> = reply.lines().filter(|l| l.trim_start().starts_with('{')).collect();
388 format!("windows [{}]", objs.join(","))
389 }
390
391 /// Pull a numeric field out of one windows-json line (no serde — the values
392 /// are flat numbers on a single line per window).
393 fn json_num(line: &str, key: &str) -> Option<f64> {
394 let pat = format!("\"{key}\":");
395 let rest = &line[line.find(&pat)? + pat.len()..];
396 let end = rest
397 .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
398 .unwrap_or(rest.len());
399 rest[..end].parse().ok()
400 }
401
402 /// The focused app window as (id, x, y, w, h) in layout px.
403 fn focused_window() -> Option<(u64, f64, f64, f64, f64)> {
404 let reply = control_command("windows --json").ok()?;
405 for line in reply.lines() {
406 if line.contains("\"focused\":true") && !line.contains("\"mode\":\"Status\"") {
407 return Some((
408 json_num(line, "id")? as u64,
409 json_num(line, "x")?,
410 json_num(line, "y")?,
411 json_num(line, "w")?,
412 json_num(line, "h")?,
413 ));
414 }
415 }
416 None
417 }
418
419 /// Screenshot the focused window via the compositor (it replies with the PNG
420 /// path), read the bytes, and DELETE the file — the remote view must not
421 /// litter ~/Pictures/screenshots.
422 fn take_screenshot() -> Option<(Vec<u8>, (u64, f64, f64, f64, f64))> {
423 let win = focused_window()?;
424 let reply = control_command(&format!("screenshot window {}", win.0)).ok()?;
425 let path = reply.trim().strip_prefix("ok ")?.trim().to_string();
426 let mut bytes = None;
427 for _ in 0..5 {
428 match std::fs::read(&path) {
429 Ok(b) if !b.is_empty() => {
430 bytes = Some(b);
431 break;
432 }
433 _ => std::thread::sleep(Duration::from_millis(60)),
434 }
435 }
436 let _ = std::fs::remove_file(&path);
437 Some((bytes?, win))
438 }
439
440 fn handle_ws(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
441 let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
442 // Unauthenticated clients can hold the socket only briefly.
443 let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
444 let mut ws = match tungstenite::accept(stream) {
445 Ok(ws) => ws,
446 Err(e) => {
447 eprintln!("[cce-remote] ws handshake failed ({peer}): {e}");
448 return;
449 }
450 };
451 // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
452 // wrong PIN) closes the connection before any input can be injected.
453 if !limiter.allow(ip, std::time::Instant::now()) {
454 // Close WITHOUT "auth fail": that message makes the page drop its
455 // stored PIN and prompt, so sending it here would punish a correctly
456 // paired client for someone else's guessing on the same address. It
457 // reconnects on close and succeeds once the bucket refills.
458 eprintln!("[cce-remote] auth rate-limited: {peer}");
459 let _ = ws.close(None);
460 return;
461 }
462 let authed = matches!(
463 ws.read(),
464 Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
465 );
466 if !authed {
467 limiter.record_failure(ip, std::time::Instant::now());
468 eprintln!("[cce-remote] auth failed: {peer}");
469 let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
470 let _ = ws.close(None);
471 return;
472 }
473 limiter.record_success(ip);
474 let _ = ws.get_ref().set_read_timeout(None);
475 let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
476 let _ = ws.send(tungstenite::Message::Text(format!("ver {}", page_version())));
477 println!("[cce-remote] client connected: {peer}");
478 loop {
479 match ws.read() {
480 Ok(msg) => {
481 if let tungstenite::Message::Text(text) = msg {
482 if text.trim() == "wl" {
483 // Window-list request: the one message with a reply.
484 let _ = ws.send(tungstenite::Message::Text(window_list_json()));
485 } else if text.trim() == "pl" {
486 // Pointer location (view mode's cursor marker):
487 // "x=N y=N" → "ploc N N".
488 if let Ok(reply) = control_command("pointer-location") {
489 let coords: String = reply
490 .split_whitespace()
491 .filter_map(|kv| kv.strip_prefix("x=").or_else(|| kv.strip_prefix("y=")))
492 .collect::<Vec<_>>()
493 .join(" ");
494 if !coords.is_empty() {
495 let _ = ws.send(tungstenite::Message::Text(format!("ploc {coords}")));
496 }
497 }
498 } else if let Some(cmds) = translate(&text) {
499 // Every input-bearing frame goes through translate() —
500 // `wl` and `pl` above are the only exceptions, and they
501 // send fixed commands with no caller-supplied content.
502 for cmd in cmds {
503 let _ = control_command(&cmd);
504 }
505 }
506 }
507 }
508 Err(_) => break,
509 }
510 }
511 println!("[cce-remote] client disconnected: {peer}");
512 }
513
514 /// MJPEG stream of the focused window: multipart/x-mixed-replace, one JPEG
515 /// part per frame taken from the same latest-wins slot `/wstream` uses (the
516 /// producer picks the source and follows focus). Fixed 560/q60, no acks — the
517 /// curl-debuggable endpoint, not the page's path. Runs until the client closes
518 /// the socket. PIN via X-Pin header or ?pin= query (an <img src> can't carry
519 /// headers).
520 fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
521 if !limiter.allow(ip, std::time::Instant::now()) {
522 let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
523 return;
524 }
525 // Header OR query: an <img src> cannot carry a header, so /stream accepts
526 // the PIN in the URL. /shot does not — see handle_http.
527 let pin_ok = header_pin_ok(request_head, pin) || query_pin_ok(request_head, pin);
528 if !pin_ok {
529 limiter.record_failure(ip, std::time::Instant::now());
530 let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
531 return;
532 }
533 limiter.record_success(ip);
534 if write!(
535 stream,
536 "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
537 )
538 .is_err()
539 {
540 return;
541 }
542 // Source selection (winstream → screencopy → grim) lives in the producer;
543 // this endpoint is the curl-debuggable MJPEG view over the same slot the
544 // page's ack-clocked /wstream uses.
545 let slot = stream::Slot::new();
546 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
547 stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
548 stream::run_mjpeg_sender(&mut stream, &slot);
549 stop.store(true, std::sync::atomic::Ordering::Relaxed);
550 }
551
552 /// The page's live view: ack-clocked latest-wins frame delivery over a
553 /// dedicated WebSocket. Same first-frame `auth <pin>` gate as the input WS —
554 /// and a separate socket on purpose: frames are 30-150KB and input events are
555 /// bytes, so sharing one TCP stream would head-of-line-block pointer motion
556 /// behind every frame on a slow link.
557 fn handle_wstream(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
558 let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
559 let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
560 let mut ws = match tungstenite::accept(stream) {
561 Ok(ws) => ws,
562 Err(e) => {
563 eprintln!("[cce-remote] wstream handshake failed ({peer}): {e}");
564 return;
565 }
566 };
567 if !limiter.allow(ip, std::time::Instant::now()) {
568 let _ = ws.close(None);
569 return;
570 }
571 let authed = matches!(
572 ws.read(),
573 Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
574 );
575 if !authed {
576 limiter.record_failure(ip, std::time::Instant::now());
577 eprintln!("[cce-remote] wstream auth failed: {peer}");
578 let _ = ws.close(None);
579 return;
580 }
581 limiter.record_success(ip);
582 let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
583 let slot = stream::Slot::new();
584 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
585 stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
586 stream::run_ws_sender(&mut ws, &slot);
587 stop.store(true, std::sync::atomic::Ordering::Relaxed);
588 let _ = ws.close(None);
589 }
590
591 fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
592 if request_head.starts_with("GET /stream") {
593 handle_stream(stream, request_head, pin, ip, limiter);
594 return;
595 }
596 // /shot: the focused window's screenshot, PIN-gated via the X-Pin header.
597 // A debug endpoint now — the page's live view rides /wstream and nothing
598 // in the page fetches this.
599 if request_head.starts_with("GET /shot") {
600 if !limiter.allow(ip, std::time::Instant::now()) {
601 let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
602 return;
603 }
604 // Header only: nothing loads this as an <img src>, so unlike /stream
605 // there is no reason to let the PIN travel in a URL (where it lands in
606 // logs).
607 if !header_pin_ok(request_head, pin) {
608 limiter.record_failure(ip, std::time::Instant::now());
609 let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
610 return;
611 }
612 limiter.record_success(ip);
613 match take_screenshot() {
614 Some((bytes, (id, x, y, w, h))) => {
615 let _ = write!(
616 stream,
617 "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-Win: {id} {x} {y} {w} {h}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
618 bytes.len(),
619 );
620 let _ = stream.write_all(&bytes);
621 }
622 None => {
623 let _ = write!(stream, "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
624 }
625 }
626 return;
627 }
628 let ok = request_head.starts_with("GET / ") || request_head.starts_with("GET /index.html ");
629 let (status, body) = if ok {
630 ("200 OK", INDEX_HTML)
631 } else {
632 ("404 Not Found", "not found")
633 };
634 // no-cache: a reload (manual or the automatic version-mismatch one) must
635 // refetch the page, not revalidate a heuristic cache entry.
636 let _ = write!(
637 stream,
638 "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
639 body.len(),
640 );
641 }
642
643 fn main() {
644 let mut port = DEFAULT_PORT;
645 let mut lan = std::env::var("CCE_REMOTE_LAN").is_ok_and(|v| v == "1");
646 for arg in std::env::args().skip(1) {
647 if arg == "--lan" {
648 lan = true;
649 } else if let Ok(p) = arg.parse::<u16>() {
650 port = p;
651 } else {
652 eprintln!("[cce-remote] usage: cce-remote [--lan] [port]");
653 std::process::exit(2);
654 }
655 }
656 let pin = match load_or_create_pin() {
657 Ok(p) => p,
658 Err(e) => {
659 eprintln!("[cce-remote] cannot read/create PIN file {:?}: {e}", pin_path());
660 std::process::exit(1);
661 }
662 };
663 let listener = match TcpListener::bind(("0.0.0.0", port)) {
664 Ok(l) => l,
665 Err(e) => {
666 eprintln!("[cce-remote] cannot bind port {port}: {e}");
667 std::process::exit(1);
668 }
669 };
670 let limiter = std::sync::Arc::new(RateLimiter::new());
671 if lan {
672 println!("[cce-remote] serving EVERY interface on :{port} (--lan: the PIN crosses the network in clear)");
673 } else {
674 println!("[cce-remote] serving loopback and the tailnet on :{port} (--lan or CCE_REMOTE_LAN=1 for every interface)");
675 }
676 println!("[cce-remote] control socket: {}", control_socket_path());
677 println!("[cce-remote] pairing PIN: {pin} (stored in {:?})", pin_path());
678
679 for stream in listener.incoming() {
680 let stream = match stream {
681 Ok(s) => s,
682 Err(_) => continue,
683 };
684 // Identify the peer once, here: every PIN gate is rate-limited per
685 // source address, and a socket whose peer cannot be resolved cannot be
686 // held accountable for its guesses, so it is refused rather than
687 // exempted.
688 let Ok(ip) = stream.peer_addr().map(|a| a.ip()) else { continue };
689 let Ok(local) = stream.local_addr().map(|a| a.ip()) else { continue };
690 if !reachable(ip, local, lan) {
691 // Say why rather than just dropping it, so a phone still holding
692 // the old LAN address learns where to go; nothing past this line
693 // is reachable from here, the request is never parsed.
694 eprintln!("[cce-remote] refused {ip} -> {local}: not the tailnet (--lan to serve it)");
695 std::thread::spawn(move || {
696 let mut s = stream;
697 let _ = s.set_read_timeout(Some(Duration::from_secs(2)));
698 let mut sink = [0u8; 1024];
699 let _ = s.read(&mut sink);
700 let body = "cce-remote serves the tailnet only: open it at this machine's Tailscale address.\n";
701 let _ = write!(
702 s,
703 "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
704 body.len()
705 );
706 });
707 continue;
708 }
709 // Input events and stream acks are tiny and latency-critical; Nagle
710 // would batch them behind delayed ACKs.
711 let _ = stream.set_nodelay(true);
712 let pin = pin.clone();
713 let limiter = std::sync::Arc::clone(&limiter);
714 std::thread::spawn(move || {
715 // Peek the request head without consuming it, so a WS upgrade can
716 // be handed to tungstenite with the handshake bytes intact.
717 let mut buf = [0u8; 1024];
718 let n = match stream.peek(&mut buf) {
719 Ok(n) if n > 0 => n,
720 _ => return,
721 };
722 let head = String::from_utf8_lossy(&buf[..n]).to_string();
723 if head.starts_with("GET /wstream") {
724 // before /ws: "GET /ws" is a prefix of this
725 handle_wstream(stream, &pin, ip, &limiter);
726 } else if head.starts_with("GET /ws") {
727 handle_ws(stream, &pin, ip, &limiter);
728 } else {
729 // Consume the request before replying (keeps curl happy).
730 let mut sink = [0u8; 1024];
731 let mut s = stream;
732 let _ = s.read(&mut sink);
733 handle_http(s, &head, &pin, ip, &limiter);
734 }
735 });
736 }
737 }
738
739 #[cfg(test)]
740 mod tests {
741 use super::*;
742
743 // `translate` is the security boundary of this crate: it is the only thing
744 // between an untrusted WebSocket frame and a control socket that can move
745 // the pointer and type into whatever the user has focused. These tests
746 // cover the two halves of that job — the fixed shapes it accepts, and
747 // everything it must refuse — because a regression here is not a wrong
748 // pixel, it is remote input injection.
749
750 /// A frame expected to mean exactly one command.
751 fn one(frame: &str) -> String {
752 let cmds = translate(frame).expect("frame should translate");
753 assert_eq!(cmds.len(), 1, "{frame:?} yielded {cmds:?}, expected one command");
754 cmds.into_iter().next().unwrap()
755 }
756
757 // ---- rate limiting ----
758 //
759 // What actually makes a 6-digit PIN a credential: not the comparison, but
760 // that a peer cannot try often. Time is injected, so none of this sleeps.
761
762 use std::net::{IpAddr, Ipv4Addr};
763 use std::time::Instant;
764
765 fn ip(last: u8) -> IpAddr {
766 IpAddr::V4(Ipv4Addr::new(192, 168, 1, last))
767 }
768
769 #[test]
770 fn a_peer_gets_a_burst_then_must_wait() {
771 let rl = RateLimiter::new();
772 let t0 = Instant::now();
773 let peer = ip(10);
774
775 for i in 0..PIN_ATTEMPT_BURST as u32 {
776 assert!(rl.allow(peer, t0), "attempt {i} should be allowed");
777 rl.record_failure(peer, t0);
778 }
779 assert!(!rl.allow(peer, t0), "the burst must be exhausted");
780
781 // Still locked out just short of the refill interval, allowed after it.
782 assert!(!rl.allow(peer, t0 + Duration::from_secs(29)));
783 assert!(rl.allow(peer, t0 + Duration::from_secs(31)));
784 }
785
786 #[test]
787 fn recovery_is_capped_at_the_burst_size() {
788 // Asserted on refilled() directly rather than through the public API:
789 // record_failure() prunes recovered peers and re-creates them at full,
790 // which masks a missing cap end-to-end. (It did — this test passed
791 // against a build with the .min() removed until it was written this
792 // way.) Idle time must not bank attempts.
793 let t0 = Instant::now();
794 let drained = Bucket { tokens: 0.0, last: t0 };
795 // Full recovery takes BURST * 30s = 150s; well past that, nothing accrues.
796 for idle in [300u64, 3600, 86_400] {
797 let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(idle));
798 assert_eq!(
799 b.tokens, PIN_ATTEMPT_BURST,
800 "{idle}s idle banked {} attempts", b.tokens
801 );
802 }
803 // Partial recovery is proportional, not all-or-nothing.
804 let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(60));
805 assert!(b.tokens > 1.0);
806 let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(15));
807 assert!(b.tokens < 1.0, "half an interval must not buy a whole attempt");
808 }
809
810 #[test]
811 fn peers_are_limited_independently() {
812 let rl = RateLimiter::new();
813 let t0 = Instant::now();
814 let (attacker, phone) = (ip(20), ip(21));
815 for _ in 0..PIN_ATTEMPT_BURST as u32 {
816 rl.record_failure(attacker, t0);
817 }
818 assert!(!rl.allow(attacker, t0));
819 assert!(rl.allow(phone, t0), "one peer's guessing must not lock out another");
820 }
821
822 #[test]
823 fn a_correct_pin_costs_nothing_and_clears_the_record() {
824 let rl = RateLimiter::new();
825 let t0 = Instant::now();
826 let peer = ip(30);
827
828 // The page reconnects its stream on every hiccup, each time presenting
829 // a correct PIN. If that consumed budget it would throttle itself.
830 for _ in 0..1000 {
831 assert!(rl.allow(peer, t0));
832 }
833
834 for _ in 0..(PIN_ATTEMPT_BURST as u32 - 1) {
835 rl.record_failure(peer, t0);
836 }
837 rl.record_success(peer);
838 for _ in 0..PIN_ATTEMPT_BURST as u32 {
839 assert!(rl.allow(peer, t0), "success should restore the full burst");
840 rl.record_failure(peer, t0);
841 }
842 }
843
844 #[test]
845 fn the_tracking_table_does_not_grow_without_bound() {
846 let rl = RateLimiter::new();
847 let t0 = Instant::now();
848
849 // Recovered peers carry no information and must not be retained.
850 for i in 0..200u8 {
851 rl.record_failure(ip(i), t0);
852 }
853 assert!(rl.peers.lock().unwrap().len() > 1);
854 rl.record_failure(ip(255), t0 + Duration::from_secs(3600));
855 assert_eq!(
856 rl.peers.lock().unwrap().len(),
857 1,
858 "fully refilled peers should have been pruned"
859 );
860
861 // And the table is capped even when every entry is still penalized.
862 let mut rl2 = RateLimiter::new();
863 // One failure per peer is enough to create (and hold) an entry.
864 for i in 0..(PIN_MAX_TRACKED_PEERS + 50) {
865 rl2.record_failure(IpAddr::V4(Ipv4Addr::from((i as u32).to_be_bytes())), t0);
866 }
867 assert!(
868 rl2.peers.get_mut().unwrap().len() <= PIN_MAX_TRACKED_PEERS,
869 "table exceeded its cap"
870 );
871 }
872
873 #[test]
874 fn many_addresses_share_one_global_budget() {
875 // One failure from each of a LAN's worth of addresses: every per-peer
876 // bucket still has four left, and still the guessing stops.
877 let rl = RateLimiter::new();
878 let t0 = Instant::now();
879 for i in 0..PIN_GLOBAL_BURST as u8 {
880 assert!(rl.allow(ip(i), t0), "global attempt {i} should be allowed");
881 rl.record_failure(ip(i), t0);
882 }
883 assert!(!rl.allow(ip(200), t0), "a fresh address must not bring a fresh budget");
884 // The documented price: a paired phone waits too, then recovers.
885 assert!(!rl.allow(ip(201), t0 + Duration::from_secs(29)));
886 assert!(rl.allow(ip(201), t0 + Duration::from_secs(31)));
887 // A success does not refund the global budget.
888 rl.record_success(ip(0));
889 assert!(!rl.allow(ip(0), t0));
890 }
891
892 #[test]
893 fn only_loopback_and_the_tailnet_are_served_by_default() {
894 let v4 = |a, b, c, d| IpAddr::V4(Ipv4Addr::new(a, b, c, d));
895 let me_ts = v4(100, 105, 214, 102);
896 let me_lan = v4(192, 168, 68, 55);
897 let phone_ts = v4(100, 90, 1, 2);
898 let phone_lan = v4(192, 168, 68, 77);
899 let lo = v4(127, 0, 0, 1);
900
901 assert!(reachable(phone_ts, me_ts, false));
902 assert!(reachable(lo, lo, false));
903 assert!(!reachable(phone_lan, me_lan, false), "the LAN is opt-in");
904 // Linux takes a packet for the tailnet address off the Wi-Fi; the
905 // peer is what gives it away.
906 assert!(!reachable(phone_lan, me_ts, false));
907 // A tailnet-shaped source aimed at the LAN address is not the tunnel.
908 assert!(!reachable(phone_ts, me_lan, false));
909 // Just outside 100.64.0.0/10 on either side.
910 assert!(!reachable(v4(100, 63, 255, 255), me_ts, false));
911 assert!(!reachable(v4(100, 128, 0, 1), me_ts, false));
912 // Tailscale's IPv6 range, and v4 seen through a dual-stack socket.
913 let ts6: IpAddr = "fd7a:115c:a1e0::dd34:d667".parse().unwrap();
914 let other6: IpAddr = "fd7a:115c:a1e1::1".parse().unwrap();
915 assert!(reachable(ts6, ts6, false));
916 assert!(!reachable(other6, ts6, false));
917 let mapped: IpAddr = "::ffff:100.90.1.2".parse().unwrap();
918 assert!(reachable(mapped, me_ts, false));
919
920 assert!(reachable(phone_lan, me_lan, true), "--lan serves everything");
921 }
922
923 // ---- the pairing PIN ----
924 //
925 // The other half of the security model: translate() decides what a paired
926 // client may say, these decide who is paired at all. Both are reachable by
927 // anyone who can open a socket to this port.
928
929 const PIN: &str = "123456";
930
931 fn head(lines: &[&str]) -> String {
932 format!("{}\r\n\r\n", lines.join("\r\n"))
933 }
934
935 #[test]
936 fn ws_auth_requires_exactly_auth_then_pin() {
937 assert!(auth_frame_ok("auth 123456", PIN));
938 assert!(auth_frame_ok("auth 123456 ", PIN)); // value is trimmed
939 for frame in [
940 "auth 123457", // wrong PIN
941 "auth 12345", // prefix of it
942 "auth 1234567", // superstring of it
943 "auth ", // empty candidate
944 "auth", // no separator
945 "AUTH 123456", // verb is case-sensitive
946 "auth123456",
947 " auth 123456", // must be the whole frame, unprefixed
948 "m 1 2", // an input event before pairing
949 "",
950 ] {
951 assert!(!auth_frame_ok(frame, PIN), "{frame:?} must not authenticate");
952 }
953 }
954
955 #[test]
956 fn an_empty_pin_authorizes_nothing() {
957 // load_or_create_pin() regenerates on an empty file, so this should be
958 // unreachable — which is exactly why it is worth pinning. A truncated
959 // PIN file must fail closed, not open.
960 assert!(!auth_frame_ok("auth ", ""));
961 assert!(!auth_frame_ok("auth", ""));
962 assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin:"]), ""));
963 assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin: "]), ""));
964 assert!(!query_pin_ok(&head(&["GET /stream?pin= HTTP/1.1"]), ""));
965 }
966
967 #[test]
968 fn x_pin_header_is_matched_case_insensitively_by_name_only() {
969 for name in ["X-Pin", "x-pin", "X-PIN", "x-PiN"] {
970 let h = head(&["GET /shot HTTP/1.1", &format!("{name}: {PIN}"), "Host: x"]);
971 assert!(header_pin_ok(&h, PIN), "{name} should be accepted");
972 }
973 // Value whitespace is trimmed; the value itself must match exactly.
974 assert!(header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin: 123456 "]), PIN));
975 for bad in ["X-Pin: 123457", "X-Pin: 12345", "X-Pin: 1234567", "X-Pin:", "X-Pinx: 123456"] {
976 let h = head(&["GET /shot HTTP/1.1", bad]);
977 assert!(!header_pin_ok(&h, PIN), "{bad:?} must not authenticate");
978 }
979 // No header at all.
980 assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "Host: x"]), PIN));
981 }
982
983 #[test]
984 fn query_pin_is_a_parameter_not_a_substring() {
985 assert!(query_pin_ok(&head(&["GET /stream?pin=123456 HTTP/1.1"]), PIN));
986 assert!(query_pin_ok(&head(&["GET /stream?pin=123456&g=7 HTTP/1.1"]), PIN));
987 assert!(query_pin_ok(&head(&["GET /stream?g=7&pin=123456 HTTP/1.1"]), PIN));
988 for bad in [
989 "GET /stream?notpin=123456 HTTP/1.1", // substring match used to pass this
990 "GET /stream?pin=1234567 HTTP/1.1", // and this
991 "GET /stream?xpin=123456 HTTP/1.1",
992 "GET /stream?pin=12345 HTTP/1.1",
993 "GET /stream?pin= HTTP/1.1",
994 "GET /stream?pin HTTP/1.1",
995 "GET /stream HTTP/1.1", // no query at all
996 "GET /pin=123456 HTTP/1.1", // in the PATH, not the query
997 ] {
998 assert!(!query_pin_ok(&head(&[bad]), PIN), "{bad:?} must not authenticate");
999 }
1000 }
1001
1002 #[test]
1003 fn the_two_http_gates_are_not_interchangeable() {
1004 // /stream takes either (an <img src> cannot send headers); /shot takes
1005 // the header only, so the PIN stays out of URLs and logs where it can.
1006 let query_only = head(&["GET /stream?pin=123456 HTTP/1.1", "Host: x"]);
1007 assert!(query_pin_ok(&query_only, PIN));
1008 assert!(!header_pin_ok(&query_only, PIN), "/shot must not accept a URL PIN");
1009
1010 let header_only = head(&["GET /shot HTTP/1.1", "X-Pin: 123456"]);
1011 assert!(header_pin_ok(&header_only, PIN));
1012 assert!(!query_pin_ok(&header_only, PIN));
1013 }
1014
1015 #[test]
1016 fn pointer_and_scroll_carry_fixed_precision() {
1017 assert_eq!(one("m 1 -2"), "pointer-move-by 1.00 -2.00");
1018 assert_eq!(one("m 0.126 -0.126"), "pointer-move-by 0.13 -0.13");
1019 // Exact .5 ties round half-to-even, not away from zero — sub-pixel
1020 // detail the page never notices, but pin it so a formatting change
1021 // shows up here rather than as drifting pointer feel.
1022 assert_eq!(one("m 0.125 0.135"), "pointer-move-by 0.12 0.14");
1023 // `s` takes dy first; dx is optional and defaults to 0.
1024 assert_eq!(one("s 5"), "pointer-scroll 5.000 0.000");
1025 assert_eq!(one("s 5 -1.5"), "pointer-scroll 5.000 -1.500");
1026 }
1027
1028 #[test]
1029 fn buttons_map_to_press_release_click() {
1030 assert_eq!(one("b left down"), "pointer-press left");
1031 assert_eq!(one("b left up"), "pointer-release left");
1032 assert_eq!(one("b right click"), "pointer-click right");
1033 assert_eq!(one("b middle click"), "pointer-click middle");
1034 }
1035
1036 #[test]
1037 fn keys_map_to_tap_and_hold() {
1038 assert_eq!(one("k 28"), "keypress 28");
1039 assert_eq!(one("kd 42"), "key-down 42");
1040 assert_eq!(one("ku 42"), "key-up 42");
1041 }
1042
1043 #[test]
1044 fn unknown_verbs_are_dropped() {
1045 // Note the compositor's own command names: a frame naming one directly
1046 // must NOT be honored, or the whitelist would be decorative.
1047 for frame in [
1048 "", " ", "x 1", "exit", "spawn foot", "reload",
1049 "pointer-click left", "keypress 28", "restart-compositor",
1050 // retired 2026-08-23: view-mode taps are plain trackpad clicks,
1051 // so the verbs left the whitelist rather than lingering as
1052 // unused injection surface
1053 "tap 100 200", "tapr 5 5",
1054 ] {
1055 assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1056 }
1057 }
1058
1059 #[test]
1060 fn missing_or_malformed_arguments_are_dropped() {
1061 for frame in [
1062 "m", "m 1", "m a b", "m 1 b",
1063 "s", "s abc", "s 1 abc",
1064 "k", "k abc", "k -1", "k 1.5", "k 99999999999999999999",
1065 "kd", "ku",
1066 "b", "b left", "b left bogus", "b sideways click", "b LEFT click",
1067 "wf", "cmd",
1068 ] {
1069 assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1070 }
1071 }
1072
1073 #[test]
1074 fn non_finite_coordinates_are_dropped() {
1075 // The hazard is real rather than theoretical — this is exactly what
1076 // finite() exists to stop, and it is why parse().ok() alone is not
1077 // enough validation for a float.
1078 assert!("NaN".parse::<f64>().is_ok());
1079 assert_eq!(format!("{:.2}", "NaN".parse::<f64>().unwrap()), "NaN");
1080 assert_eq!(format!("{:.2}", "inf".parse::<f64>().unwrap()), "inf");
1081
1082 for frame in [
1083 "m NaN 1", "m 1 NaN", "m inf 0", "m -inf 0", "m 1 infinity",
1084 "s NaN", "s 1 inf", "s nan 0",
1085 ] {
1086 assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1087 }
1088 }
1089
1090 #[test]
1091 fn focus_target_is_restricted_to_safe_tokens() {
1092 assert_eq!(one("wf 12"), "focus-window 12");
1093 assert_eq!(one("wf org.cce.files"), "focus-window org.cce.files");
1094 assert_eq!(one("wf a-b_c:d.1"), "focus-window a-b_c:d.1");
1095 for frame in [
1096 "wf ../etc", "wf a/b", "wf a;b", "wf a$b", "wf a*b",
1097 "wf a'b", "wf a\"b", "wf a|b", "wf a&b", "wf a\\b",
1098 ] {
1099 assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1100 }
1101 // safe_token's length bound, exercised on both sides.
1102 assert!(translate(&format!("wf {}", "a".repeat(128))).is_some());
1103 assert!(translate(&format!("wf {}", "a".repeat(129))).is_none());
1104 }
1105
1106 #[test]
1107 fn named_commands_are_whitelisted_never_passed_through() {
1108 assert_eq!(one("cmd restart-compositor"), "restart-compositor");
1109 // Trailing junk is discarded, not appended.
1110 assert_eq!(one("cmd restart-compositor rm -rf"), "restart-compositor");
1111 for frame in ["cmd exit", "cmd reload", "cmd spawn foot", "cmd RESTART-COMPOSITOR"] {
1112 assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1113 }
1114 }
1115
1116 #[test]
1117 fn a_newline_can_never_smuggle_a_second_command() {
1118 // control_command() appends "\n", so an embedded newline in the output
1119 // would be a second command on the socket. split_ascii_whitespace()
1120 // eats it and every command is REBUILT from re-parsed values, so
1121 // trailing tokens are discarded rather than forwarded.
1122 assert_eq!(one("m 1 2\npointer-click left"), "pointer-move-by 1.00 2.00");
1123 assert_eq!(one("wf 12\nexit"), "focus-window 12");
1124
1125 // The property that matters, over every shape the page can send plus
1126 // deliberate junk: whatever comes out is a single line.
1127 for frame in [
1128 "m 1 2\nexit", "m 1\n2", "s 1\nexit", "b left\nclick", "b left click\nexit",
1129 "k 28\nexit", "kd 42\nexit", "ku 42\nexit", "wf 1\nexit",
1130 "cmd restart-compositor\nexit", "m\t1\t2", "wf\n12", " m 1 2 ",
1131 "tap 1 2\nexit",
1132 ] {
1133 for out in translate(frame).unwrap_or_default() {
1134 assert!(!out.contains('\n'), "{frame:?} produced a multi-line command: {out:?}");
1135 assert!(!out.contains('\r'), "{frame:?} produced a CR: {out:?}");
1136 }
1137 }
1138 }
1139 }