Skip to content

feat: remote console (#89) — phases 1a→3 + per-box toggle + WS TLS pinning - #127

Merged
sarg3nt merged 12 commits into
mainfrom
feature/issue-89-console
May 17, 2026
Merged

feat: remote console (#89) — phases 1a→3 + per-box toggle + WS TLS pinning#127
sarg3nt merged 12 commits into
mainfrom
feature/issue-89-console

Conversation

@sarg3nt

@sarg3nt sarg3nt commented May 17, 2026

Copy link
Copy Markdown
Owner

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

Slice Issue What
1a #117 Agent plumbing: token endpoint, capabilities, WS echo, audit events, off-by-default 404 contract
1b #120 Real PTY (host mode) via creack/pty, signal-to-pgroup, resize, exit-code propagation, idle timeout
1c #121 Dashboard UI: box_console permission, agent-client extension, WS proxy, xterm.js drawer, Bx tile icon, command palette
1d #122 nsenter mode for Linux containers with pid:host + privileged
2 #123 SSH bridge for restricted containers (TrueNAS-friendly); --generate-console-key CLI; key perms 0600 enforced
2b #124 Optional NDJSON session recording (opt-in via env)
3 #125 Windows ConPTY backend (cross-compiles clean; macOS auto-covered by //go:build unix)
2c this PR Per-box console_enabled toggle: schema migration, UI checkbox on box edit form, proxy enforcement, Bx tile/palette hides disabled boxes
follow-up this PR WS proxy now honors AGENT_CA_CERT_PATH and GEARBOX_INSECURE_TLS (same TLS trust policy as REST)
follow-up this PR HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT env knob (default 15m, refuses non-positive)
docs docs/console-setup.md, docs/security-review/console-threat-model.md

Security model

Every layer of the existing model still applies:

  • Agent TLS + API key on the wire; new console token endpoint is API-key-gated, console WS uses a separate 60s single-use token namespace
  • Dashboard cookie auth + new box_console:connect permission (not granted by default to any role)
  • Belt-and-suspenders: both the agent-side HAPROXY_AGENT_CONSOLE_ENABLED and the dashboard's per-box flag must be true for a session to open
  • Shell inherits the agent's UID — no escalation step, but no automatic drop either (root agent = root shell, by design; documented loudly)
  • Session-start audit always records the effective UID, not the configured "same as agent" abstraction
  • Threat model: docs/security-review/console-threat-model.md

Why 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 main is the cleaner path.

Test plan

  • go test -race ./... green on both apps (gearbox + gearbox-agent)
  • GOOS=windows go build ./... cross-compiles clean
  • make build (templ-generate + go build) green
  • npx markdownlint-cli clean on new docs
  • Manual smoke on light-hugger (Mode A): enable agent flag, grant permission, open console from Bx tile, run whoami / htop, exit cleanly
  • Manual smoke on mjolnir (Mode B.1 or B.2 — operator's call): toggle per-box flag on, verify TrueNAS apps work via SSH bridge
  • Verify revocation: toggle per-box flag off in UI, confirm dashboard hides icon and WS proxy returns 403

Closes #117, #120, #121, #122, #123, #124, #125; part of #89.

🤖 Generated with Claude Code

sarg3nt and others added 12 commits May 16, 2026 23:02
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>
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_enabled DB 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.
Comment thread docs/console-setup.md
| `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"
@sarg3nt
sarg3nt merged commit 49d18ca into main May 17, 2026
30 checks passed
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>
@sarg3nt
sarg3nt deleted the feature/issue-89-console branch May 28, 2026 16:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1a: agent plumbing for remote console (#89)

2 participants