remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
fix iOS backspace; add pairing-PIN auth
Backspace: iOS never fires deleteContentBackward on an empty field — the
hidden textarea now keeps sentinel padding (cursor at end, re-armed on
focus and drift) so delete events always fire; deleteWordBackward maps
to backspace too.
Auth: a persistent 6-digit PIN (generated on first run, 0600 at
~/.config/cce/cce-remote.pin, printed at startup) must arrive as the
first WS frame (auth <pin>) within 10s; anything else closes the
connection before any input can be injected. The page prompts once per
device and remembers the PIN in localStorage, re-prompting on failure.
Co-Authored-By: Claude Fable 5 <[email protected]>
README.md | 9 +++++---
index.html | 43 +++++++++++++++++++++++++++-------
src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 114 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 3ef6c32..8f37634 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,9 @@ Home Screen for a fullscreen app feel.
## Security
-LAN-trust only: anyone who can reach the port controls the desktop. Run it
-on a trusted network, or bind it behind a firewall / tunnel. There is no
-authentication in v1.
+Pairing PIN: a persistent 6-digit PIN is generated on first run (printed at
+startup, stored 0600 in `~/.config/cce/cce-remote.pin`). The page asks for
+it once per device and remembers it (localStorage); the server closes any
+WebSocket whose first frame isn't `auth <pin>`, so no input can be injected
+without pairing. Delete the PIN file to rotate it. Traffic is plain HTTP on
+the LAN — for hostile networks, tunnel it.
diff --git a/index.html b/index.html
index 40ce3c4..acbd0e1 100644
--- a/index.html
+++ b/index.html
@@ -53,16 +53,35 @@
<textarea id="kb" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
<script>
"use strict";
-// ── WebSocket ────────────────────────────────────────────────────────
-let ws = null;
+// ── WebSocket + pairing ──────────────────────────────────────────────
+// First frame must be `auth <pin>` (the PIN cce-remote prints at startup).
+// Remembered in localStorage after the first successful pairing.
+let ws = null, authed = false;
const status = document.getElementById("status");
+function askPin(msg) {
+ const pin = window.prompt(msg || "cce-remote pairing PIN (shown in the server terminal):");
+ if (pin) localStorage.setItem("cce-remote-pin", pin.trim());
+ return pin ? pin.trim() : null;
+}
function connect() {
+ authed = false;
ws = new WebSocket("ws://" + location.host + "/ws");
- ws.onopen = () => { status.textContent = "●"; status.style.color = "#5fbf6f"; };
- ws.onclose = () => { status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
+ ws.onopen = () => {
+ const pin = localStorage.getItem("cce-remote-pin") || askPin();
+ if (pin) ws.send("auth " + pin); else ws.close();
+ };
+ ws.onmessage = e => {
+ if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
+ else if (e.data === "auth fail") {
+ localStorage.removeItem("cce-remote-pin");
+ askPin("Wrong PIN — try again:");
+ // server closed on failure; onclose reconnects with the new PIN
+ }
+ };
+ ws.onclose = () => { authed = false; status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
}
connect();
-function send(s) { if (ws && ws.readyState === 1) ws.send(s); }
+function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
// ── Trackpad ─────────────────────────────────────────────────────────
const pad = document.getElementById("pad");
@@ -141,6 +160,13 @@ const kb = document.getElementById("kb");
document.getElementById("kbtn").addEventListener("click", () => {
if (document.activeElement === kb) { kb.blur(); } else { kb.focus(); }
});
+// iOS never fires deleteContentBackward on an EMPTY field — keep sentinel
+// padding in the textarea (cursor at the end) so backspace always has
+// something to "delete". beforeinput preventDefault keeps it untouched;
+// the re-arm below repairs any drift (autocorrect ghosts etc.).
+const SENTINEL = "········";
+function armKb() { kb.value = SENTINEL; kb.setSelectionRange(SENTINEL.length, SENTINEL.length); }
+kb.addEventListener("focus", armKb);
// US-layout char → [evdev keycode, needs shift]
const KEYS = {};
"abcdefghijklmnopqrstuvwxyz".split("").forEach((c, i) => {
@@ -171,12 +197,13 @@ kb.addEventListener("beforeinput", e => {
for (const ch of (e.data || "")) typeChar(ch);
break;
case "insertLineBreak": send("k 28"); break;
- case "deleteContentBackward": send("k 14"); break;
+ case "deleteContentBackward":
+ case "deleteWordBackward": send("k 14"); break;
case "deleteContentForward": send("k 111"); break;
}
});
-// keep the textarea from accumulating anything sneaky
-setInterval(() => { if (kb.value.length > 0) kb.value = ""; }, 2000);
+// repair sentinel drift while the keyboard is up
+setInterval(() => { if (document.activeElement === kb && kb.value !== SENTINEL) armKb(); }, 1000);
</script>
</body>
</html>
diff --git a/src/main.rs b/src/main.rs
index 0ec2b28..32f8d50 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,12 +15,16 @@
//! k <keycode> tap an evdev keycode
//! kd <keycode> / ku <keycode> hold / release (modifiers)
//!
-//! Security model: LAN-trust. Anyone who can reach the port controls the
-//! desktop — run it on a trusted network, or bind 127.0.0.1 and tunnel.
+//! Security model: pairing PIN. A persistent 6-digit PIN (generated on first
+//! run, stored 0600 under ~/.config/cce/cce-remote.pin, printed at startup)
+//! must arrive as the FIRST WebSocket frame (`auth <pin>`) before any input
+//! event is accepted; anything else closes the connection. The page remembers
+//! the PIN in localStorage after the first pairing.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::unix::net::UnixStream;
+use std::time::Duration;
const INDEX_HTML: &str = include_str!("../index.html");
const DEFAULT_PORT: u16 = 17017;
@@ -30,6 +34,45 @@ fn control_socket_path() -> String {
format!("/tmp/cce-{display}.sock")
}
+fn pin_path() -> std::path::PathBuf {
+ let base = std::env::var("XDG_CONFIG_HOME")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|_| {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+ std::path::PathBuf::from(home).join(".config")
+ });
+ base.join("cce").join("cce-remote.pin")
+}
+
+/// The pairing PIN: read from disk, or generated (6 digits from /dev/urandom)
+/// and stored 0600 on first run.
+fn load_or_create_pin() -> std::io::Result<String> {
+ let path = pin_path();
+ if let Ok(existing) = std::fs::read_to_string(&path) {
+ let trimmed = existing.trim().to_string();
+ if !trimmed.is_empty() {
+ return Ok(trimmed);
+ }
+ }
+ let mut bytes = [0u8; 4];
+ std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
+ let pin = format!("{:06}", u32::from_le_bytes(bytes) % 1_000_000);
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ let mut f = std::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .mode(0o600)
+ .open(&path)?;
+ writeln!(f, "{pin}")?;
+ }
+ Ok(pin)
+}
+
/// One persistent line-oriented connection to the compositor's control socket.
struct Control {
stream: BufReader<UnixStream>,
@@ -88,8 +131,10 @@ fn translate(frame: &str) -> Option<String> {
Some(cmd)
}
-fn handle_ws(stream: TcpStream) {
+fn handle_ws(stream: TcpStream, pin: &str) {
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
+ // Unauthenticated clients can hold the socket only briefly.
+ let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let mut ws = match tungstenite::accept(stream) {
Ok(ws) => ws,
Err(e) => {
@@ -97,6 +142,21 @@ fn handle_ws(stream: TcpStream) {
return;
}
};
+ // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
+ // wrong PIN) closes the connection before any input can be injected.
+ let authed = matches!(
+ ws.read(),
+ Ok(tungstenite::Message::Text(t))
+ if t.strip_prefix("auth ").map(str::trim) == Some(pin)
+ );
+ if !authed {
+ eprintln!("[cce-remote] auth failed: {peer}");
+ let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
+ let _ = ws.close(None);
+ return;
+ }
+ let _ = ws.get_ref().set_read_timeout(None);
+ let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
let mut control = match Control::connect() {
Ok(c) => c,
Err(e) => {
@@ -147,6 +207,13 @@ fn main() {
.nth(1)
.and_then(|a| a.parse::<u16>().ok())
.unwrap_or(DEFAULT_PORT);
+ let pin = match load_or_create_pin() {
+ Ok(p) => p,
+ Err(e) => {
+ eprintln!("[cce-remote] cannot read/create PIN file {:?}: {e}", pin_path());
+ std::process::exit(1);
+ }
+ };
let listener = match TcpListener::bind(("0.0.0.0", port)) {
Ok(l) => l,
Err(e) => {
@@ -155,12 +222,14 @@ fn main() {
}
};
println!("[cce-remote] serving on http://0.0.0.0:{port} (control socket: {})", control_socket_path());
+ println!("[cce-remote] pairing PIN: {pin} (stored in {:?})", pin_path());
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
+ let pin = pin.clone();
std::thread::spawn(move || {
// Peek the request head without consuming it, so a WS upgrade can
// be handed to tungstenite with the handshake bytes intact.
@@ -171,7 +240,7 @@ fn main() {
};
let head = String::from_utf8_lossy(&buf[..n]).to_string();
if head.starts_with("GET /ws") {
- handle_ws(stream);
+ handle_ws(stream, &pin);
} else {
// Consume the request before replying (keeps curl happy).
let mut sink = [0u8; 1024];