Files
netdesk/native/core/src/host.rs
T

1050 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8>, // 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<u8> },
/// 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<String>,
png: Vec<u8>,
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<u8> },
/// One Opus audio packet (system-audio capture): sent as `[CHAN_AUDIO][opus]`,
/// E2E-sealed like video, over the reliable Ctl lane.
AudioFrame(Vec<u8>),
/// 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<u8> },
/// 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<u8> },
/// 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<u8> },
/// 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<u8> },
/// 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<u8> },
/// 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<Value> = 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:<bool>} 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<HostFrame>,
events: mpsc::UnboundedSender<HostEvent>,
control: mpsc::UnboundedReceiver<HostControl>,
) -> 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<HostFrame>,
events: mpsc::UnboundedSender<HostEvent>,
mut control: mpsc::UnboundedReceiver<HostControl>,
mut transport: Box<dyn crate::transport::Transport>,
) -> 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<Aes256Gcm> = 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<String> = 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<Aes256Gcm>, 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<u32> = 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(())
}