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 }