Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 33 additions & 19 deletions docs/console-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,37 +42,52 @@ If your workflow is "I'm at my terminal anyway and have SSH keys
distributed," keep using SSH. The console is for the
"already-in-the-dashboard" path.

## Mode A — Host install (the simple case)
## Enable for a box

Agent runs directly on the box (systemd unit on Linux, launchd on
macOS). No container, no bridge — `pty.SpawnUnix` opens a real PTY
and runs `/bin/bash -l` as the agent's UID.
Console endpoints are always exposed by the agent — there is no
agent-side enable flag. To turn on console for a specific box, flip
the **Remote console** toggle on that box's settings page
(*Settings → Boxes → \<box\> → Edit*). The dashboard's per-box
toggle is the sole gate; flipping it off revokes access immediately.

### Enable
> [!IMPORTANT]
> The agent's `/api/v1/console/*` endpoints accept any caller
> holding the agent's API key. The per-box dashboard toggle only
> gates the *dashboard's* proxy path; anyone who already has the
> API key can open a session directly. This matches the existing
> trust model — API key = full administrative control of the box
> (logs, services, restart, etc.) — but is worth knowing.

Edit the agent's environment (typically `/etc/default/gearbox-agent`
or a systemd `Environment=` line):
Optional agent-side configuration (these are runtime config, not
access control):

```bash
HAPROXY_AGENT_CONSOLE_ENABLED=true
# optional overrides:
# HAPROXY_AGENT_CONSOLE_SHELL=/bin/bash -l
# HAPROXY_AGENT_CONSOLE_RUN_AS=1000 # numeric UID; default = inherit
# /etc/default/gearbox-agent (or systemd Environment= line)
HAPROXY_AGENT_CONSOLE_SHELL=/bin/bash -l # default
HAPROXY_AGENT_CONSOLE_RUN_AS=1000 # numeric UID; default = inherit agent
HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT=2h # default 15m
HAPROXY_AGENT_CONSOLE_RECORD=true # off by default; see "Session recording"
```

Restart the agent:
Restart the agent after editing:

```bash
sudo systemctl restart gearbox-agent
```

Confirm with `journalctl -u gearbox-agent | grep -i console` — you
should see:
Confirm with `journalctl -u gearbox-agent | grep -i console`:

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

## Mode A — Host install (the simple case)

Agent runs directly on the box (systemd unit on Linux, launchd on
macOS). No container, no bridge — `pty.SpawnUnix` opens a real PTY
and runs `/bin/bash -l` as the agent's UID. Nothing extra to
configure; just flip the per-box toggle and go.

## Mode B.1 — Container with `pid:host` + `privileged` (nsenter)

Agent runs in a container on a Docker host (e.g. plain Docker
Expand All @@ -97,7 +112,6 @@ services:
- /:/host:ro # so the host's bash path resolves
- ./data:/var/lib/gearbox-agent
environment:
HAPROXY_AGENT_CONSOLE_ENABLED: "true"
HAPROXY_AGENT_HOST_EXEC: "nsenter"
# the shell path is resolved in the HOST's mount ns, not the container's
HAPROXY_AGENT_CONSOLE_SHELL: "/bin/bash -l"
Expand All @@ -107,7 +121,7 @@ Bring it up and check the agent log:

```text
console: nsenter host-exec selected (container → host via PID 1 namespaces)
Console: ENABLED — token + WS at /api/v1/console/*
Console: endpoints mounted at /api/v1/console/* (per-box opt-in is dashboard-side)
```

## Mode B.2 — Container with SSH bridge (TrueNAS-friendly)
Expand Down Expand Up @@ -141,7 +155,6 @@ mount) on the host using a dedicated keypair.
4. **Set the env vars on the agent**:

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

