Fleet P2a: in-dashboard remote terminal (PTY over the WSS channel)
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:
co-authored by
Claude Opus 4.8
parent
7332d69f8a
commit
f5f96508eb
Generated
+173
-13
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,19 +188,91 @@ 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" => {
|
||||||
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
let (ok, output) = verify().await;
|
let (ok, output) = verify().await;
|
||||||
Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string())
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user