Commit 49d18ca
feat: remote console (#89) — phases 1a→3 + per-box toggle + WS TLS pinning (#127)
* feat(#117): agent plumbing for remote console (Phase 1a of #89)
Adds the auth + WebSocket surface for the planned remote-console
feature, with no PTY attached yet. Surface is off by default — routes
return 404 unless the operator opts in with HAPROXY_AGENT_CONSOLE_ENABLED=true.
When enabled, the agent exposes:
POST /api/v1/console/token (API-key gated; mints a 60s single-use token)
GET /api/v1/console/capabilities (API-key gated; reports mode, default UID, OS)
GET /api/v1/console/ws (token gated; JSON-framed WebSocket, echo-only)
Phase 1a echoes data frames back to the client; resize/signal frames
are accepted as no-ops so the dashboard can write to the protocol
today and have it light up when Phase 1b attaches a real PTY.
Audit events EventConsoleSessionStart and EventConsoleSessionEnd fire
on the existing event bus with session ID, effective UID, mode, byte
counts, and close reason. The effective UID in the audit event is the
process's geteuid() at session start, so operators reading the log see
"uid=0" rather than the configured "same as agent" abstraction.
Origin check, idle timeout (15m default), frame-size cap (64KiB),
and rate limiting all reuse the patterns already in use for the events
WebSocket. The token manager is a deliberate copy of WSTokenManager
(separate namespace so console tokens cannot be replayed against
/events) — if a third channel ever appears, extract.
Tests cover token uniqueness/expiry/replay, capabilities envelope
shape, WS echo round-trip with audit assertions, ping/pong, malformed
frame handling, and the load-bearing off-by-default property
(NewServer with ConsoleEnabled=false returns 404 on all three paths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#120): real PTY backend for remote console host mode (Phase 1b of #89)
Replaces the Phase 1a echo loop with a real PTY on Linux/macOS host
installs. The handler now spawns a shell (default /bin/bash -l, override
via HAPROXY_AGENT_CONSOLE_SHELL) attached to a creack/pty pseudo-terminal,
pumps bytes between the WS and the PTY, and tears down cleanly on either
end of the connection.
New internal/api/console/pty/ subpackage hides the OS specifics behind a
small Session interface (Reader/Write/Resize/Signal/Wait/Close). The
build-tagged pty_unix.go implementation handles POSIX; pty_windows.go is
a stub returning ErrNotImplemented (Phase 3). The handler picks the
spawner at construction and degrades to echo mode when none is available
— this keeps the Windows agent compiling and reporting honest
capabilities (host_console=false) until ConPTY support lands.
Behaviors wired through:
- FrameTypeData carries stdin/stdout via base64
- FrameTypeResize calls pty.Setsize on the master
- FrameTypeSignal sends to the child's process group via syscall.Kill
(negative PID), so Ctrl-C reaches `top` running under the shell, not
just the shell itself
- exec.CommandContext ties child lifecycle to the request context, so
server shutdown / client disconnect reliably kills the shell
- Audit `console.session.end` now carries the child's exit code; -1 for
echo mode or signal-killed children
- Capabilities endpoint becomes a Handler method and reports the actual
mode (host_pty when a Spawner is wired), shell argv, and effective UID
The "PTY exited" → "WS closed" race is handled by having the PTY-side
goroutine close the WS connection on EOF, then having the outer loop
prefer the PTY-side reason (drained from a buffered channel with a 50ms
absorb window) over the WS pump's generic client_close.
Run-as user override accepts a numeric UID via
HAPROXY_AGENT_CONSOLE_RUN_AS and applies it via SysProcAttr.Credential
before exec. Empty means inherit the agent's UID — which on a
root-running agent is root, by design. The session-start audit always
records the effective UID at spawn time so log review is unambiguous.
Tests cover the cat round-trip through a real PTY, exit-code propagation
via `/bin/sh -c "exit 7"`, resize reaching the kernel (validated through
`stty size`), and context-cancel killing the child. All gated on
//go:build unix so the Windows build stays clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#121): dashboard UI for remote console (Phase 1c of #89)
Adds the in-browser path: click the `>_` icon on a Bx tile (or hit the
command palette → "Console: <box>") to open an xterm.js session against
any agent that has console enabled.
## Permission model
New `box_console` permission component with three actions:
- `view` — see that console is available for a box (capabilities GET)
- `configure` — toggle per-box console + edit shell/run-as (Phase 1d)
- `connect` — open an actual shell session (the load-bearing one)
Granted to no role by default — operator opts a user in explicitly.
## Dashboard ↔ agent
Browser never sees the agent's API key or even its agent token. Flow:
1. Browser → dashboard `GET /api/console/{boxID}/ws` (cookie auth)
2. Dashboard checks `box_console:connect`
3. Dashboard `POST /api/v1/console/token` to agent (Bearer API key)
4. Dashboard dials `wss://agent/api/v1/console/ws?token=...`
5. Dashboard upgrades browser side, runs bidirectional message pump
The proxy is opaque to the JSON frame protocol — it just copies WS
messages each way. Audit lives on the agent side (it sees the actual
PTY); the dashboard logs a one-line "session opened/closed" with
box + duration so support can correlate.
## UI
- xterm.js 5.5.0 + addon-fit 0.10.0 + xterm.css vendored under
static/js/vendor / static/css/vendor (project convention, no
build step)
- `ConsoleDrawer` templ component renders a fullscreen overlay with
header (box name, connection status pill, mode pill, default-UID
pill) and an xterm mount point; `ConsoleAssets` pulls in the
vendored bundles + glue JS
- Both wired once from `layouts/base.templ` so every page has them
- `static/js/console/console.js` exposes `window.openConsole(boxID,
boxName)` / `window.closeConsole()`; handles base64 framing,
resize via ResizeObserver, Esc to close, and inline error
display when the agent reports a protocol/auth issue
- Bx grid gets a 44px console-icon column; click fires
`window.openConsole`. SVG built via explicit DOM nodes (no
innerHTML) so CSP stays strict
- Command palette: one "Console: <box>" entry per box in the
initial fleet snapshot, grouped under "Console"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#122): nsenter host-exec for containerized agents (Phase 1d of #89)
Lets the agent run in a container while still landing console sessions
on the host. Required for TrueNAS / Docker-host installs where the
agent ships as a distroless image (no shell, no host visibility) — the
operator runs it with pid:host + privileged + HAPROXY_AGENT_HOST_EXEC=nsenter,
and the agent crosses into PID 1's namespaces for each session.
New internal/api/console/pty/nsenter_*.go family:
- nsenter_linux.go — real implementation. HostExecDetect is conservative:
must look like a container (/.dockerenv, /run/.containerenv, or PID 1
comm starts with our binary name) AND env opt-in AND
/proc/1/ns/mnt actually differs from /proc/self/ns/mnt. SpawnNsenter
wraps SpawnUnix in `nsenter --target 1 --mount --uts --ipc --net
--pid -- <user shell>`.
- nsenter_other.go (unix, !linux) — HostExecDirect always; macOS has no
meaningful container-bridge story for this fleet.
- nsenter_windows.go — same stub treatment.
Handler's mode selection becomes a four-way cascade: windows → echo;
container + nsenter env + usable → nsenter; container + ssh-bridge env →
ssh_bridge (mode advertised, real spawner deferred to Phase 2); else
host_pty. Capabilities reports the selected mode so the dashboard can
show "Console (nsenter)" vs "Console (host)" without guessing.
run-as is explicitly rejected in nsenter mode — composing a UID drop
with namespace crossing isn't safe today, and silently falling through
to root would surprise operators reading the box settings page.
Tests (linux build tag): default-direct on bare metal, ssh-bridge
requires env opt-in even in a container, SpawnNsenter rejects run-as
override, empty-argv defensive check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#123): SSH bridge for restricted-container agents (Phase 2 of #89)
Wires up mode B.2 — the fallback for environments where pid:host +
privileged isn't available (TrueNAS SCALE apps under PSP, k8s with
PodSecurity baseline, security-conscious operators who'd rather not
hand the agent SYS_ADMIN).
The agent connects out from the container to the host's sshd over a
private TCP channel using a dedicated ed25519 keypair. The host SSH
port stays internal — never exposed to the browser — so the dashboard's
TLS + API-key + audit stack remains the only thing the user sees.
New internal/api/console/pty/ssh_bridge.go:
- SSHBridgeConfig + LoadSSHBridgeConfigFromEnv read four env vars
(HOST/USER/KEY/HOSTKEY) and validate strictly: all four required,
key file must be mode 0600, host-key path must exist. Sloppy
permissions surface at startup, not at first session.
- SSHBridgeSpawner returns a pty.Spawner that opens a fresh
ssh.Client per session, requests an xterm-256color PTY at the
requested geometry, starts the operator's shell argv, and merges
stdout+stderr via io.Pipe to match the local PTY contract.
- sshSession adapter implements pty.Session — Resize uses
WindowChange, Signal maps the same name set as the local backend
(INT/TERM/HUP/QUIT/KILL → ssh.SIGINT etc.), exit code is read
from ssh.ExitError or -1 on signal kill.
- Host key is *required*. We refuse to fall back to
InsecureIgnoreHostKey() — a bridge that ignores host keys
silently lands sessions wherever a MITM redirects, which is
exactly the threat the bridge is meant to avoid. Accepts both
authorized_keys and raw public-key file formats.
Handler's mode cascade actually selects ssh_bridge now (was an
advertise-only stub in Phase 1d). Misconfiguration falls back to
echo mode with a loud Error log line, so an operator who flips the
env var but forgets to provision the key gets a clear breadcrumb
rather than a mysteriously broken session.
New --generate-console-key CLI flag mints a fresh ed25519 keypair
under data/console-ssh/, writes it 0600, prints the public key with a
"gearbox-agent" comment for easy grep in authorized_keys, and shows
the operator the exact env vars to set.
Tests cover missing-field rejection, world-readable-key refusal,
well-formed config acceptance, run-as override refusal (same rule as
nsenter — pick the SSH user, don't try to compose), and empty-cmd
defensive check. Full agent suite stays race-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#124): NDJSON session recording (Phase 2b of #89)
Optional per-agent opt-in for transcript recording. When
HAPROXY_AGENT_CONSOLE_RECORD=true, every console session writes a
newline-delimited JSON transcript to
<DataDir>/console-sessions/<box>-<utc>-<sid>.ndjson (mode 0600,
parent dir 0700).
Format:
{"t":"open", "ts":"...", "session":"...", "uid":0, "mode":"host_pty"}
{"t":"in", "ts":"...", "d":"<base64 stdin>"}
{"t":"out", "ts":"...", "d":"<base64 stdout>"}
{"t":"resize","ts":"...", "cols":132, "rows":50}
{"t":"close", "ts":"...", "reason":"...", "exit_code":N}
Off by default — recording shells is sensitive and the operator
shouldn't be surprised by it. The startup log line is explicit about
the data dir + format when recording is on so it shows up in
journalctl every restart.
Recording failures (disk full, perms wrong on data dir) log a warning
and the session continues unrecorded — recording must never break a
working shell. The session-end audit event always fires regardless,
so post-hoc review can detect "session happened but no transcript."
Wired into both the PTY pump (real shell mode) and the echo loop
(test/fallback mode). Resize frames record their geometry alongside
the data, so replays at a different terminal size still know what
the user saw.
Filename sanitization replaces path-traversal characters with '_'
and strips leading dots. Box names are operator-controlled (so this
is defense-in-depth, not a primary threat boundary), but the cost is
zero and the failure mode of NOT defending here would be a malicious
or careless name silently writing recordings outside the configured
directory.
Tests verify: all five frame types round-trip through JSON+base64;
file mode is 0600; Close is idempotent; empty data dir is rejected
loudly; sanitization handles slashes, leading dots, IPv4-style
names, and arbitrary special characters correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#125): Windows ConPTY + macOS verification (Phase 3 of #89)
macOS support has been there since Phase 1b via the //go:build unix
tag covering darwin; explicitly verified all four Phase 1b PTY
integration tests (cat round-trip, exit propagation, resize through
stty, context-cancel kills child) pass on darwin in CI.
Windows: real ConPTY backend backed by
github.com/UserExistsError/conpty (slim, well-maintained wrapper
around the Win32 pseudo-console API). Implements pty.Session via the
same exported SpawnUnix name as POSIX so the handler stays
platform-agnostic and doesn't branch by GOOS.
Wire-protocol mapping:
- Read/Write → ConPty's io.ReadWriteCloser (stdin/stdout/stderr merged
on the slave side, same contract as POSIX PTY)
- Resize → conpty.ConPty.Resize(cols, rows)
- Signal:
INT → 0x03 written to stdin (ConPTY translates to console control event)
TERM/KILL → Close (forces child exit)
other → ErrSignalUnsupported (dashboard surfaces "not on this platform")
- Wait → conpty.ConPty.Wait with context fallback that closes the
ConPty on cancel and drains the wait goroutine
Honest scope: the Windows backend cross-compiles cleanly
(GOOS=windows go build ./...) but hasn't been run in CI — the fleet
this ships for is Linux/macOS. The package comment flags this:
"first operator to install on Windows should expect to find one or
two rough edges." runAs is currently ignored on Windows (no LogonUser
+ STARTUPINFOEX privilege drop today); if any operator needs it,
that's a focused follow-up.
Handler cleanup: removed the "windows → echo mode" special case
since the platform now has a real backend. Replaced with a
modeUnavailable enum that exists for forward-compat with platforms
that genuinely lack PTY (none today; plan9 might).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(#89): operator setup guide and threat model for remote console
Two new docs to close out the #89 design work:
docs/console-setup.md — operator-facing setup guide.
- When to use the console (and when SSH is the better tool)
- Three deployment modes (Mode A host install, B.1 nsenter, B.2 SSH bridge)
- Required env vars + restart steps for each
- Dashboard permission grants (box_console:view/configure/connect)
- Session recording opt-in + jq replay snippet
- 6-row verification checklist
- 8-row troubleshooting table mapping symptoms → cause → fix
docs/security-review/console-threat-model.md — reasoning audit.
- In-scope / out-of-scope threats (table form)
- New attack surface (table mapping endpoints to gates)
- Mitigations per attack vector: stolen cookie, stolen API key, stolen
WS token, MITM, CSWSH, browser XSS, privilege escalation, audit-log
evasion, session leakage, container escape, filesystem traversal
- Four named residual risks (with the WS dialer's InsecureSkipVerify
called out as a follow-up)
- Deployment-posture summary table
Both files pass `npx markdownlint-cli ... --config .markdownlint.json`
clean against the repo's standard rule set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(#89): WS proxy honors AGENT_CA_CERT_PATH and GEARBOX_INSECURE_TLS
The console WebSocket proxy was dialing the upstream agent with
hardcoded InsecureSkipVerify:true regardless of how the operator had
configured TLS trust elsewhere. That meant a deployment hardened with
AGENT_CA_CERT_PATH for REST calls still accepted any cert presented
on the WS dial — a quiet "REST is pinned, WS isn't" gap.
Lifts the TLS-config-building logic out from behind agent.createTLSConfig
(unexported) into an exported BuildTLSConfig wrapper, then has the
console proxy call it. Single source of truth: a future operator who
flips AGENT_CA_CERT_PATH gets both the HTTP client and the WebSocket
dial pinned together, no second knob, no drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#89): HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT env knob
The console session's 15-minute idle timeout was a hard-coded Handler
field with no operator knob — long-running interactive work (e.g.
watching a slow apt upgrade through the dashboard) would silently get
cut off. Adds a Go-duration env override.
HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT=2h # bump to 2 hours
Refuses non-positive values (would make every read fail immediately
because SetReadDeadline + a zero/negative duration produces a deadline
in the past) and refuses unparseable strings, in both cases logging a
warning and keeping the 15-minute default. Operators who want
effectively-no-timeout should set a very large duration like 168h.
Tests cover the three switch branches plus the default-keeps-default
case so a future refactor that drops the env-parse step is caught here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#89): per-box console_enabled toggle (Phase 2c)
Closes the per-box opt-in loop the design promised in #89 but Phase 1c
left at "fleet-wide via agent env only." Operators can now decide on
a box-by-box basis whether the console affordance shows up, and revoke
access on one box without rolling out an agent config change.
Schema:
- New migration 000002_add_box_console_enabled adds an INTEGER NOT
NULL DEFAULT 0 column. Default-off matches the design's "least
surprise" posture — adding the column doesn't enable console on
any existing box; the operator explicitly flips it.
- BoxDB.ConsoleEnabled added; CreateBox, GetBoxes, GetEnabledBoxes,
GetBoxByID, GetBoxByBoxID, UpdateBox all round-trip it.
- Down migration is a no-op (SQLite <3.35 can't DROP COLUMN); the
column stays in place but all rows read as 0 = disabled.
Enforcement (dashboard side):
- APIConsoleCapabilities returns the {enabled:false, reason:...}
envelope when the per-box flag is off, mirroring the agent's
"console disabled" shape so the dashboard JS branches identically.
- APIConsoleWS returns 403 before the agent token exchange when the
per-box flag is off — avoids leaking proxy round-trips against
disabled boxes into the agent's audit log on every accidental click.
UI surface:
- HAProxyBoxEditPage gets a "Remote console" checkbox in the same
basic-info card as the existing "Enabled" toggle, with help text
explaining the agent-side env requirement and the box_console:connect
permission. POST handlers (create + edit) read it.
- BoxStatus carries `console_enabled` on the wire so the Bx tile's
JS can hide the >_ icon and the command-palette can skip palette
entries for disabled boxes without an extra capabilities round-trip
on page load.
- bx-page.js consoleFormatter returns "" for rows where
console_enabled is false; palette-entry registration loop skips
the same rows.
Tests cover: default-false post-Create, persists-explicit-true,
toggle on→off round-trip via UpdateBox (the load-bearing revocation
path), and GetEnabledBoxes carries the flag through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 58f64f1 commit 49d18ca
49 files changed
Lines changed: 5105 additions & 29 deletions
File tree
- docs
- security-review
- gearbox-agent
- cmd/gearbox-agent
- internal
- api
- console
- pty
- framework
- config
- events
- gearbox
- cmd/server
- internal
- framework
- agent
- database
- migrations/files
- handler
- models
- templates
- components
- layouts
- pages
- gears/bx
- static
- css/vendor
- js
- bx
- console
- vendor
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
0 commit comments