feat: remote console (#89) — phases 1a→3 + per-box toggle + WS TLS pinning - #127
Merged
Conversation
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>
…f #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>
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>
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>
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>
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>
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>
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>
…e-89-console-phase-1a
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>
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>
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>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements the “remote console” feature across the Gearbox dashboard and gearbox-agent, adding a token-gated WebSocket console path with PTY backends, per-box enablement, and supporting docs.
Changes:
- Adds agent-side console endpoints (
/api/v1/console/*) with single-use tokens, audit events, PTY backends (unix + Windows ConPTY), container host-exec modes (nsenter / SSH bridge), idle-timeout knob, and optional NDJSON session recording. - Adds dashboard-side console UI (xterm drawer + Bx tile/palette entrypoints) and a WS proxy to the agent that now honors the same TLS trust policy as REST.
- Adds per-box
console_enabledDB column + UI toggle, and threads the flag through Bx status payloads to hide console affordances for non-opted-in boxes.
Reviewed changes
Copilot reviewed 45 out of 49 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| gearbox/static/js/vendor/xterm-addon-fit.min.js | Vendors xterm fit addon for resizing terminal. |
| gearbox/static/js/console/console.js | Implements console drawer behavior and WS client protocol. |
| gearbox/static/js/bx/bx-page.js | Adds console icon column + command palette entries (filtered by per-box flag). |
| gearbox/static/css/vendor/xterm.min.css | Vendors xterm CSS styling. |
| gearbox/internal/gears/bx/status.go | Exposes console_enabled in Bx status JSON. |
| gearbox/internal/framework/templates/pages/haproxy_settings.templ | Adds per-box “Remote console” checkbox to box edit form. |
| gearbox/internal/framework/templates/layouts/base.templ | Mounts console drawer/assets globally in the base layout. |
| gearbox/internal/framework/templates/components/console.templ | Adds ConsoleDrawer + ConsoleAssets templ components. |
| gearbox/internal/framework/models/permissions.go | Introduces box_console component + connect permission. |
| gearbox/internal/framework/handler/haproxy_config.go | Persists console_enabled from box create/update forms. |
| gearbox/internal/framework/handler/api_console.go | Adds dashboard capabilities proxy + WS proxy to agent console. |
| gearbox/internal/framework/database/servers.go | Stores/queries console_enabled column for boxes. |
| gearbox/internal/framework/database/servers_console_test.go | Tests console_enabled default/persist/update/query behavior. |
| gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.up.sql | Migration adding console_enabled column (default off). |
| gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.down.sql | No-op down migration (SQLite portability). |
| gearbox/internal/framework/agent/console_client.go | Adds agent client methods for console capabilities/token + base URL accessors. |
| gearbox/internal/framework/agent/client.go | Exposes BuildTLSConfig for WS dialing parity with REST. |
| gearbox/cmd/server/main.go | Registers dashboard console API routes under authenticated /api. |
| gearbox-agent/internal/framework/events/bus.go | Adds console session audit event types. |
| gearbox-agent/internal/framework/config/config.go | Adds agent config flag for enabling console surface. |
| gearbox-agent/internal/api/server.go | Conditionally mounts agent console routes (404 when disabled). |
| gearbox-agent/internal/api/server_test.go | Tests console routes are absent/present based on config. |
| gearbox-agent/internal/api/console/token.go | Implements console token mint/exchange for WS upgrades. |
| gearbox-agent/internal/api/console/token_test.go | Tests token properties (unique, single-use, expiry, concurrency). |
| gearbox-agent/internal/api/console/recorder.go | Adds optional NDJSON session recording with sanitization + perms. |
| gearbox-agent/internal/api/console/recorder_test.go | Tests recording format, file perms, sanitization, idempotent close. |
| gearbox-agent/internal/api/console/pty/ssh_bridge.go | Implements SSH bridge PTY backend with host-key pinning + key-perm validation. |
| gearbox-agent/internal/api/console/pty/ssh_bridge_test.go | Tests SSH bridge env validation + refusal cases. |
| gearbox-agent/internal/api/console/pty/pty.go | Defines cross-platform PTY session/spawner interfaces. |
| gearbox-agent/internal/api/console/pty/pty_windows.go | Windows ConPTY PTY backend implementation. |
| gearbox-agent/internal/api/console/pty/pty_unix.go | Unix PTY backend using creack/pty, signals, resize, exit code. |
| gearbox-agent/internal/api/console/pty/nsenter_windows.go | Windows stub for nsenter host-exec mode. |
| gearbox-agent/internal/api/console/pty/nsenter_other.go | Non-linux unix stub for nsenter host-exec mode. |
| gearbox-agent/internal/api/console/pty/nsenter_linux.go | Linux host-exec detection + nsenter spawner. |
| gearbox-agent/internal/api/console/pty/nsenter_linux_test.go | Tests host-exec detection and nsenter refusal behaviors. |
| gearbox-agent/internal/api/console/protocol.go | Defines JSON frame protocol for console WS. |
| gearbox-agent/internal/api/console/idle_timeout_test.go | Tests idle-timeout env override parsing behavior. |
| gearbox-agent/internal/api/console/handler.go | Implements WS upgrade + echo/PTY loops, audit, recording, timeouts. |
| gearbox-agent/internal/api/console/handler_test.go | Tests WS auth, echo round-trip, ping/pong, protocol violations, audit emission. |
| gearbox-agent/internal/api/console/handler_pty_test.go | Unix-only integration tests for PTY round-trip, resize, exit, cancel. |
| gearbox-agent/internal/api/console/capabilities.go | Implements capabilities endpoint and mode classification. |
| gearbox-agent/internal/api/console/capabilities_test.go | Tests capabilities response fields across modes. |
| gearbox-agent/internal/api/console/audit.go | Emits flat audit events for session start/end. |
| gearbox-agent/go.sum | Adds/updates module checksums for new deps. |
| gearbox-agent/go.mod | Adds new indirect deps (pty, conpty, x/crypto, etc.). |
| gearbox-agent/cmd/gearbox-agent/main.go | Adds --generate-console-key + logs console enabled/disabled at startup. |
| docs/security-review/console-threat-model.md | Threat model documentation for console feature. |
| docs/console-setup.md | Operator setup guide for console feature. |
Comments suppressed due to low confidence (1)
docs/security-review/console-threat-model.md:203
- Residual risk #4 says the idle timeout is “fixed at 15 minutes” and there is “no env knob today”, but this PR adds HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT parsing in console/handler.go and tests for it. Please update the threat model to reflect the new operator control (and any remaining constraints, e.g., refusing non-positive durations).
4. **Idle timeout is currently fixed at 15 minutes.** Operators who
want a longer/shorter cap have to patch the Handler field; no env
knob today. Follow-up.
Comment on lines
+73
to
+85
| caps, err := client.GetConsoleCapabilities() | ||
| if err != nil { | ||
| // 404 from the agent → operator hasn't opted in. Surface | ||
| // that explicitly to the dashboard rather than a generic | ||
| // 502 so the UI can show "console disabled on this box." | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusNotFound) | ||
| _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| "enabled": false, | ||
| "reason": "console not enabled on this agent", | ||
| }) | ||
| return | ||
| } |
Comment on lines
+53
to
+61
| // Error codes carried in Frame.Code when Type == FrameTypeErr. | ||
| // Stable strings so dashboards can branch on them; new codes append | ||
| // rather than rename. | ||
| const ( | ||
| ErrCodeAuthDenied = "AUTH_DENIED" | ||
| ErrCodeNoShell = "NO_SHELL" | ||
| ErrCodeContainerNoHostAcc = "CONTAINER_NO_HOST_ACCESS" | ||
| ErrCodeProtocolViolation = "PROTOCOL_VIOLATION" | ||
| ErrCodeIdleTimeout = "IDLE_TIMEOUT" |
Comment on lines
+3
to
+6
| // the box the agent runs on. See [#89] for the full design. | ||
| // | ||
| // Phase 1a (this file): token exchange + WebSocket echo. No PTY yet; that | ||
| // lands in Phase 1b ([#119] or successor). |
Comment on lines
+14
to
+15
| // without spawning a shell. Phase 1a default, Windows fallback, | ||
| // and what tests run with when no Spawner is wired. |
Comment on lines
+248
to
+255
| // defaultShell returns the platform's typical interactive login shell. | ||
| // The empty-array case (Windows) is never used because the spawner is | ||
| // nil there. | ||
| func defaultShell() []string { | ||
| switch runtime.GOOS { | ||
| case "windows": | ||
| return []string{"powershell.exe", "-NoLogo"} | ||
| default: |
Comment on lines
+195
to
+200
| 3. **The dashboard's WS proxy uses `InsecureSkipVerify: true` for the | ||
| upstream TLS dial.** The HTTP agent client validates certs at the | ||
| HTTP layer; the WebSocket dial relies on the operator-controlled | ||
| trust path (LAN, mTLS, etc.) and the agent's own API-key + token | ||
| gate. A follow-up is to wire the WS dialer to honor | ||
| `AGENT_CA_CERT_PATH` the same way the HTTP client does. |
| | `nsenter: namespaces unreachable` | Container missing `pid:host` or `privileged` | Add both to compose / k8s manifest | | ||
| | `ssh_bridge: private key has too-open permissions` | Key file isn't `0600` | `chmod 600 <key path>` | | ||
| | `nsenter mode but lands in container` | `/proc/1/ns/mnt` same as `/proc/self/ns/mnt` | Container wasn't started with `pid:host` | | ||
| | Session disconnects after 15 min of idle | Default idle timeout | Raise `IdleTimeout` (currently env-fixed; PR welcome) | |
Comment on lines
+41
to
+45
| // PermissionConnect opens a remote-console session against a box. | ||
| // Distinct from PermissionAction because the blast radius is larger | ||
| // (a shell, not a single API call) and audit semantics differ — | ||
| // every connect is a logged session, not a stateless request. See #89. | ||
| PermissionConnect Permission = "connect" |
6 tasks
sarg3nt
added a commit
that referenced
this pull request
May 17, 2026
… the sole gate (#137) The two-layer "agent env var AND dashboard per-box flag" gate was friction without proportionate security benefit for the single-operator homelab case this is built for. The agent's API key already grants full administrative control of the box (logs, systemd, restarts, package management); the marginal exposure of also exposing the console endpoints by default is small. Simplifies the enable path to one click: flip the "Remote console" checkbox on the box edit page. No agent-side restart, no env-var plumbing, no per-host configuration management work. Agent changes: - internal/framework/config/config.go: drop ConsoleEnabled field + HAPROXY_AGENT_CONSOLE_ENABLED env-var read - internal/api/server.go: drop ConsoleEnabled from ServerConfig; always construct the console handler and mount its routes - cmd/gearbox-agent/main.go: drop the conditional startup log; replace with a single Info line noting the surface is mounted and that the per-box opt-in is dashboard-side - internal/api/server_test.go: replace TestNewServer_ConsoleDisabled_RoutesReturn404 (premise gone) with TestNewServer_ConsoleRoutesAlwaysMounted, which pins that all three console routes exist and that each is behind its appropriate auth (API key for token + capabilities, single-use token for WS — verified by 401 on token-less call) - internal/api/console/capabilities.go: drop stale env-var reference in the Enabled-field comment Dashboard changes: - internal/framework/templates/pages/haproxy_settings.templ: trim the "Requires HAPROXY_AGENT_CONSOLE_ENABLED=true on the agent" clause from the toggle's help text; keep the box_console:connect mention with "non-admin users" framing (admins get the permission for free via the IsAdmin shortcut) - internal/framework/database/servers.go: rewrite ConsoleEnabled field doc to drop the now-misleading "belt and suspenders" framing - internal/framework/handler/api_console.go: rewrite comment on the per-box check for the same reason Docs: - docs/console-setup.md: replace the per-mode "set env var, restart" instruction with a single "Enable for a box" section pointing at the dashboard toggle. Adds an IMPORTANT note that the API key alone is sufficient to use the console — matching the existing trust model, surfaced explicitly. Removes HAPROXY_AGENT_CONSOLE_ENABLED from the Mode A/B.1/B.2 examples. Troubleshooting table updates: "/api/v1/console/* returns 404" now means the agent build predates this feature, not a flipped flag. Adds a new row for "capabilities returns 404 from the dashboard" → "flip the per-box toggle." - docs/security-review/console-threat-model.md: rewrite the "Stolen agent API key" section to be honest about the new posture — API key alone is enough to open a session directly against the agent; the dashboard's per-box toggle only gates the dashboard path. Adds a residual-risk entry making this explicit. Drops two stale residuals (InsecureSkipVerify on the WS dialer + fixed idle timeout — both resolved in #127). Tests: full agent suite + dashboard handler/database/agent suites race-clean; markdownlint clean on both docs. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sarg3nt
added a commit
that referenced
this pull request
May 17, 2026
Two changes required by main moving forward (PRs #127, #134, #137): 1. internal/api/server_test.go was added in PR #127 (remote console) after Phase 1 branched. It uses the old ServerConfig.APIKey field that Phase 1 replaced with KeyRing. Updated the test to construct a one-entry KeyRing and send the legacy 64-hex bearer token. 2. PR #127 also added migration 000002_add_box_console_enabled, colliding with Phase 1's 000002_add_box_agent_keys. Renumbered Phase 1's migration to 000003. Migrations are content-addressed by the embedded iofs, so the rename is mechanical — no schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sarg3nt
added a commit
that referenced
this pull request
May 17, 2026
* feat(rotation): Phase 1 multi-key keyring plumbing (#72) Foundation for issue #72's rotation work. Adds the data structures and storage required for N-entry keyrings on both the agent and dashboard sides, with no operator-visible behaviour change yet — rotation endpoints and UI follow in Phase 2. Agent side ---------- - `internal/framework/crypto/keyring.go` — `KeyRing` type with up to `MaxKeyRingEntries = 4` accepted keys, atomic tmpfile+rename on disk, AES-256-GCM (GBE1) encryption when `GEARBOX_AGENT_ENCRYPTION_KEY` is set. Wire token format: `gbx_<6-hex-kid>_<base64url(32 random bytes)>`, with legacy 64-hex tokens still accepted for one release cycle. - `LoadOrCreateKeyRing(keyringPath, legacyAPIKeyPath)` migrates an existing `/var/lib/gearbox-agent/api-key` file into a single keyring entry tagged `kid="legacy"`, role=primary. Legacy file stays on disk as a read-only fallback. - `KeyRingPointer` wraps `atomic.Pointer[KeyRing]` so Phase 2's install/use/remove endpoints can swap the live keyring without middleware restart. Verified by the new auth-middleware test `TestAPIKeyAuth_HotSwapVisibleImmediately`. - `internal/framework/middleware/auth.go` rewritten to take a keyring pointer instead of a static key. Accepts both prefixed and legacy token formats; matched `kid` echoed back as `X-Gearbox-Kid:` header on every authenticated response so the dashboard can detect drift (consumed in Phase 5). Auth with a secondary key logs at INFO so the audit log can later flag "old key still in use after rotation". - New endpoint `GET /api/v1/system/keyring` (authenticated) returns metadata only — kids, roles, created_at, sha256-prefix fingerprint for diagnostic equality checks — never the secret bytes themselves. - `--show-api-key` and `--rotate-api-key` CLI flags work against the keyring; the printed key uses the new `gbx_<kid>_<b64>` wire format the dashboard can paste verbatim. - `GEARBOX_AGENT_KEYRING_PATH` env var (default `<DataDir>/keyring.json`) is now a config field alongside the legacy `HAPROXY_AGENT_API_KEY_PATH`. Dashboard side -------------- - Migration `000002_add_box_agent_keys` adds the `(box_id, kid)`-keyed `box_agent_keys` table and idempotently backfills one `kid='legacy'` row per existing box from `boxes.api_key_encrypted`. The legacy column stays for one release. - `database/box_agent_keys.go` exposes Get/Insert/SetPrimary/Delete/ TouchLastUsed — the storage primitives Phase 2's rotator service composes into the install→use→remove dance. Tests ----- - 19 keyring unit tests covering token parsing (prefixed + legacy + malformed), keyring mutation, file round-trip with and without encryption, legacy api-key migration, and pointer hot-swap. - 8 auth-middleware integration tests covering bearer parsing, kid header echo, secondary-key acceptance, and the live hot-swap path Phase 2 depends on. - 5 storage tests covering primary-key lookup, atomic role flip, delete-refuses-last guard, and last_used_at touch. Carry-overs to Phase 2 (intentional gaps surfaced from this PR) --------------------------------------------------------------- - `DeleteBox` does not yet cascade to `box_agent_keys` (SQLite `PRAGMA foreign_keys` is off in this codebase; enabling it is a broader change). Phase 2's box-delete path will clean dependent rows explicitly. Documented in box_agent_keys_test.go. - The dashboard's `agent.Client` does not yet send `X-Gearbox-Kid` on outbound requests — there's no kid to send while every box's keyring contains only the legacy entry. Phase 2 wires this when the rotator starts mutating keyrings. Refs: research summary and implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(rotation): address Copilot review on PR #128 Nine findings from the Copilot review on PR #128, all valid or worth addressing. Fixed in this commit; replies + thread-resolves go with the push. 1. LoadOrCreateKeyRing fall-through (keyring.go:131-167) Was: any error reading the legacy api-key file (incl. ErrKeyRequired from a missing encryption-key env, or a permission error) silently fell through to generating a fresh keyring — would rotate every dashboard out for a transient operator mistake. Now: distinguish "file doesn't exist" (proceed to fresh-gen) from "file exists but errored / malformed" (return the error to the caller). os.Stat + os.IsNotExist gates the choice explicitly. 2. MatchToken constant-time guarantee (keyring.go ~195) Was: the prefixed-token path returned early on the first kid match, making total runtime depend on which kid the request claimed — kid enumeration via timing. The doc said "All comparisons are constant-time" but the prefixed branch broke that promise. Now: walk every entry, compare both kid and secret with subtle.ConstantTimeCompare, AND the two results. Match is recorded without short-circuit; runtime is uniform regardless of which kid (if any) matches. Doc updated to reflect the actual guarantee. 3. writeKeyRingFile mutates input (keyring.go ~415) Was: the function populated SecretHex on each entry of the passed- in keyring before marshaling. KeyRing values are shared via atomic.Pointer and treated as immutable; mutating in-place risks races with concurrent middleware readers. Now: marshal off a local snapshot whose entries have SecretHex backfilled from Secret where needed. Input is never written to. 4. --rotate-api-key zero CreatedAt (main.go ~155) Was: the fresh KeyRingEntry built for the CLI rotate command omitted CreatedAt, so the keyring file got 0001-01-01T00:00:00Z and the /api/v1/system/keyring metadata exposed the same. Now: CreatedAt: time.Now().UTC(). 5. handleGet nil-guard (api/keyring.go ~50) Was: h.keyring.Load() was dereferenced unconditionally; a future wiring bug that left the pointer nil would panic the agent on every keyring request. Now: nil check + 500 + log line. Fails loud rather than crashing. 6. At-most-one-primary-per-box constraint (migration 000002) Was: nothing in the schema stopped two rows with role='primary' for the same box. SetBoxPrimaryKey's transactional flip is correct, but a buggy code path or a manual DB edit could produce the invalid state and GetBoxPrimaryKey would return an arbitrary row. Now: partial unique index on box_agent_keys(box_id) WHERE role='primary'. SQLite supports this directly; index is dropped in the down migration too. 7. Test naming clarity (box_agent_keys_test.go) Was: TestBoxAgentKeys_MigrationBackfillsLegacyEntry was named as if it validated migration behaviour but actually only exercised InsertBoxAgentKey + GetBoxPrimaryKey roundtrip; the comment also misled. Now: split into two clearly-named tests — InsertAndLookup covers the roundtrip, and a new MigrationBackfillStatementWorks test wipes the migrated rows for a single box, re-executes the migration's INSERT-FROM-boxes statement, and asserts the row appears + reruns are idempotent. 8. DeleteBox cascade gap (servers.go DeleteBox) Was: the schema declared ON DELETE CASCADE but PRAGMA foreign_keys is off in this codebase, so deleting a box left orphaned box_agent_keys rows holding encrypted secrets. Phase 1 docs flagged this as a deferred gap; Copilot pushed back, and fairly — it's a small, contained fix. Now: DeleteBox runs inside a transaction that wipes box_agent_keys WHERE box_id = ? before deleting from boxes. Both succeed or neither does. Test re-added: TestBoxAgentKeys_DeleteBoxClearsDependentKeys. 9. APIKeyAuth nil-guard (middleware/auth.go) Was: keyring.Load() was called without first checking the pointer itself for nil. A miswired ServerConfig would panic on every authenticated request. Now: fail-closed nil check at the top of the request handler — returns 401 + logs at error level. Same defensive treatment as fix #5. Tests ----- All 3 dashboard-side suites pass (`database` package, 7 new tests including the new DeleteBox cascade test). All 3 agent-side suites pass (`crypto`, `middleware`, `api`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(rotation): clarify constant-time precondition on MatchToken Add a note that subtle.ConstantTimeCompare's length-dependent fast-fail is fine here because every kid in the system is exactly 6 chars long (kidLength = 6 hex chars; the legacy entry uses 'legacy' which is also 6 chars by deliberate convention). Custom kids of a different length would naturally hash-mismatch — which is the intended failure mode. Also serves to force a synchronize event so PR #128's CI re-runs on the fix commit; the prior synchronize from fe3c762 didn't trigger workflows (still unclear why; not blocking the work). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(rotation): adapt phase-1 to main after rebase Two changes required by main moving forward (PRs #127, #134, #137): 1. internal/api/server_test.go was added in PR #127 (remote console) after Phase 1 branched. It uses the old ServerConfig.APIKey field that Phase 1 replaced with KeyRing. Updated the test to construct a one-entry KeyRing and send the legacy 64-hex bearer token. 2. PR #127 also added migration 000002_add_box_console_enabled, colliding with Phase 1's 000002_add_box_agent_keys. Renumbered Phase 1's migration to 000003. Migrations are content-addressed by the embedded iofs, so the rename is mechanical — no schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the full remote-console feature designed in #89 — a token-gated, audit-logged in-browser shell that goes through
gearbox-agent's existing TLS surface. Closes all seven scoped phase issues plus the three follow-ups identified after Phase 3.What ships
creack/pty, signal-to-pgroup, resize, exit-code propagation, idle timeoutbox_consolepermission, agent-client extension, WS proxy, xterm.js drawer, Bx tile icon, command palettepid:host + privileged--generate-console-keyCLI; key perms 0600 enforced//go:build unix)console_enabledtoggle: schema migration, UI checkbox on box edit form, proxy enforcement, Bx tile/palette hides disabled boxesAGENT_CA_CERT_PATHandGEARBOX_INSECURE_TLS(same TLS trust policy as REST)HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUTenv knob (default 15m, refuses non-positive)docs/console-setup.md,docs/security-review/console-threat-model.mdSecurity model
Every layer of the existing model still applies:
box_console:connectpermission (not granted by default to any role)HAPROXY_AGENT_CONSOLE_ENABLEDand the dashboard's per-box flag must be true for a session to opendocs/security-review/console-threat-model.mdWhy one PR
The seven phases form a hard dependency chain (each builds on the prior one's exported types, route shapes, and DB columns) and the three follow-ups touch code from multiple phases. Splitting into 8+ PRs would mean each one rebases on the previous merge, which creates a serialization mess for a feature where the test surface is interdependent. A single review pass against
mainis the cleaner path.Test plan
go test -race ./...green on both apps (gearbox + gearbox-agent)GOOS=windows go build ./...cross-compiles cleanmake build(templ-generate + go build) greennpx markdownlint-cliclean on new docslight-hugger(Mode A): enable agent flag, grant permission, open console from Bx tile, runwhoami/htop, exit cleanlymjolnir(Mode B.1 or B.2 — operator's call): toggle per-box flag on, verify TrueNAS apps work via SSH bridgeCloses #117, #120, #121, #122, #123, #124, #125; part of #89.
🤖 Generated with Claude Code