Fleet terminal: WebRTC DataChannel direct path (low-latency) + WS fallback
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

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:
Omar Sobh
2026-06-25 16:55:32 -07:00
co-authored by Claude Opus 4.8
parent 94828ed887
commit 4de2f31b50
7 changed files with 1312 additions and 72 deletions
+50 -9
View File
@@ -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,