472 lines
18 KiB
Rust
472 lines
18 KiB
Rust
//! 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<u8>),
|
|
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<u8>) -> 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<Result<TransportMsg>>;
|
|
|
|
/// 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<u8>, 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<MaybeTlsStream<TcpStream>>,
|
|
}
|
|
|
|
impl WsRelay {
|
|
/// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping.
|
|
pub async fn connect(ws_url: &str) -> Result<Self> {
|
|
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<MaybeTlsStream<TcpStream>>) -> Self {
|
|
Self { ws }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Transport for WsRelay {
|
|
async fn send_binary(&mut self, data: Vec<u8>) -> 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<Result<TransportMsg>> {
|
|
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<Option<Vec<u8>>>,
|
|
}
|
|
|
|
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<u8> {
|
|
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<RTCDataChannel>, out: mpsc::UnboundedSender<TransportMsg>) {
|
|
let reasm: Arc<Mutex<HashMap<u32, FrameAsm>>> = 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<RTCDataChannel>, 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<RTCDataChannel>,
|
|
inbound: mpsc::UnboundedReceiver<TransportMsg>,
|
|
/// 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<AtomicBool>,
|
|
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<RTCDataChannel>) -> Self {
|
|
let (in_tx, in_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
|
|
|
// --- 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<RTCDataChannel> {
|
|
&self.dc
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Transport for WebRtcChannel {
|
|
async fn send_binary(&mut self, data: Vec<u8>) -> 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<Result<TransportMsg>> {
|
|
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<Result<TransportMsg>>,
|
|
}
|
|
|
|
/// 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<u8>) -> 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<Result<TransportMsg>> {
|
|
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();
|
|
}
|
|
}
|