Fleet terminal: WebRTC DataChannel direct path (low-latency) + WS fallback
Terminal keystrokes were ~400ms because every byte relayed browser→Cloudflare→
gw-04 (Europe)→tailscale→node, even when the node is on the user's own LAN. Add a
direct browser↔node WebRTC DataChannel so co-located terminals run at LAN speed;
the gateway is reduced to signaling; the WebSocket relay stays as the automatic
fallback (graceful degradation — never worse than before).
Daemon (clawmates-node v0.4.0, new src/rtc.rs):
- Add the `webrtc` crate (reuses the ring crypto provider we already install — no
conflict). Browser is the offerer; we answer, trickle ICE back over the control
channel, and on DataChannel open spawn a host PTY (tmux) bridged DIRECTLY to the
channel. Refactor open_pty → spawn_terminal_pty shared by both transports.
iceServers: STUN + auto host/tailnet candidates (direct, no relay, for LAN/tailnet).
Server (cm-api):
- NodeConn.signal_sinks; Uplink WebRtcAnswer/WebRtcIce/WebRtcFailed routed to the
browser; NodeHub webrtc_offer/ice/close + open_session/open_pty (open_terminal
split so the PTY opens only once the transport is chosen). bridge_terminal relays
signaling over the existing ticket-authed WS and opens the relay PTY on
{type:"fallback"}.
Browser (NodeTerminalApp):
- RTCPeerConnection + reliable/ordered DataChannel; offer/answer/ICE over the WS;
2.5s race → use the DataChannel if it opens, else fall back to the WS relay.
Reconnect wraps both. A direct⚡/relayed indicator shows the live transport.
Deployed; both nodes (morpheus, tank) updated to v0.4.0 and steady online. Direct-
path proof is a browser action (the ⚡ indicator + latency); confirmable from the
daemon's [rtc] logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
94828ed887
commit
4de2f31b50
@@ -202,35 +202,76 @@ pub async fn terminal_ws(
|
||||
upgrade.on_upgrade(move |socket| bridge_terminal(hub, node_id, socket))
|
||||
}
|
||||
|
||||
/// Browser→server control frames over the terminal WS: a resize, the
|
||||
/// `fallback` request to open the WS-relay PTY, or WebRTC signaling (offer/ICE).
|
||||
#[derive(Deserialize)]
|
||||
struct TermCtrl {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
sdp: Option<String>,
|
||||
candidate: Option<String>,
|
||||
sdp_mid: Option<String>,
|
||||
sdp_mline_index: Option<u16>,
|
||||
}
|
||||
|
||||
/// The terminal WS is BOTH the WebRTC signaling channel and the fallback data
|
||||
/// path. The browser tries a direct DataChannel first (offer/ICE relayed here);
|
||||
/// if that fails it sends `{type:"fallback"}` and we open the WS-relay PTY.
|
||||
async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket) {
|
||||
let Some((sid, mut rx)) = hub.open_terminal(node_id, 80, 24).await else {
|
||||
let Some((sid, mut pty_rx, mut sig_rx)) = hub.open_session(node_id).await else {
|
||||
return;
|
||||
};
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
let to_browser = async {
|
||||
while let Some(bytes) = rx.recv().await {
|
||||
if ws_tx.send(Message::Binary(bytes.into())).await.is_err() {
|
||||
break;
|
||||
loop {
|
||||
tokio::select! {
|
||||
bytes = pty_rx.recv() => match bytes {
|
||||
Some(b) => {
|
||||
if ws_tx.send(Message::Binary(b.into())).await.is_err() { break; }
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
text = sig_rx.recv() => match text {
|
||||
Some(t) => {
|
||||
if ws_tx.send(Message::Text(t.into())).await.is_err() { break; }
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
let to_node = async {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
match msg {
|
||||
// Binary = keystrokes over the WS fallback path only (the direct
|
||||
// DataChannel carries its own input).
|
||||
Message::Binary(b) => hub.terminal_input(node_id, sid, b.as_ref()).await,
|
||||
Message::Text(t) => {
|
||||
if let Ok(c) = serde_json::from_str::<TermCtrl>(t.as_str()) {
|
||||
if c.kind == "resize" {
|
||||
hub.terminal_resize(node_id, sid, c.cols, c.rows).await;
|
||||
let Ok(c) = serde_json::from_str::<TermCtrl>(t.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let cols = c.cols.unwrap_or(80);
|
||||
let rows = c.rows.unwrap_or(24);
|
||||
match c.kind.as_str() {
|
||||
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
|
||||
"fallback" => hub.open_pty(node_id, sid, cols, rows).await,
|
||||
"webrtc_offer" => {
|
||||
hub.webrtc_offer(node_id, sid, c.sdp.as_deref().unwrap_or("")).await
|
||||
}
|
||||
"webrtc_ice" => {
|
||||
hub.webrtc_ice(
|
||||
node_id,
|
||||
sid,
|
||||
c.candidate.as_deref().unwrap_or(""),
|
||||
c.sdp_mid.as_deref(),
|
||||
c.sdp_mline_index,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"webrtc_close" => hub.webrtc_close(node_id, sid).await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
|
||||
Reference in New Issue
Block a user