git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

src/main.rs (40.7K)

  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 LAN, 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 /// Failed PIN attempts allowed back-to-back from one peer before it must wait.
168 /// Sized for a human mistyping a PIN, not for a client retry loop.
169 const PIN_ATTEMPT_BURST: f64 = 5.0;
170 /// Sustained rate a peer recovers attempts at: one per 30s. That caps a
171 /// brute-force at ~2/min, so walking a 6-digit space takes on the order of a
172 /// year rather than the couple of hours an unthrottled LAN socket allows.
173 const PIN_ATTEMPT_REFILL_PER_SEC: f64 = 1.0 / 30.0;
174 /// Backstop on the tracking table so the limiter cannot itself be turned into
175 /// a memory-exhaustion vector by cycling source addresses.
176 const PIN_MAX_TRACKED_PEERS: usize = 4096;
177 
178 #[derive(Clone, Copy, Debug)]
179 struct Bucket {
180     tokens: f64,
181     last: std::time::Instant,
182 }
183 
184 /// Per-peer token bucket over failed PIN attempts.
185 ///
186 /// The PIN is ~20 bits and every gate compares it with `==`, so the thing that
187 /// actually makes it a credential is that an attacker cannot try often. Only
188 /// FAILURES consume tokens — a paired client reconnecting its stream, which
189 /// the page does on every hiccup, must never be throttled — and a success
190 /// clears the peer's record entirely.
191 ///
192 /// `now` is a parameter rather than read inside, so the behavior is testable
193 /// without sleeping.
194 struct RateLimiter {
195     peers: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, Bucket>>,
196 }
197 
198 impl RateLimiter {
199     fn new() -> Self {
200         Self { peers: std::sync::Mutex::new(std::collections::HashMap::new()) }
201     }
202 
203     fn refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
204         let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
205         Bucket {
206             tokens: (bucket.tokens + elapsed * PIN_ATTEMPT_REFILL_PER_SEC).min(PIN_ATTEMPT_BURST),
207             last: now,
208         }
209     }
210 
211     /// May this peer attempt a PIN right now? Does not consume anything —
212     /// a correct PIN costs nothing.
213     fn allow(&self, ip: std::net::IpAddr, now: std::time::Instant) -> bool {
214         let peers = self.peers.lock().unwrap();
215         match peers.get(&ip) {
216             Some(&b) => Self::refilled(b, now).tokens >= 1.0,
217             None => true,
218         }
219     }
220 
221     /// Charge this peer for a wrong PIN.
222     fn record_failure(&self, ip: std::net::IpAddr, now: std::time::Instant) {
223         let mut peers = self.peers.lock().unwrap();
224         // A fully refilled bucket is indistinguishable from an absent one, so
225         // dropping those keeps the table proportional to peers currently being
226         // penalized rather than to every peer ever seen.
227         peers.retain(|_, b| Self::refilled(*b, now).tokens < PIN_ATTEMPT_BURST);
228         if peers.len() >= PIN_MAX_TRACKED_PEERS && !peers.contains_key(&ip) {
229             // At capacity: evict whoever is closest to having recovered.
230             if let Some(&victim) = peers
231                 .iter()
232                 .max_by(|a, b| a.1.tokens.total_cmp(&b.1.tokens))
233                 .map(|(k, _)| k)
234             {
235                 peers.remove(&victim);
236             }
237         }
238         let entry = peers
239             .entry(ip)
240             .or_insert(Bucket { tokens: PIN_ATTEMPT_BURST, last: now });
241         let mut b = Self::refilled(*entry, now);
242         b.tokens = (b.tokens - 1.0).max(0.0);
243         *entry = b;
244     }
245 
246     /// A correct PIN clears the peer's record.
247     fn record_success(&self, ip: std::net::IpAddr) {
248         self.peers.lock().unwrap().remove(&ip);
249     }
250 }
251 
252 /// `f64::from_str` accepts "NaN" / "inf" / "infinity", and `{:.2}` formats them
253 /// straight back out, so without this a frame of `m NaN NaN` would reach the
254 /// compositor's pointer math verbatim. Reject rather than clamp: no legitimate
255 /// frame from the page contains one.
256 fn finite(v: f64) -> Option<f64> {
257     v.is_finite().then_some(v)
258 }
259 
260 /// Translate one WS frame into the control-socket commands it means. Returns
261 /// None for frames that don't parse — they're dropped, never forwarded raw
262 /// (the WS payload is untrusted; only these fixed shapes reach the
263 /// compositor). A list rather than one command so a verb can expand to
264 /// several commands without the expansion living inline at the call site (the
265 /// retired view-mode tap did: absolute move, then click); keeping every
266 /// expansion here is what makes this the single place input is validated.
267 fn translate(frame: &str) -> Option<Vec<String>> {
268     let mut it = frame.split_ascii_whitespace();
269     let cmd = match it.next()? {
270         "m" => {
271             let dx = finite(it.next()?.parse().ok()?)?;
272             let dy = finite(it.next()?.parse().ok()?)?;
273             format!("pointer-move-by {dx:.2} {dy:.2}")
274         }
275         "s" => {
276             let dy = finite(it.next()?.parse().ok()?)?;
277             let dx = finite(it.next().unwrap_or("0").parse().ok()?)?;
278             format!("pointer-scroll {dy:.3} {dx:.3}")
279         }
280         "b" => {
281             let btn = match it.next()? {
282                 b @ ("left" | "right" | "middle") => b,
283                 _ => return None,
284             };
285             match it.next()? {
286                 "down" => format!("pointer-press {btn}"),
287                 "up" => format!("pointer-release {btn}"),
288                 "click" => format!("pointer-click {btn}"),
289                 _ => return None,
290             }
291         }
292         "k" => format!("keypress {}", it.next()?.parse::<u32>().ok()?),
293         "kd" => format!("key-down {}", it.next()?.parse::<u32>().ok()?),
294         "ku" => format!("key-up {}", it.next()?.parse::<u32>().ok()?),
295         "wf" => {
296             let target = it.next()?;
297             if !safe_token(target) {
298                 return None;
299             }
300             format!("focus-window {target}")
301         }
302         // Named commands, individually whitelisted — never pass-through.
303         "cmd" => match it.next()? {
304             "restart-compositor" => "restart-compositor".to_string(),
305             _ => return None,
306         },
307         _ => return None,
308     };
309     Some(vec![cmd])
310 }
311 
312 /// The `windows --json` reply (one JSON object per line) as a JSON array
313 /// for the page's switcher.
314 fn window_list_json() -> String {
315     let reply = control_command("windows --json").unwrap_or_default();
316     let objs: Vec<&str> = reply.lines().filter(|l| l.trim_start().starts_with('{')).collect();
317     format!("windows [{}]", objs.join(","))
318 }
319 
320 /// Pull a numeric field out of one windows-json line (no serde — the values
321 /// are flat numbers on a single line per window).
322 fn json_num(line: &str, key: &str) -> Option<f64> {
323     let pat = format!("\"{key}\":");
324     let rest = &line[line.find(&pat)? + pat.len()..];
325     let end = rest
326         .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
327         .unwrap_or(rest.len());
328     rest[..end].parse().ok()
329 }
330 
331 /// The focused app window as (id, x, y, w, h) in layout px.
332 fn focused_window() -> Option<(u64, f64, f64, f64, f64)> {
333     let reply = control_command("windows --json").ok()?;
334     for line in reply.lines() {
335         if line.contains("\"focused\":true") && !line.contains("\"mode\":\"Status\"") {
336             return Some((
337                 json_num(line, "id")? as u64,
338                 json_num(line, "x")?,
339                 json_num(line, "y")?,
340                 json_num(line, "w")?,
341                 json_num(line, "h")?,
342             ));
343         }
344     }
345     None
346 }
347 
348 /// Screenshot the focused window via the compositor (it replies with the PNG
349 /// path), read the bytes, and DELETE the file — the remote view must not
350 /// litter ~/Pictures/screenshots.
351 fn take_screenshot() -> Option<(Vec<u8>, (u64, f64, f64, f64, f64))> {
352     let win = focused_window()?;
353     let reply = control_command(&format!("screenshot window {}", win.0)).ok()?;
354     let path = reply.trim().strip_prefix("ok ")?.trim().to_string();
355     let mut bytes = None;
356     for _ in 0..5 {
357         match std::fs::read(&path) {
358             Ok(b) if !b.is_empty() => {
359                 bytes = Some(b);
360                 break;
361             }
362             _ => std::thread::sleep(Duration::from_millis(60)),
363         }
364     }
365     let _ = std::fs::remove_file(&path);
366     Some((bytes?, win))
367 }
368 
369 fn handle_ws(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
370     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
371     // Unauthenticated clients can hold the socket only briefly.
372     let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
373     let mut ws = match tungstenite::accept(stream) {
374         Ok(ws) => ws,
375         Err(e) => {
376             eprintln!("[cce-remote] ws handshake failed ({peer}): {e}");
377             return;
378         }
379     };
380     // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
381     // wrong PIN) closes the connection before any input can be injected.
382     if !limiter.allow(ip, std::time::Instant::now()) {
383         // Close WITHOUT "auth fail": that message makes the page drop its
384         // stored PIN and prompt, so sending it here would punish a correctly
385         // paired client for someone else's guessing on the same address. It
386         // reconnects on close and succeeds once the bucket refills.
387         eprintln!("[cce-remote] auth rate-limited: {peer}");
388         let _ = ws.close(None);
389         return;
390     }
391     let authed = matches!(
392         ws.read(),
393         Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
394     );
395     if !authed {
396         limiter.record_failure(ip, std::time::Instant::now());
397         eprintln!("[cce-remote] auth failed: {peer}");
398         let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
399         let _ = ws.close(None);
400         return;
401     }
402     limiter.record_success(ip);
403     let _ = ws.get_ref().set_read_timeout(None);
404     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
405     let _ = ws.send(tungstenite::Message::Text(format!("ver {}", page_version())));
406     println!("[cce-remote] client connected: {peer}");
407     loop {
408         match ws.read() {
409             Ok(msg) => {
410                 if let tungstenite::Message::Text(text) = msg {
411                     if text.trim() == "wl" {
412                         // Window-list request: the one message with a reply.
413                         let _ = ws.send(tungstenite::Message::Text(window_list_json()));
414                     } else if text.trim() == "pl" {
415                         // Pointer location (view mode's cursor marker):
416                         // "x=N y=N" → "ploc N N".
417                         if let Ok(reply) = control_command("pointer-location") {
418                             let coords: String = reply
419                                 .split_whitespace()
420                                 .filter_map(|kv| kv.strip_prefix("x=").or_else(|| kv.strip_prefix("y=")))
421                                 .collect::<Vec<_>>()
422                                 .join(" ");
423                             if !coords.is_empty() {
424                                 let _ = ws.send(tungstenite::Message::Text(format!("ploc {coords}")));
425                             }
426                         }
427                     } else if let Some(cmds) = translate(&text) {
428                         // Every input-bearing frame goes through translate() —
429                         // `wl` and `pl` above are the only exceptions, and they
430                         // send fixed commands with no caller-supplied content.
431                         for cmd in cmds {
432                             let _ = control_command(&cmd);
433                         }
434                     }
435                 }
436             }
437             Err(_) => break,
438         }
439     }
440     println!("[cce-remote] client disconnected: {peer}");
441 }
442 
443 /// MJPEG stream of the focused window: multipart/x-mixed-replace, one JPEG
444 /// part per frame taken from the same latest-wins slot `/wstream` uses (the
445 /// producer picks the source and follows focus). Fixed 560/q60, no acks — the
446 /// curl-debuggable endpoint, not the page's path. Runs until the client closes
447 /// the socket. PIN via X-Pin header or ?pin= query (an <img src> can't carry
448 /// headers).
449 fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
450     if !limiter.allow(ip, std::time::Instant::now()) {
451         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");
452         return;
453     }
454     // Header OR query: an <img src> cannot carry a header, so /stream accepts
455     // the PIN in the URL. /shot does not — see handle_http.
456     let pin_ok = header_pin_ok(request_head, pin) || query_pin_ok(request_head, pin);
457     if !pin_ok {
458         limiter.record_failure(ip, std::time::Instant::now());
459         let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
460         return;
461     }
462     limiter.record_success(ip);
463     if write!(
464         stream,
465         "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"
466     )
467     .is_err()
468     {
469         return;
470     }
471     // Source selection (winstream → screencopy → grim) lives in the producer;
472     // this endpoint is the curl-debuggable MJPEG view over the same slot the
473     // page's ack-clocked /wstream uses.
474     let slot = stream::Slot::new();
475     let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
476     stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
477     stream::run_mjpeg_sender(&mut stream, &slot);
478     stop.store(true, std::sync::atomic::Ordering::Relaxed);
479 }
480 
481 /// The page's live view: ack-clocked latest-wins frame delivery over a
482 /// dedicated WebSocket. Same first-frame `auth <pin>` gate as the input WS —
483 /// and a separate socket on purpose: frames are 30-150KB and input events are
484 /// bytes, so sharing one TCP stream would head-of-line-block pointer motion
485 /// behind every frame on a slow link.
486 fn handle_wstream(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
487     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
488     let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
489     let mut ws = match tungstenite::accept(stream) {
490         Ok(ws) => ws,
491         Err(e) => {
492             eprintln!("[cce-remote] wstream handshake failed ({peer}): {e}");
493             return;
494         }
495     };
496     if !limiter.allow(ip, std::time::Instant::now()) {
497         let _ = ws.close(None);
498         return;
499     }
500     let authed = matches!(
501         ws.read(),
502         Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
503     );
504     if !authed {
505         limiter.record_failure(ip, std::time::Instant::now());
506         eprintln!("[cce-remote] wstream auth failed: {peer}");
507         let _ = ws.close(None);
508         return;
509     }
510     limiter.record_success(ip);
511     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
512     let slot = stream::Slot::new();
513     let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
514     stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
515     stream::run_ws_sender(&mut ws, &slot);
516     stop.store(true, std::sync::atomic::Ordering::Relaxed);
517     let _ = ws.close(None);
518 }
519 
520 fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
521     if request_head.starts_with("GET /stream") {
522         handle_stream(stream, request_head, pin, ip, limiter);
523         return;
524     }
525     // /shot: the focused window's screenshot, PIN-gated via the X-Pin header.
526     // A debug endpoint now — the page's live view rides /wstream and nothing
527     // in the page fetches this.
528     if request_head.starts_with("GET /shot") {
529         if !limiter.allow(ip, std::time::Instant::now()) {
530             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");
531             return;
532         }
533         // Header only: nothing loads this as an <img src>, so unlike /stream
534         // there is no reason to let the PIN travel in a URL (where it lands in
535         // logs).
536         if !header_pin_ok(request_head, pin) {
537             limiter.record_failure(ip, std::time::Instant::now());
538             let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
539             return;
540         }
541         limiter.record_success(ip);
542         match take_screenshot() {
543             Some((bytes, (id, x, y, w, h))) => {
544                 let _ = write!(
545                     stream,
546                     "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",
547                     bytes.len(),
548                 );
549                 let _ = stream.write_all(&bytes);
550             }
551             None => {
552                 let _ = write!(stream, "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
553             }
554         }
555         return;
556     }
557     let ok = request_head.starts_with("GET / ") || request_head.starts_with("GET /index.html ");
558     let (status, body) = if ok {
559         ("200 OK", INDEX_HTML)
560     } else {
561         ("404 Not Found", "not found")
562     };
563     // no-cache: a reload (manual or the automatic version-mismatch one) must
564     // refetch the page, not revalidate a heuristic cache entry.
565     let _ = write!(
566         stream,
567         "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}",
568         body.len(),
569     );
570 }
571 
572 fn main() {
573     let port = std::env::args()
574         .nth(1)
575         .and_then(|a| a.parse::<u16>().ok())
576         .unwrap_or(DEFAULT_PORT);
577     let pin = match load_or_create_pin() {
578         Ok(p) => p,
579         Err(e) => {
580             eprintln!("[cce-remote] cannot read/create PIN file {:?}: {e}", pin_path());
581             std::process::exit(1);
582         }
583     };
584     let listener = match TcpListener::bind(("0.0.0.0", port)) {
585         Ok(l) => l,
586         Err(e) => {
587             eprintln!("[cce-remote] cannot bind port {port}: {e}");
588             std::process::exit(1);
589         }
590     };
591     let limiter = std::sync::Arc::new(RateLimiter::new());
592     println!("[cce-remote] serving on http://0.0.0.0:{port} (control socket: {})", control_socket_path());
593     println!("[cce-remote] pairing PIN: {pin}   (stored in {:?})", pin_path());
594 
595     for stream in listener.incoming() {
596         let stream = match stream {
597             Ok(s) => s,
598             Err(_) => continue,
599         };
600         // Identify the peer once, here: every PIN gate is rate-limited per
601         // source address, and a socket whose peer cannot be resolved cannot be
602         // held accountable for its guesses, so it is refused rather than
603         // exempted.
604         let Ok(ip) = stream.peer_addr().map(|a| a.ip()) else { continue };
605         // Input events and stream acks are tiny and latency-critical; Nagle
606         // would batch them behind delayed ACKs.
607         let _ = stream.set_nodelay(true);
608         let pin = pin.clone();
609         let limiter = std::sync::Arc::clone(&limiter);
610         std::thread::spawn(move || {
611             // Peek the request head without consuming it, so a WS upgrade can
612             // be handed to tungstenite with the handshake bytes intact.
613             let mut buf = [0u8; 1024];
614             let n = match stream.peek(&mut buf) {
615                 Ok(n) if n > 0 => n,
616                 _ => return,
617             };
618             let head = String::from_utf8_lossy(&buf[..n]).to_string();
619             if head.starts_with("GET /wstream") {
620                 // before /ws: "GET /ws" is a prefix of this
621                 handle_wstream(stream, &pin, ip, &limiter);
622             } else if head.starts_with("GET /ws") {
623                 handle_ws(stream, &pin, ip, &limiter);
624             } else {
625                 // Consume the request before replying (keeps curl happy).
626                 let mut sink = [0u8; 1024];
627                 let mut s = stream;
628                 let _ = s.read(&mut sink);
629                 handle_http(s, &head, &pin, ip, &limiter);
630             }
631         });
632     }
633 }
634 
635 #[cfg(test)]
636 mod tests {
637     use super::*;
638 
639     // `translate` is the security boundary of this crate: it is the only thing
640     // between an untrusted WebSocket frame and a control socket that can move
641     // the pointer and type into whatever the user has focused. These tests
642     // cover the two halves of that job — the fixed shapes it accepts, and
643     // everything it must refuse — because a regression here is not a wrong
644     // pixel, it is remote input injection.
645 
646     /// A frame expected to mean exactly one command.
647     fn one(frame: &str) -> String {
648         let cmds = translate(frame).expect("frame should translate");
649         assert_eq!(cmds.len(), 1, "{frame:?} yielded {cmds:?}, expected one command");
650         cmds.into_iter().next().unwrap()
651     }
652 
653     // ---- rate limiting ----
654     //
655     // What actually makes a 6-digit PIN a credential: not the comparison, but
656     // that a peer cannot try often. Time is injected, so none of this sleeps.
657 
658     use std::net::{IpAddr, Ipv4Addr};
659     use std::time::Instant;
660 
661     fn ip(last: u8) -> IpAddr {
662         IpAddr::V4(Ipv4Addr::new(192, 168, 1, last))
663     }
664 
665     #[test]
666     fn a_peer_gets_a_burst_then_must_wait() {
667         let rl = RateLimiter::new();
668         let t0 = Instant::now();
669         let peer = ip(10);
670 
671         for i in 0..PIN_ATTEMPT_BURST as u32 {
672             assert!(rl.allow(peer, t0), "attempt {i} should be allowed");
673             rl.record_failure(peer, t0);
674         }
675         assert!(!rl.allow(peer, t0), "the burst must be exhausted");
676 
677         // Still locked out just short of the refill interval, allowed after it.
678         assert!(!rl.allow(peer, t0 + Duration::from_secs(29)));
679         assert!(rl.allow(peer, t0 + Duration::from_secs(31)));
680     }
681 
682     #[test]
683     fn recovery_is_capped_at_the_burst_size() {
684         // Asserted on refilled() directly rather than through the public API:
685         // record_failure() prunes recovered peers and re-creates them at full,
686         // which masks a missing cap end-to-end. (It did — this test passed
687         // against a build with the .min() removed until it was written this
688         // way.) Idle time must not bank attempts.
689         let t0 = Instant::now();
690         let drained = Bucket { tokens: 0.0, last: t0 };
691         // Full recovery takes BURST * 30s = 150s; well past that, nothing accrues.
692         for idle in [300u64, 3600, 86_400] {
693             let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(idle));
694             assert_eq!(
695                 b.tokens, PIN_ATTEMPT_BURST,
696                 "{idle}s idle banked {} attempts", b.tokens
697             );
698         }
699         // Partial recovery is proportional, not all-or-nothing.
700         let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(60));
701         assert!(b.tokens > 1.0);
702         let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(15));
703         assert!(b.tokens < 1.0, "half an interval must not buy a whole attempt");
704     }
705 
706     #[test]
707     fn peers_are_limited_independently() {
708         let rl = RateLimiter::new();
709         let t0 = Instant::now();
710         let (attacker, phone) = (ip(20), ip(21));
711         for _ in 0..PIN_ATTEMPT_BURST as u32 {
712             rl.record_failure(attacker, t0);
713         }
714         assert!(!rl.allow(attacker, t0));
715         assert!(rl.allow(phone, t0), "one peer's guessing must not lock out another");
716     }
717 
718     #[test]
719     fn a_correct_pin_costs_nothing_and_clears_the_record() {
720         let rl = RateLimiter::new();
721         let t0 = Instant::now();
722         let peer = ip(30);
723 
724         // The page reconnects its stream on every hiccup, each time presenting
725         // a correct PIN. If that consumed budget it would throttle itself.
726         for _ in 0..1000 {
727             assert!(rl.allow(peer, t0));
728         }
729 
730         for _ in 0..(PIN_ATTEMPT_BURST as u32 - 1) {
731             rl.record_failure(peer, t0);
732         }
733         rl.record_success(peer);
734         for _ in 0..PIN_ATTEMPT_BURST as u32 {
735             assert!(rl.allow(peer, t0), "success should restore the full burst");
736             rl.record_failure(peer, t0);
737         }
738     }
739 
740     #[test]
741     fn the_tracking_table_does_not_grow_without_bound() {
742         let rl = RateLimiter::new();
743         let t0 = Instant::now();
744 
745         // Recovered peers carry no information and must not be retained.
746         for i in 0..200u8 {
747             rl.record_failure(ip(i), t0);
748         }
749         assert!(rl.peers.lock().unwrap().len() > 1);
750         rl.record_failure(ip(255), t0 + Duration::from_secs(3600));
751         assert_eq!(
752             rl.peers.lock().unwrap().len(),
753             1,
754             "fully refilled peers should have been pruned"
755         );
756 
757         // And the table is capped even when every entry is still penalized.
758         let mut rl2 = RateLimiter::new();
759         // One failure per peer is enough to create (and hold) an entry.
760         for i in 0..(PIN_MAX_TRACKED_PEERS + 50) {
761             rl2.record_failure(IpAddr::V4(Ipv4Addr::from((i as u32).to_be_bytes())), t0);
762         }
763         assert!(
764             rl2.peers.get_mut().unwrap().len() <= PIN_MAX_TRACKED_PEERS,
765             "table exceeded its cap"
766         );
767     }
768 
769     // ---- the pairing PIN ----
770     //
771     // The other half of the security model: translate() decides what a paired
772     // client may say, these decide who is paired at all. Both are reachable by
773     // anyone who can open a socket to this port.
774 
775     const PIN: &str = "123456";
776 
777     fn head(lines: &[&str]) -> String {
778         format!("{}\r\n\r\n", lines.join("\r\n"))
779     }
780 
781     #[test]
782     fn ws_auth_requires_exactly_auth_then_pin() {
783         assert!(auth_frame_ok("auth 123456", PIN));
784         assert!(auth_frame_ok("auth   123456  ", PIN)); // value is trimmed
785         for frame in [
786             "auth 123457",      // wrong PIN
787             "auth 12345",       // prefix of it
788             "auth 1234567",     // superstring of it
789             "auth ",            // empty candidate
790             "auth",             // no separator
791             "AUTH 123456",      // verb is case-sensitive
792             "auth123456",
793             " auth 123456",     // must be the whole frame, unprefixed
794             "m 1 2",            // an input event before pairing
795             "",
796         ] {
797             assert!(!auth_frame_ok(frame, PIN), "{frame:?} must not authenticate");
798         }
799     }
800 
801     #[test]
802     fn an_empty_pin_authorizes_nothing() {
803         // load_or_create_pin() regenerates on an empty file, so this should be
804         // unreachable — which is exactly why it is worth pinning. A truncated
805         // PIN file must fail closed, not open.
806         assert!(!auth_frame_ok("auth ", ""));
807         assert!(!auth_frame_ok("auth", ""));
808         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin:"]), ""));
809         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin: "]), ""));
810         assert!(!query_pin_ok(&head(&["GET /stream?pin= HTTP/1.1"]), ""));
811     }
812 
813     #[test]
814     fn x_pin_header_is_matched_case_insensitively_by_name_only() {
815         for name in ["X-Pin", "x-pin", "X-PIN", "x-PiN"] {
816             let h = head(&["GET /shot HTTP/1.1", &format!("{name}: {PIN}"), "Host: x"]);
817             assert!(header_pin_ok(&h, PIN), "{name} should be accepted");
818         }
819         // Value whitespace is trimmed; the value itself must match exactly.
820         assert!(header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin:   123456  "]), PIN));
821         for bad in ["X-Pin: 123457", "X-Pin: 12345", "X-Pin: 1234567", "X-Pin:", "X-Pinx: 123456"] {
822             let h = head(&["GET /shot HTTP/1.1", bad]);
823             assert!(!header_pin_ok(&h, PIN), "{bad:?} must not authenticate");
824         }
825         // No header at all.
826         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "Host: x"]), PIN));
827     }
828 
829     #[test]
830     fn query_pin_is_a_parameter_not_a_substring() {
831         assert!(query_pin_ok(&head(&["GET /stream?pin=123456 HTTP/1.1"]), PIN));
832         assert!(query_pin_ok(&head(&["GET /stream?pin=123456&g=7 HTTP/1.1"]), PIN));
833         assert!(query_pin_ok(&head(&["GET /stream?g=7&pin=123456 HTTP/1.1"]), PIN));
834         for bad in [
835             "GET /stream?notpin=123456 HTTP/1.1",  // substring match used to pass this
836             "GET /stream?pin=1234567 HTTP/1.1",    // and this
837             "GET /stream?xpin=123456 HTTP/1.1",
838             "GET /stream?pin=12345 HTTP/1.1",
839             "GET /stream?pin= HTTP/1.1",
840             "GET /stream?pin HTTP/1.1",
841             "GET /stream HTTP/1.1",                // no query at all
842             "GET /pin=123456 HTTP/1.1",            // in the PATH, not the query
843         ] {
844             assert!(!query_pin_ok(&head(&[bad]), PIN), "{bad:?} must not authenticate");
845         }
846     }
847 
848     #[test]
849     fn the_two_http_gates_are_not_interchangeable() {
850         // /stream takes either (an <img src> cannot send headers); /shot takes
851         // the header only, so the PIN stays out of URLs and logs where it can.
852         let query_only = head(&["GET /stream?pin=123456 HTTP/1.1", "Host: x"]);
853         assert!(query_pin_ok(&query_only, PIN));
854         assert!(!header_pin_ok(&query_only, PIN), "/shot must not accept a URL PIN");
855 
856         let header_only = head(&["GET /shot HTTP/1.1", "X-Pin: 123456"]);
857         assert!(header_pin_ok(&header_only, PIN));
858         assert!(!query_pin_ok(&header_only, PIN));
859     }
860 
861     #[test]
862     fn pointer_and_scroll_carry_fixed_precision() {
863         assert_eq!(one("m 1 -2"), "pointer-move-by 1.00 -2.00");
864         assert_eq!(one("m 0.126 -0.126"), "pointer-move-by 0.13 -0.13");
865         // Exact .5 ties round half-to-even, not away from zero — sub-pixel
866         // detail the page never notices, but pin it so a formatting change
867         // shows up here rather than as drifting pointer feel.
868         assert_eq!(one("m 0.125 0.135"), "pointer-move-by 0.12 0.14");
869         // `s` takes dy first; dx is optional and defaults to 0.
870         assert_eq!(one("s 5"), "pointer-scroll 5.000 0.000");
871         assert_eq!(one("s 5 -1.5"), "pointer-scroll 5.000 -1.500");
872     }
873 
874     #[test]
875     fn buttons_map_to_press_release_click() {
876         assert_eq!(one("b left down"), "pointer-press left");
877         assert_eq!(one("b left up"), "pointer-release left");
878         assert_eq!(one("b right click"), "pointer-click right");
879         assert_eq!(one("b middle click"), "pointer-click middle");
880     }
881 
882     #[test]
883     fn keys_map_to_tap_and_hold() {
884         assert_eq!(one("k 28"), "keypress 28");
885         assert_eq!(one("kd 42"), "key-down 42");
886         assert_eq!(one("ku 42"), "key-up 42");
887     }
888 
889     #[test]
890     fn unknown_verbs_are_dropped() {
891         // Note the compositor's own command names: a frame naming one directly
892         // must NOT be honored, or the whitelist would be decorative.
893         for frame in [
894             "", "   ", "x 1", "exit", "spawn foot", "reload",
895             "pointer-click left", "keypress 28", "restart-compositor",
896             // retired 2026-08-23: view-mode taps are plain trackpad clicks,
897             // so the verbs left the whitelist rather than lingering as
898             // unused injection surface
899             "tap 100 200", "tapr 5 5",
900         ] {
901             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
902         }
903     }
904 
905     #[test]
906     fn missing_or_malformed_arguments_are_dropped() {
907         for frame in [
908             "m", "m 1", "m a b", "m 1 b",
909             "s", "s abc", "s 1 abc",
910             "k", "k abc", "k -1", "k 1.5", "k 99999999999999999999",
911             "kd", "ku",
912             "b", "b left", "b left bogus", "b sideways click", "b LEFT click",
913             "wf", "cmd",
914         ] {
915             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
916         }
917     }
918 
919     #[test]
920     fn non_finite_coordinates_are_dropped() {
921         // The hazard is real rather than theoretical — this is exactly what
922         // finite() exists to stop, and it is why parse().ok() alone is not
923         // enough validation for a float.
924         assert!("NaN".parse::<f64>().is_ok());
925         assert_eq!(format!("{:.2}", "NaN".parse::<f64>().unwrap()), "NaN");
926         assert_eq!(format!("{:.2}", "inf".parse::<f64>().unwrap()), "inf");
927 
928         for frame in [
929             "m NaN 1", "m 1 NaN", "m inf 0", "m -inf 0", "m 1 infinity",
930             "s NaN", "s 1 inf", "s nan 0",
931         ] {
932             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
933         }
934     }
935 
936     #[test]
937     fn focus_target_is_restricted_to_safe_tokens() {
938         assert_eq!(one("wf 12"), "focus-window 12");
939         assert_eq!(one("wf org.cce.files"), "focus-window org.cce.files");
940         assert_eq!(one("wf a-b_c:d.1"), "focus-window a-b_c:d.1");
941         for frame in [
942             "wf ../etc", "wf a/b", "wf a;b", "wf a$b", "wf a*b",
943             "wf a'b", "wf a\"b", "wf a|b", "wf a&b", "wf a\\b",
944         ] {
945             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
946         }
947         // safe_token's length bound, exercised on both sides.
948         assert!(translate(&format!("wf {}", "a".repeat(128))).is_some());
949         assert!(translate(&format!("wf {}", "a".repeat(129))).is_none());
950     }
951 
952     #[test]
953     fn named_commands_are_whitelisted_never_passed_through() {
954         assert_eq!(one("cmd restart-compositor"), "restart-compositor");
955         // Trailing junk is discarded, not appended.
956         assert_eq!(one("cmd restart-compositor rm -rf"), "restart-compositor");
957         for frame in ["cmd exit", "cmd reload", "cmd spawn foot", "cmd RESTART-COMPOSITOR"] {
958             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
959         }
960     }
961 
962     #[test]
963     fn a_newline_can_never_smuggle_a_second_command() {
964         // control_command() appends "\n", so an embedded newline in the output
965         // would be a second command on the socket. split_ascii_whitespace()
966         // eats it and every command is REBUILT from re-parsed values, so
967         // trailing tokens are discarded rather than forwarded.
968         assert_eq!(one("m 1 2\npointer-click left"), "pointer-move-by 1.00 2.00");
969         assert_eq!(one("wf 12\nexit"), "focus-window 12");
970 
971         // The property that matters, over every shape the page can send plus
972         // deliberate junk: whatever comes out is a single line.
973         for frame in [
974             "m 1 2\nexit", "m 1\n2", "s 1\nexit", "b left\nclick", "b left click\nexit",
975             "k 28\nexit", "kd 42\nexit", "ku 42\nexit", "wf 1\nexit",
976             "cmd restart-compositor\nexit", "m\t1\t2", "wf\n12", "  m   1   2  ",
977             "tap 1 2\nexit",
978         ] {
979             for out in translate(frame).unwrap_or_default() {
980                 assert!(!out.contains('\n'), "{frame:?} produced a multi-line command: {out:?}");
981                 assert!(!out.contains('\r'), "{frame:?} produced a CR: {out:?}");
982             }
983         }
984     }
985 }