Skip to content

Commit 99e73f6

Browse files
sarg3ntclaude
andauthored
refactor(#89): drop HAPROXY_AGENT_CONSOLE_ENABLED — per-box toggle is 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>
1 parent 49d18ca commit 99e73f6

10 files changed

Lines changed: 122 additions & 156 deletions

File tree

docs/console-setup.md

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,37 +42,52 @@ If your workflow is "I'm at my terminal anyway and have SSH keys
4242
distributed," keep using SSH. The console is for the
4343
"already-in-the-dashboard" path.
4444

45-
## Mode A — Host install (the simple case)
45+
## Enable for a box
4646

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.
47+
Console endpoints are always exposed by the agent — there is no
48+
agent-side enable flag. To turn on console for a specific box, flip
49+
the **Remote console** toggle on that box's settings page
50+
(*Settings → Boxes → \<box\> → Edit*). The dashboard's per-box
51+
toggle is the sole gate; flipping it off revokes access immediately.
5052

51-
### Enable
53+
> [!IMPORTANT]
54+
> The agent's `/api/v1/console/*` endpoints accept any caller
55+
> holding the agent's API key. The per-box dashboard toggle only
56+
> gates the *dashboard's* proxy path; anyone who already has the
57+
> API key can open a session directly. This matches the existing
58+
> trust model — API key = full administrative control of the box
59+
> (logs, services, restart, etc.) — but is worth knowing.
5260
53-
Edit the agent's environment (typically `/etc/default/gearbox-agent`
54-
or a systemd `Environment=` line):
61+
Optional agent-side configuration (these are runtime config, not
62+
access control):
5563

5664
```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
65+
# /etc/default/gearbox-agent (or systemd Environment= line)
66+
HAPROXY_AGENT_CONSOLE_SHELL=/bin/bash -l # default
67+
HAPROXY_AGENT_CONSOLE_RUN_AS=1000 # numeric UID; default = inherit agent
68+
HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT=2h # default 15m
69+
HAPROXY_AGENT_CONSOLE_RECORD=true # off by default; see "Session recording"
6170
```
6271

63-
Restart the agent:
72+
Restart the agent after editing:
6473

6574
```bash
6675
sudo systemctl restart gearbox-agent
6776
```
6877

69-
Confirm with `journalctl -u gearbox-agent | grep -i console` — you
70-
should see:
78+
Confirm with `journalctl -u gearbox-agent | grep -i console`:
7179

7280
```text
73-
Console: ENABLED — token + WS at /api/v1/console/*; sessions inherit agent UID
81+
Console: endpoints mounted at /api/v1/console/* (per-box opt-in is dashboard-side)
7482
```
7583

84+
## Mode A — Host install (the simple case)
85+
86+
Agent runs directly on the box (systemd unit on Linux, launchd on
87+
macOS). No container, no bridge — `pty.SpawnUnix` opens a real PTY
88+
and runs `/bin/bash -l` as the agent's UID. Nothing extra to
89+
configure; just flip the per-box toggle and go.
90+
7691
## Mode B.1 — Container with `pid:host` + `privileged` (nsenter)
7792

7893
Agent runs in a container on a Docker host (e.g. plain Docker
@@ -97,7 +112,6 @@ services:
97112
- /:/host:ro # so the host's bash path resolves
98113
- ./data:/var/lib/gearbox-agent
99114
environment:
100-
HAPROXY_AGENT_CONSOLE_ENABLED: "true"
101115
HAPROXY_AGENT_HOST_EXEC: "nsenter"
102116
# the shell path is resolved in the HOST's mount ns, not the container's
103117
HAPROXY_AGENT_CONSOLE_SHELL: "/bin/bash -l"
@@ -107,7 +121,7 @@ Bring it up and check the agent log:
107121
108122
```text
109123
console: nsenter host-exec selected (container → host via PID 1 namespaces)
110-
Console: ENABLED — token + WS at /api/v1/console/*
124+
Console: endpoints mounted at /api/v1/console/* (per-box opt-in is dashboard-side)
111125
```
112126
113127
## Mode B.2 — Container with SSH bridge (TrueNAS-friendly)
@@ -141,7 +155,6 @@ mount) on the host using a dedicated keypair.
141155
4. **Set the env vars on the agent**:
142156

