1050 lines
60 KiB
Rust
1050 lines
60 KiB
Rust
//! 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(())
|
||
}
|