//! Pluggable transport for the session protocol. The E2E AES-GCM framing sits //! ABOVE this layer on both the viewer and host, so a transport only ever moves //! opaque frames (binary media/file chunks, or text control JSON). The current //! WSS relay ([`WsRelay`]) and the P2P WebRTC DataChannel (added later) both //! implement [`Transport`], so `connect_with` / `run_host_with` carry either and //! the relay→P2P upgrade / downgrade can swap transports without touching the //! protocol, crypto, or framing. use crate::error::{NdError, Result}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use bytes::Bytes; use webrtc::data_channel::data_channel_message::DataChannelMessage; use webrtc::data_channel::RTCDataChannel; /// One message crossing a transport. Opaque here; the caller encrypts/decrypts /// and dispatches by channel byte above this layer. #[derive(Debug, Clone)] pub enum TransportMsg { Binary(Vec), Text(String), } /// Which logical stream a binary frame belongs to. The caller knows this from /// the (pre-encryption) channel byte; the transport cannot see it because the /// byte is inside the E2E ciphertext. It decides per-lane delivery: `Media` is /// drop-tolerant (video — drop a fresh frame rather than queue it late), `Ctl` /// must be reliable (file bytes, control), `Move` is drop-tolerant pointer /// motion. Maps 1:1 to the web/Python `media` / `ctl` / `move` DataChannels; /// the relay and the native single channel use it only for back-pressure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Lane { Media, Ctl, Move, } /// A bidirectional message transport. Used from a single task (the viewer's /// socket task / the host's `run_host` loop): `recv` is awaited in one /// `tokio::select!` arm and `send_*` in other arms' bodies, so `&mut self` for /// all three is sound (select drops the non-chosen futures first). #[async_trait] pub trait Transport: Send + 'static { async fn send_binary(&mut self, data: Vec) -> Result<()>; async fn send_text(&mut self, text: String) -> Result<()>; /// Next inbound message, or `None` when the transport has closed. async fn recv(&mut self) -> Option>; /// Send a binary frame tagged with its [`Lane`]. The default ignores the /// lane (relay, native single channel today), so existing transports need /// no change; the P2P multiplexer overrides it to pick the channel / /// back-pressure policy. Callers that know the channel (video vs file) /// should prefer this over [`Transport::send_binary`]. async fn send_binary_lane(&mut self, data: Vec, lane: Lane) -> Result<()> { let _ = lane; self.send_binary(data).await } } /// The WSS relay transport — the current path, behaviourally identical to the /// pre-seam code. Wraps the tungstenite WebSocket (TLS to the relay); the server /// relays frames opaquely between the two session peers. pub struct WsRelay { ws: WebSocketStream>, } impl WsRelay { /// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping. pub async fn connect(ws_url: &str) -> Result { let (ws, _resp) = connect_async(ws_url) .await .map_err(|e| NdError::Connect(e.to_string()))?; Ok(Self { ws }) } /// Wrap an already-connected WebSocket (e.g. one opened during a handshake /// elsewhere). pub fn from_ws(ws: WebSocketStream>) -> Self { Self { ws } } } #[async_trait] impl Transport for WsRelay { async fn send_binary(&mut self, data: Vec) -> Result<()> { self.ws .send(Message::Binary(data)) .await .map_err(|e| NdError::Connect(e.to_string())) } async fn send_text(&mut self, text: String) -> Result<()> { self.ws .send(Message::Text(text)) .await .map_err(|e| NdError::Connect(e.to_string())) } async fn recv(&mut self) -> Option> { loop { match self.ws.next().await { Some(Ok(Message::Binary(b))) => return Some(Ok(TransportMsg::Binary(b))), Some(Ok(Message::Text(t))) => return Some(Ok(TransportMsg::Text(t))), Some(Ok(Message::Close(_))) | None => return None, // Ping/Pong/Frame are handled by tungstenite; skip at this layer. Some(Ok(_)) => continue, Some(Err(e)) => return Some(Err(NdError::Connect(e.to_string()))), } } } } // ============================ P2P WebRTC DataChannel ============================ /// 9-byte fragment header prepended to every binary chunk: /// `[frame_id:u32 BE][idx:u16 BE][count:u16 BE][flags:u8]`. `flags` bit0 is /// reserved for a keyframe hint (set by a higher layer in a later phase); the /// reassembler ignores it. const FRAG_HEADER: usize = 9; /// Each SCTP message stays at or below 16 KiB (header + payload) — safely under /// the negotiated max-message-size, and small enough that one slow frame never /// head-of-line-blocks the queue for long. const MAX_CHUNK_PAYLOAD: usize = 16 * 1024 - FRAG_HEADER; /// Pause sending once this much is queued in the SCTP send buffer... const HIGH_WATER: usize = 1024 * 1024; /// ...and resume when it drains to here (`on_buffered_amount_low` fires). const LOW_WATER: usize = 512 * 1024; /// Reassembly accumulator for one multi-chunk binary frame. struct FrameAsm { count: u16, got: u16, parts: Vec>>, } impl FrameAsm { fn new(count: u16) -> Self { Self { count, got: 0, parts: vec![None; count as usize], } } fn put(&mut self, idx: u16, payload: &[u8]) { let i = idx as usize; if i < self.parts.len() && self.parts[i].is_none() { self.parts[i] = Some(payload.to_vec()); self.got += 1; } } fn complete(&self) -> bool { self.got == self.count } /// Concatenate the chunks in index order. Because the chunks are contiguous /// slices of the original nonce||ciphertext||tag blob, the rejoined bytes /// are byte-identical — a mis-ordered or missing chunk would corrupt the /// blob and the viewer AES-GCM decrypt_frame would fail the auth tag, which /// is exactly the integrity guarantee we want. fn concat(self) -> Vec { let mut out = Vec::new(); for p in self.parts.into_iter().flatten() { out.extend_from_slice(&p); } out } } /// Install the inbound reassembly handler on `dc`: SCTP string messages become /// `TransportMsg::Text`; fragmented binary chunks are rejoined by `frame_id` /// and pushed as exactly one contiguous `TransportMsg::Binary` (so the E2E /// `nonce||ct||tag` blob stays intact for AES-GCM). Shared by `WebRtcChannel` /// (direct P2P) and the relay/P2P multiplexer so both frame the wire identically. pub fn install_reassembler(dc: &Arc, out: mpsc::UnboundedSender) { let reasm: Arc>> = Arc::new(Mutex::new(HashMap::new())); dc.on_message(Box::new(move |msg: DataChannelMessage| { let out = out.clone(); let reasm = reasm.clone(); Box::pin(async move { if msg.is_string { if let Ok(s) = String::from_utf8(msg.data.to_vec()) { let _ = out.send(TransportMsg::Text(s)); } return; } let data = &msg.data; if data.len() < FRAG_HEADER { return; // malformed; drop } let frame_id = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); let idx = u16::from_be_bytes([data[4], data[5]]); let count = u16::from_be_bytes([data[6], data[7]]); // data[8] = flags (bit0 = keyframe), reserved; unused for reassembly. let payload = &data[FRAG_HEADER..]; match count { 0 => {} 1 => { let _ = out.send(TransportMsg::Binary(payload.to_vec())); } _ => { let mut map = reasm.lock().unwrap(); let entry = map.entry(frame_id).or_insert_with(|| FrameAsm::new(count)); entry.put(idx, payload); if entry.complete() { if let Some(asm) = map.remove(&frame_id) { drop(map); let _ = out.send(TransportMsg::Binary(asm.concat())); } } } } }) })); } /// Fragment `data` into `<=16 KiB` SCTP messages (9-byte header each) under one /// `frame_id` and send them in order on `dc`. No back-pressure here — the caller /// applies its own policy (wait vs drop-oldest) around this. Returns the error /// from the first failed `dc.send`. pub async fn send_fragmented(dc: &Arc, frame_id: u32, data: &[u8]) -> Result<()> { let total = data.len(); let count = ((total + MAX_CHUNK_PAYLOAD - 1) / MAX_CHUNK_PAYLOAD).max(1); if count > u16::MAX as usize { return Err(NdError::Connect(format!( "frame too large to fragment: {total} bytes" ))); } for idx in 0..count { let start = idx * MAX_CHUNK_PAYLOAD; let end = (start + MAX_CHUNK_PAYLOAD).min(total); let chunk = &data[start..end]; let mut framed = Vec::with_capacity(FRAG_HEADER + chunk.len()); framed.extend_from_slice(&frame_id.to_be_bytes()); framed.extend_from_slice(&(idx as u16).to_be_bytes()); framed.extend_from_slice(&(count as u16).to_be_bytes()); framed.push(0u8); // flags (bit0 = keyframe) reserved framed.extend_from_slice(chunk); dc.send(&Bytes::from(framed)) .await .map_err(|e| NdError::Connect(e.to_string()))?; } Ok(()) } /// A [`Transport`] over a single reliable+ordered WebRTC DataChannel — the /// native-to-native direct P2P fast path. The E2E AES-GCM framing is unchanged /// above this layer; this channel only moves opaque binary frames (fragmented /// to fit SCTP, reassembled whole so the GCM blob stays contiguous) and opaque /// text control JSON (sent as SCTP string messages, so the peer tells the two /// apart with `DataChannelMessage::is_string`). pub struct WebRtcChannel { dc: Arc, inbound: mpsc::UnboundedReceiver, /// Drains one token whenever the SCTP send buffer falls below `LOW_WATER`. low_water_rx: mpsc::Receiver<()>, /// Set true by `on_close`; makes `recv` return `None` once inbound is empty. closed_flag: Arc, closed_rx: mpsc::Receiver<()>, next_frame_id: u32, } impl WebRtcChannel { /// Wrap an open DataChannel. Registers the inbound reassembly handler, the /// back-pressure low-water signal, and the close signal. Call once the /// channel is open (sends before open error at the SCTP layer). pub async fn new(dc: Arc) -> Self { let (in_tx, in_rx) = mpsc::unbounded_channel::(); // --- inbound: reassemble binary frames, pass text straight through. install_reassembler(&dc, in_tx); // --- back-pressure: a size-1 mpsc is race-free vs Notify here. The // low-water event only fires when the buffer crosses DOWN through the // threshold, so a token can be pending at most once; try_send drops // extras. let (low_tx, low_rx) = mpsc::channel::<()>(1); dc.set_buffered_amount_low_threshold(LOW_WATER).await; dc.on_buffered_amount_low(Box::new(move || { let low_tx = low_tx.clone(); Box::pin(async move { let _ = low_tx.try_send(()); }) })) .await; // --- close signal so recv() can return None on a dropped channel. let closed_flag = Arc::new(AtomicBool::new(false)); let (closed_tx, closed_rx) = mpsc::channel::<()>(1); let cf = closed_flag.clone(); dc.on_close(Box::new(move || { let cf = cf.clone(); let closed_tx = closed_tx.clone(); Box::pin(async move { cf.store(true, Ordering::SeqCst); let _ = closed_tx.try_send(()); }) })); Self { dc, inbound: in_rx, low_water_rx: low_rx, closed_flag, closed_rx, next_frame_id: 0, } } /// The underlying channel, for stats / state inspection (upgrade logic). pub fn data_channel(&self) -> &Arc { &self.dc } } #[async_trait] impl Transport for WebRtcChannel { async fn send_binary(&mut self, data: Vec) -> Result<()> { // Pause if the SCTP send queue is already deep: wait for it to drain // below LOW_WATER (fresh frames never pile up unbounded in RAM). while self.dc.buffered_amount().await + FRAG_HEADER + data.len() > HIGH_WATER { if self.low_water_rx.recv().await.is_none() { break; } } let frame_id = self.next_frame_id; self.next_frame_id = self.next_frame_id.wrapping_add(1); send_fragmented(&self.dc, frame_id, &data).await } async fn send_text(&mut self, text: String) -> Result<()> { self.dc .send_text(text) .await .map_err(|e| NdError::Connect(e.to_string()))?; Ok(()) } async fn recv(&mut self) -> Option> { if self.closed_flag.load(Ordering::SeqCst) { // Drain anything already reassembled, then report closed. return self.inbound.try_recv().ok().map(Ok); } tokio::select! { biased; msg = self.inbound.recv() => msg.map(Ok), _ = self.closed_rx.recv() => self.inbound.try_recv().ok().map(Ok), } } } /// Direct-IP transport — a raw TCP socket instead of the relay. The viewer dials /// the host's `IP:port` and the session runs the SAME challenge/auth + E2E /// AES-GCM above this layer (so direct-IP is encrypted, unlike RustDesk's). /// Framing is `[type:u8][len:u32 BE][payload]` (type 0 = binary, 1 = text). A /// dedicated reader task parses frames into a channel, so `recv` is /// cancellation-safe in the caller's `select!` (a bare `read_exact` is not). pub struct DirectTcp { writer: tokio::net::tcp::OwnedWriteHalf, rx: mpsc::UnboundedReceiver>, } /// Cap on a single framed message (generous for a 4K keyframe). const DIRECT_MAX_FRAME: usize = 64 * 1024 * 1024; impl DirectTcp { /// Wrap an accepted / dialed TCP stream. pub fn new(stream: TcpStream) -> Self { let _ = stream.set_nodelay(true); let (mut read, write) = stream.into_split(); let (tx, rx) = mpsc::unbounded_channel(); tokio::spawn(async move { use tokio::io::AsyncReadExt; let mut hdr = [0u8; 5]; loop { if read.read_exact(&mut hdr).await.is_err() { break; } let ty = hdr[0]; let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize; if len > DIRECT_MAX_FRAME { break; } let mut buf = vec![0u8; len]; if read.read_exact(&mut buf).await.is_err() { break; } let msg = if ty == 1 { TransportMsg::Text(String::from_utf8_lossy(&buf).into_owned()) } else { TransportMsg::Binary(buf) }; if tx.send(Ok(msg)).is_err() { break; } } }); DirectTcp { writer: write, rx } } async fn write_frame(&mut self, ty: u8, payload: &[u8]) -> Result<()> { use tokio::io::AsyncWriteExt; let mut hdr = [0u8; 5]; hdr[0] = ty; hdr[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes()); self.writer .write_all(&hdr) .await .map_err(|e| NdError::Connect(e.to_string()))?; self.writer .write_all(payload) .await .map_err(|e| NdError::Connect(e.to_string()))?; Ok(()) } } #[async_trait] impl Transport for DirectTcp { async fn send_binary(&mut self, data: Vec) -> Result<()> { self.write_frame(0, &data).await } async fn send_text(&mut self, text: String) -> Result<()> { self.write_frame(1, text.as_bytes()).await } async fn recv(&mut self) -> Option> { self.rx.recv().await } } #[cfg(test)] mod direct_tests { use super::*; // DirectTcp framing must round-trip text + binary, in order, over a real // loopback socket (the foundation of encrypted direct-IP access). #[tokio::test] async fn direct_tcp_roundtrips_text_and_binary_in_order() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let srv = tokio::spawn(async move { let (s, _) = listener.accept().await.unwrap(); let mut t = DirectTcp::new(s); for _ in 0..3 { match t.recv().await.unwrap().unwrap() { TransportMsg::Text(s) => t.send_text(format!("echo:{s}")).await.unwrap(), TransportMsg::Binary(b) => { let mut r = b.clone(); r.reverse(); t.send_binary(r).await.unwrap(); } } } }); let cs = tokio::net::TcpStream::connect(addr).await.unwrap(); let mut c = DirectTcp::new(cs); c.send_text("hello".into()).await.unwrap(); c.send_binary(vec![1, 2, 3, 4]).await.unwrap(); c.send_text("world".into()).await.unwrap(); assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:hello")); assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Binary(b) if b == vec![4, 3, 2, 1])); assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:world")); srv.await.unwrap(); } }