diff --git a/Cargo.lock b/Cargo.lock index ed8f299aa7..400ab631dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,7 +509,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.11", ] [[package]] @@ -1054,7 +1054,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.11", ] [[package]] @@ -2099,6 +2099,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + [[package]] name = "hybrid-array" version = "0.4.11" @@ -2491,6 +2500,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2585,6 +2604,25 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2779,11 +2817,20 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "serde", "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -2930,6 +2977,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "monostate" version = "0.1.18" @@ -3017,6 +3076,17 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -4804,6 +4874,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4837,6 +4925,16 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6089,9 +6187,13 @@ version = "0.1.0" dependencies = [ "chacha20poly1305", "ed25519-dalek", + "getrandom 0.2.17", + "ml-kem", "rand_core 0.6.4", "serde", "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", "trios-chat-cr-chat-00", "trios-chat-cr-chat-04", "x25519-dalek", @@ -6106,6 +6208,7 @@ dependencies = [ "hkdf", "rand_core 0.6.4", "sha2 0.10.9", + "subtle", "trios-chat-cr-chat-00", "x25519-dalek", "zeroize", @@ -6116,6 +6219,7 @@ name = "trios-chat-cr-chat-03" version = "0.1.0" dependencies = [ "serde", + "thiserror 1.0.69", "trios-chat-cr-chat-00", ] @@ -6123,6 +6227,8 @@ dependencies = [ name = "trios-chat-cr-chat-04" version = "0.1.0" dependencies = [ + "sha2 0.10.9", + "subtle", "trios-chat-cr-chat-00", ] @@ -6132,6 +6238,8 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2 0.10.9", + "subtle", "thiserror 1.0.69", "trios-chat-cr-chat-00", ] @@ -6493,6 +6601,7 @@ dependencies = [ "anyhow", "axum", "axum-extra", + "base64 0.22.1", "chrono", "dotenv", "futures", @@ -6502,6 +6611,7 @@ dependencies = [ "rust-mcp-schema", "serde", "serde_json", + "serialport", "tempfile", "tokio", "tokio-stream", @@ -6688,6 +6798,15 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/crates/trios-server/Cargo.toml b/crates/trios-server/Cargo.toml index 2b971892f7..236cad549a 100644 --- a/crates/trios-server/Cargo.toml +++ b/crates/trios-server/Cargo.toml @@ -30,12 +30,16 @@ uuid = { version = "1", features = ["v4"] } chrono = "0.4" lazy_static = "1.4" serde = { workspace = true, features = ["derive"] } -tokio-stream = { version = "0.1", features = ["sync"] } +tokio-stream = { version = "0.1", features = ["sync", "time"] } rust-mcp-schema = "0.10" hex = "0.4" reqwest = { version = "0.12", features = ["json"] } dotenv = "0.15" +# UART endpoint (see src/uart.rs) +serialport = { version = "4.7", default-features = false } +base64 = "0.22" + [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/trios-server/src/main.rs b/crates/trios-server/src/main.rs index c939bc1622..65f7d9efcc 100644 --- a/crates/trios-server/src/main.rs +++ b/crates/trios-server/src/main.rs @@ -5,6 +5,7 @@ mod rainbow_routes; mod security; mod sse_handler; mod tools; +mod uart; mod ws_handler; use axum::extract::State; @@ -44,7 +45,10 @@ async fn main() -> anyhow::Result<()> { .allow_methods(Any) .allow_headers(Any); - let app = Router::new() + // UART bridge — only mounted if TRIOS_UART_TOKEN is set (fail-closed). + uart::log_startup_state(); + + let mut app = Router::new() // WebSocket (agents, internal tools) .route("/ws", get(ws_handler::ws_handler)) .route("/operator", get(operator::operator_ws_handler)) @@ -58,8 +62,13 @@ async fn main() -> anyhow::Result<()> { .route("/health", get(health)) .route("/", get(health)) // Rainbow Bridge (L13 / INV-8) — see crates/trios-rainbow-bridge. - .merge(rainbow_routes::rainbow_routes()) - .layer( + .merge(rainbow_routes::rainbow_routes()); + + if let Some(uart_router) = uart::router() { + app = app.nest("/api/uart", uart_router); + } + + let app = app.layer( ServiceBuilder::new() .layer(cors) .layer(axum::middleware::from_fn(security::auth_middleware)) diff --git a/crates/trios-server/src/security.rs b/crates/trios-server/src/security.rs index 4ef84a1eb9..86f297db5d 100644 --- a/crates/trios-server/src/security.rs +++ b/crates/trios-server/src/security.rs @@ -67,6 +67,13 @@ pub async fn auth_middleware(request: Request, next: Next) -> Result Option { + match std::env::var(ENV_TOKEN) { + Ok(v) if !v.is_empty() => Some(v), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Router assembly +// --------------------------------------------------------------------------- + +/// Build the `/api/uart/*` router. +/// +/// Returns `None` if `TRIOS_UART_TOKEN` is unset — in that case the caller +/// should not mount UART at all. This is the fail-closed default. +/// +/// The router is generic over the parent's state type so it can be nested +/// into a `Router` without requiring UART handlers to know about +/// `AppState`. UART handlers themselves are stateless. +pub fn router() -> Option> +where + S: Clone + Send + Sync + 'static, +{ + let _ = configured_token()?; + Some( + Router::new() + .route("/ports", get(list_ports)) + .route("/stream", get(stream_port)) + .route("/write", post(write_port)) + .layer(axum::middleware::from_fn(uart_auth_middleware)), + ) +} + +/// Log whether the endpoint is enabled at startup. Called from `main` so the +/// operator sees the state on boot without leaking the token. +pub fn log_startup_state() { + match configured_token() { + Some(_) => info!("UART endpoint ENABLED at /api/uart (auth: TRIOS_UART_TOKEN)"), + None => warn!( + "UART endpoint DISABLED — set {} to enable /api/uart/*", + ENV_TOKEN + ), + } +} + +// --------------------------------------------------------------------------- +// Auth middleware — separate from global auth +// --------------------------------------------------------------------------- + +async fn uart_auth_middleware(request: Request, next: Next) -> Result { + let expected = match configured_token() { + Some(t) => t, + None => { + // Should be unreachable — router() returns None in this case — but + // fail closed just in case. + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + }; + + let provided = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_owned); + + match provided { + Some(t) if t == expected => Ok(next.run(request).await), + Some(_) => { + warn!("UART: invalid bearer token"); + Err(StatusCode::UNAUTHORIZED) + } + None => { + warn!("UART: missing Authorization header"); + Err(StatusCode::UNAUTHORIZED) + } + } +} + +// --------------------------------------------------------------------------- +// GET /ports — metadata only, no port is opened +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct PortInfo { + device: String, + port_type: &'static str, + vid: Option, + pid: Option, + serial_number: Option, + manufacturer: Option, + product: Option, +} + +async fn list_ports() -> Response { + let ports = match serialport::available_ports() { + Ok(p) => p, + Err(e) => { + warn!("serialport::available_ports failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ) + .into_response(); + } + }; + + let listed: Vec = ports + .into_iter() + .map(|p| { + use serialport::SerialPortType; + let (kind, vid, pid, sn, mfr, prod) = match p.port_type { + SerialPortType::UsbPort(info) => ( + "usb", + Some(info.vid), + Some(info.pid), + info.serial_number.clone(), + info.manufacturer.clone(), + info.product.clone(), + ), + SerialPortType::BluetoothPort => ("bluetooth", None, None, None, None, None), + SerialPortType::PciPort => ("pci", None, None, None, None, None), + SerialPortType::Unknown => ("unknown", None, None, None, None, None), + }; + PortInfo { + device: p.port_name, + port_type: kind, + vid, + pid, + serial_number: sn, + manufacturer: mfr, + product: prod, + } + }) + .collect(); + + Json(json!({ "ports": listed })).into_response() +} + +// --------------------------------------------------------------------------- +// GET /stream?port=/dev/cu.usbmodemXXXX&baud=115200 — SSE of raw bytes +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct StreamQuery { + port: String, + baud: Option, +} + +async fn stream_port( + Query(q): Query, +) -> Result>>, Response> { + let baud = q.baud.unwrap_or(DEFAULT_BAUD); + let port_name = q.port.clone(); + debug!(port = %port_name, baud, "UART stream open"); + + // Open port in a blocking task; keep it in a thread for its lifetime. + let (tx, rx) = broadcast::channel::>(CHANNEL_CAPACITY); + let port_thread = std::thread::Builder::new() + .name(format!("uart-read-{}", port_name)) + .spawn(move || read_loop(port_name, baud, tx)); + + if let Err(e) = port_thread { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("thread spawn failed: {}", e) })), + ) + .into_response()); + } + + // Wrap broadcast rx into a stream; each item = one chunk of bytes. + let stream = BroadcastStream::new(rx) + .timeout(STREAM_IDLE_TIMEOUT) + .map(|item| { + let ev = match item { + Ok(Ok(bytes)) => Event::default().event("data").data(B64.encode(bytes)), + Ok(Err(BroadcastStreamRecvError::Lagged(n))) => { + Event::default().event("lag").data(n.to_string()) + } + Err(_elapsed) => Event::default().event("idle").data("30s no data"), + }; + Ok::<_, Infallible>(ev) + }); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +/// Blocking read loop — runs in a dedicated OS thread because `serialport` +/// is sync. Broadcasts every chunk to all SSE subscribers. +fn read_loop(port_name: String, baud: u32, tx: broadcast::Sender>) { + let mut port = match serialport::new(&port_name, baud) + .timeout(READ_POLL) + .open() + { + Ok(p) => p, + Err(e) => { + warn!(port = %port_name, "UART open failed: {}", e); + return; + } + }; + info!(port = %port_name, baud, "UART reader started"); + + let mut buf = vec![0u8; READ_CHUNK]; + loop { + // Stop when no more receivers exist (all clients disconnected). + if tx.receiver_count() == 0 { + debug!(port = %port_name, "UART reader stopping — no receivers"); + return; + } + match port.read(&mut buf) { + Ok(0) => { + std::thread::sleep(READ_POLL); + } + Ok(n) => { + let chunk = buf[..n].to_vec(); + let _ = tx.send(chunk); + } + Err(e) if e.kind() == ErrorKind::TimedOut => { + // Normal — no data in the polling window. + continue; + } + Err(e) => { + warn!(port = %port_name, "UART read error: {}", e); + return; + } + } + } +} + +// --------------------------------------------------------------------------- +// POST /write — one-shot byte write +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct WriteBody { + port: String, + baud: Option, + /// base64-encoded bytes to send. + data: String, +} + +#[derive(Serialize)] +struct WriteResult { + written: usize, +} + +async fn write_port(Json(body): Json) -> Response { + let bytes = match B64.decode(&body.data) { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("invalid base64: {}", e) })), + ) + .into_response(); + } + }; + let baud = body.baud.unwrap_or(DEFAULT_BAUD); + + let write_result = tokio::task::spawn_blocking(move || -> Result { + let mut port = serialport::new(&body.port, baud) + .timeout(Duration::from_secs(2)) + .open() + .map_err(|e| format!("open {}: {}", body.port, e))?; + port.write_all(&bytes) + .map_err(|e| format!("write: {}", e))?; + port.flush().map_err(|e| format!("flush: {}", e))?; + Ok(bytes.len()) + }) + .await; + + match write_result { + Ok(Ok(n)) => Json(WriteResult { written: n }).into_response(), + Ok(Err(e)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("join: {}", e) })), + ) + .into_response(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn router_disabled_without_token() { + // Ensure env var is not set — override any inherited value from CI. + std::env::remove_var(ENV_TOKEN); + assert!(router::<()>().is_none()); + } + + #[test] + fn router_enabled_with_token() { + std::env::set_var(ENV_TOKEN, "test-token-abc"); + assert!(router::<()>().is_some()); + std::env::remove_var(ENV_TOKEN); + } + + #[test] + fn base64_write_body_roundtrip() { + // Round-trip through the same base64 config the handler uses. + let raw: &[u8] = b"\r\nroot\r\nanalog\r\n"; + let encoded = B64.encode(raw); + let decoded = B64.decode(&encoded).unwrap(); + assert_eq!(decoded, raw); + } + + #[test] + fn control_chars_survive_base64() { + // Ctrl-C = 0x03, Ctrl-A = 0x01 — must not be mangled. + let raw: &[u8] = &[0x03, 0x01, 0x1b, b'[', b'B']; + let encoded = B64.encode(raw); + let decoded = B64.decode(&encoded).unwrap(); + assert_eq!(decoded, raw); + } + + #[test] + fn default_baud_is_115200() { + assert_eq!(DEFAULT_BAUD, 115_200); + } + + #[test] + fn env_var_name_is_stable() { + // If this ever changes, docs and tri-tunnel wiring must be updated too. + assert_eq!(ENV_TOKEN, "TRIOS_UART_TOKEN"); + } +} diff --git a/docs/UART_ENDPOINT.md b/docs/UART_ENDPOINT.md new file mode 100644 index 0000000000..dcbcb04da6 --- /dev/null +++ b/docs/UART_ENDPOINT.md @@ -0,0 +1,173 @@ +# UART Endpoint + +Remote access to the host's USB-serial adapters through `trios-server`, +intended for pairing with [tri-tunnel](https://github.com/gHashTag/tri-tunnel) +so that a cloud agent can drive a physical UART on the developer Mac. + +Anchor: `phi^2 + phi^-2 = 3`. + +## Threat model + +- Assumes `trios-server` is either bound to `127.0.0.1` or fronted by + `tri-tunnel` / Tailscale Funnel. In the latter case, the tailnet ACL provides + transport auth. `TRIOS_UART_TOKEN` is a defence-in-depth bearer on top. +- The UART endpoint uses a **separate** token from `TRIOS_API_KEY`. This is + deliberate: git access should not automatically grant serial-port access. +- If `TRIOS_UART_TOKEN` is unset, the router is not mounted at all + (fail-closed). The server logs `UART endpoint DISABLED` on startup. + +## Configuration + +```bash +# One-time: generate a strong token (macOS / Linux) +export TRIOS_UART_TOKEN="$(openssl rand -hex 32)" + +# Then start the server +cargo run -p trios-server +``` + +Look for one of these lines in the startup log: + +``` +INFO UART endpoint ENABLED at /api/uart (auth: TRIOS_UART_TOKEN) +WARN UART endpoint DISABLED — set TRIOS_UART_TOKEN to enable /api/uart/* +``` + +## Endpoints + +All endpoints require `Authorization: Bearer $TRIOS_UART_TOKEN`. The global +`TRIOS_API_KEY` check is bypassed for `/api/uart/*` — clients only need one +credential. + +### `GET /api/uart/ports` + +Enumerate available serial ports without opening them. + +```bash +curl -s http://127.0.0.1:9005/api/uart/ports \ + -H "Authorization: Bearer $TRIOS_UART_TOKEN" | jq +``` + +Response: + +```json +{ + "ports": [ + { + "device": "/dev/cu.usbmodem14201", + "port_type": "usb", + "vid": 1027, + "pid": 24597, + "serial_number": "FTBXYZ", + "manufacturer": "FTDI", + "product": "FT232R USB UART" + } + ] +} +``` + +### `GET /api/uart/stream?port=&baud=` + +Server-Sent Events stream of bytes read from the port. `baud` defaults to +115200. + +Event types: + +| Event | Payload | +|----------|-----------------------------------------------------| +| `data` | base64-encoded chunk of bytes read from the port | +| `lag` | integer — number of chunks dropped due to backpressure | +| `idle` | literal `"30s no data"` — sent after 30s of silence | +| `closed` | literal `"port closed"` — port reader stopped | + +Example client (curl in the raw): + +```bash +curl -N -s "http://127.0.0.1:9005/api/uart/stream?port=/dev/cu.usbmodem14201&baud=115200" \ + -H "Authorization: Bearer $TRIOS_UART_TOKEN" +``` + +Decoding one event in shell: + +```bash +# Pipe SSE through a small filter to decode `data:` payloads. +awk '/^data: / { print substr($0, 7) }' \ + | while read line; do echo -n "$line" | base64 -d; done +``` + +### `POST /api/uart/write` + +One-shot write. Body: + +```json +{ + "port": "/dev/cu.usbmodem14201", + "baud": 115200, + "data": "cm9vdApwYXNzd29yZAo=" +} +``` + +`data` must be base64. This is intentional: it lets control characters +(`Ctrl-C = 0x03`, `Ctrl-A = 0x01`, escape sequences) survive JSON transport +without shell-escaping games. + +```bash +# Send "root\n" then wait, then send "analog\n" +printf 'root\n' | base64 | xargs -I {} curl -sX POST \ + http://127.0.0.1:9005/api/uart/write \ + -H "Authorization: Bearer $TRIOS_UART_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"port":"/dev/cu.usbmodem14201","data":"'{}'"}' +``` + +Response: + +```json +{ "written": 5 } +``` + +## Failure modes + +| Symptom | Diagnosis | +|--------------------------------------------|----------------------------------------------------| +| `503 Service Unavailable` on `/api/uart/*` | `TRIOS_UART_TOKEN` unset — endpoint not mounted | +| `401 Unauthorized` | Wrong or missing `Authorization: Bearer ` | +| `500 Internal Server Error` with `open ...`| macOS blocked port access, or wrong device path | +| `data` event never arrives | Port opens but the device is not transmitting yet — try cold-power-cycling the target board | +| `idle` event repeats | Port healthy, target silent for 30s — expected between boot messages | +| `lag` event with large N | Client is reading SSE slower than port produces — reduce baud or fix the client | + +## Wiring with `tri-tunnel` + +Once the endpoint is up locally, expose it through Tailscale Funnel: + +```bash +# Assuming tri-tunnel already targets port 9005 (or 9105 in newer builds). +tri-tunnel start +``` + +Any tailnet-member client (including a cloud agent that installed `tailscaled` +via an auth-key) can now reach: + +``` +https://.tailXXXX.ts.net/api/uart/ports +``` + +## Not implemented on purpose + +- No **write-then-read** convenience endpoint. Compose `POST /write` + + `GET /stream` on the client. This keeps the server's state minimal and + concurrency easy to reason about. +- No **port sharing** between multiple SSE clients on the same device. + serialport-rs opens exclusively; if you need multi-viewer, run one broadcaster + process locally and put the fan-out in front of it. +- No **flow control settings** exposed (RTS/CTS/DTR). Default settings match + P201Mini U-Boot / Linux console. Extend the query params if a future target + needs otherwise. + +## References + +- [`serialport` crate v4](https://docs.rs/serialport/4/serialport/) +- [Axum SSE guide](https://docs.rs/axum/latest/axum/response/sse/index.html) +- [`docs/LOCAL_FLASH.md`](./LOCAL_FLASH.md) — the identical-image trap that + motivated remote UART access in the first place