143157
```bash
144-
HAPROXY_AGENT_CONSOLE_ENABLED=true
145158
HAPROXY_AGENT_HOST_EXEC=ssh-bridge
146159
HAPROXY_AGENT_CONSOLE_SSH_HOST=127.0.0.1:22
147160
HAPROXY_AGENT_CONSOLE_SSH_USER=root
@@ -212,7 +225,8 @@ No rotation is built in — wire `logrotate` or a cron sweep yourself.
212225

213226
| Symptom | Likely cause | Fix |
214227
|---------------------------------------------------------------|----------------------------------------------------------|------------------------------------------------------------|
215-
| `/api/v1/console/*` returns 404 | Agent has console disabled | Set `HAPROXY_AGENT_CONSOLE_ENABLED=true` and restart |
228+
| `/api/v1/console/*` returns 404 | Agent build predates this feature | Update agent to a build that includes #127 / post-#89 |
229+
| `/api/v1/console/capabilities` returns 404 from the dashboard | Per-box `console_enabled` toggle is off | Flip on at *Settings → Boxes → \<box\> → Edit* |
216230
| `console icon missing on Bx tile` | User lacks `box_console:connect` | Grant via Settings → Users → Permissions |
217231
| `"Failed to open console session"` in browser | Agent unreachable, or token exchange failed | Check agent logs, network from dashboard host to agent |
218232
| `nsenter: namespaces unreachable` | Container missing `pid:host` or `privileged` | Add both to compose / k8s manifest |

docs/security-review/console-threat-model.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,15 @@ The new endpoints and how they're gated:
8484

8585
### Stolen agent API key
8686

87-
- Without the WS token (which requires the API key to issue), no
88-
console session can open. Possessing both the API key AND knowing
89-
which box has console enabled raises the cost of a stolen key.
90-
- Tokens are single-use and 60s — even a stolen token cannot be
87+
- The agent unconditionally mounts the console surface. A caller
88+
holding the API key can directly open a session against the
89+
agent — the dashboard's per-box `console_enabled` toggle gates
90+
only the *dashboard's* proxy path, not direct API-key callers.
91+
This matches the existing trust model: the API key already
92+
grants effectively full administrative control of the box
93+
(logs, services, restart, package management). Granting shell
94+
access too just makes the equivalent capability more direct.
95+
- Tokens are single-use and 60s — even a stolen *token* cannot be
9196
replayed.
9297
- Operators are encouraged to rotate API keys (`gearbox-agent
9398
--rotate-api-key`) periodically.
@@ -188,19 +193,18 @@ The new endpoints and how they're gated:
188193
1. **A user with `box_console:connect` is effectively a root operator
189194
on the boxes they can reach.** This is by design — a shell is the
190195
maximum-impact thing. Treat the permission like sudo.
191-
2. **Session recordings (when enabled) capture credentials typed at
196+
2. **The agent unconditionally exposes the console surface.** Anyone
197+
holding the agent's API key can open a session directly without
198+
going through the dashboard, bypassing the per-box
199+
`console_enabled` toggle. The toggle is a *dashboard-side* gate
200+
only. The API key already grants full administrative capability
201+
on the box (logs, systemd, restarts, package management), so the
202+
marginal exposure is small — but operators should treat the API
203+
key accordingly.
204+
3. **Session recordings (when enabled) capture credentials typed at
192205
the prompt.** No automated redaction. Operators who enable
193206
recording for compliance should also enable encryption-at-rest on
194207
the data dir and restrict who can read the recordings directory.
195-
3. **The dashboard's WS proxy uses `InsecureSkipVerify: true` for the
196-
upstream TLS dial.** The HTTP agent client validates certs at the
197-
HTTP layer; the WebSocket dial relies on the operator-controlled
198-
trust path (LAN, mTLS, etc.) and the agent's own API-key + token
199-
gate. A follow-up is to wire the WS dialer to honor
200-
`AGENT_CA_CERT_PATH` the same way the HTTP client does.
201-
4. **Idle timeout is currently fixed at 15 minutes.** Operators who
202-
want a longer/shorter cap have to patch the Handler field; no env
203-
knob today. Follow-up.
204208

205209
## Deployment posture summary
206210

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -389,14 +389,11 @@ func main() {
389389
}
390390
logger.Info("WebSocket: Enabled - real-time events at GET /api/v1/events")
391391

392-
// [#89] Phase 1a: log the console-surface state on startup. Loud
393-
// enough for `journalctl -u gearbox-agent` to surface, but no PII —
394-
// just whether the endpoints exist.
395-
if cfg.ConsoleEnabled {
396-
logger.Warn("Console: ENABLED — token + WS at /api/v1/console/*; sessions inherit agent UID, see [#89]")
397-
} else {
398-
logger.Info("Console: Disabled (set HAPROXY_AGENT_CONSOLE_ENABLED=true to enable)")
399-
}
392+
// [#89] Console endpoints are always mounted; access is gated by
393+
// the API key on the token endpoint and by the dashboard's
394+
// per-box console_enabled toggle for the WS path. One startup
395+
// line so operators see the surface exists in `journalctl`.
396+
logger.Info("Console: endpoints mounted at /api/v1/console/* (per-box opt-in is dashboard-side; sessions inherit agent UID)")
400397

401398
// Create and start API server
402399
serverCfg := api.ServerConfig{
@@ -407,7 +404,6 @@ func main() {
407404
Version: Version,
408405
Logger: logger,
409406
SwaggerEnabled: cfg.SwaggerEnabled, // P3-2: off by default; opt in via GEARBOX_AGENT_SWAGGER_ENABLED=true
410-
ConsoleEnabled: cfg.ConsoleEnabled, // [#89] Phase 1a: off by default; opt in via HAPROXY_AGENT_CONSOLE_ENABLED=true
411407
}
412408
// Only set MetadataProvider if sync service is configured
413409
// (Go interfaces holding nil pointers are not themselves nil)

gearbox-agent/internal/api/console/capabilities.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ const (
3434
// where the operator expected host access, the dashboard hides the
3535
// console button and surfaces the reason in box settings.
3636
type CapabilitiesResponse struct {
37-
// Enabled mirrors HAPROXY_AGENT_CONSOLE_ENABLED — true iff this
38-
// surface is registered at all. Always true when this handler
39-
// runs (registration is gated on the same flag), but exposed for
40-
// symmetry with future "registered but degraded" states.
37+
// Enabled is always true today — the agent unconditionally
38+
// mounts the console surface. Kept in the envelope for
39+
// symmetry with future "registered but degraded" states
40+
// (e.g. a platform that probes negative for PTY support).
4141
Enabled bool `json:"enabled" example:"true"`
4242

4343
// Mode is the exec strategy the agent will use when a session

gearbox-agent/internal/api/server.go

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,6 @@ type ServerConfig struct {
5454
// 2026-05 security audit P3-2.
5555
SwaggerEnabled bool
5656

57-
// ConsoleEnabled, when true, mounts the remote-console endpoints
58-
// (POST /api/v1/console/token, GET /api/v1/console/ws, GET
59-
// /api/v1/console/capabilities). When false, those paths return
60-
// 404 — the surface doesn't exist. Off by default. See [#89]; the
61-
// dashboard adds a second per-box opt-in on top of this. Phase 1a
62-
// echoes data frames; later phases attach a real PTY.
63-
ConsoleEnabled bool
64-
6557
// WebSocket settings (optional)
6658
EventBus *events.Bus
6759

@@ -137,20 +129,20 @@ func NewServer(cfg ServerConfig) *Server {
137129
})
138130
}
139131

140-
// Remote console handler (optional, token-gated WebSocket). Mounted
141-
// only when HAPROXY_AGENT_CONSOLE_ENABLED=true; otherwise the
142-
// routes simply don't exist (404). Phase 1a echo-only — see [#89]
143-
// for the staged rollout.
144-
var consoleHandler *console.Handler
145-
if cfg.ConsoleEnabled {
146-
consoleHandler = console.NewHandler(cfg.EventBus, cfg.Logger)
147-
// The token-exchange + capabilities endpoints sit behind the
148-
// shared API-key + rate-limit + auth-backoff stack; the WS
149-
// endpoint trusts the single-use token alone (consistent
150-
// with /api/v1/events).
151-
r.With(frameworkmiddleware.RateLimitMiddleware(rateLimiter)).Get(
152-
"/api/v1/console/ws", consoleHandler.HandleWS)
153-
}
132+
// Remote console handler. Always mounted — the dashboard's
133+
// per-box console_enabled toggle is the sole gate on whether a
134+
// session can actually open. The agent's surface is still
135+
// API-key gated (token endpoint) and single-use-token gated
136+
// (WS endpoint), and the dashboard refuses to proxy to a box
137+
// that hasn't opted in. See [#89] and the per-box toggle in
138+
// the dashboard's box settings page.
139+
consoleHandler := console.NewHandler(cfg.EventBus, cfg.Logger)
140+
// The token-exchange + capabilities endpoints sit behind the
141+
// shared API-key + rate-limit + auth-backoff stack; the WS
142+
// endpoint trusts the single-use token alone (consistent
143+
// with /api/v1/events).
144+
r.With(frameworkmiddleware.RateLimitMiddleware(rateLimiter)).Get(
145+
"/api/v1/console/ws", consoleHandler.HandleWS)
154146

155147
// Protected API routes (require API key auth)
156148
r.Group(func(r chi.Router) {
@@ -174,13 +166,11 @@ func NewServer(cfg ServerConfig) *Server {
174166
}
175167
}
176168

177-
// Console token exchange + capabilities (if enabled). These two
178-
// sit inside the API-key + auth-backoff group; the WS endpoint
179-
// itself uses the single-use token and is mounted above.
180-
if consoleHandler != nil {
181-
r.Post("/api/v1/console/token", consoleHandler.Tokens.HandleTokenExchange)
182-
r.Get("/api/v1/console/capabilities", consoleHandler.HandleCapabilities)
183-
}
169+
// Console token exchange + capabilities. These two sit inside
170+
// the API-key + auth-backoff group; the WS endpoint itself uses
171+
// the single-use token and is mounted above.
172+
r.Post("/api/v1/console/token", consoleHandler.Tokens.HandleTokenExchange)
173+
r.Get("/api/v1/console/capabilities", consoleHandler.HandleCapabilities)
184174
})
185175

186176
return &Server{

gearbox-agent/internal/api/server_test.go

Lines changed: 33 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,70 +14,33 @@ func newSilentLogger() *slog.Logger {
1414
return slog.New(slog.NewTextHandler(io.Discard, nil))
1515
}
1616

17-
// Off-by-default is the load-bearing security property of the console
18-
// surface: an agent that hasn't explicitly opted in MUST NOT expose any
19-
// /api/v1/console/* route. A regression here is silently giving every
20-
// box in the fleet a shell-by-token, so this test pins the contract
21-
// from the server-config level (not just the handler level). See [#89].
22-
func TestNewServer_ConsoleDisabled_RoutesReturn404(t *testing.T) {
17+
// Console routes are always mounted. The agent-side env-var gate
18+
// (HAPROXY_AGENT_CONSOLE_ENABLED) was removed in the post-#127
19+
// cleanup — the dashboard's per-box console_enabled toggle is the
20+
// sole opt-in. This test pins the contract that the three routes
21+
// exist and stay behind their respective auth gates (API key on
22+
// token + capabilities; single-use token on the WS endpoint, which
23+
// returns 401 when called with no token).
24+
//
25+
// A regression that re-introduces a conditional mount here would
26+
// silently break any deployment that flipped the per-box toggle on
27+
// but didn't also set a now-defunct env var.
28+
func TestNewServer_ConsoleRoutesAlwaysMounted(t *testing.T) {
2329
bus := events.NewBus()
2430
defer bus.Close()
2531

2632
srv := NewServer(ServerConfig{
27-
ListenAddr: "127.0.0.1:0",
28-
APIKey: "test-key",
29-
Logger: newSilentLogger(),
30-
EventBus: bus,
31-
ConsoleEnabled: false, // the property under test
33+
ListenAddr: "127.0.0.1:0",
34+
APIKey: "test-key",
35+
Logger: newSilentLogger(),
36+
EventBus: bus,
3237
})
3338

3439
ts := httptest.NewServer(srv.Router())
3540
defer ts.Close()
3641

37-
cases := []struct {
38-
method string
39-
path string
40-
}{
41-
{http.MethodPost, "/api/v1/console/token"},
42-
{http.MethodGet, "/api/v1/console/capabilities"},
43-
{http.MethodGet, "/api/v1/console/ws"},
44-
}
45-
for _, tc := range cases {
46-
req, _ := http.NewRequest(tc.method, ts.URL+tc.path, nil)
47-
req.Header.Set("Authorization", "Bearer test-key")
48-
resp, err := http.DefaultClient.Do(req)
49-
if err != nil {
50-
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
51-
}
52-
_ = resp.Body.Close()
53-
if resp.StatusCode != http.StatusNotFound {
54-
t.Errorf("%s %s: status = %d, want 404 (route should not exist when ConsoleEnabled=false)",
55-
tc.method, tc.path, resp.StatusCode)
56-
}
57-
}
58-
}
59-
60-
// Mirror of the above: when the operator has opted in, the routes
61-
// exist. We don't exercise the full WS upgrade here (that's covered in
62-
// console/handler_test.go) — just that the routes are mounted and
63-
// auth-gated correctly.
64-
func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
65-
bus := events.NewBus()
66-
defer bus.Close()
67-
68-
srv := NewServer(ServerConfig{
69-
ListenAddr: "127.0.0.1:0",
70-
APIKey: "test-key",
71-
Logger: newSilentLogger(),
72-
EventBus: bus,
73-
ConsoleEnabled: true,
74-
})
75-
76-
ts := httptest.NewServer(srv.Router())
77-
defer ts.Close()
78-
79-
// Capabilities is the easiest reach — auth-gated, no token
80-
// required, deterministic response shape.
42+
// Capabilities — auth-gated, no token required, deterministic
43+
// response.
8144
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil)
8245
req.Header.Set("Authorization", "Bearer test-key")
8346
resp, err := http.DefaultClient.Do(req)
@@ -89,8 +52,8 @@ func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
8952
t.Errorf("capabilities status = %d, want 200", resp.StatusCode)
9053
}
9154

92-
// Without auth, the same path must be unauthorized (proves the
93-
// route is behind the API-key middleware, not unauth-readable).
55+
// Same path without auth → 401 (proves the route is behind the
56+
// API-key middleware, not unauth-readable).
9457
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil)
9558
resp2, err := http.DefaultClient.Do(req2)
9659
if err != nil {
@@ -111,4 +74,17 @@ func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
11174
if resp3.StatusCode != http.StatusUnauthorized {
11275
t.Errorf("token (no auth) status = %d, want 401", resp3.StatusCode)
11376
}
77+
78+
// WS endpoint requires a single-use console token; no token in
79+
// the query string ⇒ 401. (We don't drive the upgrade here;
80+
// that lives in console/handler_test.go.)
81+
req4, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/ws", nil)
82+
resp4, err := http.DefaultClient.Do(req4)
83+
if err != nil {
84+
t.Fatalf("ws (no token) request: %v", err)
85+
}
86+
defer resp4.Body.Close()
87+
if resp4.StatusCode != http.StatusUnauthorized {
88+
t.Errorf("ws (no token) status = %d, want 401", resp4.StatusCode)
89+
}
11490
}

0 commit comments

Comments
 (0)