//! HOST side of the NetDesk protocol — lets a viewer connect to and control //! THIS device. Built for the Android host (screen via MediaProjection + //! MediaCodec, input via the accessibility service), but transport-agnostic: //! it takes encoded H.264 access units on a channel and emits the viewer's //! input as events for the platform layer to inject. //! //! It mirrors [`crate::signaling::connect`] (the viewer) in reverse: //! reg.host -> session.begin -> session.challenge -> session.auth //! -> session.accept -> stream `CHAN_VIDEO` frames (E2E-sealed) //! Auth + E2E reuse [`crate::crypto`] (`verify_proof`, [`HostE2e`], //! `encrypt_frame`), proven to interoperate with the web/desktop/native viewers. use crate::crypto::{self, HostE2e}; use crate::error::{NdError, Result}; use crate::proto::{CHAN_AUDIO, CHAN_FILE, CHAN_FILE_END, CHAN_VIDEO, CHAN_VOICE}; use aes_gcm::Aes256Gcm; use rand::RngCore; use serde_json::{json, Value}; use tokio::sync::mpsc; fn install_crypto_provider() { use std::sync::Once; static ONCE: Once = Once::new(); ONCE.call_once(|| { let _ = rustls::crypto::ring::default_provider().install_default(); }); } /// Per-session permission switches the host ENFORCES (events for a disabled /// channel are never emitted to the platform layer, so a modified viewer can't /// bypass them). Advertised in `session.accept` `info.perms` with the same /// full-name keys the Python engine uses, so the web viewer's existing /// `info.perms.clipboard === false` checks light up unchanged. #[derive(Clone, Copy, Debug)] pub struct HostPerms { pub input: bool, pub clipboard: bool, pub files: bool, pub audio: bool, } impl Default for HostPerms { fn default() -> Self { Self { input: true, clipboard: true, files: true, audio: true } } } /// Host identity + unattended credentials (the fields the Python engine keeps in /// config.json). Generate fresh ones with [`crate::crypto::new_password_record`] /// + [`crate::crypto::gen_host_id`]. #[derive(Clone)] pub struct HostConfig { pub ws_url: String, pub host_id: String, pub name: String, /// base64 of the RAW salt bytes (sent in the challenge). pub salt_b64: String, pub iters: u32, /// PBKDF2 verifier (hex) — checked against the viewer's proof. pub verifier_hex: String, /// Current capture resolution advertised to the viewer. pub width: u32, pub height: u32, /// Whether this host streams system audio (Opus over CHAN_AUDIO). Advertised /// in session.accept so the viewer knows to expect audio. pub audio: bool, /// All display outputs as (index, width, height); `index` is what the viewer /// echoes in `monitor.switch{mon}`. Empty falls back to a single monitor. pub monitors: Vec<(u32, u32, u32)>, /// The currently-captured monitor index (advertised as `info.mon`). pub monitor: u32, /// Host-enforced per-session permissions (see [`HostPerms`]). Defaults to /// everything allowed — exactly the pre-perms behavior. pub perms: HostPerms, /// Optional TOTP (RFC 6238) 2FA secret (base32). When set, the host /// advertises `totp:true` in the challenge and requires a valid code in /// `session.auth` before accepting. Empty disables 2FA. pub totp_secret: String, /// Approve-before-connect: when true, a viewer that passes password auth is /// HELD until the platform answers [`HostEvent::ApproveRequest`] with /// [`HostControl::ApproveReply`]. No answer within ~20s denies (the relay /// frees a pre-accept session slot at 25s, so the window must beat that). pub approve: bool, } /// One encoded video access unit from the platform encoder. Clone so one /// capture+encode can fan out to multiple simultaneous viewer sessions (each /// seals it with its own E2E key). #[derive(Clone)] pub struct HostFrame { pub data: Vec, // codec bitstream (H.264 Annex-B, or VP9) pub keyframe: bool, /// Codec id for this frame (see `proto::VideoCodec`): 0 = H.264, 1 = VP9. /// Packed into the frame's flags byte so the viewer selects the decoder. pub codec: u8, } /// Outbound control the platform layer pushes to the connected viewer (sent as /// plaintext JSON over the relay, like the rest of the control channel). The /// video stream stays E2E-encrypted; these mirror the existing protocol. #[derive(Clone, Debug)] pub enum HostControl { /// Resolution / stream info changed (e.g. the quality scale lever) — the /// viewer trusts `session.info` for the authoritative frame size. Info { w: u32, h: u32 }, /// The host's OS clipboard changed; push it to the viewer (origin "host"). Clipboard { text: String }, /// The host's OS clipboard now holds an IMAGE; push it to the viewer as PNG /// bytes (origin "host"). Sent as `clip.image` with base64 PNG. ClipboardImage { png: Vec }, /// A ready-made control JSON object to send verbatim (e.g. a `sys.res` /// reply the platform built, already carrying its `sid`). Raw { json: String }, /// Remote cursor position in frame-pixel coordinates (`hidden` = not shown). Cursor { id: u64, x: i32, y: i32, hidden: bool }, /// Remote cursor shape: a CSS name for a standard system cursor, and/or a /// PNG bitmap (+ size/hotspot) for an app-custom cursor so it renders on the /// viewer instead of falling back to the default pointer. `png` empty = none. CursorShape { id: u64, css: Option, png: Vec, w: u32, h: u32, hx: i32, hy: i32, }, /// One download data chunk (host→viewer, 0xf1 frame): fid + byte offset + /// bytes. Sent E2E-sealed exactly like video. FileData { fid: u32, offset: u64, data: Vec }, /// One Opus audio packet (system-audio capture): sent as `[CHAN_AUDIO][opus]`, /// E2E-sealed like video, over the reliable Ctl lane. AudioFrame(Vec), /// Raw TCP-tunnel bytes from the host's local socket to relay to the tunnel /// peer: sent as `[0xF3][tid u32 LE][data]`, NOT E2E-sealed (the server /// relays it verbatim to the portal-authed tunnel client). TunnelData { tid: u32, data: Vec }, /// The host's local TCP socket for `tid` closed: drop tracking + tell the /// server (`tunnel.closed`) so it can close the client socket. TunnelClosed { tid: u32 }, /// Terminal `tid` produced output — send it to the viewer (`term.data`). TermData { tid: u32, data: String }, /// Terminal `tid`'s shell exited — tell the viewer (`term.exit`). TermExit { tid: u32 }, /// The platform's answer to [`HostEvent::ApproveRequest`]: accept finalizes /// the held session (session.accept + ViewerConnected); reject denies it. /// A reply whose `sid` no longer matches the held session is ignored (e.g. /// the engine's own 20s timeout already denied it). ApproveReply { sid: String, accept: bool }, } /// Events the platform layer reacts to (input to inject, keyframe requests, …). #[derive(Clone, Debug)] pub enum HostEvent { /// Registered with the server under `host_id`; now reachable. Registered, /// A viewer authenticated and the session is live. `vp9` is the viewer's /// advertised VP9 decode capability (from `session.begin`); the host only /// sends VP9 frames when it is true. ViewerConnected { e2e: bool, vp9: bool, i444: bool }, /// The viewer updated its capabilities post-connect (e.g. VP9 decode), sent /// as a `viewer.caps` control message since the server strips the handshake /// advertisement. Updates the live codec gate. ViewerCaps { vp9: bool, i444: bool }, /// The viewer ended the session (host stays registered for the next one). ViewerLeft, /// Pointer in the captured image's pixel space (buttons = bitmask, wheel = rows). Mouse { x: i32, y: i32, buttons: u32, wheel: f32 }, /// Relative pointer motion (game / pointer-lock mode): dx/dy deltas. MouseRel { dx: i32, dy: i32, buttons: u32, wheel: f32 }, /// Typed text for translate / international-keyboard mode (Unicode). Text { text: String }, /// Key: DOM-style code (e.g. "KeyA", "Enter", "Backspace") + down/up. Key { code: String, down: bool }, /// The viewer pushed its local clipboard text; apply it to the host OS /// clipboard (RustDesk-style clipboard sync, viewer -> host). Clipboard { text: String }, /// The viewer pushed an IMAGE to apply to the host clipboard (PNG bytes). ClipboardImage { png: Vec }, /// A `sys.cmd` control (process list/kill, block-input, wallpaper, power, /// run, …). The raw JSON is handed to the platform, which acts and replies. Sys { json: String }, /// Collaborative annotation: a viewer stroke/clear for the host overlay. Annot { json: String }, /// A `file.*` control (roots/list/get/put). The platform enumerates or /// starts the transfer and replies via [`HostControl`]. File { json: String }, /// Open an interactive terminal (ConPTY) `tid`; stream its output back via /// [`HostControl::TermData`] and signal exit with [`HostControl::TermExit`]. TermOpen { tid: u32 }, /// Keystrokes / command bytes for terminal `tid` (write to its ConPTY input). TermInput { tid: u32, data: String }, /// Resize terminal `tid` to cols×rows (ResizePseudoConsole). TermResize { tid: u32, cols: u32, rows: u32 }, /// Close terminal `tid` (kill its shell). TermClose { tid: u32 }, /// One decrypted upload data chunk (viewer→host, 0xf2 frame) to write. FileChunk { fid: u32, offset: u64, data: Vec }, /// One Opus packet of the viewer's microphone (voice call, `[CHAN_VOICE][opus]`). /// The platform decodes it and plays it on the host's default render device. Voice { opus: Vec }, /// The viewer requested a fresh keyframe — feed one next. KeyframeRequested, /// The viewer asked to capture a different monitor (`monitor.switch{mon}`). SwitchMonitor { mon: u32 }, /// The viewer changed quality targets (manual lever — the adaptive ceiling). Quality { fps: u32, bitrate: u32, scale: f32 }, /// A network telemetry sample measured by the host (round-trip time to the /// viewer + packet loss), driving the adaptive QoS controller. NetStat { rtt_ms: u32, loss: f32 }, /// The viewer selected the host audio source: 0 = off, 1 = system (what you /// hear), 2 = microphone. AudioSource { src: u8 }, /// The viewer pinned the video codec for this session (e.g. while recording, /// which muxes H.264 only): -1 = auto (release the pin), 0 = H.264, 1 = VP9. /// The capture loop forces this codec instead of auto-selecting by link. PinCodec { codec: i32 }, /// Approve-before-connect (`cfg.approve`): a viewer passed password auth and /// is HELD pending the platform's Accept/Reject decision — show a prompt and /// answer with [`HostControl::ApproveReply`]. `peer`/`user` are what the /// relay reported in `session.begin` (viewer IP + account), for display. ApproveRequest { sid: String, peer: String, user: String }, /// The viewer toggled host-enforced view-only for this session: while on, /// the engine drops all mouse/key input before it reaches the platform. /// Platforms should release any held buttons when it turns on. ViewOnly { on: bool }, /// The server (portal-authed tunnel client) asks this host to open a TCP /// tunnel to `thost:port`. Independent of any viewer session. The platform /// opens the socket and replies `tunnel.ok`/`tunnel.err` via HostControl::Raw. TunnelOpen { tid: u32, thost: String, port: u16 }, /// Close the TCP tunnel `tid` (the tunnel client disconnected). TunnelClose { tid: u32 }, /// Raw bytes from the tunnel peer to write to the host's local TCP socket. TunnelData { tid: u32, data: Vec }, /// The host link ended (socket closed / error). The caller should reconnect. Ended { reason: String }, } fn gen_nonce_hex() -> String { let mut b = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut b); hex::encode(b) // 32 hex chars, matching secrets.token_hex(16) } /// Build the `session.accept` info object: resolution/codec hints, the REAL /// enforced permission set (full-name keys, matching the Python engine so the /// web viewer's `info.perms.*` checks just work), and capability flags. fn build_info(cfg: &HostConfig, e2e: bool) -> Value { let monitors: Vec = if cfg.monitors.is_empty() { vec![json!({"i": cfg.monitor, "w": cfg.width, "h": cfg.height})] } else { cfg.monitors .iter() .map(|(i, w, h)| { // u32::MAX is the all-monitors sentinel; advertise it as -1 // (MonitorInfo.i is i32, so u32::MAX could not deserialize). let iv: i64 = if *i == u32::MAX { -1 } else { *i as i64 }; json!({"i": iv, "w": w, "h": h}) }) .collect() }; let mut info = json!({ "mon": cfg.monitor, "monitors": monitors, "w": cfg.width, "h": cfg.height, "fps": 30, "bitrate": 6000, "e2e": e2e, "audio": cfg.audio && cfg.perms.audio, "host_name": cfg.name, "os": "windows", // The ENFORCED per-session permissions — not a suggestion: the engine // never emits events for a disabled channel (see the handler gates). "perms": { "input": cfg.perms.input, "clipboard": cfg.perms.clipboard, "files": cfg.perms.files, "audio": cfg.perms.audio, }, "version": "native-host", // Privacy mode is off at connect; live changes are reported via // sys.res {kind:"privacy", on:} when the viewer toggles it. "privacy_active": false, // File-range capability: this host supports ranged file.get{offset,len} // + file.ack flow control, so viewers may download in parallel segments // (IDM-style) with resume. "fr": true, }); // Advertise native single-channel P2P so a native viewer only upgrades // against a native host; web/Python hosts omit this and the viewer stays on // the relay (safe default-on). NETDESK_P2P_FORCE_WEB suppresses it so a // native host presents as a web host — used to e2e the web answerer path. if std::env::var("NETDESK_P2P_FORCE_WEB").is_err() { info["p2p"] = json!("native"); } info } /// Run the host until `frames` closes or the socket drops. Serves one viewer at /// a time (a second `session.begin` while busy is denied). Returns when the link /// ends; the caller reconnects for resilience. pub async fn run_host( cfg: HostConfig, frames: mpsc::UnboundedReceiver, events: mpsc::UnboundedSender, control: mpsc::UnboundedReceiver, ) -> Result<()> { install_crypto_provider(); let transport = crate::transport::WsRelay::connect(&cfg.ws_url).await?; // Host answers a viewer-initiated P2P upgrade when NETDESK_P2P is on; the // multiplexer is a no-op passthrough to the relay otherwise. if crate::p2p::p2p_enabled() { let api = std::sync::Arc::new( crate::rtc::build_api().map_err(|e| NdError::Connect(e.to_string()))?, ); let mux = crate::p2p::upgrade_relay(Box::new(transport), crate::p2p::P2pRole::Answerer, api); run_host_with(cfg, frames, events, control, Box::new(mux)).await } else { run_host_with(cfg, frames, events, control, Box::new(transport)).await } } /// Run the host over any [`crate::transport::Transport`]. `run_host` is the thin /// WSS wrapper over this. pub async fn run_host_with( cfg: HostConfig, mut frames: mpsc::UnboundedReceiver, events: mpsc::UnboundedSender, mut control: mpsc::UnboundedReceiver, mut transport: Box, ) -> Result<()> { use crate::transport::TransportMsg; // Register as the host for `host_id` (takeover reclaims an idle stale holder; // the server never displaces an ACTIVE session). let reg = json!({ "t": "reg.host", "id": cfg.host_id, "name": cfg.name, "ver": "native-host", "key": "", "token": "", "takeover": true, }); transport.send_text(reg.to_string()) .await .map_err(|e| NdError::Connect(e.to_string()))?; let _ = events.send(HostEvent::Registered); // Handshake + session state. let mut pending: Option<(String, String, HostE2e)> = None; // (sid, nonce, ecdh) let mut cipher: Option = None; let mut viewer_vp9 = false; // viewer's advertised VP9 decode capability let mut viewer_i444 = false; // viewer can decode VP9 profile-1 4:4:4 (crisp text) let mut active_sid: Option = None; // Approve-before-connect: a password-authenticated viewer HELD for the // platform's Accept/Reject (sid, derived session cipher, deadline). let mut approving: Option<(String, Option, std::time::Instant)> = None; // Viewer identity the relay attached to session.begin (IP + account), shown // in the approve prompt. let mut peer_info: (String, String) = (String::new(), String::new()); // Host-enforced view-only for the CURRENT session (viewer-requested via the // `view.only` control message; input is dropped here, not just in the UI). let mut view_only = false; // One log line per session when input is dropped, not one per event. let mut warned_input = false; // Open TCP-tunnel ids (server-relayed, portal-authed, independent of a video // session). Tracked so a raw `[0xF3][tid]` frame is only routed to the tunnel // when that tid is actually open — otherwise an E2E session frame whose random // nonce starts with 0xF3 could be misread as tunnel data. let mut tunnel_tids: std::collections::HashSet = std::collections::HashSet::new(); // Outbound control channel stays polled until its sender is dropped. let mut control_open = true; // Network RTT probe: once a second, while a viewer is connected, send a // net.ping; the viewer echoes net.pong and we measure the round trip. This // drives the adaptive QoS controller. The ctl path shares the P2P SCTP // association / the relay socket, so it rises under congestion. let mut ping_tick = tokio::time::interval(std::time::Duration::from_millis(1000)); ping_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut ping_seq: u64 = 0; let mut ping_inflight: Option<(u64, std::time::Instant)> = None; // Adaptive-QoS telemetry: a rolling-baseline RTT (delay above the link's // inherent floor, not absolute RTT) + a real packet-loss ratio from // ping/pong hit-miss. The viewer's frame-arrival jitter rides in net.pong. let mut rttcalc = crate::netqos::RttCalc::new(); let mut losscalc = crate::netqos::LossCalc::new(); loop { tokio::select! { _ = ping_tick.tick() => { if let Some(sid) = active_sid.as_deref() { // A probe still in flight from the previous tick never got a // pong -> count it as lost and let the loss drive the QoS. if ping_inflight.is_some() { let loss = losscalc.record(true); let _ = events.send(HostEvent::NetStat { rtt_ms: 0, loss }); } // Only one probe in flight; a dropped pong just re-probes next tick. ping_seq = ping_seq.wrapping_add(1); ping_inflight = Some((ping_seq, std::time::Instant::now())); let _ = transport.send_text( json!({"t":"net.ping","sid":sid,"id":ping_seq}).to_string()).await; } // Approve-before-connect timeout: nobody answered the prompt in // time — deny so the viewer gets a clear reason (and the relay's // own 25s pre-accept reaper never has to fire). if let Some((sid, _, deadline)) = &approving { if std::time::Instant::now() >= *deadline { let sid = sid.clone(); approving = None; log::info!("approve: timed out — denying sid={sid}"); let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; } } } inbound = transport.recv() => { let msg = match inbound { Some(Ok(m)) => m, Some(Err(e)) => { let _ = events.send(HostEvent::Ended { reason: e.to_string() }); break; } None => { let _ = events.send(HostEvent::Ended { reason: "socket closed".into() }); break; } }; match msg { TransportMsg::Text(txt) => { let v: Value = match serde_json::from_str(&txt) { Ok(v) => v, Err(_) => continue }; match v.get("t").and_then(|x| x.as_str()).unwrap_or("") { "session.begin" => { let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); let sid = if sid.is_empty() { gen_nonce_hex() } else { sid }; // Remember whether this viewer can decode VP9 (+ 4:4:4). viewer_vp9 = v.get("vp9").and_then(|x| x.as_bool()).unwrap_or(false); viewer_i444 = v.get("i444").and_then(|x| x.as_bool()).unwrap_or(false); // Viewer identity the relay attached (IP + account) — shown // in the approve prompt. peer_info = ( v.get("peer").and_then(|x| x.as_str()).unwrap_or("").to_string(), v.get("user").and_then(|x| x.as_str()).unwrap_or("").to_string(), ); if active_sid.is_some() || approving.is_some() { let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"host busy"}).to_string()).await; } else { let he = HostE2e::new(); let nonce = gen_nonce_hex(); let ch = json!({ "t": "session.challenge", "sid": sid, "salt": cfg.salt_b64, "iters": cfg.iters, "nonce": nonce, "e2e": true, "totp": !cfg.totp_secret.is_empty(), "pub": he.our_pub_b64, }); pending = Some((sid.clone(), nonce, he)); let _ = transport.send_text(ch.to_string()).await; } } "session.auth" => { let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string(); let proof = v.get("proof").and_then(|x| x.as_str()).unwrap_or(""); match pending.take() { Some((psid, nonce, he)) if psid == sid => { // Channel-bound proof (MITM hardening): if the viewer // sent `proof_bind`, it must verify against OUR ephemeral // pubkey — a relay that swapped the pub in the challenge // can't satisfy this, so a key swap is detected and denied. // When absent (legacy viewer / non-E2E), fall back to the // plain proof so nothing regresses. let bind_ok = match v.get("proof_bind").and_then(|x| x.as_str()) { Some(pb) => { let ok = crypto::verify_proof_bound( &nonce, &cfg.verifier_hex, &he.our_pub_b64, pb); if !ok { log::warn!("auth: channel-bound proof MISMATCH (possible relay key-swap) sid={sid}"); } ok } None => { log::debug!("auth: no proof_bind (legacy/non-E2E viewer) sid={sid}"); true } }; if !bind_ok { let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"key verification failed"}).to_string()).await; } else if !crypto::verify_proof(&nonce, &cfg.verifier_hex, proof) { let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"wrong password"}).to_string()).await; } else if !cfg.totp_secret.is_empty() && !crypto::totp_verify( &cfg.totp_secret, v.get("totp").and_then(|x| x.as_str()).unwrap_or(""), 1, ) { log::info!("auth: bad 2FA code sid={sid}"); let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"bad 2FA code"}).to_string()).await; } else { let derived = v.get("pub").and_then(|x| x.as_str()) .and_then(|vp| he.derive(vp).ok()); // VP9 capability rides session.auth (the server // strips session.begin's custom fields but relays // auth). Auth's value wins if present. if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { viewer_vp9 = b; } if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { viewer_i444 = b; } if cfg.approve { // Hold the session for the platform's Accept/ // Reject prompt. 20s beats the relay's 25s // pre-accept reaper, so the deny is always ours. log::info!( "approve: viewer {} {} passed auth — awaiting accept (sid={sid})", peer_info.0, peer_info.1 ); approving = Some(( sid.clone(), derived, std::time::Instant::now() + std::time::Duration::from_secs(20), )); let _ = events.send(HostEvent::ApproveRequest { sid, peer: peer_info.0.clone(), user: peer_info.1.clone(), }); } else { cipher = derived; active_sid = Some(sid.clone()); view_only = false; warned_input = false; let info = build_info(&cfg, cipher.is_some()); let _ = transport.send_text( json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; let _ = events.send(HostEvent::ViewerConnected { e2e: cipher.is_some(), vp9: viewer_vp9, i444: viewer_i444 }); } } } _ => { let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"no pending session"}).to_string()).await; } } } "i.mouse" if active_sid.is_some() => { // HOST-ENFORCED gate: a disabled input perm (or // host-enforced view-only) drops the event here, // so a modified viewer can't inject anyway. if !cfg.perms.input || view_only { if !warned_input { log::info!( "perm: input blocked (perms.input={}, view_only={view_only})", cfg.perms.input ); warned_input = true; } continue; } let _ = events.send(HostEvent::Mouse { x: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0) as i32, y: v.get("y").and_then(|x| x.as_i64()).unwrap_or(0) as i32, buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, }); } "i.mouse.rel" if active_sid.is_some() => { // Same HOST-ENFORCED input gate as i.mouse. if !cfg.perms.input || view_only { if !warned_input { log::info!( "perm: input blocked (perms.input={}, view_only={view_only})", cfg.perms.input ); warned_input = true; } continue; } let _ = events.send(HostEvent::MouseRel { dx: v.get("dx").and_then(|x| x.as_i64()).unwrap_or(0) as i32, dy: v.get("dy").and_then(|x| x.as_i64()).unwrap_or(0) as i32, buttons: v.get("b").and_then(|x| x.as_u64()).unwrap_or(0) as u32, wheel: v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, }); } "i.text" if active_sid.is_some() => { // Same HOST-ENFORCED input gate as i.mouse / i.key. if !cfg.perms.input || view_only { if !warned_input { log::info!( "perm: input blocked (perms.input={}, view_only={view_only})", cfg.perms.input ); warned_input = true; } continue; } if let Some(t) = v.get("text").and_then(|x| x.as_str()) { if !t.is_empty() { let _ = events.send(HostEvent::Text { text: t.to_string() }); } } } "i.key" if active_sid.is_some() => { if !cfg.perms.input || view_only { if !warned_input { log::info!( "perm: input blocked (perms.input={}, view_only={view_only})", cfg.perms.input ); warned_input = true; } continue; } let code = v.get("c").and_then(|x| x.as_str()).unwrap_or("").to_string(); if !code.is_empty() { let _ = events.send(HostEvent::Key { code, down: v.get("d").and_then(|x| x.as_bool()).unwrap_or(false), }); } } // Host-enforced view-only toggle from the viewer: input // is dropped in the gates above while on (defense in // depth under the viewer's own local toggle). "view.only" if active_sid.is_some() => { let on = v.get("on").and_then(|x| x.as_bool()).unwrap_or(false); if view_only != on { view_only = on; warned_input = false; log::info!("view-only (host-enforced): {}", if on { "ON" } else { "off" }); let _ = events.send(HostEvent::ViewOnly { on }); } } "clip.set" if active_sid.is_some() => { // Only apply viewer-originated clips (the host's // own clipboard is pushed the other way). if !cfg.perms.clipboard { log::info!("perm: clipboard push from viewer blocked"); continue; } if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { if let Some(text) = v.get("text").and_then(|x| x.as_str()) { let _ = events.send(HostEvent::Clipboard { text: text.to_string(), }); } } } "clip.image" if active_sid.is_some() => { if !cfg.perms.clipboard { log::info!("perm: clipboard image from viewer blocked"); continue; } if v.get("origin").and_then(|x| x.as_str()) == Some("viewer") { if let Some(b64) = v.get("png").and_then(|x| x.as_str()) { use base64::Engine as _; if let Ok(png) = base64::engine::general_purpose::STANDARD.decode(b64) { let _ = events.send(HostEvent::ClipboardImage { png }); } } } } "annot" if active_sid.is_some() => { // Drawing on the host screen is an interaction, // so it rides the same input/view-only gate. if !cfg.perms.input || view_only { continue; } let _ = events.send(HostEvent::Annot { json: txt.clone() }); } "sys.cmd" if active_sid.is_some() => { let _ = events.send(HostEvent::Sys { json: txt.clone() }); } "term.open" if active_sid.is_some() => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let _ = events.send(HostEvent::TermOpen { tid }); } "term.data" if active_sid.is_some() => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let data = v.get("d").and_then(|x| x.as_str()).unwrap_or("").to_string(); let _ = events.send(HostEvent::TermInput { tid, data }); } "term.resize" if active_sid.is_some() => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let cols = v.get("cols").and_then(|x| x.as_u64()).unwrap_or(80) as u32; let rows = v.get("rows").and_then(|x| x.as_u64()).unwrap_or(25) as u32; let _ = events.send(HostEvent::TermResize { tid, cols, rows }); } "term.close" if active_sid.is_some() => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let _ = events.send(HostEvent::TermClose { tid }); } "file.roots" | "file.list" | "file.walk" | "file.get" | "file.put" if active_sid.is_some() => { if !cfg.perms.files { log::info!("perm: file request blocked"); let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or(""); let _ = transport.send_text(json!({ "t":"file.err","sid":sid, "msg":"file transfer is disabled on this host" }).to_string()).await; continue; } let _ = events.send(HostEvent::File { json: txt.clone() }); } "net.pong" => { let id = v.get("id").and_then(|x| x.as_u64()).unwrap_or(0); if let Some((pid, sent)) = ping_inflight { if pid == id { let rtt_ms = sent.elapsed().as_millis().min(60_000) as u32; ping_inflight = None; // Feed QoS the delay ABOVE the rolling baseline // (not the absolute RTT), folded with the // viewer's reported frame-arrival jitter, plus // the real loss ratio. let delay = rttcalc.add(rtt_ms); let jit = v.get("jit").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let loss = losscalc.record(false); log::debug!( "netstat: rtt={rtt_ms}ms baseline_delay={delay}ms jit={jit}ms loss={loss:.2} -> qos_delay={}ms", delay.max(jit) ); let _ = events.send(HostEvent::NetStat { rtt_ms: delay.max(jit), loss, }); } } } // Post-connect capability advertisement. The server // strips custom handshake fields, so the viewer sends // its VP9 decode capability here (relayed by sid like // any input message). Enables auto-VP9 on slow links. "viewer.caps" => { let mut changed = false; if let Some(b) = v.get("vp9").and_then(|x| x.as_bool()) { viewer_vp9 = b; changed = true; } if let Some(b) = v.get("i444").and_then(|x| x.as_bool()) { viewer_i444 = b; changed = true; } if changed { let _ = events.send(HostEvent::ViewerCaps { vp9: viewer_vp9, i444: viewer_i444 }); } } "keyframe" => { let _ = events.send(HostEvent::KeyframeRequested); } "audio.set" if active_sid.is_some() => { let src = v.get("src").and_then(|x| x.as_u64()).unwrap_or(0) as u8; // "off" is always allowed; starting capture needs the perm. if src != 0 && !cfg.perms.audio { log::info!("perm: audio blocked"); continue; } let _ = events.send(HostEvent::AudioSource { src }); } "set.codec" if active_sid.is_some() => { // Viewer pins the codec (e.g. H.264 while recording). let codec = v.get("codec").and_then(|x| x.as_i64()).unwrap_or(-1) as i32; let _ = events.send(HostEvent::PinCodec { codec }); } "monitor.switch" if active_sid.is_some() => { if let Some(mon) = v.get("mon").and_then(|x| x.as_i64()) { // -1 (any negative) selects the all-monitors virtual // desktop (capture::MON_ALL == u32::MAX on the host). let mon = if mon < 0 { u32::MAX } else { mon as u32 }; let _ = events.send(HostEvent::SwitchMonitor { mon }); } } "set.quality" => { let _ = events.send(HostEvent::Quality { fps: v.get("fps").and_then(|x| x.as_u64()).unwrap_or(30) as u32, bitrate: v.get("bitrate").and_then(|x| x.as_u64()).unwrap_or(6000) as u32, scale: v.get("scale").and_then(|x| x.as_f64()).unwrap_or(1.0) as f32, }); } // TCP tunnels live OUTSIDE the video session (server // portal-gated), so they are handled without an active_sid. "tunnel.open" => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let thost = v.get("thost").and_then(|x| x.as_str()) .unwrap_or("127.0.0.1").to_string(); let port = v.get("port").and_then(|x| x.as_u64()).unwrap_or(0) as u16; tunnel_tids.insert(tid); let _ = events.send(HostEvent::TunnelOpen { tid, thost, port }); } "tunnel.close" => { let tid = v.get("tid").and_then(|x| x.as_u64()).unwrap_or(0) as u32; tunnel_tids.remove(&tid); let _ = events.send(HostEvent::TunnelClose { tid }); } "session.end" => { active_sid = None; cipher = None; pending = None; approving = None; view_only = false; let _ = events.send(HostEvent::ViewerLeft); } _ => {} } } TransportMsg::Binary(buf) => { // Raw TCP-tunnel frame [0xF3][tid u32 LE][payload] — server- // relayed, NOT E2E-sealed. Routed only when tid is an open // tunnel, so it can't be confused with an E2E session frame // whose random nonce happens to start with 0xF3. if buf.len() >= 5 && buf[0] == 0xF3 { let tid = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]); if tunnel_tids.contains(&tid) { let _ = events.send(HostEvent::TunnelData { tid, data: buf[5..].to_vec(), }); continue; } } // Viewer->host binary is an upload data frame, E2E-sealed // like video: decrypt, then [0xf2][fid u32][offset u64][data]. if active_sid.is_some() { let plain = match &cipher { Some(c) => match crypto::decrypt_frame(c, &buf) { Ok(p) => p, Err(_) => continue, }, None => buf, }; if plain.len() >= 13 && (plain[0] == CHAN_FILE_END || plain[0] == CHAN_FILE) { if !cfg.perms.files { log::debug!("perm: upload chunk dropped"); continue; } let fid = u32::from_le_bytes( [plain[1], plain[2], plain[3], plain[4]]); let offset = u64::from_le_bytes([ plain[5], plain[6], plain[7], plain[8], plain[9], plain[10], plain[11], plain[12], ]); let _ = events.send(HostEvent::FileChunk { fid, offset, data: plain[13..].to_vec(), }); } else if plain.len() >= 2 && plain[0] == CHAN_VOICE { // Viewer microphone (voice call): [CHAN_VOICE][opus]. // Voice rides the audio permission. if !cfg.perms.audio { log::debug!("perm: voice packet dropped"); continue; } let _ = events.send(HostEvent::Voice { opus: plain[1..].to_vec(), }); } } } } } frame = frames.recv() => { match frame { Some(f) => { if active_sid.is_some() { // plaintext = CHAN_VIDEO || flags || bitstream, then // the WHOLE thing is E2E-sealed (viewer decrypts, reads // plain[0]=channel, plain[1]=flags, plain[2..]=bitstream). // flags: bit0 = keyframe, bits1-3 = codec id (0=H.264, // 1=VP9) — the viewer picks the matching decoder. let mut plain = Vec::with_capacity(2 + f.data.len()); plain.push(CHAN_VIDEO); plain.push(u8::from(f.keyframe) | (f.codec << 1)); plain.extend_from_slice(&f.data); let wire = match &cipher { Some(c) => match crypto::encrypt_frame(c, &plain) { Ok(w) => w, Err(_) => continue }, None => plain, }; // Video -> Media lane (drop-tolerant: a late frame is // worse than a dropped one). if transport.send_binary_lane(wire, crate::transport::Lane::Media).await.is_err() { break; } } } None => break, // encoder stopped -> end the host run } } ctl = control.recv(), if control_open => { match ctl { // The relay routes host->viewer control by `sid`; messages // without the active sid are dropped, so always include it. Some(HostControl::Info { w, h }) => { if let Some(sid) = active_sid.as_deref() { let _ = transport.send_text( json!({"t":"session.info","sid":sid,"info":{"w":w,"h":h}}).to_string()).await; } } Some(HostControl::Clipboard { text }) => { if let Some(sid) = active_sid.as_deref() { let _ = transport.send_text( json!({"t":"clip.set","sid":sid,"text":text,"origin":"host"}).to_string()).await; } } Some(HostControl::ClipboardImage { png }) => { if let Some(sid) = active_sid.as_deref() { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(&png); let _ = transport.send_text( json!({"t":"clip.image","sid":sid,"png":b64,"origin":"host"}).to_string()).await; } } Some(HostControl::Raw { json }) => { // Already a complete control object (carries its own sid). let _ = transport.send_text(json).await; } Some(HostControl::TunnelData { tid, data }) => { // Raw tunnel bytes to the server: [0xF3][tid LE][data], NOT // E2E-sealed (the server relays it verbatim). Over the relay // (reliable), never the P2P media lane. let mut frame = Vec::with_capacity(5 + data.len()); frame.push(0xF3); frame.extend_from_slice(&tid.to_le_bytes()); frame.extend_from_slice(&data); if transport.send_binary(frame).await.is_err() { break; } } Some(HostControl::TunnelClosed { tid }) => { tunnel_tids.remove(&tid); let _ = transport.send_text( json!({"t":"tunnel.closed","tid":tid}).to_string()).await; } Some(HostControl::TermData { tid, data }) => { if let Some(sid) = active_sid.as_deref() { let _ = transport.send_text( json!({"t":"term.data","sid":sid,"tid":tid,"d":data}).to_string()).await; } } Some(HostControl::TermExit { tid }) => { if let Some(sid) = active_sid.as_deref() { let _ = transport.send_text( json!({"t":"term.exit","sid":sid,"tid":tid}).to_string()).await; } } Some(HostControl::ApproveReply { sid, accept }) => { // Finalize (or deny) the session held for approve-before- // connect. A stale reply (sid mismatch — e.g. the 20s // timeout already denied it) is ignored. let matches = approving.as_ref().map(|(s, _, _)| s == &sid).unwrap_or(false); if matches { let (sid, derived, _) = approving.take().unwrap(); if accept { log::info!("approve: ACCEPTED sid={sid}"); cipher = derived; active_sid = Some(sid.clone()); view_only = false; warned_input = false; let info = build_info(&cfg, cipher.is_some()); let _ = transport.send_text( json!({"t":"session.accept","sid":sid,"info":info}).to_string()).await; let _ = events.send(HostEvent::ViewerConnected { e2e: cipher.is_some(), vp9: viewer_vp9, i444: viewer_i444, }); } else { log::info!("approve: REJECTED sid={sid}"); let _ = transport.send_text( json!({"t":"session.deny","sid":sid,"reason":"not accepted by host"}).to_string()).await; } } } Some(HostControl::Cursor { id, x, y, hidden }) => { if let Some(sid) = active_sid.as_deref() { let _ = transport.send_text( json!({"t":"cursor","sid":sid,"id":id,"x":x,"y":y,"hidden":hidden}).to_string()).await; } } Some(HostControl::CursorShape { id, css, png, w, h, hx, hy }) => { if let Some(sid) = active_sid.as_deref() { use base64::Engine as _; let mut msg = json!({"t":"cursor.shape","sid":sid,"id":id}); if let Some(css) = css { msg["css"] = json!(css); } if !png.is_empty() { msg["png"] = json!(base64::engine::general_purpose::STANDARD.encode(&png)); msg["w"] = json!(w); msg["h"] = json!(h); msg["hx"] = json!(hx); msg["hy"] = json!(hy); } let _ = transport.send_text(msg.to_string()).await; } } Some(HostControl::FileData { fid, offset, data }) => { // Download chunk host->viewer: [0xf1][fid][offset][data], // E2E-sealed like video. if active_sid.is_some() { let mut plain = Vec::with_capacity(13 + data.len()); plain.push(CHAN_FILE); plain.extend_from_slice(&fid.to_le_bytes()); plain.extend_from_slice(&offset.to_le_bytes()); plain.extend_from_slice(&data); let wire = match &cipher { Some(c) => match crypto::encrypt_frame(c, &plain) { Ok(w) => w, Err(_) => continue, }, None => plain, }; // File download chunk -> Ctl lane (must be reliable). if transport .send_binary_lane(wire, crate::transport::Lane::Ctl) .await .is_err() { break; } } } Some(HostControl::AudioFrame(opus)) => { // [CHAN_AUDIO][opus], E2E-sealed like video. Reliable Ctl // lane: audio dropouts are more jarring than a late video // frame, and packets are tiny (~50 B at 50/s). if active_sid.is_some() { let mut plain = Vec::with_capacity(1 + opus.len()); plain.push(CHAN_AUDIO); plain.extend_from_slice(&opus); let wire = match &cipher { Some(c) => match crypto::encrypt_frame(c, &plain) { Ok(w) => w, Err(_) => continue, }, None => plain, }; if transport .send_binary_lane(wire, crate::transport::Lane::Ctl) .await .is_err() { break; } } } None => { control_open = false; } // sender dropped; stop polling this arm } } } } Ok(()) }