Compare commits
13
Commits
v0.3.1
..
3476892cd1
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/vt100"]
|
||||||
|
path = vendor/vt100
|
||||||
|
url = https://github.com/ldlda/vt100-rust-1
|
||||||
+18
@@ -60,6 +60,18 @@ _Avoid_: Device when referring to the agent machine
|
|||||||
Something an agent host can observe or do on the network.
|
Something an agent host can observe or do on the network.
|
||||||
_Avoid_: Router flag
|
_Avoid_: Router flag
|
||||||
|
|
||||||
|
**Terminal Capability**:
|
||||||
|
An explicitly enabled agent capability that allows the agent host to create remote PTY sessions.
|
||||||
|
_Avoid_: Command runner, implicit root access
|
||||||
|
|
||||||
|
**Terminal Session**:
|
||||||
|
One operator attachment to one PTY process hosted by an agent.
|
||||||
|
_Avoid_: Command, SSH session, stored shell
|
||||||
|
|
||||||
|
**Terminal Relay**:
|
||||||
|
The control plane's in-memory pairing of one operator terminal socket with one outbound agent terminal socket.
|
||||||
|
_Avoid_: Terminal session storage, terminal emulator
|
||||||
|
|
||||||
**Interface Telemetry**:
|
**Interface Telemetry**:
|
||||||
Current operational measurements for a network interface visible to an agent.
|
Current operational measurements for a network interface visible to an agent.
|
||||||
_Avoid_: Endpoint state, device presence
|
_Avoid_: Endpoint state, device presence
|
||||||
@@ -84,6 +96,9 @@ _Avoid_: Endpoint state, device presence
|
|||||||
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
|
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
|
||||||
- An **Agent** runs on one **Agent Host**.
|
- An **Agent** runs on one **Agent Host**.
|
||||||
- An **Agent Capability** describes what an **Agent Host** can observe or do.
|
- An **Agent Capability** describes what an **Agent Host** can observe or do.
|
||||||
|
- **Terminal Capability** is an **Agent Capability** and must be explicitly enabled before an agent can host a **Terminal Session**.
|
||||||
|
- A **Terminal Session** owns one PTY and is independent of one-shot agent commands.
|
||||||
|
- A **Terminal Relay** pairs exactly one operator socket with exactly one agent socket for a **Terminal Session**.
|
||||||
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
|
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
|
||||||
|
|
||||||
## Example dialogue
|
## Example dialogue
|
||||||
@@ -126,3 +141,6 @@ _Avoid_: Endpoint state, device presence
|
|||||||
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
|
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
|
||||||
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
|
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
|
||||||
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
|
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
|
||||||
|
- **Terminal Sessions** are not agent commands — resolved: command dispatch remains request/result, while terminals use dedicated streaming sockets.
|
||||||
|
- **Terminal Relay** is ephemeral control-plane state — resolved: terminal bytes and scrollback are not stored or audited.
|
||||||
|
- Terminal input, output, and terminal-generated control sequences are byte streams; only lifecycle and resize operations are structured terminal protocol messages.
|
||||||
Generated
+38
@@ -97,6 +97,12 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -1976,6 +1982,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pty-process"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -3596,6 +3612,25 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vt100"
|
||||||
|
version = "0.16.2"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3637,6 +3672,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
|
"vt100",
|
||||||
"wakey",
|
"wakey",
|
||||||
"wakey-core",
|
"wakey-core",
|
||||||
]
|
]
|
||||||
@@ -3690,6 +3726,8 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"lda-ipjs",
|
"lda-ipjs",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
|
"nix 0.31.2",
|
||||||
|
"pty-process",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ strip = true
|
|||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||||
|
exclude = ["vendor/vt100"]
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# vt100 exposes only its active grid. Wakey's reconnect snapshot needs the
|
||||||
|
# retained primary grid while a full-screen application owns the alternate
|
||||||
|
# grid, so keep the small read-only extension local and auditable.
|
||||||
|
vt100 = { path = "vendor/vt100" }
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# lda rambling
|
|
||||||
|
|
||||||
currently the collection of fleetwakeroutes doesnt make much sense.
|
|
||||||
|
|
||||||
it goes deep. Read code, then this again.
|
|
||||||
|
|
||||||
## big fleet shows everything.
|
|
||||||
|
|
||||||
all the IPs, offline or not. I at least want to hide the offline/unknown IPs into a drawer somewhere. its too cluttered.
|
|
||||||
|
|
||||||
i do need offline IPs, because, a failed / dhcp-rotated IP may be of use. but what use? copying only. You cant even send shit to it, its FAILED.
|
|
||||||
|
|
||||||
so not all the IPs should be immediately invisible.
|
|
||||||
|
|
||||||
## big fleet DOESNT show everything
|
|
||||||
|
|
||||||
yea showing all the offline IP but only ONE online ip per mac for wake routes.
|
|
||||||
|
|
||||||
BRING This BACK. i need ALL the routes. Color coded (already has that with disabled wake button)
|
|
||||||
|
|
||||||
## big collect collects everything
|
|
||||||
|
|
||||||
at wakey, collection makes no sense; a device saves all IPs without saying which of them is not real.
|
|
||||||
|
|
||||||
oh you do have to search again for the observations because they are unknown ahh hints,
|
|
||||||
|
|
||||||
## logics are misleading
|
|
||||||
|
|
||||||
also IPs without macs are unknown, (should be OFFLINE? ips without macs? what do you think it is)
|
|
||||||
while macs without ips are offline (pulled straight from the Big Mac Name cache)
|
|
||||||
|
|
||||||
what is this ahh logic
|
|
||||||
|
|
||||||
## struct names are misleading
|
|
||||||
|
|
||||||
Big Device over here, collected (fresh AND old) From the router ITSELF. Is BY MAC.
|
|
||||||
|
|
||||||
but the Device struct was designed to take multiple MACs, Why. This is very misleading.
|
|
||||||
|
|
||||||
The migration was from Observations, which is a string fest, to Device, which
|
|
||||||
|
|
||||||
1. give up the very cool observations based design, which fundamentally is just tracking identifing pairs of (ip, mac, maybe hostname)
|
|
||||||
2. doesnt solve the confusing ahh mac-keyed Devices. Why is the deviceid optional? What!
|
|
||||||
|
|
||||||
## big code needs a rewrite
|
|
||||||
|
|
||||||
i think we need to have a very clear model. And such model is ALREADY PRESENT! fleet wake route MIGHT be the coolest thing ever, solves everything.
|
|
||||||
|
|
||||||
Should type instead of free string construction. For example. alot of the keys are currently freehanded. You need a struct with Display or ToString or a dedicated method.
|
|
||||||
hopefully those keys can have a helper contructor. HOPEFULLY NOONE PULLS INFO OUT OF THE STRING BY SLICING IT.
|
|
||||||
|
|
||||||
If you want to keep just device.macs, device.ips, you can, we need to collect the Wake routes! which goes deep,
|
|
||||||
|
|
||||||
but is reasonable, because there are two sources that give us identifiers, neighbors and observations. We flatten this to device, at the router its keyed by mac or failed ip.
|
|
||||||
At the fleet idfk, i dont control that part of the code; its all AI.
|
|
||||||
|
|
||||||
So, if we can collect wake targets directly from device. Saves a whole plane of problems. suddenly wakey-cc is like 6 million times slimmer, because this is a wakey or wakey-core problem.
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Caddy template for wakey-control-plane with Cloudflare Access.
|
# Caddy template for wakey-control-plane with Cloudflare Access.
|
||||||
#
|
#
|
||||||
# Security model:
|
# Security model:
|
||||||
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
|
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS
|
||||||
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
||||||
#
|
#
|
||||||
# Replace cp.example.com with your public control-plane domain.
|
# Replace cp.example.com with your public control-plane domain.
|
||||||
@@ -10,7 +10,7 @@ wakey.ldlda.com {
|
|||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Public agent-facing endpoints.
|
# Public agent-facing endpoints.
|
||||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws /api/v1/agent/terminals/*
|
||||||
handle @public {
|
handle @public {
|
||||||
reverse_proxy 127.0.0.1:6767
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Remote Terminal Sessions
|
||||||
|
|
||||||
|
Wakey will provide opt-in remote terminal access by pairing a browser terminal WebSocket with a dedicated outbound agent terminal WebSocket. The existing agent WebSocket remains the control channel and does not carry terminal byte streams.
|
||||||
|
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The control plane already maintains an authenticated outbound WebSocket from each connected agent. Its command protocol is intentionally request/result shaped: the control plane sends one command, the agent executes it, and one result completes the pending request.
|
||||||
|
|
||||||
|
An interactive terminal has different semantics. It is long-lived, bidirectional, byte-oriented, and must handle window resizing, process exit, disconnect cleanup, and backpressure. Treating it as an ordinary command would either block the agent session loop or turn the main agent WebSocket into a general stream multiplexer. It would also allow terminal output to compete with heartbeats, snapshots, and wake commands.
|
||||||
|
|
||||||
|
Agents commonly run behind NAT and must not expose an inbound listener. Terminal connections therefore remain agent-initiated and outbound.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use one dedicated agent WebSocket and one dedicated operator WebSocket for each terminal session. The control plane holds an in-memory terminal relay that pairs those sockets.
|
||||||
|
|
||||||
|
Session establishment follows this sequence:
|
||||||
|
|
||||||
|
1. An authenticated operator requests a terminal for a connected, terminal-capable agent.
|
||||||
|
2. The control plane creates an ephemeral terminal ID plus short-lived, single-use attachment credentials.
|
||||||
|
3. The control plane sends an open-terminal control message over the existing authenticated agent WebSocket.
|
||||||
|
4. The agent opens a new outbound terminal WebSocket to the control plane and authenticates it for that terminal ID.
|
||||||
|
5. The browser opens the corresponding protected operator terminal WebSocket.
|
||||||
|
6. The control plane pairs the sockets and relays terminal frames while an operator is attached.
|
||||||
|
|
||||||
|
The main agent WebSocket carries terminal creation and cancellation control only. It does not carry PTY input or output.
|
||||||
|
|
||||||
|
## PTY Ownership
|
||||||
|
|
||||||
|
The agent owns the PTY, shell process, process group, and all process cleanup. The control plane never spawns or emulates a shell.
|
||||||
|
|
||||||
|
Use `pty-process` with its async feature as the initial PTY abstraction because it provides:
|
||||||
|
|
||||||
|
- Tokio `AsyncRead` and `AsyncWrite` integration;
|
||||||
|
- owned read and write halves for concurrent tasks;
|
||||||
|
- terminal resizing;
|
||||||
|
- child session-leader and controlling-terminal setup; and
|
||||||
|
- a small implementation built on `rustix`.
|
||||||
|
|
||||||
|
The PTY wrapper is cross-compiled for `armv7-unknown-linux-musleabihf` and its input, output, resize, and exit test has passed on the target router. That focused test remains part of the remote compatibility workflow. Wakey does not call `forkpty` or add its own unsafe PTY implementation.
|
||||||
|
|
||||||
|
The executable, working directory, environment, UID, and GID are agent-controlled configuration. The browser cannot supply an arbitrary executable or process environment.
|
||||||
|
|
||||||
|
## Wire Protocol
|
||||||
|
|
||||||
|
WebSocket frame type separates terminal data from terminal control:
|
||||||
|
|
||||||
|
- Binary frames carry raw PTY input and output bytes.
|
||||||
|
- Text JSON frames carry lifecycle, error, and resize messages.
|
||||||
|
|
||||||
|
The terminal control vocabulary is deliberately small:
|
||||||
|
|
||||||
|
```text
|
||||||
|
operator -> agent: resize, close
|
||||||
|
control plane -> agent: snapshot
|
||||||
|
agent -> operator: ready, exited, error
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them.
|
||||||
|
|
||||||
|
Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. The agent also feeds PTY output into a `vt100` parser so it can reconstruct the current visible screen and terminal input modes after detachment. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol.
|
||||||
|
|
||||||
|
WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol.
|
||||||
|
|
||||||
|
## Lifecycle and Cleanup
|
||||||
|
|
||||||
|
Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection.
|
||||||
|
|
||||||
|
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane does not retain terminal output.
|
||||||
|
|
||||||
|
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||||
|
|
||||||
|
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 5,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session.
|
||||||
|
|
||||||
|
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||||
|
|
||||||
|
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into the agent-owned terminal parser while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
|
||||||
|
|
||||||
|
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
|
||||||
|
|
||||||
|
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
|
||||||
|
|
||||||
|
The agent reports a normal exit status or terminating signal when available. The UI must distinguish an exited shell from a transport failure.
|
||||||
|
|
||||||
|
## Backpressure and Resource Limits
|
||||||
|
|
||||||
|
Terminal transport must not use unbounded queues.
|
||||||
|
|
||||||
|
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 5,000-row scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||||
|
|
||||||
|
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
|
||||||
|
|
||||||
|
The implementation also enforces:
|
||||||
|
|
||||||
|
- maximum terminal frame size;
|
||||||
|
- per-agent concurrent-session limits;
|
||||||
|
- idle and absolute session timeouts;
|
||||||
|
- bounded parsed terminal state;
|
||||||
|
- bounded control-plane relay buffers; and
|
||||||
|
- cleanup when any relay task exits unexpectedly.
|
||||||
|
|
||||||
|
Exact limits are configuration and may be tuned from router testing. The bounded behavior is an architectural requirement.
|
||||||
|
|
||||||
|
## Security and Audit
|
||||||
|
|
||||||
|
Remote terminal access is root-equivalent on agents that run as root. It is therefore an explicit agent capability and is disabled by default.
|
||||||
|
|
||||||
|
Operator terminal endpoints belong to the protected control API surface. Session identifiers are not credentials. Agent and operator attachment credentials are short-lived, scoped to one terminal session, and single-use. The operator WebSocket must enforce the same-origin/protected-control assumptions used when the session was created.
|
||||||
|
|
||||||
|
Audit records include terminal request, open, ready, disconnect, close, timeout, and exit metadata. Wakey never stores or audits terminal input, terminal output, command history, or scrollback.
|
||||||
|
|
||||||
|
Terminal capability and session state are separate from device inventory, known-device identity, wake routes, and the debug Command Runner.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
Persistence across agent restart or machine reboot is not supported. A later design would need an external process owner, reconnectable local IPC, and explicit secret handling; persisted metadata alone cannot recover a PTY.
|
||||||
|
|
||||||
|
File transfer, multi-operator attachment, terminal recording, shell-history storage, and arbitrary process launch are also deferred.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Multiplex terminal bytes over the main agent WebSocket
|
||||||
|
|
||||||
|
Rejected for the initial version. It requires refactoring the current request/result session into a general concurrent writer and stream router, adds head-of-line coupling with fleet traffic, and requires terminal-byte framing within a shared connection.
|
||||||
|
|
||||||
|
### Model a terminal as a long-running agent command
|
||||||
|
|
||||||
|
Rejected. Commands have one result and a timeout; terminals have continuous bidirectional traffic and independent lifecycle.
|
||||||
|
|
||||||
|
### Accept an inbound connection on the agent
|
||||||
|
|
||||||
|
Rejected. Agents run behind NAT and should not expose a new network service.
|
||||||
|
|
||||||
|
### Use `portable-pty`
|
||||||
|
|
||||||
|
Viable, but not selected initially. Its cross-platform abstraction is broader than the Linux agent requires and its reader/writer API would need blocking-task bridges. It remains a fallback if `pty-process` does not build or behave correctly on the target router.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Each active terminal consumes two additional WebSockets and one PTY process on the agent.
|
||||||
|
- Terminal traffic cannot delay main agent heartbeat, snapshot, or command messages.
|
||||||
|
- Control-plane terminal state remains ephemeral; agents rebuild it from their in-memory live-session inventory after a control-plane restart.
|
||||||
|
- The agent session protocol gains terminal-open control and capability advertisement but does not become a terminal-data multiplexer.
|
||||||
|
- Browser and agent terminal transports can be tested independently before adding xterm.js.
|
||||||
|
- Browser navigation and control-plane restart do not terminate an agent-owned PTY.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
*
|
*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!copy.ps1
|
!copy.ps1
|
||||||
|
!wslfix.sh
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# sometimes it breaks, run to fix
|
||||||
|
|
||||||
|
sudo sh -c 'echo :WSLInterop:M::MZ::/init:PF > /usr/lib/binfmt.d/WSLInterop.conf'
|
||||||
|
sudo systemctl restart systemd-binfmt
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
|||||||
Generated
+1388
-2728
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: false
|
||||||
|
msw: false
|
||||||
+24
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
setAgentNickname,
|
setAgentNickname,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AppLayout } from "@/layout/AppLayout";
|
import { AppLayout } from "@/layout/AppLayout";
|
||||||
import { AgentsPage } from "@/pages/AgentsPage";
|
import { AgentsPage } from "@/pages/AgentsPage";
|
||||||
import { AlertsPage } from "@/pages/AlertsPage";
|
import { AlertsPage } from "@/pages/AlertsPage";
|
||||||
@@ -23,6 +24,12 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
|||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { DevicesPage } from "@/pages/DevicesPage";
|
import { DevicesPage } from "@/pages/DevicesPage";
|
||||||
import { TokensPage } from "@/pages/TokensPage";
|
import { TokensPage } from "@/pages/TokensPage";
|
||||||
|
|
||||||
|
const TerminalPage = lazy(() =>
|
||||||
|
import("@/pages/TerminalPage").then((module) => ({
|
||||||
|
default: module.TerminalPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||||
|
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
@@ -215,6 +222,22 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="tokens" element={<TokensPage />} />
|
<Route path="tokens" element={<TokensPage />} />
|
||||||
|
<Route
|
||||||
|
path="terminal"
|
||||||
|
element={
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TerminalPage
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ export type Agent = {
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
|
capabilities: "terminal"[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalSession = {
|
||||||
|
terminal_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
created_at_unix: number;
|
||||||
|
agent_attached: boolean;
|
||||||
|
operator_attached: boolean;
|
||||||
|
websocket_url: string;
|
||||||
|
attachment_token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Alert = {
|
export type Alert = {
|
||||||
@@ -225,6 +236,44 @@ export function fetchAgents(): Promise<Agent[]> {
|
|||||||
return request<Agent[]>("/api/v1/control/agents");
|
return request<Agent[]>("/api/v1/control/agents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createTerminal(
|
||||||
|
agentId: string,
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
): Promise<TerminalSession> {
|
||||||
|
return request<TerminalSession>("/api/v1/control/terminals", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ agent_id: agentId, rows, cols }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTerminals(): Promise<TerminalSession[]> {
|
||||||
|
return request<TerminalSession[]>("/api/v1/control/terminals");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
operatorId: string,
|
||||||
|
): Promise<TerminalSession> {
|
||||||
|
return request<TerminalSession>(
|
||||||
|
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ operator_id: operatorId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeTerminal(terminalId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to close terminal: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAlerts(): Promise<Alert[]> {
|
export function fetchAlerts(): Promise<Alert[]> {
|
||||||
return request<Alert[]>("/api/v1/control/alerts");
|
return request<Alert[]>("/api/v1/control/alerts");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
SquareTerminal,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -57,6 +58,7 @@ const navSections: NavSection[] = [
|
|||||||
{ to: "/audit", label: "Audit", icon: FileText },
|
{ to: "/audit", label: "Audit", icon: FileText },
|
||||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||||
|
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
|
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||||
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
|
import { Terminal as XTerm } from "@xterm/xterm";
|
||||||
|
import { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
APIError,
|
||||||
|
type Agent,
|
||||||
|
type TerminalSession,
|
||||||
|
attachTerminal,
|
||||||
|
closeTerminal,
|
||||||
|
createTerminal,
|
||||||
|
listTerminals,
|
||||||
|
} from "@/api";
|
||||||
|
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConnectionState =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "ready"
|
||||||
|
| "disconnected"
|
||||||
|
| "exited";
|
||||||
|
|
||||||
|
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||||
|
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||||
|
|
||||||
|
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||||
|
const thing = () =>
|
||||||
|
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||||
|
const num = Number(c);
|
||||||
|
return (
|
||||||
|
num ^
|
||||||
|
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||||
|
).toString(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
function terminalOperatorId(): string {
|
||||||
|
// Session storage survives route unmounts but remains scoped to this browser
|
||||||
|
// tab. The ID coordinates attachment ownership; API authentication remains
|
||||||
|
// the security boundary.
|
||||||
|
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||||
|
if (remembered) return remembered;
|
||||||
|
const created = window.crypto.randomUUID?.() ?? thing();
|
||||||
|
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTerminalSession(
|
||||||
|
sessions: TerminalSession[],
|
||||||
|
next: TerminalSession,
|
||||||
|
): TerminalSession[] {
|
||||||
|
const remaining = sessions.filter(
|
||||||
|
(item) => item.terminal_id !== next.terminal_id,
|
||||||
|
);
|
||||||
|
return [...remaining, next].sort(
|
||||||
|
(left, right) => left.created_at_unix - right.created_at_unix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderTerminalSessions(sessions: TerminalSession[]): TerminalSession[] {
|
||||||
|
return [...sessions].sort(
|
||||||
|
(left, right) => left.created_at_unix - right.created_at_unix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileTerminalSessions(
|
||||||
|
current: TerminalSession[],
|
||||||
|
listed: TerminalSession[],
|
||||||
|
): TerminalSession[] {
|
||||||
|
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||||
|
const retained = current.flatMap((item) => {
|
||||||
|
const updated = listedById.get(item.terminal_id);
|
||||||
|
if (!updated) return [];
|
||||||
|
listedById.delete(item.terminal_id);
|
||||||
|
return [updated];
|
||||||
|
});
|
||||||
|
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCandidates(
|
||||||
|
sessions: TerminalSession[],
|
||||||
|
rememberedId: string | null,
|
||||||
|
selectedAgentId: string,
|
||||||
|
): TerminalSession[] {
|
||||||
|
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||||
|
const available = sessions.filter(
|
||||||
|
(item) => !item.operator_attached && item !== remembered,
|
||||||
|
);
|
||||||
|
available.sort((left, right) => {
|
||||||
|
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||||
|
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||||
|
return (
|
||||||
|
rightPreferred - leftPreferred ||
|
||||||
|
right.created_at_unix - left.created_at_unix
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return remembered ? [remembered, ...available] : available;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketUrl(path: string): string {
|
||||||
|
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${scheme}//${window.location.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalCreatedTime(createdAtUnix: number): string {
|
||||||
|
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalPage({
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelectAgent,
|
||||||
|
}: Props) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalRef = useRef<XTerm | null>(null);
|
||||||
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||||
|
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||||
|
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||||
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||||
|
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||||
|
const [connection, setConnection] = useState<ConnectionState>("idle");
|
||||||
|
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [operatorId] = useState(terminalOperatorId);
|
||||||
|
|
||||||
|
activeSessionRef.current = session;
|
||||||
|
selectedAgentIdRef.current = selectedAgentId;
|
||||||
|
|
||||||
|
const selectedAgent = agents.find(
|
||||||
|
(agent) => agent.agent_id === selectedAgentId,
|
||||||
|
);
|
||||||
|
const selectedAgentSessionCount = sessions.filter(
|
||||||
|
(item) => item.agent_id === selectedAgentId,
|
||||||
|
).length;
|
||||||
|
const agentAtSessionLimit = selectedAgentSessionCount >= 2;
|
||||||
|
const canRequestStart =
|
||||||
|
selectedAgent?.connected &&
|
||||||
|
selectedAgent.capabilities.includes("terminal") &&
|
||||||
|
connection !== "connecting";
|
||||||
|
|
||||||
|
const detachTransport = useCallback(() => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
socketRef.current = null;
|
||||||
|
socket?.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendResize = useCallback(() => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!terminal || socket?.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (
|
||||||
|
lastTerminalSizeRef.current.rows === terminal.rows &&
|
||||||
|
lastTerminalSizeRef.current.cols === terminal.cols
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTerminalSizeRef.current = {
|
||||||
|
rows: terminal.rows,
|
||||||
|
cols: terminal.cols,
|
||||||
|
};
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "resize",
|
||||||
|
rows: terminal.rows,
|
||||||
|
cols: terminal.cols,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(
|
||||||
|
(nextSession: TerminalSession) => {
|
||||||
|
if (!nextSession.attachment_token) {
|
||||||
|
throw new Error(
|
||||||
|
"Control plane did not issue a terminal attachment token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
detachTransport();
|
||||||
|
setSession(nextSession);
|
||||||
|
activeSessionRef.current = nextSession;
|
||||||
|
setSessions((current) =>
|
||||||
|
mergeTerminalSession(current, {
|
||||||
|
...nextSession,
|
||||||
|
operator_attached: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
REMEMBERED_TERMINAL_KEY,
|
||||||
|
nextSession.terminal_id,
|
||||||
|
);
|
||||||
|
setConnection("connecting");
|
||||||
|
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "attach",
|
||||||
|
attachment_token: nextSession.attachment_token,
|
||||||
|
operator_id: operatorId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fitRef.current?.fit();
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (socketRef.current !== socket) return;
|
||||||
|
if (typeof event.data !== "string") {
|
||||||
|
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const control = JSON.parse(event.data) as {
|
||||||
|
type: string;
|
||||||
|
exit_code?: number | null;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
if (control.type === "ready") {
|
||||||
|
setConnection("ready");
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||||
|
sendResize();
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
});
|
||||||
|
} else if (control.type === "exited") {
|
||||||
|
setConnection("exited");
|
||||||
|
terminalRef.current?.writeln(
|
||||||
|
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||||
|
);
|
||||||
|
} else if (control.type === "error") {
|
||||||
|
setConnection("exited");
|
||||||
|
terminalRef.current?.writeln(
|
||||||
|
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
terminalRef.current?.writeln("\r\n[invalid terminal control frame]");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (socketRef.current !== socket) return;
|
||||||
|
terminalRef.current?.writeln("\r\n[terminal transport error]");
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (socketRef.current !== socket) return;
|
||||||
|
socketRef.current = null;
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.terminal_id === nextSession.terminal_id
|
||||||
|
? { ...item, operator_attached: false }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setConnection((current) => {
|
||||||
|
if (current === "exited") return current;
|
||||||
|
return "disconnected";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[detachTransport, operatorId, sendResize],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const terminal = new XTerm({
|
||||||
|
cursorBlink: true,
|
||||||
|
convertEol: false,
|
||||||
|
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
// xterm uses this width for its internal scrollbar as well as the
|
||||||
|
// overview ruler. Reserve a larger touch target on coarse pointers.
|
||||||
|
overviewRuler: {
|
||||||
|
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||||
|
},
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: {
|
||||||
|
background: "#0b1117",
|
||||||
|
foreground: "#e5e7eb",
|
||||||
|
cursor: "#6ee7a8",
|
||||||
|
scrollbarSliderBackground: "#52617099",
|
||||||
|
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||||
|
scrollbarSliderActiveBackground: "#8294a6",
|
||||||
|
},
|
||||||
|
allowProposedApi: true, // this for unicode11 addon. FOR SOME reason xtermjs doesnt tell me this in the readme.
|
||||||
|
});
|
||||||
|
terminal.loadAddon(new Unicode11Addon());
|
||||||
|
terminal.unicode.activeVersion = "11";
|
||||||
|
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||||
|
terminal.loadAddon(new WebLinksAddon());
|
||||||
|
const fit = new FitAddon();
|
||||||
|
terminal.loadAddon(new ClipboardAddon());
|
||||||
|
terminal.loadAddon(fit);
|
||||||
|
terminal.open(hostRef.current);
|
||||||
|
terminal.attachCustomKeyEventHandler((event) => {
|
||||||
|
const copiesTerminalSelection =
|
||||||
|
event.type === "keydown" &&
|
||||||
|
event.ctrlKey &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.code === "KeyC";
|
||||||
|
if (!copiesTerminalSelection) return true;
|
||||||
|
|
||||||
|
// Chromium reserves Ctrl+Shift+C for DevTools. Other clipboard shortcuts
|
||||||
|
// fall through to xterm's native copy/paste event handlers.
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (terminal.hasSelection()) {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
toast.error("Clipboard access requires HTTPS or localhost");
|
||||||
|
} else {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(terminal.getSelection())
|
||||||
|
.catch((error) =>
|
||||||
|
toast.error("Clipboard access was denied", {
|
||||||
|
description: String(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const titleChange = terminal.onTitleChange((title) => {
|
||||||
|
const terminalId = activeSessionRef.current?.terminal_id;
|
||||||
|
if (!terminalId) return;
|
||||||
|
const normalized = title.trim();
|
||||||
|
setSessionTitles((current) => {
|
||||||
|
if (current[terminalId] === normalized) return current;
|
||||||
|
if (!normalized) {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[terminalId];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return { ...current, [terminalId]: normalized };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fit.fit();
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
fitRef.current = fit;
|
||||||
|
|
||||||
|
async function restoreTerminalSession() {
|
||||||
|
try {
|
||||||
|
const listed = await listTerminals();
|
||||||
|
if (cancelled) return;
|
||||||
|
setSessions(orderTerminalSessions(listed));
|
||||||
|
const rememberedId = window.sessionStorage.getItem(
|
||||||
|
REMEMBERED_TERMINAL_KEY,
|
||||||
|
);
|
||||||
|
const candidates = restoreCandidates(
|
||||||
|
listed,
|
||||||
|
rememberedId,
|
||||||
|
selectedAgentIdRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
setConnection("connecting");
|
||||||
|
try {
|
||||||
|
const attached = await attachTerminal(
|
||||||
|
candidate.terminal_id,
|
||||||
|
operatorId,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
terminal.reset();
|
||||||
|
connect(attached);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!(error instanceof APIError) ||
|
||||||
|
error.code !== "terminal_operator_already_attached"
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConnection("idle");
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConnection("idle");
|
||||||
|
toast.error("Could not restore terminal session", {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void document.fonts.ready.then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
fit.fit();
|
||||||
|
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = terminal.onData((data) => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(new TextEncoder().encode(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let resizeFrame = 0;
|
||||||
|
let lastHostWidth = 0;
|
||||||
|
let lastHostHeight = 0;
|
||||||
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
|
const width = Math.round(entry.contentRect.width);
|
||||||
|
const height = Math.round(entry.contentRect.height);
|
||||||
|
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||||
|
lastHostWidth = width;
|
||||||
|
lastHostHeight = height;
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
resizeFrame = window.requestAnimationFrame(() => {
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
resizeObserver.observe(hostRef.current);
|
||||||
|
void restoreTerminalSession();
|
||||||
|
const sessionRefresh = window.setInterval(() => {
|
||||||
|
void listTerminals()
|
||||||
|
.then((listed) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessions((current) =>
|
||||||
|
reconcileTerminalSessions(current, listed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The attached terminal transport remains authoritative while a
|
||||||
|
// background list refresh is temporarily unavailable.
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
input.dispose();
|
||||||
|
titleChange.dispose();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
window.clearInterval(sessionRefresh);
|
||||||
|
detachTransport();
|
||||||
|
terminal.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
};
|
||||||
|
}, [connect, detachTransport, sendResize]);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!selectedAgentId || !terminalRef.current) return;
|
||||||
|
if (agentAtSessionLimit) {
|
||||||
|
toast.error("Terminal session limit reached", {
|
||||||
|
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousSession = activeSessionRef.current;
|
||||||
|
detachTransport();
|
||||||
|
if (previousSession) {
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.terminal_id === previousSession.terminal_id
|
||||||
|
? { ...item, operator_attached: false }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setConnection("connecting");
|
||||||
|
terminalRef.current.reset();
|
||||||
|
try {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
const created = await createTerminal(
|
||||||
|
selectedAgentId,
|
||||||
|
terminalRef.current.rows,
|
||||||
|
terminalRef.current.cols,
|
||||||
|
);
|
||||||
|
connect(created);
|
||||||
|
} catch (error) {
|
||||||
|
setSession(null);
|
||||||
|
activeSessionRef.current = null;
|
||||||
|
setConnection("idle");
|
||||||
|
toast.error("Could not start terminal", { description: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateSession(nextSession: TerminalSession) {
|
||||||
|
if (nextSession.terminal_id === session?.terminal_id) return;
|
||||||
|
if (nextSession.operator_attached) return;
|
||||||
|
|
||||||
|
const previousSession = activeSessionRef.current;
|
||||||
|
detachTransport();
|
||||||
|
if (previousSession) {
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.terminal_id === previousSession.terminal_id
|
||||||
|
? { ...item, operator_attached: false }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSession(null);
|
||||||
|
activeSessionRef.current = null;
|
||||||
|
setConnection("connecting");
|
||||||
|
terminalRef.current?.reset();
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
REMEMBERED_TERMINAL_KEY,
|
||||||
|
nextSession.terminal_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attached = await attachTerminal(
|
||||||
|
nextSession.terminal_id,
|
||||||
|
operatorId,
|
||||||
|
);
|
||||||
|
connect(attached);
|
||||||
|
} catch (error) {
|
||||||
|
setConnection("idle");
|
||||||
|
toast.error("Could not attach terminal session", {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
void listTerminals()
|
||||||
|
.then((listed) =>
|
||||||
|
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnect() {
|
||||||
|
if (!session) return;
|
||||||
|
detachTransport();
|
||||||
|
try {
|
||||||
|
terminalRef.current?.reset();
|
||||||
|
setConnection("connecting");
|
||||||
|
const attached = await attachTerminal(session.terminal_id, operatorId);
|
||||||
|
connect(attached);
|
||||||
|
} catch (error) {
|
||||||
|
setConnection("disconnected");
|
||||||
|
toast.error("Terminal session is no longer available", {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSession(closingSession: TerminalSession) {
|
||||||
|
const closingId = closingSession.terminal_id;
|
||||||
|
const closesActiveSession =
|
||||||
|
closingId === activeSessionRef.current?.terminal_id;
|
||||||
|
const remaining = sessions.filter((item) => item.terminal_id !== closingId);
|
||||||
|
const fallback = remaining.find((item) => !item.operator_attached);
|
||||||
|
|
||||||
|
if (closesActiveSession) {
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||||
|
}
|
||||||
|
detachTransport();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await closeTerminal(closingId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||||
|
void listTerminals()
|
||||||
|
.then((listed) =>
|
||||||
|
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessions((current) =>
|
||||||
|
current.filter((item) => item.terminal_id !== closingId),
|
||||||
|
);
|
||||||
|
setSessionTitles((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[closingId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||||
|
) {
|
||||||
|
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||||
|
}
|
||||||
|
if (closesActiveSession) {
|
||||||
|
setSession(null);
|
||||||
|
activeSessionRef.current = null;
|
||||||
|
setConnection("idle");
|
||||||
|
// xterm.clear() deliberately preserves the active cursor line. Closing
|
||||||
|
// a session should discard its complete screen and terminal modes.
|
||||||
|
terminalRef.current?.reset();
|
||||||
|
if (fallback) void activateSession(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="terminal-page" aria-label="Remote terminal">
|
||||||
|
<header className="terminal-toolbar">
|
||||||
|
<div className="terminal-heading">
|
||||||
|
<Terminal className="size-5 text-primary" aria-hidden />
|
||||||
|
<h1>Remote Terminal</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-controls">
|
||||||
|
<AgentSelector
|
||||||
|
agents={agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.connected && agent.capabilities.includes("terminal"),
|
||||||
|
)}
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={onSelectAgent}
|
||||||
|
disabled={connection === "connecting"}
|
||||||
|
className="w-full min-w-0 sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!selectedAgent && connection === "idle" ? (
|
||||||
|
<p className="terminal-notice">
|
||||||
|
No connected terminal-capable agent is selected.
|
||||||
|
</p>
|
||||||
|
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
|
||||||
|
<p className="terminal-notice">
|
||||||
|
This agent has not enabled remote terminal capability.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="terminal-frame">
|
||||||
|
<div className="terminal-framebar">
|
||||||
|
<div className="terminal-tabs">
|
||||||
|
<div
|
||||||
|
className="terminal-tab-list"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Terminal sessions"
|
||||||
|
>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="terminal-session-label">
|
||||||
|
<Terminal className="size-4" aria-hidden />
|
||||||
|
<span>No active session</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((item) => {
|
||||||
|
const agent = agents.find(
|
||||||
|
(candidate) => candidate.agent_id === item.agent_id,
|
||||||
|
);
|
||||||
|
const active = item.terminal_id === session?.terminal_id;
|
||||||
|
const locked = item.operator_attached && !active;
|
||||||
|
const agentLabel = agent
|
||||||
|
? displayAgentLabel(agent)
|
||||||
|
: item.agent_id;
|
||||||
|
const sessionTitle = sessionTitles[item.terminal_id];
|
||||||
|
const tabLabel = sessionTitle
|
||||||
|
? `${agentLabel} · ${sessionTitle}`
|
||||||
|
: agentLabel;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.terminal_id}
|
||||||
|
role="presentation"
|
||||||
|
className="terminal-tab"
|
||||||
|
data-active={active || undefined}
|
||||||
|
data-locked={locked || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
className="terminal-tab-select"
|
||||||
|
disabled={locked || connection === "connecting"}
|
||||||
|
title={
|
||||||
|
locked ? "Attached in another browser" : tabLabel
|
||||||
|
}
|
||||||
|
onClick={() => void activateSession(item)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="terminal-tab-dot"
|
||||||
|
data-state={
|
||||||
|
active
|
||||||
|
? connection
|
||||||
|
: locked
|
||||||
|
? "attached"
|
||||||
|
: item.agent_attached
|
||||||
|
? "detached"
|
||||||
|
: "disconnected"
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="terminal-tab-label">{tabLabel}</span>
|
||||||
|
<time
|
||||||
|
dateTime={new Date(
|
||||||
|
item.created_at_unix * 1000,
|
||||||
|
).toISOString()}
|
||||||
|
>
|
||||||
|
{terminalCreatedTime(item.created_at_unix)}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="terminal-tab-close"
|
||||||
|
disabled={locked}
|
||||||
|
aria-label={`Close ${agentLabel} terminal session`}
|
||||||
|
title={
|
||||||
|
locked
|
||||||
|
? "Attached in another browser"
|
||||||
|
: "Close session"
|
||||||
|
}
|
||||||
|
onClick={() => void closeSession(item)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-new-tab"
|
||||||
|
disabled={!canRequestStart}
|
||||||
|
aria-label="New terminal session"
|
||||||
|
onClick={start}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{agentAtSessionLimit
|
||||||
|
? "Two-session limit reached"
|
||||||
|
: "New session"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-frame-actions">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="terminal-status"
|
||||||
|
data-state={connection}
|
||||||
|
>
|
||||||
|
<span aria-hidden />
|
||||||
|
{connection}
|
||||||
|
</Badge>
|
||||||
|
{connection === "disconnected" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={reconnect}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" aria-hidden />
|
||||||
|
Reconnect
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={connection === "idle"}
|
||||||
|
aria-label="Clear terminal"
|
||||||
|
onClick={() => terminalRef.current?.clear()}
|
||||||
|
>
|
||||||
|
<Eraser className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Clear terminal</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-surface" ref={hostRef} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
agentLabel,
|
agentLabel,
|
||||||
formatSeen,
|
formatSeen,
|
||||||
identifiersFor,
|
identifiersFor,
|
||||||
|
presenceRank,
|
||||||
routeLabel,
|
routeLabel,
|
||||||
} from "@/pages/fleet/utils";
|
} from "@/pages/fleet/utils";
|
||||||
|
|
||||||
@@ -189,7 +190,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -237,8 +238,20 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<Badge variant="outline">{device.endpoints.length}</Badge>
|
<Badge variant="outline">{device.endpoints.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{device.endpoints.length ? (
|
{device.endpoints.length ? (
|
||||||
<div className="divide-y overflow-hidden rounded-md border">
|
<div className="grid gap-1.5">
|
||||||
{device.endpoints.map((endpoint, index) => (
|
<div className="hidden grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground sm:grid">
|
||||||
|
<span>Source</span>
|
||||||
|
<span>Target</span>
|
||||||
|
<span className="text-right">Last seen</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{[...device.endpoints]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const p = presenceRank(b.presence) - presenceRank(a.presence);
|
||||||
|
if (p !== 0) return p;
|
||||||
|
return (b.last_seen_unix ?? 0) - (a.last_seen_unix ?? 0);
|
||||||
|
})
|
||||||
|
.map((endpoint, index) => (
|
||||||
<EndpointRow
|
<EndpointRow
|
||||||
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
|
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
|
||||||
endpoint={endpoint}
|
endpoint={endpoint}
|
||||||
@@ -557,7 +570,7 @@ function EndpointRow({
|
|||||||
.join(" / ");
|
.join(" / ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 p-3 text-sm sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_auto] sm:items-center">
|
<div className="grid gap-3 rounded-md border bg-card px-3 py-2.5 text-sm sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] sm:items-center sm:gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
<PresenceBadge presence={endpoint.presence} />
|
<PresenceBadge presence={endpoint.presence} />
|
||||||
@@ -570,14 +583,14 @@ function EndpointRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-mono text-xs">{target || "-"}</p>
|
<p className="truncate font-mono text-xs">{target || "-"}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{location || "No interface or hostname"}
|
{location || "No interface or hostname"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
<span className="whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatSeen(endpoint.last_seen_unix)}
|
{formatSeen(endpoint.last_seen_unix)}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex justify-end">
|
||||||
{target && (
|
{target && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -192,6 +192,12 @@
|
|||||||
transition: background 100ms ease-in-out;
|
transition: background 100ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* xterm focuses an input near the active row. Keep that focus operation from
|
||||||
|
scrolling the page shell and moving the terminal toolbar off-screen. */
|
||||||
|
.app-main:has(.terminal-page) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
@@ -316,6 +322,307 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Remote terminal ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.terminal-page {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100dvh - 2.5rem);
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status {
|
||||||
|
gap: 0.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
padding-inline: 0.45rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status > span {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--presence-unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status[data-state="ready"] > span {
|
||||||
|
background: var(--presence-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status[data-state="connecting"] > span {
|
||||||
|
background: var(--presence-likely);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status[data-state="disconnected"] > span,
|
||||||
|
.terminal-status[data-state="exited"] > span {
|
||||||
|
background: var(--presence-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-notice {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-frame {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0b1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-framebar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.35rem 0.45rem 0.35rem 0.75rem;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, white 9%, transparent);
|
||||||
|
background: #101820;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-session-label,
|
||||||
|
.terminal-frame-actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tabs {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-list::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(20rem, 48vw);
|
||||||
|
height: 1.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aab7c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-select {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0 0.35rem 0 0.6rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #7f8d9a;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab[data-active] {
|
||||||
|
max-width: min(30rem, 60vw);
|
||||||
|
border-color: rgb(255 255 255 / 10%);
|
||||||
|
background: rgb(255 255 255 / 7%);
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab[data-locked] {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close {
|
||||||
|
display: grid;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--radius) - 3px);
|
||||||
|
background: transparent;
|
||||||
|
color: #7f8d9a;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close:focus-visible,
|
||||||
|
.terminal-tab-close:hover,
|
||||||
|
.terminal-tab[data-active] .terminal-tab-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close:disabled {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-new-tab {
|
||||||
|
width: 1.85rem;
|
||||||
|
height: 1.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-dot {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--presence-unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-dot[data-state="ready"],
|
||||||
|
.terminal-tab-dot[data-state="attached"] {
|
||||||
|
background: var(--presence-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-dot[data-state="connecting"],
|
||||||
|
.terminal-tab-dot[data-state="detached"] {
|
||||||
|
background: var(--presence-likely);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-dot[data-state="disconnected"],
|
||||||
|
.terminal-tab-dot[data-state="exited"] {
|
||||||
|
background: var(--presence-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-frame-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-session-label > span:not(.terminal-status) {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #dbe4ee;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 550;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-framebar button {
|
||||||
|
color: #aab7c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-framebar button:hover {
|
||||||
|
color: #f1f5f9;
|
||||||
|
background: rgb(255 255 255 / 7%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b1117;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface .xterm {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #0b1117;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface .xterm-viewport {
|
||||||
|
/* xterm defaults this element to pure black. It remains visible around the
|
||||||
|
fitted canvas, so keep it on the same surface as the terminal theme. */
|
||||||
|
background-color: #0b1117;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface .xterm-scrollable-element > .scrollbar,
|
||||||
|
.terminal-surface .xterm-scrollable-element > .scrollbar > .slider {
|
||||||
|
/* Without this, mobile browsers claim a thumb drag as page panning before
|
||||||
|
xterm's pointer-capture scrollbar can process it. */
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.terminal-page {
|
||||||
|
height: calc(100dvh - 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface .xterm {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.presence-dot {
|
.presence-dot {
|
||||||
|
|||||||
+1
Submodule vendor/vt100 added at e9ec458d9b.
@@ -23,6 +23,7 @@ serde_json = "1"
|
|||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"macros",
|
"macros",
|
||||||
|
"io-util",
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"time",
|
"time",
|
||||||
"signal",
|
"signal",
|
||||||
@@ -38,5 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
|
|||||||
] }
|
] }
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
vt100 = "0.16.2"
|
||||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||||
@@ -35,6 +35,28 @@ pub struct AgentConfig {
|
|||||||
pub mac_name_cache_path: PathBuf,
|
pub mac_name_cache_path: PathBuf,
|
||||||
#[serde(default = "default_observation_store_path")]
|
#[serde(default = "default_observation_store_path")]
|
||||||
pub observation_store_path: PathBuf,
|
pub observation_store_path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal: TerminalConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TerminalConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_terminal_shell")]
|
||||||
|
pub shell: PathBuf,
|
||||||
|
#[serde(default = "default_terminal_max_sessions")]
|
||||||
|
pub max_sessions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerminalConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
shell: default_terminal_shell(),
|
||||||
|
max_sessions: default_terminal_max_sessions(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig {
|
pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig {
|
||||||
@@ -49,6 +71,7 @@ pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig
|
|||||||
dhcp_leases_path: default_dhcp_leases_path(),
|
dhcp_leases_path: default_dhcp_leases_path(),
|
||||||
mac_name_cache_path: default_mac_name_cache_path(),
|
mac_name_cache_path: default_mac_name_cache_path(),
|
||||||
observation_store_path: default_observation_store_path(),
|
observation_store_path: default_observation_store_path(),
|
||||||
|
terminal: TerminalConfig::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
impl fmt::Debug for AgentConfig {
|
impl fmt::Debug for AgentConfig {
|
||||||
@@ -71,6 +94,7 @@ impl fmt::Debug for AgentConfig {
|
|||||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||||
.field("observation_store_path", &self.observation_store_path)
|
.field("observation_store_path", &self.observation_store_path)
|
||||||
|
.field("terminal", &self.terminal)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +131,14 @@ fn default_observation_store_path() -> PathBuf {
|
|||||||
DEFAULT_OBSERVATION_STORE_PATH.into()
|
DEFAULT_OBSERVATION_STORE_PATH.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_terminal_shell() -> PathBuf {
|
||||||
|
"/bin/sh".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_terminal_max_sessions() -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||||
vec![
|
vec![
|
||||||
@@ -214,6 +246,11 @@ mod tests {
|
|||||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||||
observation_store_path: "/tmp/test-observations.json".into(),
|
observation_store_path: "/tmp/test-observations.json".into(),
|
||||||
|
terminal: TerminalConfig {
|
||||||
|
enabled: true,
|
||||||
|
shell: "/bin/sh".into(),
|
||||||
|
max_sessions: 2,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
save_config(&path, &config).expect("save");
|
save_config(&path, &config).expect("save");
|
||||||
@@ -245,5 +282,6 @@ agent_token = "secret"
|
|||||||
config.observation_retention_days,
|
config.observation_retention_days,
|
||||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||||
);
|
);
|
||||||
|
assert_eq!(config.terminal, TerminalConfig::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,11 @@ mod tests {
|
|||||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
||||||
observation_store_path: "/tmp/custom-observations.json".into(),
|
observation_store_path: "/tmp/custom-observations.json".into(),
|
||||||
|
terminal: crate::config::TerminalConfig {
|
||||||
|
enabled: true,
|
||||||
|
shell: "/bin/ash".into(),
|
||||||
|
max_sessions: 2,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = enroll(&server_url, "enroll-abc", &path, Some(&base_config))
|
let outcome = enroll(&server_url, "enroll-abc", &path, Some(&base_config))
|
||||||
@@ -164,6 +169,7 @@ mod tests {
|
|||||||
assert_eq!(config.observation_retention_days, 11);
|
assert_eq!(config.observation_retention_days, 11);
|
||||||
assert_eq!(config.pid_file, base_config.pid_file);
|
assert_eq!(config.pid_file, base_config.pid_file);
|
||||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||||
|
assert_eq!(config.terminal, base_config.terminal);
|
||||||
assert!(outcome.backup_path.is_none());
|
assert!(outcome.backup_path.is_none());
|
||||||
|
|
||||||
let persisted = crate::config::load_config(&path).expect("load persisted config");
|
let persisted = crate::config::load_config(&path).expect("load persisted config");
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ mod cli;
|
|||||||
mod config;
|
mod config;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
mod enroll;
|
mod enroll;
|
||||||
mod protocol;
|
|
||||||
mod serve;
|
mod serve;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod terminal;
|
||||||
mod tracing;
|
mod tracing;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
|
use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
|
||||||
|
use wakey_agent::protocol;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
|||||||
@@ -17,6 +17,89 @@ impl RequestId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct TerminalId(String);
|
||||||
|
|
||||||
|
impl TerminalId {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err("terminal_id must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TerminalId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for TerminalId {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TerminalId {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = String::deserialize(deserializer)?;
|
||||||
|
Self::try_from(raw).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentCapability {
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AgentTerminalSession {
|
||||||
|
pub terminal_id: TerminalId,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum TerminalControl {
|
||||||
|
Resize { rows: u16, cols: u16 },
|
||||||
|
Snapshot,
|
||||||
|
Ready,
|
||||||
|
Exited { exit_code: Option<i32> },
|
||||||
|
Error { code: String, message: String },
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum TerminalAgentHandshake {
|
||||||
|
Auth {
|
||||||
|
agent_id: String,
|
||||||
|
relay_token: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum TerminalOperatorHandshake {
|
||||||
|
Attach {
|
||||||
|
attachment_token: String,
|
||||||
|
operator_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
impl TryFrom<String> for RequestId {
|
impl TryFrom<String> for RequestId {
|
||||||
type Error = String;
|
type Error = String;
|
||||||
|
|
||||||
@@ -153,6 +236,8 @@ pub enum CommandResult {
|
|||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
Hello {
|
Hello {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
capabilities: Vec<AgentCapability>,
|
||||||
},
|
},
|
||||||
Auth {
|
Auth {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
@@ -173,6 +258,13 @@ pub enum ClientMessage {
|
|||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
error: ErrorPayload,
|
error: ErrorPayload,
|
||||||
},
|
},
|
||||||
|
TerminalRejected {
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
error: ErrorPayload,
|
||||||
|
},
|
||||||
|
TerminalSessions {
|
||||||
|
sessions: Vec<AgentTerminalSession>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -183,6 +275,20 @@ pub enum ServerMessage {
|
|||||||
command: AgentCommand,
|
command: AgentCommand,
|
||||||
},
|
},
|
||||||
SyncDeviceSnapshot,
|
SyncDeviceSnapshot,
|
||||||
|
SyncTerminalSessions,
|
||||||
|
OpenTerminal {
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
relay_token: String,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
},
|
||||||
|
CloseTerminal {
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
},
|
||||||
|
ResumeTerminal {
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
relay_token: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -209,6 +315,16 @@ mod tests {
|
|||||||
assert!(err.contains("must not be empty"));
|
assert!(err.contains("must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_id_deserialization_enforces_validation() {
|
||||||
|
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
||||||
|
assert_eq!(valid.as_str(), "term-1");
|
||||||
|
|
||||||
|
let error = serde_json::from_str::<TerminalId>(r#"" ""#)
|
||||||
|
.expect_err("empty terminal id must fail");
|
||||||
|
assert!(error.to_string().contains("must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_result_with_devs_serializes() {
|
fn client_result_with_devs_serializes() {
|
||||||
let msg = ClientMessage::Result {
|
let msg = ClientMessage::Result {
|
||||||
@@ -232,4 +348,53 @@ mod tests {
|
|||||||
assert!(json.contains("\"type\":\"device_snapshot\""));
|
assert!(json.contains("\"type\":\"device_snapshot\""));
|
||||||
assert!(json.contains("\"devices\""));
|
assert!(json.contains("\"devices\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_messages_serialize_with_explicit_frame_types() {
|
||||||
|
let message = ServerMessage::OpenTerminal {
|
||||||
|
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||||
|
relay_token: "secret".into(),
|
||||||
|
rows: 30,
|
||||||
|
cols: 120,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&message).expect("serialize open terminal");
|
||||||
|
assert!(json.contains("\"type\":\"open_terminal\""));
|
||||||
|
assert!(json.contains("\"terminal_id\":\"term-1\""));
|
||||||
|
|
||||||
|
let resize = TerminalControl::Resize {
|
||||||
|
rows: 40,
|
||||||
|
cols: 160,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&resize).expect("serialize resize");
|
||||||
|
assert_eq!(json, r#"{"type":"resize","rows":40,"cols":160}"#);
|
||||||
|
let snapshot =
|
||||||
|
serde_json::to_string(&TerminalControl::Snapshot).expect("serialize snapshot");
|
||||||
|
assert_eq!(snapshot, r#"{"type":"snapshot"}"#);
|
||||||
|
let operator = TerminalOperatorHandshake::Attach {
|
||||||
|
attachment_token: "attach-secret".into(),
|
||||||
|
operator_id: "browser-tab".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&operator).expect("serialize operator handshake");
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
r#"{"type":"attach","attachment_token":"attach-secret","operator_id":"browser-tab"}"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let inventory = ClientMessage::TerminalSessions {
|
||||||
|
sessions: vec![AgentTerminalSession {
|
||||||
|
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||||
|
created_at_unix: 42,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&inventory).expect("serialize terminal inventory");
|
||||||
|
assert!(json.contains("\"type\":\"terminal_sessions\""));
|
||||||
|
assert!(json.contains("\"created_at_unix\":42"));
|
||||||
|
|
||||||
|
let resume = ServerMessage::ResumeTerminal {
|
||||||
|
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||||
|
relay_token: "replacement".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&resume).expect("serialize terminal resume");
|
||||||
|
assert!(json.contains("\"type\":\"resume_terminal\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,16 @@ use tracing::{debug, error, info, info_span, warn};
|
|||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::dispatch::{dispatch_command, inventory_for_config};
|
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
use crate::protocol::{AgentCapability, AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||||
|
use crate::terminal::TerminalManager;
|
||||||
|
|
||||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||||
|
// Terminal workers belong to the agent process, not a single control socket.
|
||||||
|
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
|
||||||
|
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
|
||||||
let mut backoff = config.reconnect_base_ms.max(100);
|
let mut backoff = config.reconnect_base_ms.max(100);
|
||||||
loop {
|
loop {
|
||||||
match run_once(&config).await {
|
match run_once(&config, &terminal_manager, &mut terminal_events).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
backoff = config.reconnect_base_ms.max(100);
|
backoff = config.reconnect_base_ms.max(100);
|
||||||
}
|
}
|
||||||
@@ -26,7 +30,13 @@ pub async fn run(config: AgentConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_once(config: &AgentConfig) -> Result<()> {
|
async fn run_once(
|
||||||
|
config: &AgentConfig,
|
||||||
|
terminal_manager: &TerminalManager,
|
||||||
|
terminal_events: &mut tokio::sync::mpsc::UnboundedReceiver<
|
||||||
|
crate::terminal::TerminalManagerEvent,
|
||||||
|
>,
|
||||||
|
) -> Result<()> {
|
||||||
let session_id = format!(
|
let session_id = format!(
|
||||||
"{}-{}",
|
"{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -71,6 +81,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
&mut sink,
|
&mut sink,
|
||||||
&ClientMessage::Hello {
|
&ClientMessage::Hello {
|
||||||
agent_id: config.agent_id.clone(),
|
agent_id: config.agent_id.clone(),
|
||||||
|
capabilities: agent_capabilities(config),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -82,6 +93,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
send_terminal_sessions(&mut sink, terminal_manager).await?;
|
||||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||||
|
|
||||||
let mut heartbeat = interval(Duration::from_secs(30));
|
let mut heartbeat = interval(Duration::from_secs(30));
|
||||||
@@ -91,9 +103,21 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
));
|
));
|
||||||
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
snapshot_sync.reset();
|
snapshot_sync.reset();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
Some(event) = terminal_events.recv() => {
|
||||||
|
send_json(
|
||||||
|
&mut sink,
|
||||||
|
&ClientMessage::TerminalRejected {
|
||||||
|
terminal_id: event.terminal_id,
|
||||||
|
error: ErrorPayload {
|
||||||
|
code: "terminal_worker_failed".into(),
|
||||||
|
message: event.error,
|
||||||
|
retryable: Some(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
_ = heartbeat.tick() => {
|
_ = heartbeat.tick() => {
|
||||||
send_json(&mut sink, &ClientMessage::Heartbeat {
|
send_json(&mut sink, &ClientMessage::Heartbeat {
|
||||||
agent_id: config.agent_id.clone(),
|
agent_id: config.agent_id.clone(),
|
||||||
@@ -118,7 +142,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
Message::Text(text) => {
|
Message::Text(text) => {
|
||||||
match serde_json::from_str::<ServerMessage>(&text) {
|
match serde_json::from_str::<ServerMessage>(&text) {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
handle_server_message(config, &mut sink, &mut snapshot_sync, message).await?;
|
handle_server_message(config, terminal_manager, &mut sink, &mut snapshot_sync, message).await?;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// Allow the server to introduce extra frame types without
|
// Allow the server to introduce extra frame types without
|
||||||
@@ -145,6 +169,20 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_terminal_sessions<S>(sink: &mut S, manager: &TerminalManager) -> Result<()>
|
||||||
|
where
|
||||||
|
S: SinkExt<Message> + Unpin,
|
||||||
|
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
send_json(
|
||||||
|
sink,
|
||||||
|
&ClientMessage::TerminalSessions {
|
||||||
|
sessions: manager.sessions(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||||
where
|
where
|
||||||
S: SinkExt<Message> + Unpin,
|
S: SinkExt<Message> + Unpin,
|
||||||
@@ -181,6 +219,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
|||||||
|
|
||||||
async fn handle_server_message<S>(
|
async fn handle_server_message<S>(
|
||||||
config: &AgentConfig,
|
config: &AgentConfig,
|
||||||
|
terminal_manager: &TerminalManager,
|
||||||
sink: &mut S,
|
sink: &mut S,
|
||||||
snapshot_sync: &mut tokio::time::Interval,
|
snapshot_sync: &mut tokio::time::Interval,
|
||||||
message: ServerMessage,
|
message: ServerMessage,
|
||||||
@@ -223,6 +262,48 @@ where
|
|||||||
send_device_snapshot_ws(sink, config).await?;
|
send_device_snapshot_ws(sink, config).await?;
|
||||||
snapshot_sync.reset();
|
snapshot_sync.reset();
|
||||||
}
|
}
|
||||||
|
ServerMessage::SyncTerminalSessions => {
|
||||||
|
send_terminal_sessions(sink, terminal_manager).await?;
|
||||||
|
}
|
||||||
|
ServerMessage::OpenTerminal {
|
||||||
|
terminal_id,
|
||||||
|
relay_token,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
} => {
|
||||||
|
info!(terminal_id = %terminal_id, rows, cols, "received terminal open request");
|
||||||
|
if let Err(err) =
|
||||||
|
terminal_manager.open(config, terminal_id.clone(), relay_token, rows, cols)
|
||||||
|
{
|
||||||
|
error!(terminal_id = %terminal_id, error = %err, "terminal open request rejected");
|
||||||
|
send_json(
|
||||||
|
sink,
|
||||||
|
&ClientMessage::TerminalRejected {
|
||||||
|
terminal_id,
|
||||||
|
error: ErrorPayload {
|
||||||
|
code: "terminal_open_rejected".into(),
|
||||||
|
message: err.to_string(),
|
||||||
|
retryable: Some(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerMessage::CloseTerminal { terminal_id } => {
|
||||||
|
info!(terminal_id = %terminal_id, "received terminal close request");
|
||||||
|
terminal_manager.close(&terminal_id);
|
||||||
|
}
|
||||||
|
ServerMessage::ResumeTerminal {
|
||||||
|
terminal_id,
|
||||||
|
relay_token,
|
||||||
|
} => {
|
||||||
|
info!(terminal_id = %terminal_id, "received terminal relay resume request");
|
||||||
|
if let Err(err) = terminal_manager.resume(&terminal_id, relay_token) {
|
||||||
|
warn!(terminal_id = %terminal_id, error = %err, "terminal relay resume rejected");
|
||||||
|
send_terminal_sessions(sink, terminal_manager).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -301,9 +382,20 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
|||||||
ClientMessage::DeviceSnapshot { .. } => "device_snapshot",
|
ClientMessage::DeviceSnapshot { .. } => "device_snapshot",
|
||||||
ClientMessage::Result { .. } => "result",
|
ClientMessage::Result { .. } => "result",
|
||||||
ClientMessage::Error { .. } => "error",
|
ClientMessage::Error { .. } => "error",
|
||||||
|
ClientMessage::TerminalRejected { .. } => "terminal_rejected",
|
||||||
|
ClientMessage::TerminalSessions { .. } => "terminal_sessions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn agent_capabilities(config: &AgentConfig) -> Vec<AgentCapability> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
if config.terminal.enabled {
|
||||||
|
return vec![AgentCapability::Terminal];
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
||||||
let base = url::Url::parse(server_url).context("invalid server_url")?;
|
let base = url::Url::parse(server_url).context("invalid server_url")?;
|
||||||
let scheme = match base.scheme() {
|
let scheme = match base.scheme() {
|
||||||
|
|||||||
@@ -0,0 +1,865 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex, Weak};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::AgentConfig;
|
||||||
|
use crate::protocol::{AgentTerminalSession, TerminalAgentHandshake, TerminalControl, TerminalId};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024;
|
||||||
|
const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
|
||||||
|
const RELAY_INPUT_QUEUE: usize = 32;
|
||||||
|
const PTY_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
const PROCESS_SIGNAL_GRACE: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Tracks the terminal's current rendered state while the browser is detached.
|
||||||
|
///
|
||||||
|
/// A snapshot is terminal escape output, so the browser can restore the screen
|
||||||
|
/// through its normal parser without a second state protocol.
|
||||||
|
struct TerminalState {
|
||||||
|
parser: vt100::Parser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalState {
|
||||||
|
fn new(rows: u16, cols: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
parser: vt100::Parser::new(rows, cols, TERMINAL_SCROLLBACK_ROWS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, bytes: &[u8]) {
|
||||||
|
self.parser.process(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(&mut self, rows: u16, cols: u16) {
|
||||||
|
self.parser.screen_mut().set_size(rows, cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Vec<u8> {
|
||||||
|
self.parser
|
||||||
|
.screen()
|
||||||
|
.snapshot_formatted(TERMINAL_SCROLLBACK_ROWS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveTerminal {
|
||||||
|
cancel: Option<oneshot::Sender<()>>,
|
||||||
|
relay_credentials: mpsc::UnboundedSender<String>,
|
||||||
|
created_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns PTY workers independently of any individual control-plane connection.
|
||||||
|
pub struct TerminalManager {
|
||||||
|
active: Arc<Mutex<HashMap<String, ActiveTerminal>>>,
|
||||||
|
max_sessions: usize,
|
||||||
|
events: mpsc::UnboundedSender<TerminalManagerEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TerminalManagerEvent {
|
||||||
|
pub terminal_id: TerminalId,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalManager {
|
||||||
|
pub fn new(config: &AgentConfig) -> (Self, mpsc::UnboundedReceiver<TerminalManagerEvent>) {
|
||||||
|
let (events, event_rx) = mpsc::unbounded_channel();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
active: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_sessions: config.terminal.max_sessions.max(1),
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
event_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(
|
||||||
|
&self,
|
||||||
|
config: &AgentConfig,
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
relay_token: String,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !config.terminal.enabled {
|
||||||
|
anyhow::bail!("terminal capability is disabled");
|
||||||
|
}
|
||||||
|
validate_size(rows, cols)?;
|
||||||
|
|
||||||
|
let terminal_key = terminal_id.to_string();
|
||||||
|
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||||
|
let (relay_tx, relay_rx) = mpsc::unbounded_channel();
|
||||||
|
let created_at_unix = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
{
|
||||||
|
let mut active = self.active.lock().expect("terminal manager poisoned");
|
||||||
|
if active.contains_key(&terminal_key) {
|
||||||
|
anyhow::bail!("terminal session {terminal_key} is already active");
|
||||||
|
}
|
||||||
|
if active.len() >= self.max_sessions {
|
||||||
|
anyhow::bail!("agent terminal session limit reached");
|
||||||
|
}
|
||||||
|
active.insert(
|
||||||
|
terminal_key.clone(),
|
||||||
|
ActiveTerminal {
|
||||||
|
cancel: Some(cancel_tx),
|
||||||
|
relay_credentials: relay_tx.clone(),
|
||||||
|
created_at_unix,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = relay_tx.send(relay_token);
|
||||||
|
|
||||||
|
let config = config.clone();
|
||||||
|
let active = Arc::downgrade(&self.active);
|
||||||
|
let events = self.events.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = run_terminal(&config, &terminal_id, rows, cols, cancel_rx, relay_rx).await;
|
||||||
|
// Inventory is authoritative. Remove the stopped worker before
|
||||||
|
// notifying the control session, which may immediately resync it.
|
||||||
|
remove_completed(&active, terminal_id.as_str());
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(terminal_id = %terminal_id, error = %err, "terminal worker failed");
|
||||||
|
let _ = events.send(TerminalManagerEvent {
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
error: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&self, terminal_id: &TerminalId) -> bool {
|
||||||
|
let mut active = self.active.lock().expect("terminal manager poisoned");
|
||||||
|
let Some(terminal) = active.get_mut(terminal_id.as_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(cancel) = terminal.cancel.take() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// A failed send means the worker has already dropped its receiver.
|
||||||
|
// Its wrapper still owns final registry removal, so this close request
|
||||||
|
// is successful either way.
|
||||||
|
let _ = cancel.send(());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
|
||||||
|
let active = self.active.lock().expect("terminal manager poisoned");
|
||||||
|
let session = active
|
||||||
|
.get(terminal_id.as_str())
|
||||||
|
.with_context(|| format!("terminal session {terminal_id} is not active"))?;
|
||||||
|
if session.relay_credentials.send(relay_token).is_err() {
|
||||||
|
anyhow::bail!("terminal session {terminal_id} has stopped");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sessions(&self) -> Vec<AgentTerminalSession> {
|
||||||
|
let active = self.active.lock().expect("terminal manager poisoned");
|
||||||
|
active
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, terminal)| {
|
||||||
|
terminal.cancel.is_some() && !terminal.relay_credentials.is_closed()
|
||||||
|
})
|
||||||
|
.filter_map(|(terminal_id, active)| {
|
||||||
|
TerminalId::new(terminal_id.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|terminal_id| AgentTerminalSession {
|
||||||
|
terminal_id,
|
||||||
|
created_at_unix: active.created_at_unix,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TerminalManager {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if Arc::strong_count(&self.active) == 1
|
||||||
|
&& let Ok(mut active) = self.active.lock()
|
||||||
|
{
|
||||||
|
for (_, active) in active.drain() {
|
||||||
|
if let Some(cancel) = active.cancel {
|
||||||
|
let _ = cancel.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_completed(active: &Weak<Mutex<HashMap<String, ActiveTerminal>>>, terminal_id: &str) {
|
||||||
|
if let Some(active) = active.upgrade()
|
||||||
|
&& let Ok(mut active) = active.lock()
|
||||||
|
{
|
||||||
|
active.remove(terminal_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn run_terminal(
|
||||||
|
config: &AgentConfig,
|
||||||
|
terminal_id: &TerminalId,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
mut cancel: oneshot::Receiver<()>,
|
||||||
|
mut relay_credentials: mpsc::UnboundedReceiver<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let terminal = match wakey::wakey_linux::terminal::TerminalPty::spawn(
|
||||||
|
Path::new(&config.terminal.shell),
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
) {
|
||||||
|
Ok(terminal) => terminal,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (mut reader, mut writer, mut child) = terminal.into_parts();
|
||||||
|
let process_group = child.id();
|
||||||
|
info!(terminal_id = %terminal_id, shell = %config.terminal.shell.display(), "terminal PTY ready");
|
||||||
|
|
||||||
|
let (relay_input_tx, mut relay_input_rx) = mpsc::channel(RELAY_INPUT_QUEUE);
|
||||||
|
let mut relay_output: Option<mpsc::Sender<Message>> = None;
|
||||||
|
let mut relay_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||||
|
let mut relay_generation = 0_u64;
|
||||||
|
let mut terminal_state = TerminalState::new(rows, cols);
|
||||||
|
let mut output = [0_u8; 16 * 1024];
|
||||||
|
let mut requested_close = false;
|
||||||
|
let mut observed_status = None;
|
||||||
|
let mut drain_deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = &mut cancel => {
|
||||||
|
requested_close = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
status = child.wait(), if observed_status.is_none() => {
|
||||||
|
observed_status = Some(status.context("failed waiting for terminal child")?);
|
||||||
|
drain_deadline = Some(Box::pin(tokio::time::sleep(PTY_EXIT_DRAIN_TIMEOUT)));
|
||||||
|
}
|
||||||
|
_ = async {
|
||||||
|
match drain_deadline.as_mut() {
|
||||||
|
Some(deadline) => deadline.as_mut().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
incoming = relay_input_rx.recv() => {
|
||||||
|
let Some(message) = incoming else { break; };
|
||||||
|
match message {
|
||||||
|
RelayInput::Binary { generation, bytes } if generation == relay_generation => {
|
||||||
|
if bytes.len() > MAX_TERMINAL_FRAME_BYTES {
|
||||||
|
anyhow::bail!("terminal input frame exceeds size limit");
|
||||||
|
}
|
||||||
|
writer.write_all(&bytes).await.context("failed to write PTY input")?;
|
||||||
|
}
|
||||||
|
RelayInput::Resize { generation, rows, cols } if generation == relay_generation => {
|
||||||
|
validate_size(rows, cols)?;
|
||||||
|
writer.resize(rows, cols)?;
|
||||||
|
terminal_state.resize(rows, cols);
|
||||||
|
}
|
||||||
|
RelayInput::Snapshot { generation } if generation == relay_generation => {
|
||||||
|
if !send_terminal_snapshot(terminal_state.snapshot(), &mut relay_output) {
|
||||||
|
if let Some(task) = relay_task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated during snapshot");
|
||||||
|
}
|
||||||
|
if let Err(err) = writer.refresh() {
|
||||||
|
warn!(terminal_id = %terminal_id, error = %err, "terminal redraw signal failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayInput::Close { generation } if generation == relay_generation => {
|
||||||
|
requested_close = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RelayInput::Connected { generation, output } if generation == relay_generation => {
|
||||||
|
relay_output = Some(output);
|
||||||
|
}
|
||||||
|
RelayInput::Disconnected { generation } if generation == relay_generation => {
|
||||||
|
relay_output = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
credential = relay_credentials.recv() => {
|
||||||
|
let Some(relay_token) = credential else { break; };
|
||||||
|
if let Some(task) = relay_task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
relay_generation = relay_generation.wrapping_add(1);
|
||||||
|
let generation = relay_generation;
|
||||||
|
let (output_tx, output_rx) = mpsc::channel(32);
|
||||||
|
relay_output = None;
|
||||||
|
let config = config.clone();
|
||||||
|
let terminal_id = terminal_id.clone();
|
||||||
|
let relay_input_tx = relay_input_tx.clone();
|
||||||
|
relay_task = Some(tokio::spawn(async move {
|
||||||
|
if let Err(err) = run_terminal_relay(RelayConnection {
|
||||||
|
config,
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
relay_token,
|
||||||
|
generation,
|
||||||
|
output_tx,
|
||||||
|
output_rx,
|
||||||
|
input: relay_input_tx.clone(),
|
||||||
|
}).await {
|
||||||
|
warn!(terminal_id = %terminal_id, error = %err, "terminal relay disconnected");
|
||||||
|
}
|
||||||
|
let _ = relay_input_tx.send(RelayInput::Disconnected { generation }).await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
read = reader.read(&mut output) => {
|
||||||
|
match read {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(count) => {
|
||||||
|
terminal_state.process(&output[..count]);
|
||||||
|
if !send_terminal_output(
|
||||||
|
Message::Binary(output[..count].to_vec().into()),
|
||||||
|
&mut relay_output,
|
||||||
|
) {
|
||||||
|
if let Some(task) = relay_task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Linux PTY masters commonly report EIO after the slave closes.
|
||||||
|
Err(err) if err.raw_os_error() == Some(5) => break,
|
||||||
|
Err(err) => return Err(err).context("failed to read PTY output"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = match observed_status {
|
||||||
|
Some(status) => status,
|
||||||
|
None => terminate_process_group(&mut child, process_group).await?,
|
||||||
|
};
|
||||||
|
if let Some(output) = relay_output {
|
||||||
|
let control = TerminalControl::Exited {
|
||||||
|
exit_code: status.code(),
|
||||||
|
};
|
||||||
|
if let Ok(text) = serde_json::to_string(&control) {
|
||||||
|
let _ = output.try_send(Message::Text(text.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!(terminal_id = %terminal_id, exit_code = ?status.code(), requested_close, "terminal worker exited");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayInput {
|
||||||
|
Connected {
|
||||||
|
generation: u64,
|
||||||
|
output: mpsc::Sender<Message>,
|
||||||
|
},
|
||||||
|
Binary {
|
||||||
|
generation: u64,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
Resize {
|
||||||
|
generation: u64,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
},
|
||||||
|
Snapshot {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
Close {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
Disconnected {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
struct RelayConnection {
|
||||||
|
config: AgentConfig,
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
relay_token: String,
|
||||||
|
generation: u64,
|
||||||
|
output_tx: mpsc::Sender<Message>,
|
||||||
|
output_rx: mpsc::Receiver<Message>,
|
||||||
|
input: mpsc::Sender<RelayInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
|
||||||
|
let ws_url = terminal_websocket_url(&relay.config.server_url, &relay.terminal_id)?;
|
||||||
|
let (stream, _) = tokio_tungstenite::connect_async(ws_url.as_str())
|
||||||
|
.await
|
||||||
|
.context("failed to connect terminal relay websocket")?;
|
||||||
|
let (mut sink, mut source) = stream.split();
|
||||||
|
send_json(
|
||||||
|
&mut sink,
|
||||||
|
&TerminalAgentHandshake::Auth {
|
||||||
|
agent_id: relay.config.agent_id.clone(),
|
||||||
|
relay_token: relay.relay_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
send_json(&mut sink, &TerminalControl::Ready).await?;
|
||||||
|
relay
|
||||||
|
.input
|
||||||
|
.send(RelayInput::Connected {
|
||||||
|
generation: relay.generation,
|
||||||
|
output: relay.output_tx,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||||
|
|
||||||
|
let mut output = relay.output_rx;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
incoming = source.next() => {
|
||||||
|
let Some(message) = incoming else { break; };
|
||||||
|
match message.context("terminal relay websocket receive failed")? {
|
||||||
|
Message::Binary(bytes) => {
|
||||||
|
relay.input.send(RelayInput::Binary {
|
||||||
|
generation: relay.generation,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
}).await.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||||
|
}
|
||||||
|
Message::Text(text) => match serde_json::from_str::<TerminalControl>(&text)
|
||||||
|
.context("invalid terminal control frame")?
|
||||||
|
{
|
||||||
|
TerminalControl::Resize { rows, cols } => {
|
||||||
|
relay.input.send(RelayInput::Resize {
|
||||||
|
generation: relay.generation,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
}).await
|
||||||
|
.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||||
|
}
|
||||||
|
TerminalControl::Snapshot => {
|
||||||
|
relay.input.send(RelayInput::Snapshot {
|
||||||
|
generation: relay.generation,
|
||||||
|
}).await.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||||
|
}
|
||||||
|
TerminalControl::Close => {
|
||||||
|
let _ = relay.input.send(RelayInput::Close {
|
||||||
|
generation: relay.generation,
|
||||||
|
}).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("terminal control frame has invalid direction"),
|
||||||
|
},
|
||||||
|
Message::Ping(payload) => sink.send(Message::Pong(payload)).await?,
|
||||||
|
Message::Pong(_) => {}
|
||||||
|
// Transport closure only detaches the relay. The agent-owned
|
||||||
|
// PTY remains alive and waits for replacement credentials.
|
||||||
|
Message::Close(_) => break,
|
||||||
|
Message::Frame(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outgoing = output.recv() => {
|
||||||
|
let Some(message) = outgoing else { break; };
|
||||||
|
sink.send(message).await.context("failed to send terminal relay output")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_terminal_output(frame: Message, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||||
|
if let Some(tx) = relay.as_ref()
|
||||||
|
&& tx.try_send(frame).is_err()
|
||||||
|
{
|
||||||
|
*relay = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_terminal_snapshot(snapshot: Vec<u8>, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||||
|
let Some(tx) = relay.as_ref() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
|
||||||
|
if tx.try_send(Message::Binary(chunk.to_vec().into())).is_err() {
|
||||||
|
*relay = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
async fn run_terminal(
|
||||||
|
_config: &AgentConfig,
|
||||||
|
_terminal_id: &TerminalId,
|
||||||
|
_rows: u16,
|
||||||
|
_cols: u16,
|
||||||
|
_cancel: oneshot::Receiver<()>,
|
||||||
|
_relay_credentials: mpsc::UnboundedReceiver<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
anyhow::bail!("terminal sessions are unsupported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn terminate_process_group(
|
||||||
|
child: &mut tokio::process::Child,
|
||||||
|
process_group: Option<u32>,
|
||||||
|
) -> Result<std::process::ExitStatus> {
|
||||||
|
use nix::sys::signal::{Signal, killpg};
|
||||||
|
use nix::unistd::Pid;
|
||||||
|
|
||||||
|
let Some(process_group) = process_group else {
|
||||||
|
child
|
||||||
|
.start_kill()
|
||||||
|
.context("failed to kill terminal child")?;
|
||||||
|
return child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.context("failed waiting for terminal child");
|
||||||
|
};
|
||||||
|
let pid = Pid::from_raw(process_group as i32);
|
||||||
|
let _ = killpg(pid, Signal::SIGHUP);
|
||||||
|
if let Ok(status) = tokio::time::timeout(PROCESS_SIGNAL_GRACE, child.wait()).await {
|
||||||
|
return status.context("failed waiting for terminal child after SIGHUP");
|
||||||
|
}
|
||||||
|
let _ = killpg(pid, Signal::SIGTERM);
|
||||||
|
if let Ok(status) = tokio::time::timeout(PROCESS_SIGNAL_GRACE, child.wait()).await {
|
||||||
|
return status.context("failed waiting for terminal child after SIGTERM");
|
||||||
|
}
|
||||||
|
let _ = killpg(pid, Signal::SIGKILL);
|
||||||
|
child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.context("failed waiting for terminal child after SIGKILL")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_json<S, T>(sink: &mut S, value: &T) -> Result<()>
|
||||||
|
where
|
||||||
|
S: futures_util::Sink<Message> + Unpin,
|
||||||
|
S::Error: std::error::Error + Send + Sync + 'static,
|
||||||
|
T: serde::Serialize,
|
||||||
|
{
|
||||||
|
let json = serde_json::to_string(value).context("failed to encode terminal frame")?;
|
||||||
|
sink.send(Message::Text(json.into()))
|
||||||
|
.await
|
||||||
|
.context("failed to send terminal frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_websocket_url(server_url: &str, terminal_id: &TerminalId) -> Result<url::Url> {
|
||||||
|
let mut url = url::Url::parse(server_url).context("invalid server_url")?;
|
||||||
|
let scheme = match url.scheme() {
|
||||||
|
"http" => "ws",
|
||||||
|
"https" => "wss",
|
||||||
|
"ws" => "ws",
|
||||||
|
"wss" => "wss",
|
||||||
|
other => anyhow::bail!("unsupported server_url scheme `{other}`"),
|
||||||
|
};
|
||||||
|
url.set_scheme(scheme)
|
||||||
|
.map_err(|_| anyhow::anyhow!("failed to convert server_url scheme"))?;
|
||||||
|
url.set_path(&format!(
|
||||||
|
"/api/v1/agent/terminals/{}/ws",
|
||||||
|
terminal_id.as_str()
|
||||||
|
));
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_size(rows: u16, cols: u16) -> Result<()> {
|
||||||
|
if !(1..=300).contains(&rows) || !(1..=500).contains(&cols) {
|
||||||
|
anyhow::bail!("terminal size is outside supported bounds");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn manager_with_stopped_terminal(terminal_id: &str) -> TerminalManager {
|
||||||
|
let (cancel, cancel_rx) = oneshot::channel();
|
||||||
|
drop(cancel_rx);
|
||||||
|
let (relay_credentials, relay_rx) = mpsc::unbounded_channel();
|
||||||
|
drop(relay_rx);
|
||||||
|
let (events, event_rx) = mpsc::unbounded_channel();
|
||||||
|
drop(event_rx);
|
||||||
|
TerminalManager {
|
||||||
|
active: Arc::new(Mutex::new(HashMap::from([(
|
||||||
|
terminal_id.to_string(),
|
||||||
|
ActiveTerminal {
|
||||||
|
cancel: Some(cancel),
|
||||||
|
relay_credentials,
|
||||||
|
created_at_unix: 42,
|
||||||
|
},
|
||||||
|
)]))),
|
||||||
|
max_sessions: 2,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_url_uses_dedicated_agent_path() {
|
||||||
|
let id = TerminalId::new("term-1").expect("terminal id");
|
||||||
|
let url = terminal_websocket_url("https://example.com/base", &id).expect("url");
|
||||||
|
assert_eq!(
|
||||||
|
url.as_str(),
|
||||||
|
"wss://example.com/api/v1/agent/terminals/term-1/ws"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_resume_hides_stopped_terminal_from_inventory() {
|
||||||
|
let manager = manager_with_stopped_terminal("stopped-terminal");
|
||||||
|
let terminal_id = TerminalId::new("stopped-terminal").expect("terminal id");
|
||||||
|
|
||||||
|
assert!(manager.resume(&terminal_id, "replacement".into()).is_err());
|
||||||
|
assert!(manager.sessions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inventory_prunes_terminal_with_stopped_worker() {
|
||||||
|
let manager = manager_with_stopped_terminal("stopped-terminal");
|
||||||
|
|
||||||
|
assert!(manager.sessions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_succeeds_when_worker_already_dropped_cancel_receiver() {
|
||||||
|
let manager = manager_with_stopped_terminal("stopped-terminal");
|
||||||
|
let terminal_id = TerminalId::new("stopped-terminal").expect("terminal id");
|
||||||
|
|
||||||
|
assert!(manager.close(&terminal_id));
|
||||||
|
assert!(manager.sessions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_keeps_session_registered_until_worker_completion() {
|
||||||
|
let (cancel, mut cancel_rx) = oneshot::channel();
|
||||||
|
let (relay_credentials, _relay_rx) = mpsc::unbounded_channel();
|
||||||
|
let (events, _event_rx) = mpsc::unbounded_channel();
|
||||||
|
let active = Arc::new(Mutex::new(HashMap::from([(
|
||||||
|
"closing-terminal".to_string(),
|
||||||
|
ActiveTerminal {
|
||||||
|
cancel: Some(cancel),
|
||||||
|
relay_credentials,
|
||||||
|
created_at_unix: 42,
|
||||||
|
},
|
||||||
|
)])));
|
||||||
|
let manager = TerminalManager {
|
||||||
|
active: active.clone(),
|
||||||
|
max_sessions: 2,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
let terminal_id = TerminalId::new("closing-terminal").expect("terminal id");
|
||||||
|
|
||||||
|
assert!(manager.close(&terminal_id));
|
||||||
|
assert!(cancel_rx.try_recv().is_ok());
|
||||||
|
assert!(
|
||||||
|
active
|
||||||
|
.lock()
|
||||||
|
.expect("terminal manager")
|
||||||
|
.contains_key("closing-terminal")
|
||||||
|
);
|
||||||
|
assert!(manager.sessions().is_empty());
|
||||||
|
|
||||||
|
remove_completed(&Arc::downgrade(&active), terminal_id.as_str());
|
||||||
|
assert!(
|
||||||
|
!active
|
||||||
|
.lock()
|
||||||
|
.expect("terminal manager")
|
||||||
|
.contains_key("closing-terminal")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_rejects_invalid_initial_size_before_spawning() {
|
||||||
|
let mut config = crate::config::DEFAULT_CONFIG.clone();
|
||||||
|
config.terminal.enabled = true;
|
||||||
|
let (manager, _events) = TerminalManager::new(&config);
|
||||||
|
let terminal_id = TerminalId::new("invalid-size").expect("terminal id");
|
||||||
|
|
||||||
|
let error = manager
|
||||||
|
.open(&config, terminal_id, "relay".into(), 0, 80)
|
||||||
|
.expect_err("invalid rows must fail");
|
||||||
|
assert!(error.to_string().contains("outside supported bounds"));
|
||||||
|
assert!(manager.active.lock().expect("terminal manager").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_reconstructs_screen_content_and_cursor() {
|
||||||
|
let mut state = TerminalState::new(24, 80);
|
||||||
|
state.process(b"hello\r\n\x1b[31mred\x1b[0m\x1b[10;20Hcursor");
|
||||||
|
|
||||||
|
let mut restored = vt100::Parser::new(24, 80, 0);
|
||||||
|
restored.process(b"stale browser contents\x1b[24;80Hjunk");
|
||||||
|
restored.process(&state.snapshot());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().contents(),
|
||||||
|
state.parser.screen().contents()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().cursor_position(),
|
||||||
|
state.parser.screen().cursor_position()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_reconstructs_terminal_input_modes() {
|
||||||
|
let mut state = TerminalState::new(24, 80);
|
||||||
|
state.process(b"\x1b[?1h\x1b[?1000h\x1b[?2004h");
|
||||||
|
|
||||||
|
let mut restored = vt100::Parser::new(24, 80, 0);
|
||||||
|
restored.process(&state.snapshot());
|
||||||
|
|
||||||
|
assert!(restored.screen().application_cursor());
|
||||||
|
assert!(restored.screen().bracketed_paste());
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().mouse_protocol_mode(),
|
||||||
|
state.parser.screen().mouse_protocol_mode()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_state_tracks_resize() {
|
||||||
|
let mut state = TerminalState::new(24, 80);
|
||||||
|
state.resize(40, 120);
|
||||||
|
assert_eq!(state.parser.screen().size(), (40, 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_reconstructs_agent_scrollback() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
for line in 0..10 {
|
||||||
|
if line == 0 {
|
||||||
|
state.process(b"\x1b[31mhistory 0\x1b[0m\r\n");
|
||||||
|
} else {
|
||||||
|
state.process(format!("history {line}\r\n").as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let expected_screen = state.parser.screen().contents();
|
||||||
|
|
||||||
|
let mut restored = vt100::Parser::new(3, 20, TERMINAL_SCROLLBACK_ROWS);
|
||||||
|
restored.process(&state.snapshot());
|
||||||
|
|
||||||
|
assert_eq!(restored.screen().contents(), expected_screen);
|
||||||
|
restored.screen_mut().set_scrollback(usize::MAX);
|
||||||
|
assert!(restored.screen().scrollback() >= 8);
|
||||||
|
assert!(restored.screen().contents().contains("history 0"));
|
||||||
|
assert_eq!(
|
||||||
|
restored
|
||||||
|
.screen()
|
||||||
|
.cell(0, 0)
|
||||||
|
.expect("first history cell")
|
||||||
|
.fgcolor(),
|
||||||
|
vt100::Color::Idx(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_restores_alternate_screen_mode() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
state.process(b"shell\r\n\x1b[?1049hfull-screen");
|
||||||
|
|
||||||
|
let mut restored = vt100::Parser::new(3, 20, TERMINAL_SCROLLBACK_ROWS);
|
||||||
|
restored.process(&state.snapshot());
|
||||||
|
|
||||||
|
assert!(restored.screen().alternate_screen());
|
||||||
|
assert_eq!(restored.screen().contents(), "full-screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alternate_screen_snapshot_restores_hidden_primary_on_exit() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
state.process(b"\x1b[31mshell history\r\n$ btop\x1b[?1049h\x1b[34mbtop dashboard");
|
||||||
|
|
||||||
|
let mut restored = vt100::Parser::new(3, 20, TERMINAL_SCROLLBACK_ROWS);
|
||||||
|
restored.process(&state.snapshot());
|
||||||
|
|
||||||
|
assert!(restored.screen().alternate_screen());
|
||||||
|
assert_eq!(restored.screen().contents(), "btop dashboard");
|
||||||
|
assert_eq!(restored.screen().fgcolor(), vt100::Color::Idx(4));
|
||||||
|
|
||||||
|
// Compare the reattached terminal with the original parser after both
|
||||||
|
// receive the application's real alternate-screen exit sequence.
|
||||||
|
state.process(b"\x1b[?1049l");
|
||||||
|
restored.process(b"\x1b[?1049l");
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().contents(),
|
||||||
|
state.parser.screen().contents()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.screen().cursor_position(),
|
||||||
|
state.parser.screen().cursor_position()
|
||||||
|
);
|
||||||
|
assert_eq!(restored.screen().fgcolor(), vt100::Color::Idx(1));
|
||||||
|
|
||||||
|
restored.screen_mut().set_scrollback(usize::MAX);
|
||||||
|
assert!(restored.screen().contents().contains("shell history"));
|
||||||
|
assert!(!restored.screen().contents().contains("btop dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_does_not_change_the_source_viewport_or_state() {
|
||||||
|
let mut state = TerminalState::new(3, 20);
|
||||||
|
for line in 0..10 {
|
||||||
|
state.process(format!("history {line}\r\n").as_bytes());
|
||||||
|
}
|
||||||
|
state.parser.screen_mut().set_scrollback(4);
|
||||||
|
|
||||||
|
let before_contents = state.parser.screen().contents();
|
||||||
|
let before_state = state.parser.screen().state_formatted();
|
||||||
|
let before_scrollback = state.parser.screen().scrollback();
|
||||||
|
let _ = state.snapshot();
|
||||||
|
|
||||||
|
assert_eq!(state.parser.screen().contents(), before_contents);
|
||||||
|
assert_eq!(state.parser.screen().state_formatted(), before_state);
|
||||||
|
assert_eq!(state.parser.screen().scrollback(), before_scrollback);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn snapshot_is_split_at_the_terminal_frame_limit() {
|
||||||
|
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];
|
||||||
|
let (tx, mut rx) = mpsc::channel(3);
|
||||||
|
let mut relay = Some(tx);
|
||||||
|
|
||||||
|
assert!(send_terminal_snapshot(snapshot.clone(), &mut relay));
|
||||||
|
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
let Message::Binary(chunk) = rx.recv().await.expect("snapshot chunk") else {
|
||||||
|
panic!("snapshot chunks must be binary");
|
||||||
|
};
|
||||||
|
assert!(chunk.len() <= MAX_TERMINAL_FRAME_BYTES);
|
||||||
|
restored.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
assert_eq!(restored, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saturated_relay_output_detaches_without_waiting() {
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
tx.try_send(Message::Binary(vec![1].into()))
|
||||||
|
.expect("prefill relay queue");
|
||||||
|
let mut relay = Some(tx);
|
||||||
|
|
||||||
|
assert!(!send_terminal_output(
|
||||||
|
Message::Binary(vec![2].into()),
|
||||||
|
&mut relay,
|
||||||
|
));
|
||||||
|
assert!(relay.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
rx.try_recv().expect("original queued frame").into_data(),
|
||||||
|
vec![1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{info, info_span, warn};
|
use tracing::{info, info_span, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use wakey_agent::protocol::AgentCapability;
|
||||||
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
|
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
|
||||||
|
|
||||||
use crate::api::ApiError;
|
use crate::api::ApiError;
|
||||||
@@ -17,6 +18,7 @@ pub struct AgentStatus {
|
|||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub nickname: Option<String>,
|
pub nickname: Option<String>,
|
||||||
|
pub capabilities: Vec<AgentCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -43,6 +45,10 @@ pub async fn list_agents(State(state): State<AppState>) -> Result<impl IntoRespo
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(agent_id, nickname)| AgentStatus {
|
.map(|(agent_id, nickname)| AgentStatus {
|
||||||
connected: sessions.contains_key(&agent_id),
|
connected: sessions.contains_key(&agent_id),
|
||||||
|
capabilities: sessions
|
||||||
|
.get(&agent_id)
|
||||||
|
.map(|session| session.capabilities.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
agent_id,
|
agent_id,
|
||||||
nickname,
|
nickname,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod stats;
|
|||||||
|
|
||||||
pub use devices::{
|
pub use devices::{
|
||||||
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
||||||
list_known_devices, merge_known_device,
|
get_known_device, list_known_devices, merge_known_device,
|
||||||
};
|
};
|
||||||
pub use enroll::{
|
pub use enroll::{
|
||||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||||
|
|||||||
@@ -111,6 +111,28 @@ pub async fn list_known_devices(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_known_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(device_id): AxumPath<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
match state.store.get_known_device(&device_id).await {
|
||||||
|
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||||
|
Ok(None) => Err(ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"known_device_not_found",
|
||||||
|
"known device not found",
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to get known device");
|
||||||
|
Err(ApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"get_known_device_failed",
|
||||||
|
err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn forget_known_device(
|
pub async fn forget_known_device(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(device_id): AxumPath<String>,
|
AxumPath(device_id): AxumPath<String>,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod alerts;
|
|||||||
mod audit;
|
mod audit;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod control;
|
mod control;
|
||||||
|
mod terminals;
|
||||||
|
|
||||||
pub use alerts::{active_alerts, alert_history, alerts_stream};
|
pub use alerts::{active_alerts, alert_history, alerts_stream};
|
||||||
pub use audit::list_audit_events;
|
pub use audit::list_audit_events;
|
||||||
@@ -12,10 +13,14 @@ pub use commands::{list_agents, run_command};
|
|||||||
pub use control::{
|
pub use control::{
|
||||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||||
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
||||||
enroll, forget_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
||||||
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
||||||
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, wake_fleet_device,
|
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, wake_fleet_device,
|
||||||
};
|
};
|
||||||
|
pub use terminals::{
|
||||||
|
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
||||||
|
list_terminals, operator_terminal_ws,
|
||||||
|
};
|
||||||
|
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -0,0 +1,699 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Response;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use wakey_agent::protocol::{
|
||||||
|
AgentCapability, ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId,
|
||||||
|
TerminalOperatorHandshake,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::api::ApiError;
|
||||||
|
use crate::runtime::terminals::{
|
||||||
|
TERMINAL_ABSOLUTE_TIMEOUT, TERMINAL_ATTACH_TIMEOUT, TERMINAL_MAX_FRAME_BYTES,
|
||||||
|
TerminalRelayFrame, TerminalSummary,
|
||||||
|
};
|
||||||
|
use crate::runtime::{AppState, SessionEvent};
|
||||||
|
use crate::state::AuditEventInput;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTerminalRequest {
|
||||||
|
pub agent_id: String,
|
||||||
|
#[serde(default = "default_rows")]
|
||||||
|
pub rows: u16,
|
||||||
|
#[serde(default = "default_cols")]
|
||||||
|
pub cols: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AttachTerminalRequest {
|
||||||
|
/// Stable for one browser tab, allowing CC to distinguish a stale socket
|
||||||
|
/// owned by this tab from a session open in another browser.
|
||||||
|
pub operator_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TerminalSessionResponse {
|
||||||
|
pub terminal_id: String,
|
||||||
|
pub agent_id: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
pub agent_attached: bool,
|
||||||
|
pub operator_attached: bool,
|
||||||
|
pub websocket_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub attachment_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_terminal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(request): Json<CreateTerminalRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<TerminalSessionResponse>), ApiError> {
|
||||||
|
validate_size(request.rows, request.cols)?;
|
||||||
|
let agent_tx = {
|
||||||
|
let sessions = state.sessions.read().await;
|
||||||
|
let session = sessions.get(&request.agent_id).ok_or_else(|| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"agent_not_connected",
|
||||||
|
"agent is not connected",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !session.capabilities.contains(&AgentCapability::Terminal) {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"terminal_not_supported",
|
||||||
|
"agent has not advertised terminal capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
session.tx.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = state
|
||||||
|
.terminals
|
||||||
|
.create(request.agent_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(registry_error)?;
|
||||||
|
let terminal_id = TerminalId::new(created.terminal_id.clone()).map_err(|message| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"terminal_id_invalid",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if agent_tx
|
||||||
|
.send(SessionEvent::Message(ServerMessage::OpenTerminal {
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
relay_token: created.relay_token,
|
||||||
|
rows: request.rows,
|
||||||
|
cols: request.cols,
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
state.terminals.remove(terminal_id.as_str()).await;
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"agent_send_failed",
|
||||||
|
"failed to send terminal request to agent",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_absolute_timeout(state.clone(), terminal_id.clone(), request.agent_id.clone());
|
||||||
|
append_terminal_audit(
|
||||||
|
&state,
|
||||||
|
&request.agent_id,
|
||||||
|
terminal_id.as_str(),
|
||||||
|
TerminalAudit {
|
||||||
|
actor_type: "admin_api",
|
||||||
|
event_type: "terminal_request",
|
||||||
|
outcome: "sent",
|
||||||
|
message: "terminal session requested",
|
||||||
|
metadata: serde_json::json!({ "rows": request.rows, "cols": request.cols }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
info!(terminal_id = %terminal_id, agent_id = %request.agent_id, "terminal session requested");
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(TerminalSessionResponse {
|
||||||
|
websocket_url: operator_ws_path(terminal_id.as_str()),
|
||||||
|
terminal_id: terminal_id.to_string(),
|
||||||
|
agent_id: request.agent_id,
|
||||||
|
created_at_unix: created.created_at_unix,
|
||||||
|
agent_attached: false,
|
||||||
|
operator_attached: false,
|
||||||
|
attachment_token: Some(created.attachment_token),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_terminal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(terminal_id): Path<String>,
|
||||||
|
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||||
|
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
||||||
|
.terminals
|
||||||
|
.summary(&terminal_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| terminal_not_found(&terminal_id))?;
|
||||||
|
Ok(Json(TerminalSessionResponse {
|
||||||
|
websocket_url: operator_ws_path(&terminal_id),
|
||||||
|
terminal_id,
|
||||||
|
agent_id,
|
||||||
|
created_at_unix,
|
||||||
|
agent_attached,
|
||||||
|
operator_attached,
|
||||||
|
attachment_token: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalSessionResponse>> {
|
||||||
|
Json(
|
||||||
|
state
|
||||||
|
.terminals
|
||||||
|
.summaries()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(terminal_response)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_terminal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(terminal_id): Path<String>,
|
||||||
|
Json(request): Json<AttachTerminalRequest>,
|
||||||
|
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||||
|
let attachment_token = state
|
||||||
|
.terminals
|
||||||
|
.issue_attachment_token_for_operator(&terminal_id, &request.operator_id)
|
||||||
|
.await
|
||||||
|
.map_err(registry_error)?;
|
||||||
|
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
||||||
|
.terminals
|
||||||
|
.summary(&terminal_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| terminal_not_found(&terminal_id))?;
|
||||||
|
Ok(Json(TerminalSessionResponse {
|
||||||
|
websocket_url: operator_ws_path(&terminal_id),
|
||||||
|
terminal_id,
|
||||||
|
agent_id,
|
||||||
|
created_at_unix,
|
||||||
|
agent_attached,
|
||||||
|
operator_attached,
|
||||||
|
attachment_token: Some(attachment_token),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_terminal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(terminal_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
if let Some(agent_id) =
|
||||||
|
close_registered_terminal(&state, &terminal_id, TerminalCloseReason::HttpDelete).await
|
||||||
|
{
|
||||||
|
info!(terminal_id, agent_id, "terminal session closed by operator");
|
||||||
|
} else {
|
||||||
|
if !state.terminals.was_closed(&terminal_id).await {
|
||||||
|
return Err(terminal_not_found(&terminal_id));
|
||||||
|
}
|
||||||
|
info!(terminal_id, "terminal session was already closed");
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn agent_terminal_ws(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(terminal_id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
ws.max_message_size(TERMINAL_MAX_FRAME_BYTES)
|
||||||
|
.on_upgrade(move |socket| handle_agent_terminal_socket(state, terminal_id, socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn operator_terminal_ws(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(terminal_id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
ws.max_message_size(TERMINAL_MAX_FRAME_BYTES)
|
||||||
|
.on_upgrade(move |socket| handle_operator_terminal_socket(state, terminal_id, socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut socket: WebSocket) {
|
||||||
|
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||||
|
serde_json::from_str::<TerminalAgentHandshake>(&text).map_err(|_| "invalid handshake")
|
||||||
|
}) {
|
||||||
|
Ok(TerminalAgentHandshake::Auth {
|
||||||
|
agent_id,
|
||||||
|
relay_token,
|
||||||
|
}) => (agent_id, relay_token),
|
||||||
|
Err(code) => {
|
||||||
|
close_socket(&mut socket, code).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (mut outbound, pending) = match state
|
||||||
|
.terminals
|
||||||
|
.attach_agent(&terminal_id, &auth.0, &auth.1)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(outbound) => outbound,
|
||||||
|
Err(code) => {
|
||||||
|
close_socket(&mut socket, code).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(terminal_id, agent_id = %auth.0, "agent terminal socket attached");
|
||||||
|
append_terminal_audit(
|
||||||
|
&state,
|
||||||
|
&auth.0,
|
||||||
|
&terminal_id,
|
||||||
|
TerminalAudit {
|
||||||
|
actor_type: "agent",
|
||||||
|
event_type: "terminal_agent_attach",
|
||||||
|
outcome: "ok",
|
||||||
|
message: "agent terminal transport attached",
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (mut write, mut read) = socket.split();
|
||||||
|
for frame in pending {
|
||||||
|
let closes = matches!(frame, TerminalRelayFrame::Close);
|
||||||
|
if send_relay_frame(&mut write, frame).await.is_err() || closes {
|
||||||
|
state.terminals.remove(&terminal_id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
outbound_frame = outbound.recv() => {
|
||||||
|
let Some(frame) = outbound_frame else { break; };
|
||||||
|
if send_relay_frame(&mut write, frame).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
incoming = read.next() => {
|
||||||
|
let Some(Ok(message)) = incoming else { break; };
|
||||||
|
match agent_relay_frame(message) {
|
||||||
|
Ok(Some(frame)) => {
|
||||||
|
let closes = matches!(frame, TerminalRelayFrame::Close);
|
||||||
|
audit_agent_control_frame(&state, &auth.0, &terminal_id, &frame).await;
|
||||||
|
if state.terminals.relay_from_agent(&terminal_id, frame).await.is_err() { break; }
|
||||||
|
if closes { break; }
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(code) => {
|
||||||
|
warn!(terminal_id, code, "invalid agent terminal frame");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(agent_id) = state.terminals.detach_agent(&terminal_id).await
|
||||||
|
&& let Some(session) = state.sessions.read().await.get(&agent_id)
|
||||||
|
{
|
||||||
|
let _ = session
|
||||||
|
.tx
|
||||||
|
.send(SessionEvent::Message(ServerMessage::SyncTerminalSessions));
|
||||||
|
}
|
||||||
|
info!(terminal_id, "agent terminal relay detached");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_operator_terminal_socket(
|
||||||
|
state: AppState,
|
||||||
|
terminal_id: String,
|
||||||
|
mut socket: WebSocket,
|
||||||
|
) {
|
||||||
|
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||||
|
serde_json::from_str::<TerminalOperatorHandshake>(&text).map_err(|_| "invalid handshake")
|
||||||
|
}) {
|
||||||
|
Ok(TerminalOperatorHandshake::Attach {
|
||||||
|
attachment_token,
|
||||||
|
operator_id,
|
||||||
|
}) => (attachment_token, operator_id),
|
||||||
|
Err(code) => {
|
||||||
|
close_socket(&mut socket, code).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut outbound = match state
|
||||||
|
.terminals
|
||||||
|
.attach_operator(&terminal_id, &auth.0, &auth.1)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(attached) => attached,
|
||||||
|
Err(code) => {
|
||||||
|
close_socket(&mut socket, code).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(terminal_id, "operator terminal socket attached");
|
||||||
|
let snapshot = serde_json::to_string(&TerminalControl::Snapshot)
|
||||||
|
.expect("terminal snapshot control serializes");
|
||||||
|
if let Err(code) = state
|
||||||
|
.terminals
|
||||||
|
.relay_to_agent(&terminal_id, TerminalRelayFrame::Text(snapshot))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(terminal_id, code, "failed to request terminal snapshot");
|
||||||
|
}
|
||||||
|
let summary = state.terminals.summary(&terminal_id).await;
|
||||||
|
if let Some((agent_id, _, _, _)) = &summary {
|
||||||
|
append_terminal_audit(
|
||||||
|
&state,
|
||||||
|
agent_id,
|
||||||
|
&terminal_id,
|
||||||
|
TerminalAudit {
|
||||||
|
actor_type: "admin_api",
|
||||||
|
event_type: "terminal_operator_attach",
|
||||||
|
outcome: "ok",
|
||||||
|
message: "operator terminal transport attached",
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut write, mut read) = socket.split();
|
||||||
|
if summary.is_some_and(|(_, _, agent_attached, _)| agent_attached) {
|
||||||
|
let ready = serde_json::to_string(&TerminalControl::Ready)
|
||||||
|
.expect("terminal ready control serializes");
|
||||||
|
if send_relay_frame(&mut write, TerminalRelayFrame::Text(ready))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut explicit_close = false;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
incoming = read.next() => {
|
||||||
|
let Some(Ok(message)) = incoming else { break; };
|
||||||
|
match operator_relay_frame(message) {
|
||||||
|
Ok(Some(frame)) => {
|
||||||
|
explicit_close = matches!(frame, TerminalRelayFrame::Close);
|
||||||
|
if state.terminals.relay_to_agent(&terminal_id, frame).await.is_err() { break; }
|
||||||
|
if explicit_close { break; }
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(code) => {
|
||||||
|
warn!(terminal_id, code, "invalid operator terminal frame");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outbound_frame = outbound.recv() => {
|
||||||
|
let Some(frame) = outbound_frame else { break; };
|
||||||
|
if send_relay_frame(&mut write, frame).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if explicit_close {
|
||||||
|
close_registered_terminal(&state, &terminal_id, TerminalCloseReason::BrowserClose).await;
|
||||||
|
} else {
|
||||||
|
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
terminal_id,
|
||||||
|
explicit_close, "operator terminal socket detached"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_response(summary: TerminalSummary) -> TerminalSessionResponse {
|
||||||
|
TerminalSessionResponse {
|
||||||
|
websocket_url: operator_ws_path(&summary.terminal_id),
|
||||||
|
terminal_id: summary.terminal_id,
|
||||||
|
agent_id: summary.agent_id,
|
||||||
|
created_at_unix: summary.created_at_unix,
|
||||||
|
agent_attached: summary.agent_attached,
|
||||||
|
operator_attached: summary.operator_attached,
|
||||||
|
attachment_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_relay_frame(message: Message) -> Result<Option<TerminalRelayFrame>, &'static str> {
|
||||||
|
match message {
|
||||||
|
Message::Binary(bytes) => Ok(Some(TerminalRelayFrame::Binary(bytes.to_vec()))),
|
||||||
|
Message::Text(text) => {
|
||||||
|
let control: TerminalControl =
|
||||||
|
serde_json::from_str(&text).map_err(|_| "terminal_control_invalid")?;
|
||||||
|
if !matches!(
|
||||||
|
control,
|
||||||
|
TerminalControl::Ready
|
||||||
|
| TerminalControl::Exited { .. }
|
||||||
|
| TerminalControl::Error { .. }
|
||||||
|
| TerminalControl::Close
|
||||||
|
) {
|
||||||
|
return Err("terminal_control_direction_invalid");
|
||||||
|
}
|
||||||
|
Ok(Some(TerminalRelayFrame::Text(
|
||||||
|
serde_json::to_string(&control).map_err(|_| "terminal_control_invalid")?,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Message::Ping(_) | Message::Pong(_) => Ok(None),
|
||||||
|
Message::Close(_) => Ok(Some(TerminalRelayFrame::Close)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operator_relay_frame(message: Message) -> Result<Option<TerminalRelayFrame>, &'static str> {
|
||||||
|
match message {
|
||||||
|
Message::Binary(bytes) => Ok(Some(TerminalRelayFrame::Binary(bytes.to_vec()))),
|
||||||
|
Message::Text(text) => {
|
||||||
|
let control: TerminalControl =
|
||||||
|
serde_json::from_str(&text).map_err(|_| "terminal_control_invalid")?;
|
||||||
|
if !matches!(
|
||||||
|
control,
|
||||||
|
TerminalControl::Resize { .. } | TerminalControl::Close
|
||||||
|
) {
|
||||||
|
return Err("terminal_control_direction_invalid");
|
||||||
|
}
|
||||||
|
if let TerminalControl::Resize { rows, cols } = control {
|
||||||
|
validate_size(rows, cols).map_err(|_| "terminal_size_invalid")?;
|
||||||
|
}
|
||||||
|
Ok(Some(TerminalRelayFrame::Text(
|
||||||
|
serde_json::to_string(&control).map_err(|_| "terminal_control_invalid")?,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Message::Ping(_) | Message::Pong(_) => Ok(None),
|
||||||
|
Message::Close(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_text_handshake(socket: &mut WebSocket) -> Result<String, &'static str> {
|
||||||
|
match tokio::time::timeout(TERMINAL_ATTACH_TIMEOUT, socket.recv()).await {
|
||||||
|
Ok(Some(Ok(Message::Text(text)))) => Ok(text.to_string()),
|
||||||
|
Ok(_) => Err("terminal_handshake_required"),
|
||||||
|
Err(_) => Err("terminal_handshake_timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close_socket(socket: &mut WebSocket, code: &str) {
|
||||||
|
let control = TerminalControl::Error {
|
||||||
|
code: code.into(),
|
||||||
|
message: code.replace('_', " "),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&control) {
|
||||||
|
let _ = socket.send(Message::Text(json.into())).await;
|
||||||
|
}
|
||||||
|
let _ = socket.send(Message::Close(None)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_relay_frame<S>(write: &mut S, frame: TerminalRelayFrame) -> Result<(), axum::Error>
|
||||||
|
where
|
||||||
|
S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
|
||||||
|
{
|
||||||
|
let message = match frame {
|
||||||
|
TerminalRelayFrame::Binary(bytes) => Message::Binary(bytes.into()),
|
||||||
|
TerminalRelayFrame::Text(text) => Message::Text(text.into()),
|
||||||
|
TerminalRelayFrame::Close => Message::Close(None),
|
||||||
|
};
|
||||||
|
write.send(message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum TerminalCloseReason {
|
||||||
|
HttpDelete,
|
||||||
|
BrowserClose,
|
||||||
|
AbsoluteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalCloseReason {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::HttpDelete => "http_delete",
|
||||||
|
Self::BrowserClose => "browser_close",
|
||||||
|
Self::AbsoluteTimeout => "absolute_timeout",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes and audits a terminal as one logical operation. Only the caller
|
||||||
|
/// that actually removes the session records the close, keeping retries and
|
||||||
|
/// simultaneous timeout/operator closes from producing duplicate events.
|
||||||
|
async fn close_registered_terminal(
|
||||||
|
state: &AppState,
|
||||||
|
terminal_id: &str,
|
||||||
|
reason: TerminalCloseReason,
|
||||||
|
) -> Option<String> {
|
||||||
|
let agent_id = state.terminals.remove(terminal_id).await?;
|
||||||
|
if let Some(session) = state.sessions.read().await.get(&agent_id) {
|
||||||
|
let terminal_id = TerminalId::new(terminal_id.to_string()).ok()?;
|
||||||
|
let _ = session
|
||||||
|
.tx
|
||||||
|
.send(SessionEvent::Message(ServerMessage::CloseTerminal {
|
||||||
|
terminal_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
append_terminal_audit(
|
||||||
|
state,
|
||||||
|
&agent_id,
|
||||||
|
terminal_id,
|
||||||
|
TerminalAudit {
|
||||||
|
actor_type: "admin_api",
|
||||||
|
event_type: "terminal_close",
|
||||||
|
outcome: "ok",
|
||||||
|
message: "terminal session closed",
|
||||||
|
metadata: serde_json::json!({ "reason": reason.as_str() }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Some(agent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_absolute_timeout(state: AppState, terminal_id: TerminalId, agent_id: String) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(TERMINAL_ABSOLUTE_TIMEOUT).await;
|
||||||
|
if close_registered_terminal(
|
||||||
|
&state,
|
||||||
|
terminal_id.as_str(),
|
||||||
|
TerminalCloseReason::AbsoluteTimeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
info!(terminal_id = %terminal_id, agent_id, "terminal absolute timeout reached");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalAudit<'a> {
|
||||||
|
actor_type: &'a str,
|
||||||
|
event_type: &'a str,
|
||||||
|
outcome: &'a str,
|
||||||
|
message: &'a str,
|
||||||
|
metadata: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audit_agent_control_frame(
|
||||||
|
state: &AppState,
|
||||||
|
agent_id: &str,
|
||||||
|
terminal_id: &str,
|
||||||
|
frame: &TerminalRelayFrame,
|
||||||
|
) {
|
||||||
|
let TerminalRelayFrame::Text(text) = frame else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(control) = serde_json::from_str::<TerminalControl>(text) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let audit = match control {
|
||||||
|
TerminalControl::Ready => TerminalAudit {
|
||||||
|
actor_type: "agent",
|
||||||
|
event_type: "terminal_ready",
|
||||||
|
outcome: "ok",
|
||||||
|
message: "terminal PTY ready",
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
TerminalControl::Exited { exit_code } => TerminalAudit {
|
||||||
|
actor_type: "agent",
|
||||||
|
event_type: "terminal_exit",
|
||||||
|
outcome: "exited",
|
||||||
|
message: "terminal process exited",
|
||||||
|
metadata: serde_json::json!({ "exit_code": exit_code }),
|
||||||
|
},
|
||||||
|
TerminalControl::Error { code, .. } => TerminalAudit {
|
||||||
|
actor_type: "agent",
|
||||||
|
event_type: "terminal_error",
|
||||||
|
outcome: "error",
|
||||||
|
message: "terminal worker reported an error",
|
||||||
|
metadata: serde_json::json!({ "code": code }),
|
||||||
|
},
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
append_terminal_audit(state, agent_id, terminal_id, audit).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_terminal_audit(
|
||||||
|
state: &AppState,
|
||||||
|
agent_id: &str,
|
||||||
|
terminal_id: &str,
|
||||||
|
audit: TerminalAudit<'_>,
|
||||||
|
) {
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: audit.actor_type.into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: Some(agent_id.to_string()),
|
||||||
|
request_id: Some(terminal_id.to_string()),
|
||||||
|
event_type: audit.event_type.into(),
|
||||||
|
outcome: audit.outcome.into(),
|
||||||
|
latency_ms: None,
|
||||||
|
message: audit.message.into(),
|
||||||
|
metadata: audit.metadata,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(terminal_id, event_type = audit.event_type, error = %err, "failed to append terminal audit event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_size(rows: u16, cols: u16) -> Result<(), ApiError> {
|
||||||
|
if !(1..=300).contains(&rows) || !(1..=500).contains(&cols) {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"terminal_size_invalid",
|
||||||
|
"terminal rows must be 1..=300 and columns must be 1..=500",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_error(code: &'static str) -> ApiError {
|
||||||
|
let status = match code {
|
||||||
|
"terminal_not_found" => StatusCode::NOT_FOUND,
|
||||||
|
"terminal_operator_id_invalid" => StatusCode::BAD_REQUEST,
|
||||||
|
"terminal_relay_token_invalid"
|
||||||
|
| "terminal_attachment_token_invalid"
|
||||||
|
| "terminal_operator_mismatch" => StatusCode::UNAUTHORIZED,
|
||||||
|
_ => StatusCode::CONFLICT,
|
||||||
|
};
|
||||||
|
ApiError::new(status, code, code.replace('_', " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_not_found(terminal_id: &str) -> ApiError {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"terminal_not_found",
|
||||||
|
format!("terminal session {terminal_id} was not found"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operator_ws_path(terminal_id: &str) -> String {
|
||||||
|
format!("/api/v1/control/terminals/{terminal_id}/ws")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_rows() -> u16 {
|
||||||
|
24
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_cols() -> u16 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_control_direction_is_enforced() {
|
||||||
|
let resize = serde_json::to_string(&TerminalControl::Resize { rows: 24, cols: 80 })
|
||||||
|
.expect("resize json");
|
||||||
|
assert!(operator_relay_frame(Message::Text(resize.into())).is_ok());
|
||||||
|
|
||||||
|
let ready = serde_json::to_string(&TerminalControl::Ready).expect("ready json");
|
||||||
|
assert!(operator_relay_frame(Message::Text(ready.into())).is_err());
|
||||||
|
|
||||||
|
let snapshot = serde_json::to_string(&TerminalControl::Snapshot).expect("snapshot json");
|
||||||
|
assert!(operator_relay_frame(Message::Text(snapshot.into())).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ use tower_http::services::ServeFile;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
|
use wakey_agent::protocol::{AgentCapability, ErrorPayload, ServerMessage};
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
@@ -25,6 +25,7 @@ use crate::ws;
|
|||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
mod process;
|
mod process;
|
||||||
|
pub mod terminals;
|
||||||
pub use admin::revoke_agent;
|
pub use admin::revoke_agent;
|
||||||
pub use admin::{
|
pub use admin::{
|
||||||
issue_enroll_token, list_enroll_tokens, migrate_sqlite_state, revoke_enroll_token, state_stats,
|
issue_enroll_token, list_enroll_tokens, migrate_sqlite_state, revoke_enroll_token, state_stats,
|
||||||
@@ -41,12 +42,14 @@ pub struct AppState {
|
|||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
pub command_timeout: Duration,
|
pub command_timeout: Duration,
|
||||||
pub enroll_token_ttl: Duration,
|
pub enroll_token_ttl: Duration,
|
||||||
|
pub terminals: terminals::TerminalRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AgentSession {
|
pub struct AgentSession {
|
||||||
pub connection_id: String,
|
pub connection_id: String,
|
||||||
pub tx: mpsc::UnboundedSender<SessionEvent>,
|
pub tx: mpsc::UnboundedSender<SessionEvent>,
|
||||||
|
pub capabilities: Vec<AgentCapability>,
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum SessionEvent {
|
pub enum SessionEvent {
|
||||||
@@ -72,6 +75,10 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
|||||||
.route("/healthz", get(api::healthz))
|
.route("/healthz", get(api::healthz))
|
||||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||||
|
.route(
|
||||||
|
"/api/v1/agent/terminals/{terminal_id}/ws",
|
||||||
|
get(api::agent_terminal_ws),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn control_api_routes() -> Router<AppState> {
|
fn control_api_routes() -> Router<AppState> {
|
||||||
@@ -104,7 +111,7 @@ fn control_api_routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/control/devices/{device_id}",
|
"/api/v1/control/devices/{device_id}",
|
||||||
axum::routing::delete(api::forget_known_device),
|
get(api::get_known_device).delete(api::forget_known_device),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/control/devices/{device_id}/merge",
|
"/api/v1/control/devices/{device_id}/merge",
|
||||||
@@ -135,6 +142,22 @@ fn control_api_routes() -> Router<AppState> {
|
|||||||
"/api/v1/control/agents/{agent_id}/command",
|
"/api/v1/control/agents/{agent_id}/command",
|
||||||
post(api::run_command),
|
post(api::run_command),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/terminals",
|
||||||
|
get(api::list_terminals).post(api::create_terminal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/terminals/{terminal_id}",
|
||||||
|
get(api::get_terminal).delete(api::close_terminal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/terminals/{terminal_id}/attach",
|
||||||
|
post(api::attach_terminal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/terminals/{terminal_id}/ws",
|
||||||
|
get(api::operator_terminal_ws),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
||||||
@@ -157,6 +180,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
public_url: daemon.public_url.clone(),
|
public_url: daemon.public_url.clone(),
|
||||||
command_timeout: daemon.command_timeout,
|
command_timeout: daemon.command_timeout,
|
||||||
enroll_token_ttl: daemon.enroll_token_ttl,
|
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||||
|
terminals: terminals::TerminalRegistry::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep route classes explicit so edge policy can map directly:
|
// Keep route classes explicit so edge policy can map directly:
|
||||||
|
|||||||
@@ -0,0 +1,844 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use wakey_agent::protocol::{AgentTerminalSession, TerminalId};
|
||||||
|
|
||||||
|
pub const TERMINAL_RELAY_QUEUE: usize = 32;
|
||||||
|
pub const TERMINAL_MAX_FRAME_BYTES: usize = 64 * 1024;
|
||||||
|
pub const TERMINAL_PENDING_AGENT_BYTES: usize = 256 * 1024;
|
||||||
|
pub const TERMINAL_MAX_SESSIONS_PER_AGENT: usize = 2;
|
||||||
|
pub const TERMINAL_ATTACH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60);
|
||||||
|
const TERMINAL_TOMBSTONE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const TERMINAL_MAX_TOMBSTONES: usize = 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum TerminalRelayFrame {
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
Text(String),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TerminalRegistry {
|
||||||
|
inner: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||||
|
closed: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalSession {
|
||||||
|
agent_id: String,
|
||||||
|
created_at_unix: u64,
|
||||||
|
expires_at: Instant,
|
||||||
|
agent_confirmed: bool,
|
||||||
|
relay_token: Option<String>,
|
||||||
|
attachment_token: Option<String>,
|
||||||
|
attachment_operator_id: Option<String>,
|
||||||
|
agent_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
||||||
|
pending_agent: VecDeque<TerminalRelayFrame>,
|
||||||
|
pending_agent_bytes: usize,
|
||||||
|
operator_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
||||||
|
operator_id: Option<String>,
|
||||||
|
operator_connection_id: Option<String>,
|
||||||
|
operator_detached_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreatedTerminal {
|
||||||
|
pub terminal_id: String,
|
||||||
|
pub relay_token: String,
|
||||||
|
pub attachment_token: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TerminalSummary {
|
||||||
|
pub terminal_id: String,
|
||||||
|
pub agent_id: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
pub agent_attached: bool,
|
||||||
|
pub operator_attached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerminalRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
closed: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, agent_id: String) -> Result<CreatedTerminal, &'static str> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
prune_expired_sessions(&mut sessions);
|
||||||
|
if sessions
|
||||||
|
.values()
|
||||||
|
.filter(|session| session.agent_id == agent_id)
|
||||||
|
.count()
|
||||||
|
>= TERMINAL_MAX_SESSIONS_PER_AGENT
|
||||||
|
{
|
||||||
|
return Err("agent_terminal_limit_reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminal_id = Uuid::new_v4().to_string();
|
||||||
|
let relay_token = new_token();
|
||||||
|
let attachment_token = new_token();
|
||||||
|
let created_at_unix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
sessions.insert(
|
||||||
|
terminal_id.clone(),
|
||||||
|
TerminalSession {
|
||||||
|
agent_id,
|
||||||
|
created_at_unix,
|
||||||
|
expires_at: Instant::now() + TERMINAL_ABSOLUTE_TIMEOUT,
|
||||||
|
agent_confirmed: false,
|
||||||
|
relay_token: Some(relay_token.clone()),
|
||||||
|
attachment_token: Some(attachment_token.clone()),
|
||||||
|
attachment_operator_id: None,
|
||||||
|
agent_tx: None,
|
||||||
|
pending_agent: VecDeque::new(),
|
||||||
|
pending_agent_bytes: 0,
|
||||||
|
operator_tx: None,
|
||||||
|
operator_id: None,
|
||||||
|
operator_connection_id: None,
|
||||||
|
operator_detached_at: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(CreatedTerminal {
|
||||||
|
terminal_id,
|
||||||
|
relay_token,
|
||||||
|
attachment_token,
|
||||||
|
created_at_unix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(&self, terminal_id: &str) -> Option<String> {
|
||||||
|
let session = self.inner.lock().await.remove(terminal_id)?;
|
||||||
|
self.remember_closed(terminal_id).await;
|
||||||
|
if let Some(tx) = session.agent_tx {
|
||||||
|
let _ =
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), tx.send(TerminalRelayFrame::Close))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(tx) = session.operator_tx {
|
||||||
|
let _ =
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), tx.send(TerminalRelayFrame::Close))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Some(session.agent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether a session ID was recently removed. Tombstones make
|
||||||
|
/// idempotent DELETE distinguishable from a completely unknown ID.
|
||||||
|
pub async fn was_closed(&self, terminal_id: &str) -> bool {
|
||||||
|
let mut closed = self.closed.lock().await;
|
||||||
|
prune_tombstones(&mut closed);
|
||||||
|
closed.contains_key(terminal_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remember_closed(&self, terminal_id: &str) {
|
||||||
|
let mut closed = self.closed.lock().await;
|
||||||
|
prune_tombstones(&mut closed);
|
||||||
|
closed.insert(terminal_id.to_string(), Instant::now());
|
||||||
|
if closed.len() > TERMINAL_MAX_TOMBSTONES
|
||||||
|
&& let Some(oldest) = closed
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, closed_at)| **closed_at)
|
||||||
|
.map(|(terminal_id, _)| terminal_id.clone())
|
||||||
|
{
|
||||||
|
closed.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds CC's volatile catalog from the PTYs still owned by an agent.
|
||||||
|
/// Returned credentials tell those workers to establish fresh relay sockets.
|
||||||
|
pub async fn reconcile_agent_sessions(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
reported: &[AgentTerminalSession],
|
||||||
|
) -> Vec<(TerminalId, String)> {
|
||||||
|
let reported_ids = reported
|
||||||
|
.iter()
|
||||||
|
.filter(|session| expires_at_for_created_at(session.created_at_unix).is_some())
|
||||||
|
.map(|session| session.terminal_id.as_str())
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
let stale_ids = {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
prune_expired_sessions(&mut sessions);
|
||||||
|
sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(terminal_id, session)| {
|
||||||
|
session.agent_id == agent_id
|
||||||
|
&& session.agent_confirmed
|
||||||
|
&& !reported_ids.contains(terminal_id.as_str())
|
||||||
|
})
|
||||||
|
.map(|(terminal_id, _)| terminal_id.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for terminal_id in stale_ids {
|
||||||
|
self.remove(&terminal_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut credentials = Vec::new();
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
for reported_session in reported {
|
||||||
|
let Some(expires_at) = expires_at_for_created_at(reported_session.created_at_unix)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let terminal_id = reported_session.terminal_id.as_str().to_string();
|
||||||
|
let session = sessions
|
||||||
|
.entry(terminal_id)
|
||||||
|
.or_insert_with(|| TerminalSession {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
created_at_unix: reported_session.created_at_unix,
|
||||||
|
expires_at,
|
||||||
|
agent_confirmed: true,
|
||||||
|
relay_token: None,
|
||||||
|
attachment_token: None,
|
||||||
|
attachment_operator_id: None,
|
||||||
|
agent_tx: None,
|
||||||
|
pending_agent: VecDeque::new(),
|
||||||
|
pending_agent_bytes: 0,
|
||||||
|
operator_tx: None,
|
||||||
|
operator_id: None,
|
||||||
|
operator_connection_id: None,
|
||||||
|
operator_detached_at: Some(Instant::now()),
|
||||||
|
});
|
||||||
|
if session.agent_id != agent_id || session.agent_tx.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
session.agent_confirmed = true;
|
||||||
|
let token = new_token();
|
||||||
|
session.relay_token = Some(token.clone());
|
||||||
|
credentials.push((reported_session.terminal_id.clone(), token));
|
||||||
|
}
|
||||||
|
credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detach_agent(&self, terminal_id: &str) -> Option<String> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = sessions.get_mut(terminal_id)?;
|
||||||
|
session.agent_tx = None;
|
||||||
|
Some(session.agent_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a token while atomically handing this operator's attachment to
|
||||||
|
/// the target. A different operator remains protected by the single-viewer
|
||||||
|
/// rule, while a stale socket owned by this operator can be replaced.
|
||||||
|
pub async fn issue_attachment_token_for_operator(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
operator_id: &str,
|
||||||
|
) -> Result<String, &'static str> {
|
||||||
|
validate_operator_id(operator_id)?;
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let target = active_session(&mut sessions, terminal_id)?;
|
||||||
|
if target.operator_tx.is_some() && target.operator_id.as_deref() != Some(operator_id) {
|
||||||
|
return Err("terminal_operator_already_attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
detach_operator_sessions(&mut sessions, operator_id);
|
||||||
|
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
let token = new_token();
|
||||||
|
session.attachment_token = Some(token.clone());
|
||||||
|
session.attachment_operator_id = Some(operator_id.to_string());
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_agent(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
agent_id: &str,
|
||||||
|
relay_token: &str,
|
||||||
|
) -> Result<(mpsc::Receiver<TerminalRelayFrame>, Vec<TerminalRelayFrame>), &'static str> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
if session.agent_id != agent_id {
|
||||||
|
return Err("terminal_agent_mismatch");
|
||||||
|
}
|
||||||
|
if session.agent_tx.is_some() {
|
||||||
|
return Err("terminal_agent_already_attached");
|
||||||
|
}
|
||||||
|
if session.relay_token.as_deref() != Some(relay_token) {
|
||||||
|
return Err("terminal_relay_token_invalid");
|
||||||
|
}
|
||||||
|
session.relay_token = None;
|
||||||
|
session.agent_confirmed = true;
|
||||||
|
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
|
||||||
|
session.agent_tx = Some(tx);
|
||||||
|
let pending = session.pending_agent.drain(..).collect();
|
||||||
|
session.pending_agent_bytes = 0;
|
||||||
|
Ok((rx, pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_operator(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
attachment_token: &str,
|
||||||
|
operator_id: &str,
|
||||||
|
) -> Result<mpsc::Receiver<TerminalRelayFrame>, &'static str> {
|
||||||
|
validate_operator_id(operator_id)?;
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
if session.operator_tx.is_some() {
|
||||||
|
return Err("terminal_operator_already_attached");
|
||||||
|
}
|
||||||
|
if session.attachment_token.as_deref() != Some(attachment_token) {
|
||||||
|
return Err("terminal_attachment_token_invalid");
|
||||||
|
}
|
||||||
|
if session
|
||||||
|
.attachment_operator_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expected| expected != operator_id)
|
||||||
|
{
|
||||||
|
return Err("terminal_operator_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial create tokens are not yet bound to an operator. Once the
|
||||||
|
// WebSocket presents that token, it still atomically releases any
|
||||||
|
// older session held by the same browser tab.
|
||||||
|
detach_operator_sessions(&mut sessions, operator_id);
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
session.attachment_token = None;
|
||||||
|
session.attachment_operator_id = None;
|
||||||
|
session.operator_detached_at = None;
|
||||||
|
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
|
||||||
|
session.operator_tx = Some(tx);
|
||||||
|
session.operator_id = Some(operator_id.to_string());
|
||||||
|
session.operator_connection_id = Some(attachment_token.to_string());
|
||||||
|
Ok(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn relay_to_agent(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
frame: TerminalRelayFrame,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
// Select the attached transport or enqueue the frame while holding one
|
||||||
|
// lock. Otherwise an agent can attach between those decisions and
|
||||||
|
// leave input stranded in the pre-attachment queue.
|
||||||
|
let tx = {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
if let Some(tx) = session.agent_tx.clone() {
|
||||||
|
tx
|
||||||
|
} else {
|
||||||
|
session.pending_agent_bytes += relay_frame_size(&frame);
|
||||||
|
session.pending_agent.push_back(frame);
|
||||||
|
while session.pending_agent_bytes > TERMINAL_PENDING_AGENT_BYTES {
|
||||||
|
if let Some(dropped) = session.pending_agent.pop_front() {
|
||||||
|
session.pending_agent_bytes -= relay_frame_size(&dropped);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.send(frame)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "terminal_agent_disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn relay_from_agent(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
frame: TerminalRelayFrame,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
let operator_tx = {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
session.operator_tx.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tx) = operator_tx {
|
||||||
|
match tx.send(frame).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
// The browser task may not have marked itself detached yet.
|
||||||
|
// Detach its stale sender; the agent's parsed screen remains
|
||||||
|
// authoritative and will reconstruct the next attachment.
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
|
if session
|
||||||
|
.operator_tx
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|current| current.same_channel(&tx))
|
||||||
|
{
|
||||||
|
clear_operator_attachment(session);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
agent_id: &str,
|
||||||
|
error_json: String,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
let matches_agent = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(terminal_id)
|
||||||
|
.is_some_and(|session| session.agent_id == agent_id);
|
||||||
|
if !matches_agent {
|
||||||
|
return Err("terminal_agent_mismatch");
|
||||||
|
}
|
||||||
|
self.relay_from_agent(terminal_id, TerminalRelayFrame::Text(error_json))
|
||||||
|
.await?;
|
||||||
|
self.remove(terminal_id)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or("terminal_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detaches only the socket identified by this attachment token. Delayed
|
||||||
|
/// cleanup from an older socket must not clear its replacement.
|
||||||
|
pub async fn detach_operator(&self, terminal_id: &str, connection_id: &str) -> Option<Instant> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
let session = sessions.get_mut(terminal_id)?;
|
||||||
|
if session.operator_connection_id.as_deref() != Some(connection_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let detached_at = Instant::now();
|
||||||
|
clear_operator_attachment(session);
|
||||||
|
Some(detached_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn summary(&self, terminal_id: &str) -> Option<(String, u64, bool, bool)> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
prune_expired_sessions(&mut sessions);
|
||||||
|
sessions.get(terminal_id).map(|session| {
|
||||||
|
(
|
||||||
|
session.agent_id.clone(),
|
||||||
|
session.created_at_unix,
|
||||||
|
session.agent_tx.is_some(),
|
||||||
|
session.operator_tx.is_some(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn summaries(&self) -> Vec<TerminalSummary> {
|
||||||
|
let mut sessions = self.inner.lock().await;
|
||||||
|
prune_expired_sessions(&mut sessions);
|
||||||
|
let mut summaries = sessions
|
||||||
|
.iter()
|
||||||
|
.map(|(terminal_id, session)| TerminalSummary {
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
agent_id: session.agent_id.clone(),
|
||||||
|
created_at_unix: session.created_at_unix,
|
||||||
|
agent_attached: session.agent_tx.is_some(),
|
||||||
|
operator_attached: session.operator_tx.is_some(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
summaries.sort_by_key(|session| std::cmp::Reverse(session.created_at_unix));
|
||||||
|
summaries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_session<'a>(
|
||||||
|
sessions: &'a mut HashMap<String, TerminalSession>,
|
||||||
|
terminal_id: &str,
|
||||||
|
) -> Result<&'a mut TerminalSession, &'static str> {
|
||||||
|
if sessions
|
||||||
|
.get(terminal_id)
|
||||||
|
.is_some_and(|session| session.expires_at <= Instant::now())
|
||||||
|
{
|
||||||
|
sessions.remove(terminal_id);
|
||||||
|
return Err("terminal_expired");
|
||||||
|
}
|
||||||
|
sessions.get_mut(terminal_id).ok_or("terminal_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_operator_id(operator_id: &str) -> Result<(), &'static str> {
|
||||||
|
if operator_id.is_empty() || operator_id.len() > 128 {
|
||||||
|
return Err("terminal_operator_id_invalid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detach_operator_sessions(sessions: &mut HashMap<String, TerminalSession>, operator_id: &str) {
|
||||||
|
for session in sessions.values_mut() {
|
||||||
|
if session.operator_id.as_deref() == Some(operator_id) {
|
||||||
|
clear_operator_attachment(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_operator_attachment(session: &mut TerminalSession) {
|
||||||
|
session.operator_tx = None;
|
||||||
|
session.operator_id = None;
|
||||||
|
session.operator_connection_id = None;
|
||||||
|
session.operator_detached_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_token() -> String {
|
||||||
|
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_tombstones(closed: &mut HashMap<String, Instant>) {
|
||||||
|
closed.retain(|_, closed_at| closed_at.elapsed() < TERMINAL_TOMBSTONE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_expired_sessions(sessions: &mut HashMap<String, TerminalSession>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
sessions.retain(|_, session| session.expires_at > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the agent's durable creation timestamp into CC's monotonic
|
||||||
|
/// deadline. Reconciliation must not grant an old PTY a fresh twelve hours.
|
||||||
|
fn expires_at_for_created_at(created_at_unix: u64) -> Option<Instant> {
|
||||||
|
let now_unix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let age = Duration::from_secs(now_unix.saturating_sub(created_at_unix));
|
||||||
|
if age >= TERMINAL_ABSOLUTE_TIMEOUT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let remaining = TERMINAL_ABSOLUTE_TIMEOUT - age;
|
||||||
|
Some(Instant::now() + remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_frame_size(frame: &TerminalRelayFrame) -> usize {
|
||||||
|
match frame {
|
||||||
|
TerminalRelayFrame::Binary(bytes) => bytes.len(),
|
||||||
|
TerminalRelayFrame::Text(text) => text.len(),
|
||||||
|
TerminalRelayFrame::Close => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const OPERATOR_A: &str = "browser-tab-a";
|
||||||
|
const OPERATOR_B: &str = "browser-tab-b";
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credentials_are_scoped_and_single_use() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.attach_agent(&created.terminal_id, "other", &created.relay_token)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let _ = registry
|
||||||
|
.attach_agent(&created.terminal_id, "router", &created.relay_token)
|
||||||
|
.await
|
||||||
|
.expect("attach agent");
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.attach_agent(&created.terminal_id, "router", &created.relay_token)
|
||||||
|
.await
|
||||||
|
.expect_err("relay token is single use"),
|
||||||
|
"terminal_agent_already_attached"
|
||||||
|
);
|
||||||
|
|
||||||
|
registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach operator");
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A,)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detached_output_is_not_transcribed_by_control_plane() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
registry
|
||||||
|
.relay_from_agent(
|
||||||
|
&created.terminal_id,
|
||||||
|
TerminalRelayFrame::Binary(b"not retained".to_vec()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("ignore detached output");
|
||||||
|
|
||||||
|
let mut outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach operator");
|
||||||
|
assert!(outbound.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attached_output_is_delivered_live_only() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
let mut outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach operator");
|
||||||
|
let frame = TerminalRelayFrame::Binary(b"recent prompt".to_vec());
|
||||||
|
registry
|
||||||
|
.relay_from_agent(&created.terminal_id, frame)
|
||||||
|
.await
|
||||||
|
.expect("relay output");
|
||||||
|
outbound.recv().await.expect("live output");
|
||||||
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &created.attachment_token)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let token = registry
|
||||||
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("reattach token");
|
||||||
|
let mut remounted = registry
|
||||||
|
.attach_operator(&created.terminal_id, &token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("reattach operator");
|
||||||
|
|
||||||
|
assert!(remounted.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn operator_input_waits_for_agent_attachment() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
let resize = TerminalRelayFrame::Text(r#"{"type":"resize","rows":30,"cols":120}"#.into());
|
||||||
|
registry
|
||||||
|
.relay_to_agent(&created.terminal_id, resize)
|
||||||
|
.await
|
||||||
|
.expect("queue resize before agent attachment");
|
||||||
|
|
||||||
|
let (_, pending) = registry
|
||||||
|
.attach_agent(&created.terminal_id, "router", &created.relay_token)
|
||||||
|
.await
|
||||||
|
.expect("attach agent");
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert!(matches!(pending[0], TerminalRelayFrame::Text(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_can_hold_two_terminal_sessions() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
registry.create("router".into()).await.expect("first");
|
||||||
|
registry.create("router".into()).await.expect("second");
|
||||||
|
let third = registry.create("router".into()).await;
|
||||||
|
assert_eq!(third.err(), Some("agent_terminal_limit_reached"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removed_session_is_distinct_from_unknown_session() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
|
||||||
|
assert!(!registry.was_closed(&created.terminal_id).await);
|
||||||
|
assert!(!registry.was_closed("never-existed").await);
|
||||||
|
registry.remove(&created.terminal_id).await.expect("remove");
|
||||||
|
assert!(registry.was_closed(&created.terminal_id).await);
|
||||||
|
assert!(!registry.was_closed("never-existed").await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_inventory_adopts_and_reconnects_live_session() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let terminal_id = TerminalId::new("survived-cc").expect("terminal id");
|
||||||
|
let created_at_unix = u64::MAX;
|
||||||
|
let reported = AgentTerminalSession {
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
created_at_unix,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials = registry
|
||||||
|
.reconcile_agent_sessions("router", &[reported])
|
||||||
|
.await;
|
||||||
|
assert_eq!(credentials.len(), 1);
|
||||||
|
let (_, relay_token) = &credentials[0];
|
||||||
|
registry
|
||||||
|
.attach_agent(terminal_id.as_str(), "router", relay_token)
|
||||||
|
.await
|
||||||
|
.expect("attach adopted agent session");
|
||||||
|
|
||||||
|
let summary = registry
|
||||||
|
.summary(terminal_id.as_str())
|
||||||
|
.await
|
||||||
|
.expect("adopted summary");
|
||||||
|
assert_eq!(summary.0, "router");
|
||||||
|
assert_eq!(summary.1, created_at_unix);
|
||||||
|
assert!(summary.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_sessions_release_agent_quota() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let first = registry.create("router".into()).await.expect("first");
|
||||||
|
registry.create("router".into()).await.expect("second");
|
||||||
|
registry
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_mut(&first.terminal_id)
|
||||||
|
.expect("first session")
|
||||||
|
.expires_at = Instant::now();
|
||||||
|
|
||||||
|
registry
|
||||||
|
.create("router".into())
|
||||||
|
.await
|
||||||
|
.expect("expired session no longer consumes quota");
|
||||||
|
assert!(registry.summary(&first.terminal_id).await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconciliation_does_not_revive_expired_agent_session() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let terminal_id = TerminalId::new("expired-agent-session").expect("terminal id");
|
||||||
|
let reported = AgentTerminalSession {
|
||||||
|
terminal_id: terminal_id.clone(),
|
||||||
|
created_at_unix: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.reconcile_agent_sessions("router", &[reported])
|
||||||
|
.await
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(registry.summary(terminal_id.as_str()).await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn operator_detach_does_not_remove_session() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach operator");
|
||||||
|
|
||||||
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &created.attachment_token)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let summary = registry
|
||||||
|
.summary(&created.terminal_id)
|
||||||
|
.await
|
||||||
|
.expect("session remains after detach");
|
||||||
|
assert!(!summary.3);
|
||||||
|
registry
|
||||||
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("detached session can be reattached");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attachment_handoff_releases_previous_operator_atomically() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let previous = registry.create("router".into()).await.expect("previous");
|
||||||
|
let next = registry.create("router".into()).await.expect("next");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&previous.terminal_id,
|
||||||
|
&previous.attachment_token,
|
||||||
|
OPERATOR_A,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach previous");
|
||||||
|
|
||||||
|
let next_token = registry
|
||||||
|
.issue_attachment_token_for_operator(&next.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("handoff token");
|
||||||
|
|
||||||
|
assert!(!registry.summary(&previous.terminal_id).await.unwrap().3);
|
||||||
|
registry
|
||||||
|
.attach_operator(&next.terminal_id, &next_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach next");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attachment_handoff_does_not_evict_unrelated_operator() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let previous = registry.create("router".into()).await.expect("previous");
|
||||||
|
let occupied = registry.create("router".into()).await.expect("occupied");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&previous.terminal_id,
|
||||||
|
&previous.attachment_token,
|
||||||
|
OPERATOR_A,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach previous");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&occupied.terminal_id,
|
||||||
|
&occupied.attachment_token,
|
||||||
|
OPERATOR_B,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach occupied");
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.issue_attachment_token_for_operator(&occupied.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect_err("occupied target remains protected");
|
||||||
|
|
||||||
|
assert_eq!(error, "terminal_operator_already_attached");
|
||||||
|
assert!(registry.summary(&previous.terminal_id).await.unwrap().3);
|
||||||
|
assert!(registry.summary(&occupied.terminal_id).await.unwrap().3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_socket_cleanup_does_not_detach_replacement() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
let old_connection_id = created.attachment_token.clone();
|
||||||
|
let _old_outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach old socket");
|
||||||
|
|
||||||
|
let replacement_token = registry
|
||||||
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("replace own socket");
|
||||||
|
let _replacement_outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &replacement_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach replacement socket");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &old_connection_id)
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(registry.summary(&created.terminal_id).await.unwrap().3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconciliation_does_not_remove_open_request_still_in_flight() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
|
||||||
|
registry.reconcile_agent_sessions("router", &[]).await;
|
||||||
|
|
||||||
|
assert!(registry.summary(&created.terminal_id).await.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,7 +216,7 @@ impl Store {
|
|||||||
input: DeviceIdentifierInput,
|
input: DeviceIdentifierInput,
|
||||||
) -> Result<Option<KnownDevice>> {
|
) -> Result<Option<KnownDevice>> {
|
||||||
let identifier = normalize_device_identifier(input)?;
|
let identifier = normalize_device_identifier(input)?;
|
||||||
let identifier_key = normalized_identifier_key_owned(identifier);
|
let identifier_key = identifier.identifier_key;
|
||||||
let device_id = sqlx::query_scalar!(
|
let device_id = sqlx::query_scalar!(
|
||||||
"SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
|
"SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
|
||||||
identifier_key
|
identifier_key
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ pub fn normalize_device_identifier(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
|
||||||
pub fn normalized_identifier_key_owned(identifier: NormalizedDeviceIdentifier) -> String {
|
|
||||||
identifier.identifier_key
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_enroll_token_rows(pool: &SqlitePool) -> Result<Vec<EnrollTokenRow>> {
|
pub async fn list_enroll_token_rows(pool: &SqlitePool) -> Result<Vec<EnrollTokenRow>> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
EnrollTokenRow,
|
EnrollTokenRow,
|
||||||
|
|||||||
+118
-18
@@ -8,7 +8,10 @@ use std::time::Instant;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info, info_span, warn};
|
use tracing::{debug, info, info_span, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wakey_agent::protocol::{ErrorPayload, RequestId, ServerMessage};
|
use wakey_agent::protocol::{
|
||||||
|
AgentCapability, AgentTerminalSession, ErrorPayload, RequestId, ServerMessage, TerminalControl,
|
||||||
|
TerminalId,
|
||||||
|
};
|
||||||
use wakey_core::Device;
|
use wakey_core::Device;
|
||||||
|
|
||||||
use crate::runtime::{AgentReply, AgentSession, AppState, SessionEvent};
|
use crate::runtime::{AgentReply, AgentSession, AppState, SessionEvent};
|
||||||
@@ -19,6 +22,8 @@ use crate::state::AuditEventInput;
|
|||||||
enum IncomingClientMessage {
|
enum IncomingClientMessage {
|
||||||
Hello {
|
Hello {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
capabilities: Vec<AgentCapability>,
|
||||||
},
|
},
|
||||||
Auth {
|
Auth {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
@@ -39,6 +44,21 @@ enum IncomingClientMessage {
|
|||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
error: ErrorPayload,
|
error: ErrorPayload,
|
||||||
},
|
},
|
||||||
|
TerminalRejected {
|
||||||
|
terminal_id: TerminalId,
|
||||||
|
error: ErrorPayload,
|
||||||
|
},
|
||||||
|
TerminalSessions {
|
||||||
|
sessions: Vec<AgentTerminalSession>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AgentConnectionState {
|
||||||
|
authed_agent_id: Option<String>,
|
||||||
|
hello_agent_id: Option<String>,
|
||||||
|
hello_at: Option<Instant>,
|
||||||
|
capabilities: Vec<AgentCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
pub async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
@@ -80,8 +100,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
debug!("websocket writer loop ended");
|
debug!("websocket writer loop ended");
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut authed_agent_id: Option<String> = None;
|
let mut connection = AgentConnectionState::default();
|
||||||
let mut hello_at: Option<Instant> = None;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let frame = read.next().await;
|
let frame = read.next().await;
|
||||||
@@ -103,8 +122,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
&state,
|
&state,
|
||||||
&tx,
|
&tx,
|
||||||
&connection_id,
|
&connection_id,
|
||||||
&mut authed_agent_id,
|
&mut connection,
|
||||||
&mut hello_at,
|
|
||||||
connected_at,
|
connected_at,
|
||||||
&text,
|
&text,
|
||||||
)
|
)
|
||||||
@@ -126,7 +144,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(agent_id) = authed_agent_id {
|
if let Some(agent_id) = connection.authed_agent_id {
|
||||||
info!(agent_id = %agent_id, "agent disconnected");
|
info!(agent_id = %agent_id, "agent disconnected");
|
||||||
let mut sessions = state.sessions.write().await;
|
let mut sessions = state.sessions.write().await;
|
||||||
let should_remove = sessions
|
let should_remove = sessions
|
||||||
@@ -136,6 +154,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
if should_remove {
|
if should_remove {
|
||||||
sessions.remove(&agent_id);
|
sessions.remove(&agent_id);
|
||||||
}
|
}
|
||||||
|
drop(sessions);
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.store
|
.store
|
||||||
.append_audit_event(AuditEventInput {
|
.append_audit_event(AuditEventInput {
|
||||||
@@ -163,8 +182,7 @@ async fn process_agent_text(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
tx: &mpsc::UnboundedSender<SessionEvent>,
|
tx: &mpsc::UnboundedSender<SessionEvent>,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
authed_agent_id: &mut Option<String>,
|
connection: &mut AgentConnectionState,
|
||||||
hello_at: &mut Option<Instant>,
|
|
||||||
connected_at: Instant,
|
connected_at: Instant,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -172,20 +190,35 @@ async fn process_agent_text(
|
|||||||
serde_json::from_str(text).context("invalid client websocket payload")?;
|
serde_json::from_str(text).context("invalid client websocket payload")?;
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
IncomingClientMessage::Hello { agent_id } => {
|
IncomingClientMessage::Hello {
|
||||||
|
agent_id,
|
||||||
|
capabilities,
|
||||||
|
} => {
|
||||||
|
if connection
|
||||||
|
.hello_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|hello_agent_id| hello_agent_id != agent_id)
|
||||||
|
{
|
||||||
|
anyhow::bail!("hello changed agent identity");
|
||||||
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if hello_at.is_none() {
|
if connection.hello_at.is_none() {
|
||||||
*hello_at = Some(now);
|
connection.hello_at = Some(now);
|
||||||
}
|
}
|
||||||
let connect_to_hello_ms = connected_at.elapsed().as_millis() as u64;
|
let connect_to_hello_ms = connected_at.elapsed().as_millis() as u64;
|
||||||
info!(agent_id = %agent_id, connect_to_hello_ms, "agent hello received");
|
info!(agent_id = %agent_id, connect_to_hello_ms, "agent hello received");
|
||||||
|
connection.hello_agent_id = Some(agent_id);
|
||||||
|
connection.capabilities = capabilities;
|
||||||
}
|
}
|
||||||
IncomingClientMessage::Auth {
|
IncomingClientMessage::Auth {
|
||||||
agent_id,
|
agent_id,
|
||||||
agent_token,
|
agent_token,
|
||||||
} => {
|
} => {
|
||||||
|
validate_auth_identity(connection, &agent_id)?;
|
||||||
let connect_to_auth_ms = connected_at.elapsed().as_millis() as u64;
|
let connect_to_auth_ms = connected_at.elapsed().as_millis() as u64;
|
||||||
let hello_to_auth_ms = hello_at.map(|t| now_duration_ms(t.elapsed()));
|
let hello_to_auth_ms = connection
|
||||||
|
.hello_at
|
||||||
|
.map(|time| now_duration_ms(time.elapsed()));
|
||||||
if !state
|
if !state
|
||||||
.store
|
.store
|
||||||
.verify_agent_token(&agent_id, &agent_token)
|
.verify_agent_token(&agent_id, &agent_token)
|
||||||
@@ -219,9 +252,10 @@ async fn process_agent_text(
|
|||||||
AgentSession {
|
AgentSession {
|
||||||
connection_id: connection_id.to_string(),
|
connection_id: connection_id.to_string(),
|
||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
|
capabilities: connection.capabilities.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
*authed_agent_id = Some(agent_id.clone());
|
connection.authed_agent_id = Some(agent_id.clone());
|
||||||
info!(agent_id = %agent_id, connect_to_auth_ms, hello_to_auth_ms = hello_to_auth_ms.unwrap_or(0), "agent authenticated");
|
info!(agent_id = %agent_id, connect_to_auth_ms, hello_to_auth_ms = hello_to_auth_ms.unwrap_or(0), "agent authenticated");
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.store
|
.store
|
||||||
@@ -246,14 +280,14 @@ async fn process_agent_text(
|
|||||||
let _ = tx.send(SessionEvent::Message(ServerMessage::SyncDeviceSnapshot));
|
let _ = tx.send(SessionEvent::Message(ServerMessage::SyncDeviceSnapshot));
|
||||||
}
|
}
|
||||||
IncomingClientMessage::Heartbeat { agent_id } => {
|
IncomingClientMessage::Heartbeat { agent_id } => {
|
||||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
if connection.authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||||
anyhow::bail!("heartbeat for unauthenticated or mismatched agent");
|
anyhow::bail!("heartbeat for unauthenticated or mismatched agent");
|
||||||
}
|
}
|
||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
debug!(agent_id = %agent_id, "heartbeat received");
|
debug!(agent_id = %agent_id, "heartbeat received");
|
||||||
}
|
}
|
||||||
IncomingClientMessage::DeviceSnapshot { agent_id, devices } => {
|
IncomingClientMessage::DeviceSnapshot { agent_id, devices } => {
|
||||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
if connection.authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||||
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
||||||
}
|
}
|
||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
@@ -272,7 +306,8 @@ async fn process_agent_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IncomingClientMessage::Result { request_id, result } => {
|
IncomingClientMessage::Result { request_id, result } => {
|
||||||
let agent_id = authed_agent_id
|
let agent_id = connection
|
||||||
|
.authed_agent_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("result before auth"))?;
|
.ok_or_else(|| anyhow::anyhow!("result before auth"))?;
|
||||||
ensure_current_session(state, agent_id, connection_id).await?;
|
ensure_current_session(state, agent_id, connection_id).await?;
|
||||||
@@ -284,7 +319,8 @@ async fn process_agent_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IncomingClientMessage::Error { request_id, error } => {
|
IncomingClientMessage::Error { request_id, error } => {
|
||||||
let agent_id = authed_agent_id
|
let agent_id = connection
|
||||||
|
.authed_agent_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("error before auth"))?;
|
.ok_or_else(|| anyhow::anyhow!("error before auth"))?;
|
||||||
ensure_current_session(state, agent_id, connection_id).await?;
|
ensure_current_session(state, agent_id, connection_id).await?;
|
||||||
@@ -295,6 +331,48 @@ async fn process_agent_text(
|
|||||||
debug!(request_id = %key, "dropping unsolicited error from agent");
|
debug!(request_id = %key, "dropping unsolicited error from agent");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IncomingClientMessage::TerminalRejected { terminal_id, error } => {
|
||||||
|
let agent_id = connection
|
||||||
|
.authed_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("terminal rejection before auth"))?;
|
||||||
|
ensure_current_session(state, agent_id, connection_id).await?;
|
||||||
|
let error_json = serde_json::to_string(&TerminalControl::Error {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
})?;
|
||||||
|
if let Err(code) = state
|
||||||
|
.terminals
|
||||||
|
.reject(terminal_id.as_str(), agent_id, error_json)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug!(terminal_id = %terminal_id, agent_id, code, "dropping rejection for inactive terminal");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
warn!(terminal_id = %terminal_id, agent_id, "agent rejected terminal request");
|
||||||
|
}
|
||||||
|
IncomingClientMessage::TerminalSessions { sessions } => {
|
||||||
|
let agent_id = connection
|
||||||
|
.authed_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("terminal inventory before auth"))?;
|
||||||
|
ensure_current_session(state, agent_id, connection_id).await?;
|
||||||
|
let credentials = state
|
||||||
|
.terminals
|
||||||
|
.reconcile_agent_sessions(agent_id, &sessions)
|
||||||
|
.await;
|
||||||
|
for (terminal_id, relay_token) in credentials {
|
||||||
|
let _ = tx.send(SessionEvent::Message(ServerMessage::ResumeTerminal {
|
||||||
|
terminal_id,
|
||||||
|
relay_token,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
agent_id,
|
||||||
|
sessions = sessions.len(),
|
||||||
|
"agent terminal sessions reconciled"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -304,6 +382,17 @@ fn now_duration_ms(duration: std::time::Duration) -> u64 {
|
|||||||
duration.as_millis() as u64
|
duration.as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_auth_identity(connection: &AgentConnectionState, agent_id: &str) -> Result<()> {
|
||||||
|
let hello_agent_id = connection
|
||||||
|
.hello_agent_id
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("auth before hello"))?;
|
||||||
|
if hello_agent_id != agent_id {
|
||||||
|
anyhow::bail!("auth agent does not match hello agent");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_current_session(
|
async fn ensure_current_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
@@ -338,7 +427,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::runtime::AgentSession;
|
use crate::runtime::AgentSession;
|
||||||
|
|
||||||
use super::is_current_session;
|
use super::{AgentConnectionState, is_current_session, validate_auth_identity};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_session_check_rejects_stale_connection_ids() {
|
fn current_session_check_rejects_stale_connection_ids() {
|
||||||
@@ -349,6 +438,7 @@ mod tests {
|
|||||||
AgentSession {
|
AgentSession {
|
||||||
connection_id: "conn-new".to_string(),
|
connection_id: "conn-new".to_string(),
|
||||||
tx,
|
tx,
|
||||||
|
capabilities: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -356,4 +446,14 @@ mod tests {
|
|||||||
assert!(!is_current_session(&sessions, "agent-a", "conn-old"));
|
assert!(!is_current_session(&sessions, "agent-a", "conn-old"));
|
||||||
assert!(!is_current_session(&sessions, "agent-b", "conn-new"));
|
assert!(!is_current_session(&sessions, "agent-b", "conn-new"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_requires_a_matching_hello_identity() {
|
||||||
|
let mut connection = AgentConnectionState::default();
|
||||||
|
assert!(validate_auth_identity(&connection, "agent-a").is_err());
|
||||||
|
|
||||||
|
connection.hello_agent_id = Some("agent-a".into());
|
||||||
|
assert!(validate_auth_identity(&connection, "agent-a").is_ok());
|
||||||
|
assert!(validate_auth_identity(&connection, "agent-b").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+13
-5
@@ -5,12 +5,12 @@ edition = "2024"
|
|||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = { workspace = true }
|
||||||
futures = "0"
|
futures = "0"
|
||||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
macaddr = { workspace = true }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = { workspace = true }
|
||||||
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
|
tokio = { workspace = true, features = ["net", "sync"] }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -21,3 +21,11 @@ path = "../ipjs"
|
|||||||
registry = "gitea"
|
registry = "gitea"
|
||||||
version = "0"
|
version = "0"
|
||||||
features = ["experimental-nl"]
|
features = ["experimental-nl"]
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
nix = { version = "0.31", default-features = false, features = [
|
||||||
|
"signal",
|
||||||
|
"process",
|
||||||
|
"term",
|
||||||
|
] }
|
||||||
|
pty-process = { version = "0.5", features = ["async"] }
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod dhcp;
|
pub mod dhcp;
|
||||||
pub mod observations;
|
pub mod observations;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod terminal;
|
||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
pub use devices::*;
|
pub use devices::*;
|
||||||
pub use dhcp::*;
|
pub use dhcp::*;
|
||||||
pub use observations::*;
|
pub use observations::*;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub use terminal::*;
|
||||||
pub use wake::*;
|
pub use wake::*;
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! Unix PTY ownership for interactive agent terminal sessions.
|
||||||
|
|
||||||
|
use std::os::fd::{AsFd, OwnedFd};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context as TaskContext, Poll};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use pty_process::{Command, OwnedReadPty, OwnedWritePty, Size};
|
||||||
|
use tokio::io::AsyncWrite;
|
||||||
|
use tokio::process::Child;
|
||||||
|
|
||||||
|
/// An interactive child process and the independently owned sides of its PTY.
|
||||||
|
///
|
||||||
|
/// Keeping the write half available is important: `OwnedWritePty` also owns the
|
||||||
|
/// resize operation used by terminal WebSocket control frames.
|
||||||
|
pub struct TerminalPty {
|
||||||
|
reader: OwnedReadPty,
|
||||||
|
writer: TerminalWriter,
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writable PTY half with the control operations needed by the agent.
|
||||||
|
pub struct TerminalWriter {
|
||||||
|
io: OwnedWritePty,
|
||||||
|
control: OwnedFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalPty {
|
||||||
|
/// Starts `program` attached to a newly allocated PTY.
|
||||||
|
pub fn spawn(program: &Path, rows: u16, cols: u16) -> Result<Self> {
|
||||||
|
let (pty, pts) = pty_process::open().context("failed to open PTY")?;
|
||||||
|
pty.resize(Size::new(rows, cols))
|
||||||
|
.context("failed to set initial PTY size")?;
|
||||||
|
let control = pty
|
||||||
|
.as_fd()
|
||||||
|
.try_clone_to_owned()
|
||||||
|
.context("failed to duplicate PTY control descriptor")?;
|
||||||
|
|
||||||
|
// The remote frontend is xterm.js regardless of the daemon's own
|
||||||
|
// environment, so advertise the terminal the child actually receives.
|
||||||
|
let command = Command::new(program)
|
||||||
|
.env("TERM", "xterm-256color")
|
||||||
|
.env("COLORTERM", "truecolor")
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let child = command
|
||||||
|
.spawn(pts)
|
||||||
|
.with_context(|| format!("failed to spawn {} in PTY", program.display()))?;
|
||||||
|
let (reader, io) = pty.into_split();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
reader,
|
||||||
|
writer: TerminalWriter { io, control },
|
||||||
|
child,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the terminal into independently driven async parts.
|
||||||
|
pub fn into_parts(self) -> (OwnedReadPty, TerminalWriter, Child) {
|
||||||
|
(self.reader, self.writer, self.child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalWriter {
|
||||||
|
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
|
||||||
|
self.io
|
||||||
|
.resize(Size::new(rows, cols))
|
||||||
|
.context("failed to resize PTY")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests a full-screen application to redraw after reattachment.
|
||||||
|
///
|
||||||
|
/// Job-control shells give foreground programs their own process groups,
|
||||||
|
/// so signaling the original shell group would miss programs like `btop`.
|
||||||
|
pub fn refresh(&self) -> Result<()> {
|
||||||
|
let foreground = nix::unistd::tcgetpgrp(&self.control)
|
||||||
|
.context("failed to get PTY foreground process group")?;
|
||||||
|
nix::sys::signal::killpg(foreground, nix::sys::signal::Signal::SIGWINCH)
|
||||||
|
.context("failed to signal PTY foreground process group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWrite for TerminalWriter {
|
||||||
|
fn poll_write(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
cx: &mut TaskContext<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> Poll<std::io::Result<usize>> {
|
||||||
|
Pin::new(&mut self.io).poll_write(cx, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
Pin::new(&mut self.io).poll_flush(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
cx: &mut TaskContext<'_>,
|
||||||
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
Pin::new(&mut self.io).poll_shutdown(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// This test intentionally uses the same PTY implementation on the router.
|
||||||
|
/// Run it remotely with:
|
||||||
|
///
|
||||||
|
/// `./scripts/test_remote.ps1 -Package wakey-linux -Filter terminal::tests::pty_round_trip_and_resize -Exact -ShowOutput`
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_round_trip_and_resize() {
|
||||||
|
let (mut reader, mut writer, mut child) = TerminalPty::spawn(Path::new("/bin/sh"), 24, 80)
|
||||||
|
.expect("spawn PTY shell")
|
||||||
|
.into_parts();
|
||||||
|
|
||||||
|
writer.resize(31, 101).expect("resize owned PTY writer");
|
||||||
|
// Print the size from the parent shell after `stty` exits, so observing
|
||||||
|
// the marker also means foreground control has returned to the shell.
|
||||||
|
// Then wait for the literal probe; WINCH can interrupt a plain `read`.
|
||||||
|
writer
|
||||||
|
.write_all(
|
||||||
|
b"trap 'printf \"WAKEY_WINCH\\n\"' WINCH; \
|
||||||
|
printf 'WAKEY_ENV:%s:%s\\n' \"$TERM\" \"$COLORTERM\"; \
|
||||||
|
size=$(stty size); printf 'WAKEY_SIZE:%s\\n' \"$size\"; \
|
||||||
|
line=; while [ \"$line\" != probe ]; do read line || line=; done; \
|
||||||
|
printf 'WAKEY_PTY_OK:%s\\n' \"$line\"; exit 0\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write shell input");
|
||||||
|
let mut output = Vec::new();
|
||||||
|
read_until(
|
||||||
|
&mut reader,
|
||||||
|
&mut output,
|
||||||
|
"WAKEY_ENV:xterm-256color:truecolor",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
read_until(&mut reader, &mut output, "WAKEY_SIZE:31 101").await;
|
||||||
|
writer.refresh().expect("signal foreground process group");
|
||||||
|
writer
|
||||||
|
.write_all(b"probe\n")
|
||||||
|
.await
|
||||||
|
.expect("write shell probe");
|
||||||
|
let output = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
read_until(&mut reader, &mut output, "WAKEY_PTY_OK:probe").await;
|
||||||
|
output
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("PTY output timed out");
|
||||||
|
let status = tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||||
|
.await
|
||||||
|
.expect("PTY child wait timed out")
|
||||||
|
.expect("wait for PTY child");
|
||||||
|
|
||||||
|
assert!(status.success(), "shell exited with {status}");
|
||||||
|
let output = String::from_utf8_lossy(&output);
|
||||||
|
assert!(
|
||||||
|
output.contains("WAKEY_PTY_OK:probe"),
|
||||||
|
"unexpected PTY output: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("WAKEY_SIZE:31 101"),
|
||||||
|
"PTY did not report resized dimensions: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("WAKEY_WINCH"),
|
||||||
|
"foreground process did not receive SIGWINCH: {output:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_until(reader: &mut OwnedReadPty, output: &mut Vec<u8>, marker: &str) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
let mut chunk = [0_u8; 4096];
|
||||||
|
while !String::from_utf8_lossy(output).contains(marker) {
|
||||||
|
// Use a fresh ReadBuf for each PTY read. pty-process 0.5.3's
|
||||||
|
// AsyncRead implementation cannot safely extend Tokio's
|
||||||
|
// partially filled buffer used by `read_to_end`.
|
||||||
|
let count = reader.read(&mut chunk).await.expect("read PTY output");
|
||||||
|
assert_ne!(count, 0, "PTY closed before emitting {marker}");
|
||||||
|
output.extend_from_slice(&chunk[..count]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("PTY output timed out waiting for {marker}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user