//! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (` mcp --connect`) wraps. //! //! The server only ever reads requests: a line that is not a JSON object with a string `method` //! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets //! one error reply and the connection is closed, so nothing after it is executed. Lines are //! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk //! never reaches the UI thread. See `docs/control-protocol.md`. use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; use dac_ui_egui::ControlRequest; use serde_json::{Value, json}; /// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; /// anything longer is refused and the connection closed. pub const MAX_LINE: usize = 4 * 1024 * 1024; /// Connections served at once; further ones get an error line and are closed. pub const MAX_CONNECTIONS: usize = 16; pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); for mut stream in listener.incoming().flatten() { if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { open.fetch_sub(1, Ordering::SeqCst); let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); continue; } let tx = tx.clone(); let ctx = ctx.clone(); let open = Arc::clone(&open); std::thread::spawn(move || { serve(stream, &tx, &ctx); open.fetch_sub(1, Ordering::SeqCst); }); } } fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { let Ok(read) = stream.try_clone() else { return }; serve_lines(read, stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); tx.send(req).ok()?; ctx.request_repaint(); Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) }); } /// One parsed line. enum Line { Request { id: Value, method: String, params: Value, }, Blank, /// Not a request: reply with this error and close the connection. Reject(String), } fn parse_line(bytes: &[u8]) -> Line { let Ok(text) = std::str::from_utf8(bytes) else { return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); }; let text = text.trim(); if text.is_empty() { return Line::Blank; } let msg = match serde_json::from_str::(text) { Ok(v) => v, Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")), }; let Some(obj) = msg.as_object() else { return Line::Reject("not a control request (expected a JSON object); closing the connection".into()); }; let Some(method) = obj.get("method").and_then(Value::as_str) else { return Line::Reject("not a control request (missing string `method`); closing the connection".into()); }; Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})), } } /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { buf.clear(); // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; if n == 0 { return Ok(false); } if buf.last() == Some(&b'\n') { buf.pop(); if buf.last() == Some(&b'\r') { buf.pop(); } return Ok(true); } if buf.len() > max { return Err(format!("request line longer than {max} bytes; closing the connection")); } // Last line without a newline, then end of stream. Ok(true) } /// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is /// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and /// returns the reply object (the request's `id` is added here). fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option) { let mut reader = BufReader::new(read); let mut buf = Vec::new(); loop { let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { Ok(false) => return, Err(e) => e, Ok(true) => match parse_line(&buf) { Line::Blank => continue, Line::Reject(e) => e, Line::Request { id, method, params } => { let Some(mut r) = handle(method, params) else { return }; if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() { return; } continue; } }, }; let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush()); return; } } #[cfg(test)] mod tests { use super::*; fn run(input: &[u8]) -> (Vec, Vec) { let mut out = Vec::new(); let mut calls = Vec::new(); serve_lines(input, &mut out, |m, _p| { calls.push(m); Some(json!({"ok": true, "result": null})) }); let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); (replies, calls) } #[test] fn requests_are_answered_in_order_with_ids() { let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); assert_eq!(calls, ["a", "b", "c"]); assert_eq!(r.len(), 3); assert_eq!(r[0]["id"], 1); assert_eq!(r[1]["id"], 2); assert_eq!(r[2]["id"], Value::Null); } #[test] fn http_request_closes_before_the_body_runs() { // A browser's cross-origin `fetch` POST: the request line is rejected and the connection // closed, so the JSON body is never executed. let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\ {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n"; let (r, calls) = run(http); assert!(calls.is_empty(), "nothing may run: {calls:?}"); assert_eq!(r.len(), 1); assert_eq!(r[0]["ok"], false); assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); } #[test] fn non_request_json_closes_the_connection() { for junk in [ &b"[1,2]\n{\"method\":\"a\"}\n"[..], b"{\"id\":1}\n{\"method\":\"a\"}\n", b"{\"method\":5}\n{\"method\":\"a\"}\n", b"\xff\xfe\n{\"method\":\"a\"}\n", ] { let (r, calls) = run(junk); assert!(calls.is_empty()); assert_eq!(r.len(), 1); assert_eq!(r[0]["ok"], false); } } #[test] fn a_request_before_junk_still_runs() { let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); assert_eq!(calls, ["a"]); assert_eq!(r.len(), 2); assert_eq!(r[1]["ok"], false); } #[test] fn overlong_line_is_refused_without_buffering_it_all() { // An endless line without a newline: reading stops after MAX_LINE + 1 bytes. let endless = std::io::repeat(b'x'); let mut out = Vec::new(); let mut ran = false; serve_lines(endless, &mut out, |_, _| { ran = true; None }); assert!(!ran); let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap(); assert!(reply["error"].as_str().unwrap().contains("longer than")); } #[test] fn line_at_the_limit_is_accepted() { let mut buf = Vec::new(); let line = vec![b'a'; 10]; let mut input = line.clone(); input.push(b'\n'); assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); assert_eq!(buf, line); assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); } #[test] fn tcp_connection_is_closed_after_junk() { // End to end over a real loopback socket (the transport the app uses). let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { let (s, _) = listener.accept().unwrap(); let read = s.try_clone().unwrap(); serve_lines(read, s, |_, _| Some(json!({"ok": true}))); }); let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection server.join().unwrap(); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines.len(), 2, "{text}"); assert!(lines[0].contains("\"id\":7")); assert!(lines[1].contains("closing the connection")); } }