| Symptom | Likely cause | Fix |
|---------------------------------------------------------------|----------------------------------------------------------|------------------------------------------------------------|
| `/api/v1/console/*` returns 404 | Agent has console disabled | Set `HAPROXY_AGENT_CONSOLE_ENABLED=true` and restart |
| `/api/v1/console/*` returns 404 | Agent build predates this feature | Update agent to a build that includes #127 / post-#89 |
| `/api/v1/console/capabilities` returns 404 from the dashboard | Per-box `console_enabled` toggle is off | Flip on at *Settings → Boxes → \<box\> → Edit* |
| `console icon missing on Bx tile` | User lacks `box_console:connect` | Grant via Settings → Users → Permissions |
| `"Failed to open console session"` in browser | Agent unreachable, or token exchange failed | Check agent logs, network from dashboard host to agent |
| `nsenter: namespaces unreachable` | Container missing `pid:host` or `privileged` | Add both to compose / k8s manifest |
Expand Down
32 changes: 18 additions & 14 deletions docs/security-review/console-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,15 @@ The new endpoints and how they're gated:

### Stolen agent API key

- Without the WS token (which requires the API key to issue), no
console session can open. Possessing both the API key AND knowing
which box has console enabled raises the cost of a stolen key.
- Tokens are single-use and 60s — even a stolen token cannot be
- The agent unconditionally mounts the console surface. A caller
holding the API key can directly open a session against the
agent — the dashboard's per-box `console_enabled` toggle gates
only the *dashboard's* proxy path, not direct API-key callers.
This matches the existing trust model: the API key already
grants effectively full administrative control of the box
(logs, services, restart, package management). Granting shell
access too just makes the equivalent capability more direct.
- Tokens are single-use and 60s — even a stolen *token* cannot be
replayed.
- Operators are encouraged to rotate API keys (`gearbox-agent
--rotate-api-key`) periodically.
Expand Down Expand Up @@ -188,19 +193,18 @@ The new endpoints and how they're gated:
1. **A user with `box_console:connect` is effectively a root operator
on the boxes they can reach.** This is by design — a shell is the
maximum-impact thing. Treat the permission like sudo.
2. **Session recordings (when enabled) capture credentials typed at
2. **The agent unconditionally exposes the console surface.** Anyone
holding the agent's API key can open a session directly without
going through the dashboard, bypassing the per-box
`console_enabled` toggle. The toggle is a *dashboard-side* gate
only. The API key already grants full administrative capability
on the box (logs, systemd, restarts, package management), so the
marginal exposure is small — but operators should treat the API
key accordingly.
3. **Session recordings (when enabled) capture credentials typed at
the prompt.** No automated redaction. Operators who enable
recording for compliance should also enable encryption-at-rest on
the data dir and restrict who can read the recordings directory.
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.
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.

## Deployment posture summary

Expand Down
14 changes: 5 additions & 9 deletions gearbox-agent/cmd/gearbox-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,11 @@ func main() {
}
logger.Info("WebSocket: Enabled - real-time events at GET /api/v1/events")

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

// Create and start API server
serverCfg := api.ServerConfig{
Expand All @@ -407,7 +404,6 @@ func main() {
Version: Version,
Logger: logger,
SwaggerEnabled: cfg.SwaggerEnabled, // P3-2: off by default; opt in via GEARBOX_AGENT_SWAGGER_ENABLED=true
ConsoleEnabled: cfg.ConsoleEnabled, // [#89] Phase 1a: off by default; opt in via HAPROXY_AGENT_CONSOLE_ENABLED=true
}
// Only set MetadataProvider if sync service is configured
// (Go interfaces holding nil pointers are not themselves nil)
Expand Down
8 changes: 4 additions & 4 deletions gearbox-agent/internal/api/console/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ const (
// where the operator expected host access, the dashboard hides the
// console button and surfaces the reason in box settings.
type CapabilitiesResponse struct {
// Enabled mirrors HAPROXY_AGENT_CONSOLE_ENABLED — true iff this
// surface is registered at all. Always true when this handler
// runs (registration is gated on the same flag), but exposed for
// symmetry with future "registered but degraded" states.
// Enabled is always true today — the agent unconditionally
// mounts the console surface. Kept in the envelope for
// symmetry with future "registered but degraded" states
// (e.g. a platform that probes negative for PTY support).
Enabled bool `json:"enabled" example:"true"`

// Mode is the exec strategy the agent will use when a session
Expand Down
48 changes: 19 additions & 29 deletions gearbox-agent/internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,6 @@ type ServerConfig struct {
// 2026-05 security audit P3-2.
SwaggerEnabled bool

// ConsoleEnabled, when true, mounts the remote-console endpoints
// (POST /api/v1/console/token, GET /api/v1/console/ws, GET
// /api/v1/console/capabilities). When false, those paths return
// 404 — the surface doesn't exist. Off by default. See [#89]; the
// dashboard adds a second per-box opt-in on top of this. Phase 1a
// echoes data frames; later phases attach a real PTY.
ConsoleEnabled bool

// WebSocket settings (optional)
EventBus *events.Bus

Expand Down Expand Up @@ -137,20 +129,20 @@ func NewServer(cfg ServerConfig) *Server {
})
}

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

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

// Console token exchange + capabilities (if enabled). These two
// sit inside the API-key + auth-backoff group; the WS endpoint
// itself uses the single-use token and is mounted above.
if consoleHandler != nil {
r.Post("/api/v1/console/token", consoleHandler.Tokens.HandleTokenExchange)
r.Get("/api/v1/console/capabilities", consoleHandler.HandleCapabilities)
}
// Console token exchange + capabilities. These two sit inside
// the API-key + auth-backoff group; the WS endpoint itself uses
// the single-use token and is mounted above.
r.Post("/api/v1/console/token", consoleHandler.Tokens.HandleTokenExchange)
r.Get("/api/v1/console/capabilities", consoleHandler.HandleCapabilities)
})

