Fleet P2a: in-dashboard remote terminal (PTY over the WSS channel)
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

You can now open a real shell on any connected node from the dashboard — the
daemon spawns a host PTY and streams it over the existing outbound control
channel (no inbound port, no Tailscale brokering needed).

Daemon:
- portable-pty host shell sessions: pty_open/pty_in/pty_resize/pty_close ops; a
  reader thread streams base64 pty_out frames. Outbound frames now funnel through
  one mpsc channel so PTY output and heartbeats interleave.

cm-api NodeHub:
- per-connection pty_sinks + sid multiplexing; open_terminal/terminal_input/
  terminal_resize/terminal_close; in-memory single-use terminal tickets (the
  browser WS can't carry a bearer, and the session is instance-local anyway).
- routes/nodes.rs: POST /api/nodes/{id}/terminal/ticket + GET .../terminal/ws
  (bridges browser xterm <-> node PTY: binary = keystrokes, text = resize).

Frontend:
- NodeTerminal xterm modal (reuses the agent Terminal's xterm setup); a Terminal
  button on each online node card opens a shell.

This proves the bidirectional streaming-over-channel mechanism the RemoteDriver
will reuse. Remaining P2: RemoteDriver + placement (run agent workloads on nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 12:13:24 -07:00
co-authored by Claude Opus 4.8
parent 7332d69f8a
commit f5f96508eb
9 changed files with 548 additions and 35 deletions
Generated
+173 -13
View File
@@ -316,6 +316,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -366,7 +372,7 @@ checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"base64", "base64",
"bitflags", "bitflags 2.13.0",
"bollard-buildkit-proto", "bollard-buildkit-proto",
"bollard-stubs", "bollard-stubs",
"bytes", "bytes",
@@ -733,7 +739,9 @@ dependencies = [
name = "clawmates-node" name = "clawmates-node"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"futures", "futures",
"portable-pty",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
@@ -1488,6 +1496,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@@ -1666,6 +1680,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -2359,6 +2384,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "ioctl-rs"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -2611,7 +2645,7 @@ version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.8.1", "redox_syscall 0.8.1",
@@ -2719,6 +2753,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -2782,6 +2825,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "nix"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset",
"pin-utils",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -3245,6 +3302,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkcs1" name = "pkcs1"
version = "0.7.5" version = "0.7.5"
@@ -3295,6 +3358,27 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-pty"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -3359,7 +3443,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"bit-vec 0.8.0", "bit-vec 0.8.0",
"bitflags", "bitflags 2.13.0",
"num-traits", "num-traits",
"rand 0.9.4", "rand 0.9.4",
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
@@ -3652,7 +3736,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -3661,7 +3745,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -3893,7 +3977,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -4086,7 +4170,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -4099,7 +4183,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -4263,6 +4347,48 @@ dependencies = [
"unsafe-libyaml", "unsafe-libyaml",
] ]
[[package]]
name = "serial"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
dependencies = [
"serial-core",
"serial-unix",
"serial-windows",
]
[[package]]
name = "serial-core"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
dependencies = [
"libc",
]
[[package]]
name = "serial-unix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
dependencies = [
"ioctl-rs",
"libc",
"serial-core",
"termios",
]
[[package]]
name = "serial-windows"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
dependencies = [
"libc",
"serial-core",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -4294,6 +4420,22 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -4503,7 +4645,7 @@ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bigdecimal", "bigdecimal",
"bitflags", "bitflags 2.13.0",
"byteorder", "byteorder",
"bytes", "bytes",
"crc", "crc",
@@ -4548,7 +4690,7 @@ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bigdecimal", "bigdecimal",
"bitflags", "bitflags 2.13.0",
"byteorder", "byteorder",
"crc", "crc",
"dotenvy", "dotenvy",
@@ -4716,6 +4858,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "termios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "testcontainers" name = "testcontainers"
version = "0.25.2" version = "0.25.2"
@@ -5058,7 +5209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"base64", "base64",
"bitflags", "bitflags 2.13.0",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -5555,7 +5706,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.14.0", "indexmap 2.14.0",
"semver", "semver",
@@ -5993,6 +6144,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -6057,7 +6217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags 2.13.0",
"indexmap 2.14.0", "indexmap 2.14.0",
"log", "log",
"serde", "serde",
+2
View File
@@ -17,6 +17,8 @@ futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sysinfo = "0.33" sysinfo = "0.33"
portable-pty = "0.8"
base64 = "0.22"
[lints] [lints]
workspace = true workspace = true
+105 -19
View File
@@ -5,13 +5,29 @@
//! commands the gateway sends (host verification today; container placement in a //! commands the gateway sends (host verification today; container placement in a
//! later phase). Outbound-only → no inbound port, NAT-friendly. //! later phase). Outbound-only → no inbound port, NAT-friendly.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use base64::Engine;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sysinfo::{Disks, System}; use sysinfo::{Disks, System};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// A live host-shell PTY the gateway opened (keyed by session id).
struct Pty {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
}
type Ptys = Arc<Mutex<HashMap<u64, Pty>>>;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main] #[tokio::main]
@@ -75,20 +91,18 @@ async fn run(ws_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?; let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
println!("connected; reporting health every 5s"); println!("connected; reporting health every 5s");
let (mut write, mut read) = ws.split(); let (mut write, mut read) = ws.split();
// Everything outbound (heartbeats, command results, PTY output) funnels
// through one channel so PTY reader threads can push asynchronously.
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
let ptys: Ptys = Arc::new(Mutex::new(HashMap::new()));
let mut sys = System::new_all(); let mut sys = System::new_all();
let mut ticker = tokio::time::interval(Duration::from_secs(5)); let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop { loop {
tokio::select! { tokio::select! {
_ = ticker.tick() => { _ = ticker.tick() => { let _ = out_tx.send(heartbeat(&mut sys)); }
let hb = heartbeat(&mut sys); Some(frame) = out_rx.recv() => { write.send(Message::Text(frame.into())).await?; }
write.send(Message::Text(hb.into())).await?;
}
msg = read.next() => match msg { msg = read.next() => match msg {
Some(Ok(Message::Text(t))) => { Some(Ok(Message::Text(t))) => handle_frame(t.as_str(), &out_tx, &ptys).await,
if let Some(resp) = handle_command(t.as_str()).await {
write.send(Message::Text(resp.into())).await?;
}
}
Some(Ok(Message::Ping(p))) => write.send(Message::Pong(p)).await?, Some(Ok(Message::Ping(p))) => write.send(Message::Pong(p)).await?,
Some(Ok(Message::Close(_))) | None => return Ok(()), Some(Ok(Message::Close(_))) | None => return Ok(()),
Some(Err(e)) => return Err(e.into()), Some(Err(e)) => return Err(e.into()),
@@ -174,21 +188,93 @@ fn tailscale_ip() -> Option<String> {
(!ip.is_empty()).then_some(ip) (!ip.is_empty()).then_some(ip)
} }
/// Handle a typed request from the gateway. The gateway never sends arbitrary /// Handle a typed frame from the gateway. The gateway never sends arbitrary
/// shell — only vetted ops the daemon runs itself (verify today; container ops /// shell — only vetted ops (verify, and an interactive host terminal the user
/// later), so the host attack surface stays minimal. /// explicitly opened), so the host attack surface stays minimal.
async fn handle_command(text: &str) -> Option<String> { async fn handle_frame(text: &str, out: &mpsc::UnboundedSender<String>, ptys: &Ptys) {
let v: Value = serde_json::from_str(text).ok()?; let Ok(v) = serde_json::from_str::<Value>(text) else {
let id = v.get("id").and_then(Value::as_u64)?; return;
match v.get("t").and_then(Value::as_str)? { };
match v.get("t").and_then(Value::as_str).unwrap_or_default() {
"verify" => { "verify" => {
let (ok, output) = verify().await; if let Some(id) = v.get("id").and_then(Value::as_u64) {
Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string()) let (ok, output) = verify().await;
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
}
} }
_ => None, "pty_open" => {
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
if let Err(e) = open_pty(sid, cols, rows, out.clone(), ptys.clone()).await {
let _ = out.send(json!({ "t": "pty_exit", "sid": sid, "error": e }).to_string());
}
}
"pty_in" => {
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
if let Some(bytes) = v.get("data").and_then(Value::as_str).and_then(|d| B64.decode(d).ok()) {
if let Some(p) = ptys.lock().await.get_mut(&sid) {
let _ = p.writer.write_all(&bytes);
let _ = p.writer.flush();
}
}
}
"pty_resize" => {
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
if let Some(p) = ptys.lock().await.get(&sid) {
let _ = p.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
}
}
"pty_close" => {
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
if let Some(mut p) = ptys.lock().await.remove(&sid) {
let _ = p.child.kill();
}
}
_ => {}
} }
} }
/// Spawn a host login shell in a PTY; stream its output back as pty_out frames.
async fn open_pty(
sid: u64,
cols: u16,
rows: u16,
out: mpsc::UnboundedSender<String>,
ptys: Ptys,
) -> Result<(), String> {
let pair = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_owned());
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
let writer = pair.master.take_writer().map_err(|e| e.to_string())?;
ptys.lock().await.insert(sid, Pty { master: pair.master, writer, child });
// Blocking PTY reads on a thread → base64 pty_out frames into the out channel.
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = B64.encode(&buf[..n]);
if out.send(json!({ "t": "pty_out", "sid": sid, "data": data }).to_string()).is_err() {
break;
}
}
}
}
let _ = out.send(json!({ "t": "pty_exit", "sid": sid }).to_string());
});
Ok(())
}
/// The daemon's built-in host check (fixed command — nothing caller-supplied /// The daemon's built-in host check (fixed command — nothing caller-supplied
/// executes): kernel info + Docker presence. /// executes): kernel info + Docker presence.
async fn verify() -> (bool, String) { async fn verify() -> (bool, String) {
+1
View File
@@ -10,6 +10,7 @@ publish.workspace = true
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
base64 = "0.22"
async-stream = "0.3" async-stream = "0.3"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
futures = "0.3" futures = "0.3"
+90
View File
@@ -6,8 +6,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use base64::Engine;
use cm_db::repo::nodes::{self, NodeHealth}; use cm_db::repo::nodes::{self, NodeHealth};
use cm_domain::NodeId; use cm_domain::NodeId;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
@@ -16,6 +18,8 @@ use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{mpsc, oneshot, Mutex};
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// The result of running a command on a node. /// The result of running a command on a node.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ExecOutput { pub struct ExecOutput {
@@ -26,6 +30,8 @@ pub struct ExecOutput {
struct NodeConn { struct NodeConn {
tx: mpsc::UnboundedSender<String>, tx: mpsc::UnboundedSender<String>,
pending: Mutex<HashMap<u64, oneshot::Sender<ExecOutput>>>, pending: Mutex<HashMap<u64, oneshot::Sender<ExecOutput>>>,
/// Live terminal sessions: sid → sink for the browser bridge.
pty_sinks: Mutex<HashMap<u64, mpsc::UnboundedSender<Vec<u8>>>>,
next_id: AtomicU64, next_id: AtomicU64,
} }
@@ -33,6 +39,8 @@ struct NodeConn {
#[derive(Default)] #[derive(Default)]
pub struct NodeHub { pub struct NodeHub {
conns: Mutex<HashMap<NodeId, Arc<NodeConn>>>, conns: Mutex<HashMap<NodeId, Arc<NodeConn>>>,
/// Short-lived single-use terminal tickets (browser WS can't send a bearer).
tickets: Mutex<HashMap<String, (NodeId, Instant)>>,
} }
impl NodeHub { impl NodeHub {
@@ -78,6 +86,72 @@ impl NodeHub {
} }
} }
} }
/// Open a host terminal on a node: allocate a session, ask the daemon to
/// spawn a PTY, and return the sid + a stream of its output bytes.
pub async fn open_terminal(
&self,
id: NodeId,
cols: u16,
rows: u16,
) -> Option<(u64, mpsc::UnboundedReceiver<Vec<u8>>)> {
let conn = self.get(id).await?;
let sid = conn.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = mpsc::unbounded_channel();
conn.pty_sinks.lock().await.insert(sid, tx);
let frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows }).to_string();
if conn.tx.send(frame).is_err() {
conn.pty_sinks.lock().await.remove(&sid);
return None;
}
Some((sid, rx))
}
pub async fn terminal_input(&self, id: NodeId, sid: u64, bytes: &[u8]) {
if let Some(conn) = self.get(id).await {
let _ = conn
.tx
.send(json!({ "t": "pty_in", "sid": sid, "data": B64.encode(bytes) }).to_string());
}
}
pub async fn terminal_resize(&self, id: NodeId, sid: u64, cols: u16, rows: u16) {
if let Some(conn) = self.get(id).await {
let _ = conn
.tx
.send(json!({ "t": "pty_resize", "sid": sid, "cols": cols, "rows": rows }).to_string());
}
}
pub async fn terminal_close(&self, id: NodeId, sid: u64) {
if let Some(conn) = self.get(id).await {
conn.pty_sinks.lock().await.remove(&sid);
let _ = conn.tx.send(json!({ "t": "pty_close", "sid": sid }).to_string());
}
}
/// Mint a single-use terminal ticket for a node (60s TTL) — the browser WS
/// handshake can't carry a bearer header.
pub async fn mint_ticket(&self, id: NodeId) -> String {
let token = format!(
"{}{}",
uuid::Uuid::now_v7().simple(),
uuid::Uuid::now_v7().simple()
);
let mut t = self.tickets.lock().await;
let now = Instant::now();
t.retain(|_, (_, exp)| *exp > now);
t.insert(token.clone(), (id, now + Duration::from_secs(60)));
token
}
/// Redeem a terminal ticket (single use).
pub async fn redeem_ticket(&self, token: &str) -> Option<NodeId> {
match self.tickets.lock().await.remove(token) {
Some((id, exp)) if exp > Instant::now() => Some(id),
_ => None,
}
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -91,6 +165,10 @@ enum Uplink {
}, },
#[serde(rename = "result")] #[serde(rename = "result")]
Result { id: u64, ok: bool, output: String }, Result { id: u64, ok: bool, output: String },
#[serde(rename = "pty_out")]
PtyOut { sid: u64, data: String },
#[serde(rename = "pty_exit")]
PtyExit { sid: u64 },
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -116,6 +194,7 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
let conn = Arc::new(NodeConn { let conn = Arc::new(NodeConn {
tx, tx,
pending: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()),
pty_sinks: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(0), next_id: AtomicU64::new(0),
}); });
hub.conns.lock().await.insert(node_id, conn.clone()); hub.conns.lock().await.insert(node_id, conn.clone());
@@ -163,6 +242,17 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
let _ = s.send(ExecOutput { ok, output }); let _ = s.send(ExecOutput { ok, output });
} }
} }
Ok(Uplink::PtyOut { sid, data }) => {
if let Ok(bytes) = B64.decode(&data) {
let sink = conn.pty_sinks.lock().await.get(&sid).cloned();
if let Some(s) = sink {
let _ = s.send(bytes);
}
}
}
Ok(Uplink::PtyExit { sid }) => {
conn.pty_sinks.lock().await.remove(&sid);
}
Err(_) => {} Err(_) => {}
} }
} }
+2
View File
@@ -113,6 +113,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/nodes/live", get(routes::nodes::live)) .route("/api/nodes/live", get(routes::nodes::live))
.route("/api/nodes/agent", get(routes::nodes::agent_ws)) .route("/api/nodes/agent", get(routes::nodes::agent_ws))
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test)) .route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
.route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket))
.route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws))
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
.route( .route(
"/api/fleet/tailscale", "/api/fleet/tailscale",
+81 -2
View File
@@ -3,20 +3,22 @@
//! daemon's outbound control channel. //! daemon's outbound control channel.
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use cm_db::repo::nodes; use cm_db::repo::nodes;
use cm_domain::NodeId; use cm_domain::NodeId;
use futures::{SinkExt, StreamExt};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
use crate::fleet::run_channel; use crate::fleet::{run_channel, NodeHub};
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
/// An unguessable control-channel token (two time-ordered UUIDs). /// An unguessable control-channel token (two time-ordered UUIDs).
@@ -146,3 +148,80 @@ pub async fn agent_ws(
let hub = state.node_hub.clone(); let hub = state.node_hub.clone();
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket)) upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
} }
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
/// (the browser WS handshake can't carry a bearer header).
pub async fn terminal_ticket(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
if !state.node_hub.is_online(node_id).await {
return Ok(Json(json!({ "error": "node is offline" })));
}
Ok(Json(json!({ "ticket": state.node_hub.mint_ticket(node_id).await })))
}
/// `GET /api/nodes/{id}/terminal/ws?ticket=…` — bridge a browser xterm to a host
/// shell on the node (PTY proxied over the daemon's control channel).
pub async fn terminal_ws(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(q): Query<AgentQuery>,
upgrade: WebSocketUpgrade,
) -> Response {
let node_id = NodeId::from(id);
match state.node_hub.redeem_ticket(&q.token).await {
Some(t) if t == node_id => {}
_ => return ApiError::Unauthorized.into_response(),
}
let hub = state.node_hub.clone();
upgrade.on_upgrade(move |socket| bridge_terminal(hub, node_id, socket))
}
#[derive(Deserialize)]
struct TermCtrl {
#[serde(rename = "type")]
kind: String,
cols: u16,
rows: u16,
}
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 {
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;
}
}
};
let to_node = async {
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
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;
}
}
}
Message::Close(_) => break,
_ => {}
}
}
};
tokio::select! {
_ = to_browser => {},
_ = to_node => {},
}
hub.terminal_close(node_id, sid).await;
}
@@ -4,11 +4,12 @@
// comes from the nodes registry (`GET /api/nodes`), polled every 3s. // comes from the nodes registry (`GET /api/nodes`), polled every 3s.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Trash2 } from "lucide-react"; import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Terminal, Trash2 } from "lucide-react";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { ConnectHostWizard } from "../ConnectHostWizard"; import { ConnectHostWizard } from "../ConnectHostWizard";
import { NodeTerminal } from "./NodeTerminal";
const mono = "'Geist Mono', ui-monospace, monospace"; const mono = "'Geist Mono', ui-monospace, monospace";
@@ -77,6 +78,7 @@ function Bar({ label, pct, detail, color }: { label: string; pct: number; detail
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) { export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
const h = node.health; const h = node.health;
const [term, setTerm] = useState(false);
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0; const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0; const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
const remove = useCallback(() => { const remove = useCallback(() => {
@@ -96,6 +98,9 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
{node.agentVersion ? <span>· v{node.agentVersion}</span> : null} {node.agentVersion ? <span>· v{node.agentVersion}</span> : null}
</div> </div>
</div> </div>
{node.status === "online" ? (
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button>
) : null}
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button> <button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
</div> </div>
@@ -119,6 +124,7 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"} {node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
</div> </div>
)} )}
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
</div> </div>
); );
} }
@@ -0,0 +1,87 @@
"use client";
// An in-dashboard shell on a fleet node: xterm.js ⇄ a node-terminal WebSocket,
// whose PTY is proxied over the daemon's control channel. Mirrors the agent
// Terminal app's xterm setup, pointed at /api/nodes/{id}/terminal.
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
export function NodeTerminal({ nodeId, nodeName, onClose }: { nodeId: string; nodeName: string; onClose: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let disposed = false;
let term: import("@xterm/xterm").Terminal | null = null;
let ws: WebSocket | null = null;
let onResize: (() => void) | null = null;
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
term.focus();
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
const body = res.ok ? ((await res.json()) as { ticket?: string; error?: string }) : {};
if (!body.ticket) {
term.writeln(`\r\n\x1b[31m${body.error ?? "could not open terminal"}\x1b[0m`);
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(body.ticket)}`);
ws.binaryType = "arraybuffer";
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN && term) {
fit.fit();
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ws.onopen = () => sendResize();
ws.onmessage = (e) => {
if (typeof e.data === "string") term?.write(e.data);
else term?.write(new Uint8Array(e.data as ArrayBuffer));
};
ws.onclose = () => term?.writeln("\r\n\x1b[33m[disconnected]\x1b[0m");
term.onData((d) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(d));
});
onResize = sendResize;
window.addEventListener("resize", onResize);
})();
return () => {
disposed = true;
if (onResize) window.removeEventListener("resize", onResize);
ws?.close();
term?.dispose();
};
}, [nodeId]);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 300, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 860, maxWidth: "100%", height: 520, maxHeight: "90vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0a0a0c", border: "1px solid rgba(255,255,255,.12)", boxShadow: "0 24px 70px rgba(0,0,0,.6)", overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid rgba(255,255,255,.08)" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#5fd08a" }} />
<span style={{ fontFamily: "'Geist Mono', ui-monospace, monospace", fontSize: 12.5, color: "#cfcfd5", flex: 1 }}>{nodeName} — shell</span>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, borderRadius: 7, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<div ref={hostRef} style={{ flex: 1, minHeight: 0, padding: 8 }} />
</div>
</div>
);
}