Skip to content

Commit 49d18ca

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

49 files changed

Lines changed: 5105 additions & 29 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/console-setup.md

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# Remote Console — Operator Setup
2+
3+
Guide for enabling the in-browser console feature on a gearbox agent.
4+
The dashboard side ships always-on; the agent side is opt-in per box.
5+
6+
## Table of Contents
7+
8+
- [Overview](#overview)
9+
- [When you need it (and when you don't)](#when-you-need-it-and-when-you-dont)
10+
- [Mode A — Host install (the simple case)](#mode-a--host-install-the-simple-case)
11+
- [Mode B.1 — Container with `pid:host` + `privileged` (nsenter)](#mode-b1--container-with-pidhost--privileged-nsenter)
12+
- [Mode B.2 — Container with SSH bridge (TrueNAS-friendly)](#mode-b2--container-with-ssh-bridge-truenas-friendly)
13+
- [Permissions on the dashboard side](#permissions-on-the-dashboard-side)
14+
- [Session recording (optional)](#session-recording-optional)
15+
- [Verifying it works](#verifying-it-works)
16+
- [Troubleshooting](#troubleshooting)
17+
18+
## Overview
19+
20+
The console feature gives an operator a real interactive shell on a
21+
monitored box from inside the dashboard — no SSH keys to manage on
22+
their workstation, no jump host, no VPN. Every session goes through
23+
the existing `gearbox-agent` TLS surface and is gated by the same
24+
API-key authentication, with audit events on every open and close.
25+
26+
> [!IMPORTANT]
27+
> The shell **inherits the agent's UID**. On a typical install the
28+
> agent runs as root (it needs root for `/var/log`, systemd, certs,
29+
> `apt`), so the default console session is a **root shell**. That's
30+
> usually what an operator wants. Set `HAPROXY_AGENT_CONSOLE_RUN_AS=<uid>`
31+
> if you want sessions to land as a less-privileged user.
32+
33+
## When you need it (and when you don't)
34+
35+
| Use the console for | Don't use the console for |
36+
|----------------------------------------------------------------|-------------------------------------------------|
37+
| Quick "I need a shell on this one box, now" investigations | Fleet-wide automation (use SSH + Ansible/etc.) |
38+
| Debugging an alert from inside the same browser tab | CI / scripted operations |
39+
| Pairing — show a colleague what you're typing in real time | Long-running interactive sessions (timeout) |
40+
41+
If your workflow is "I'm at my terminal anyway and have SSH keys
42+
distributed," keep using SSH. The console is for the
43+
"already-in-the-dashboard" path.
44+
45+
## Mode A — Host install (the simple case)
46+
47+
Agent runs directly on the box (systemd unit on Linux, launchd on
48+
macOS). No container, no bridge — `pty.SpawnUnix` opens a real PTY
49+
and runs `/bin/bash -l` as the agent's UID.
50+
51+
### Enable
52+
53+
Edit the agent's environment (typically `/etc/default/gearbox-agent`
54+
or a systemd `Environment=` line):
55+
56+
```bash
57+
HAPROXY_AGENT_CONSOLE_ENABLED=true
58+
# optional overrides:
59+
# HAPROXY_AGENT_CONSOLE_SHELL=/bin/bash -l
60+
# HAPROXY_AGENT_CONSOLE_RUN_AS=1000 # numeric UID; default = inherit
61+
```
62+
63+
Restart the agent:
64+
65+
```bash
66+
sudo systemctl restart gearbox-agent
67+
```
68+
69+
Confirm with `journalctl -u gearbox-agent | grep -i console` — you
70+
should see:
71+
72+
```text
73+
Console: ENABLED — token + WS at /api/v1/console/*; sessions inherit agent UID
74+
```
75+
76+
## Mode B.1 — Container with `pid:host` + `privileged` (nsenter)
77+
78+
Agent runs in a container on a Docker host (e.g. plain Docker
79+
Compose, not TrueNAS app). Cross into the host's namespaces via
80+
`nsenter --target 1` for each session.
81+
82+
> [!WARNING]
83+
> This grants the agent container effectively root-equivalent
84+
> capabilities on the host. Only enable if your threat model accepts
85+
> the agent itself being trusted at root level — which it usually
86+
> already is, since the agent runs as root in Mode A too.
87+
88+
### Required container settings
89+
90+
```yaml
91+
services:
92+
gearbox-agent:
93+
image: ghcr.io/sarg3nt/gearbox/gearbox-agent:VERSION
94+
pid: host
95+
privileged: true # or cap_add: [SYS_ADMIN, SYS_PTRACE]
96+
volumes:
97+
- /:/host:ro # so the host's bash path resolves
98+
- ./data:/var/lib/gearbox-agent
99+
environment:
100+
HAPROXY_AGENT_CONSOLE_ENABLED: "true"
101+
HAPROXY_AGENT_HOST_EXEC: "nsenter"
102+
# the shell path is resolved in the HOST's mount ns, not the container's
103+
HAPROXY_AGENT_CONSOLE_SHELL: "/bin/bash -l"
104+
```
105+
106+
Bring it up and check the agent log:
107+
108+
```text
109+
console: nsenter host-exec selected (container → host via PID 1 namespaces)
110+
Console: ENABLED — token + WS at /api/v1/console/*
111+
```
112+
113+
## Mode B.2 — Container with SSH bridge (TrueNAS-friendly)
114+
115+
For environments where `pid:host + privileged` is unacceptable or
116+
impossible — TrueNAS SCALE apps run under a restricted PSP that
117+
forbids both. The agent SSHs out to `127.0.0.1` (or a UNIX socket
118+
mount) on the host using a dedicated keypair.
119+
120+
### One-time setup
121+
122+
1. **Generate the agent's keypair** from inside the agent container
123+
(or wherever the agent runs):
124+
125+
```bash
126+
gearbox-agent --generate-console-key
127+
```
128+
129+
Output includes the public key and a recipe for the env vars.
130+
131+
2. **Install the public key** on the host's `authorized_keys`. The
132+
key comment is `gearbox-agent` so you can `grep gearbox-agent
133+
~/.ssh/authorized_keys` later to audit.
134+
135+
3. **Capture the host's SSH host key** so the agent can verify it:
136+
137+
```bash
138+
ssh-keyscan -t ed25519 127.0.0.1 > /var/lib/gearbox-agent/console-ssh/host.pub
139+
```
140+
141+
4. **Set the env vars on the agent**:
142+
143+
```bash
144+
HAPROXY_AGENT_CONSOLE_ENABLED=true
145+
HAPROXY_AGENT_HOST_EXEC=ssh-bridge
146+
HAPROXY_AGENT_CONSOLE_SSH_HOST=127.0.0.1:22
147+
HAPROXY_AGENT_CONSOLE_SSH_USER=root
148+
HAPROXY_AGENT_CONSOLE_SSH_KEY=/var/lib/gearbox-agent/console-ssh/agent
149+
HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY=/var/lib/gearbox-agent/console-ssh/host.pub
150+
```
151+
152+
5. Restart the agent. Log should show:
153+
154+
```text
155+
console: ssh_bridge host-exec selected host=127.0.0.1:22 user=root
156+
```
157+
158+
> [!CAUTION]
159+
> The agent refuses to start the bridge if the private key has
160+
> permissions wider than `0600`. If you see *"private key has
161+
> too-open permissions"* in the agent log, fix with `chmod 600`.
162+
163+
## Permissions on the dashboard side
164+
165+
Console adds a new permission component, `box_console`, with three
166+
actions:
167+
168+
| Permission | What it allows |
169+
|----------------------------|------------------------------------------------------------------|
170+
| `box_console:view` | See that console is available for a box |
171+
| `box_console:configure` | Toggle per-box console + edit shell / run-as (per-box UI: Phase 2c) |
172+
| `box_console:connect` | Open an actual shell session — **the load-bearing one** |
173+
174+
Grant via *Settings → Users → \<user\> → Permissions*. `connect`
175+
isn't granted to any role by default — opt users in deliberately.
176+
177+
## Session recording (optional)
178+
179+
Opt-in per agent via `HAPROXY_AGENT_CONSOLE_RECORD=true`. Each
180+
session writes a newline-delimited JSON transcript to
181+
`<data-dir>/console-sessions/<box>-<utc>-<sid>.ndjson` (mode `0600`,
182+
parent dir `0700`).
183+
184+
Replay with `jq`:
185+
186+
```bash
187+
jq -r 'select(.t=="out") | .d | @base64d' \
188+
/var/lib/gearbox-agent/console-sessions/box-20260516T010101-abc12345.ndjson
189+
```
190+
191+
No rotation is built in — wire `logrotate` or a cron sweep yourself.
192+
193+
## Verifying it works
194+
195+
1. Hit the capabilities endpoint directly:
196+
197+
```bash
198+
curl -sk -H "Authorization: Bearer <agent-api-key>" \
199+
https://<agent-host>:8405/api/v1/console/capabilities | jq
200+
```
201+
202+
You should see `{"enabled": true, "mode": "host_pty", ...}` (or
203+
`"nsenter"` / `"ssh_bridge"`).
204+
205+
2. Grant a user `box_console:connect`, log into the dashboard, open
206+
the Bx fleet view, click the `>_` icon on a tile.
207+
208+
3. You should land in a terminal. Try `whoami`, `hostname`, and
209+
verify they match what you expect.
210+
211+
## Troubleshooting
212+
213+
| Symptom | Likely cause | Fix |
214+
|---------------------------------------------------------------|----------------------------------------------------------|------------------------------------------------------------|
215+
| `/api/v1/console/*` returns 404 | Agent has console disabled | Set `HAPROXY_AGENT_CONSOLE_ENABLED=true` and restart |
216+
| `console icon missing on Bx tile` | User lacks `box_console:connect` | Grant via Settings → Users → Permissions |
217+
| `"Failed to open console session"` in browser | Agent unreachable, or token exchange failed | Check agent logs, network from dashboard host to agent |
218+
| `nsenter: namespaces unreachable` | Container missing `pid:host` or `privileged` | Add both to compose / k8s manifest |
219+
| `ssh_bridge: private key has too-open permissions` | Key file isn't `0600` | `chmod 600 <key path>` |
220+
| `nsenter mode but lands in container` | `/proc/1/ns/mnt` same as `/proc/self/ns/mnt` | Container wasn't started with `pid:host` |
221+
| Session disconnects after 15 min of idle | Default idle timeout | Raise `IdleTimeout` (currently env-fixed; PR welcome) |
222+
| `host key does not match` | Host key rotated since `ssh-keyscan` | Re-capture with `ssh-keyscan -t ed25519 127.0.0.1 > ...` |
223+
224+
See also [security-review/console-threat-model.md](security-review/console-threat-model.md)
225+
for the threat model.

0 commit comments

Comments
 (0)