return &Server{
Expand Down
90 changes: 33 additions & 57 deletions gearbox-agent/internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,70 +14,33 @@ func newSilentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

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

srv := NewServer(ServerConfig{
ListenAddr: "127.0.0.1:0",
APIKey: "test-key",
Logger: newSilentLogger(),
EventBus: bus,
ConsoleEnabled: false, // the property under test
ListenAddr: "127.0.0.1:0",
APIKey: "test-key",
Logger: newSilentLogger(),
EventBus: bus,
})

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

cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/console/token"},
{http.MethodGet, "/api/v1/console/capabilities"},
{http.MethodGet, "/api/v1/console/ws"},
}
for _, tc := range cases {
req, _ := http.NewRequest(tc.method, ts.URL+tc.path, nil)
req.Header.Set("Authorization", "Bearer test-key")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s %s: status = %d, want 404 (route should not exist when ConsoleEnabled=false)",
tc.method, tc.path, resp.StatusCode)
}
}
}

// Mirror of the above: when the operator has opted in, the routes
// exist. We don't exercise the full WS upgrade here (that's covered in
// console/handler_test.go) — just that the routes are mounted and
// auth-gated correctly.
func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
bus := events.NewBus()
defer bus.Close()

srv := NewServer(ServerConfig{
ListenAddr: "127.0.0.1:0",
APIKey: "test-key",
Logger: newSilentLogger(),
EventBus: bus,
ConsoleEnabled: true,
})

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

// Capabilities is the easiest reach — auth-gated, no token
// required, deterministic response shape.
// Capabilities — auth-gated, no token required, deterministic
// response.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil)
req.Header.Set("Authorization", "Bearer test-key")
resp, err := http.DefaultClient.Do(req)
Expand All @@ -89,8 +52,8 @@ func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
t.Errorf("capabilities status = %d, want 200", resp.StatusCode)
}

// Without auth, the same path must be unauthorized (proves the
// route is behind the API-key middleware, not unauth-readable).
// Same path without auth → 401 (proves the route is behind the
// API-key middleware, not unauth-readable).
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
Expand All @@ -111,4 +74,17 @@ func TestNewServer_ConsoleEnabled_RoutesExist(t *testing.T) {
if resp3.StatusCode != http.StatusUnauthorized {
t.Errorf("token (no auth) status = %d, want 401", resp3.StatusCode)
}

// WS endpoint requires a single-use console token; no token in
// the query string ⇒ 401. (We don't drive the upgrade here;
// that lives in console/handler_test.go.)
req4, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/ws", nil)
resp4, err := http.DefaultClient.Do(req4)
if err != nil {
t.Fatalf("ws (no token) request: %v", err)
}
defer resp4.Body.Close()
if resp4.StatusCode != http.StatusUnauthorized {
t.Errorf("ws (no token) status = %d, want 401", resp4.StatusCode)
}
}
Loading
Loading