diff --git a/docs/console-setup.md b/docs/console-setup.md new file mode 100644 index 0000000..a4d06c7 --- /dev/null +++ b/docs/console-setup.md @@ -0,0 +1,225 @@ +# Remote Console — Operator Setup + +Guide for enabling the in-browser console feature on a gearbox agent. +The dashboard side ships always-on; the agent side is opt-in per box. + +## Table of Contents + +- [Overview](#overview) +- [When you need it (and when you don't)](#when-you-need-it-and-when-you-dont) +- [Mode A — Host install (the simple case)](#mode-a--host-install-the-simple-case) +- [Mode B.1 — Container with `pid:host` + `privileged` (nsenter)](#mode-b1--container-with-pidhost--privileged-nsenter) +- [Mode B.2 — Container with SSH bridge (TrueNAS-friendly)](#mode-b2--container-with-ssh-bridge-truenas-friendly) +- [Permissions on the dashboard side](#permissions-on-the-dashboard-side) +- [Session recording (optional)](#session-recording-optional) +- [Verifying it works](#verifying-it-works) +- [Troubleshooting](#troubleshooting) + +## Overview + +The console feature gives an operator a real interactive shell on a +monitored box from inside the dashboard — no SSH keys to manage on +their workstation, no jump host, no VPN. Every session goes through +the existing `gearbox-agent` TLS surface and is gated by the same +API-key authentication, with audit events on every open and close. + +> [!IMPORTANT] +> The shell **inherits the agent's UID**. On a typical install the +> agent runs as root (it needs root for `/var/log`, systemd, certs, +> `apt`), so the default console session is a **root shell**. That's +> usually what an operator wants. Set `HAPROXY_AGENT_CONSOLE_RUN_AS=` +> if you want sessions to land as a less-privileged user. + +## When you need it (and when you don't) + +| Use the console for | Don't use the console for | +|----------------------------------------------------------------|-------------------------------------------------| +| Quick "I need a shell on this one box, now" investigations | Fleet-wide automation (use SSH + Ansible/etc.) | +| Debugging an alert from inside the same browser tab | CI / scripted operations | +| Pairing — show a colleague what you're typing in real time | Long-running interactive sessions (timeout) | + +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) + +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. + +### Enable + +Edit the agent's environment (typically `/etc/default/gearbox-agent` +or a systemd `Environment=` line): + +```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 +``` + +Restart the agent: + +```bash +sudo systemctl restart gearbox-agent +``` + +Confirm with `journalctl -u gearbox-agent | grep -i console` — you +should see: + +```text +Console: ENABLED — token + WS at /api/v1/console/*; sessions inherit agent UID +``` + +## Mode B.1 — Container with `pid:host` + `privileged` (nsenter) + +Agent runs in a container on a Docker host (e.g. plain Docker +Compose, not TrueNAS app). Cross into the host's namespaces via +`nsenter --target 1` for each session. + +> [!WARNING] +> This grants the agent container effectively root-equivalent +> capabilities on the host. Only enable if your threat model accepts +> the agent itself being trusted at root level — which it usually +> already is, since the agent runs as root in Mode A too. + +### Required container settings + +```yaml +services: + gearbox-agent: + image: ghcr.io/sarg3nt/gearbox/gearbox-agent:VERSION + pid: host + privileged: true # or cap_add: [SYS_ADMIN, SYS_PTRACE] + volumes: + - /:/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" +``` + +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/* +``` + +## Mode B.2 — Container with SSH bridge (TrueNAS-friendly) + +For environments where `pid:host + privileged` is unacceptable or +impossible — TrueNAS SCALE apps run under a restricted PSP that +forbids both. The agent SSHs out to `127.0.0.1` (or a UNIX socket +mount) on the host using a dedicated keypair. + +### One-time setup + +1. **Generate the agent's keypair** from inside the agent container + (or wherever the agent runs): + + ```bash + gearbox-agent --generate-console-key + ``` + + Output includes the public key and a recipe for the env vars. + +2. **Install the public key** on the host's `authorized_keys`. The + key comment is `gearbox-agent` so you can `grep gearbox-agent + ~/.ssh/authorized_keys` later to audit. + +3. **Capture the host's SSH host key** so the agent can verify it: + + ```bash + ssh-keyscan -t ed25519 127.0.0.1 > /var/lib/gearbox-agent/console-ssh/host.pub + ``` + +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 + HAPROXY_AGENT_CONSOLE_SSH_KEY=/var/lib/gearbox-agent/console-ssh/agent + HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY=/var/lib/gearbox-agent/console-ssh/host.pub + ``` + +5. Restart the agent. Log should show: + + ```text + console: ssh_bridge host-exec selected host=127.0.0.1:22 user=root + ``` + +> [!CAUTION] +> The agent refuses to start the bridge if the private key has +> permissions wider than `0600`. If you see *"private key has +> too-open permissions"* in the agent log, fix with `chmod 600`. + +## Permissions on the dashboard side + +Console adds a new permission component, `box_console`, with three +actions: + +| Permission | What it allows | +|----------------------------|------------------------------------------------------------------| +| `box_console:view` | See that console is available for a box | +| `box_console:configure` | Toggle per-box console + edit shell / run-as (per-box UI: Phase 2c) | +| `box_console:connect` | Open an actual shell session — **the load-bearing one** | + +Grant via *Settings → Users → \ → Permissions*. `connect` +isn't granted to any role by default — opt users in deliberately. + +## Session recording (optional) + +Opt-in per agent via `HAPROXY_AGENT_CONSOLE_RECORD=true`. Each +session writes a newline-delimited JSON transcript to +`/console-sessions/--.ndjson` (mode `0600`, +parent dir `0700`). + +Replay with `jq`: + +```bash +jq -r 'select(.t=="out") | .d | @base64d' \ + /var/lib/gearbox-agent/console-sessions/box-20260516T010101-abc12345.ndjson +``` + +No rotation is built in — wire `logrotate` or a cron sweep yourself. + +## Verifying it works + +1. Hit the capabilities endpoint directly: + + ```bash + curl -sk -H "Authorization: Bearer " \ + https://:8405/api/v1/console/capabilities | jq + ``` + + You should see `{"enabled": true, "mode": "host_pty", ...}` (or + `"nsenter"` / `"ssh_bridge"`). + +2. Grant a user `box_console:connect`, log into the dashboard, open + the Bx fleet view, click the `>_` icon on a tile. + +3. You should land in a terminal. Try `whoami`, `hostname`, and + verify they match what you expect. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------------------------------------------------------------|----------------------------------------------------------|------------------------------------------------------------| +| `/api/v1/console/*` returns 404 | Agent has console disabled | Set `HAPROXY_AGENT_CONSOLE_ENABLED=true` and restart | +| `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 | +| `ssh_bridge: private key has too-open permissions` | Key file isn't `0600` | `chmod 600 ` | +| `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) | +| `host key does not match` | Host key rotated since `ssh-keyscan` | Re-capture with `ssh-keyscan -t ed25519 127.0.0.1 > ...` | + +See also [security-review/console-threat-model.md](security-review/console-threat-model.md) +for the threat model. diff --git a/docs/security-review/console-threat-model.md b/docs/security-review/console-threat-model.md new file mode 100644 index 0000000..5477605 --- /dev/null +++ b/docs/security-review/console-threat-model.md @@ -0,0 +1,213 @@ +# Remote Console — Threat Model + +Companion to [docs/console-setup.md](../console-setup.md). This +document is the reasoning audit for why the console feature is shaped +the way it is. If you're touching anything in +`gearbox-agent/internal/api/console/` or +`gearbox/internal/framework/handler/api_console.go`, read this first. + +## Table of Contents + +- [Threat model summary](#threat-model-summary) +- [In scope](#in-scope) +- [Out of scope](#out-of-scope) +- [Attack surface](#attack-surface) +- [Mitigations by attack vector](#mitigations-by-attack-vector) +- [Residual risks](#residual-risks) +- [Deployment posture summary](#deployment-posture-summary) + +## Threat model summary + +The console exposes a path from an authenticated dashboard user to an +interactive shell on a monitored box, gated by a per-user permission. +The threat model treats: + +- **The dashboard user as authenticated and authorized** at the + permission boundary, but otherwise untrusted (input fuzzing, + protocol misuse, replay attempts). +- **The network between browser and dashboard** as TLS-protected but + potentially observed. +- **The network between dashboard and agent** as TLS-protected and + pinnable but reachable from a hostile vantage in some deployments. +- **The agent itself** as fully trusted — it already has root on the + box for non-console reasons (logs, systemd, certs). Console doesn't + widen this blast radius. + +## In scope + +| Threat | Coverage | +|---------------------------------------------------------------|----------| +| Stolen dashboard session cookie → unauthorized session | ✅ | +| Stolen agent API key → unauthorized session | ✅ | +| Stolen console WS token → replay | ✅ | +| MITM between dashboard and agent | ✅ | +| Cross-Site WebSocket Hijacking from another origin | ✅ | +| Browser XSS injecting into terminal output | ✅ | +| Privilege escalation via the console handler itself | ✅ | +| Audit-log evasion ("I was never here") | ✅ | +| Session leakage between users / boxes (token cross-use) | ✅ | +| Container escape via the agent's nsenter/SSH bridges | ✅ | +| Filesystem traversal via box names in recordings | ✅ | + +## Out of scope + +| Threat | Why | +|-----------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------| +| Compromised agent → shell on the box | The agent already runs as root for non-console reasons; console doesn't widen this. | +| Operator with `box_console:connect` running destructive commands intentionally | A shell is, by design, the maximum-impact action; permission boundary is the only control. | +| Long-term confidentiality of the shell session content from someone with file access | Recording (if on) is stored mode `0600` on disk; FDE / KMS is the operator's job. | +| Side-channel timing on the WebSocket frame stream | Out of scope for an interactive-UX feature; treat as observable. | +| Browser-side credential exfiltration via malicious browser extension | Generic browser-trust problem, no console-specific mitigation possible. | + +## Attack surface + +The new endpoints and how they're gated: + +| Endpoint | Auth | +|------------------------------------------------|-------------------------------------------------------------------| +| `POST /api/v1/console/token` (agent) | Bearer API key | +| `GET /api/v1/console/ws` (agent) | Single-use 60s console token (separate namespace from events) | +| `GET /api/v1/console/capabilities` (agent) | Bearer API key | +| `GET /api/console/{boxID}/ws` (dashboard) | Session cookie + `box_console:connect` | +| `GET /api/console/{boxID}/capabilities` (dashboard) | Session cookie + `box_console:view` | + +## Mitigations by attack vector + +### Stolen dashboard session cookie + +- Cookie is `HttpOnly`, `Secure`, `SameSite=Strict` per existing + dashboard policy. +- A stolen cookie still requires `box_console:connect` to be granted + on the victim's account. +- Audit log records every session-open by `remote_addr`, so abuse is + detectable post-hoc. + +### 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 + replayed. +- Operators are encouraged to rotate API keys (`gearbox-agent + --rotate-api-key`) periodically. + +### Stolen console WS token + +- 60-second TTL. +- Single-use: validated by deleting from the map before checking + expiry, so a replay race is impossible. +- Token namespace is separate from the events WS token — a token + minted for events cannot be replayed against `/console/ws`. +- Wire format makes the token visible only in the query string; the + dashboard proxy never logs it. + +### MITM between dashboard and agent + +- TLS 1.2+ mandatory on the agent (TLS 1.3 floor for newer agents); + optional CA-cert pinning via `AGENT_CA_CERT_PATH`. +- SSH bridge mode requires an explicit host key — `FixedHostKey` + callback, no `InsecureIgnoreHostKey()` codepath exists. + +### Cross-Site WebSocket Hijacking + +- Agent's WS upgrader uses the same canonical-origin check as the + events WS (see [websocket.go](../../gearbox-agent/internal/api/websocket.go)) + — `AGENT_ALLOWED_ORIGINS` allowlist, default same-origin only. +- The console WS endpoint additionally requires a single-use token + obtained via authenticated API call — CSWSH alone (no token) + cannot succeed. +- Dashboard's proxy upgrader is permissive because cookie auth has + already verified the user on the upstream HTTP request. + +### Browser XSS in terminal output + +- xterm.js parses VT sequences itself; output is never injected as + HTML. +- The drawer's status pills (mode, UID, box name) are set via + `textContent`, never `innerHTML`. +- The Bx tile's console-icon column uses explicit DOM construction + (no `innerHTML`) for the same reason. +- Strict CSP is in effect on the dashboard. + +### Privilege escalation via the handler itself + +- The agent **never** elevates above its own UID — no `sudo`, no + `setuid`-up codepath exists. +- run-as override (when set) uses `syscall.Credential{Uid: …}` to + drop privilege before `exec`. It cannot raise. +- nsenter and ssh-bridge modes refuse run-as overrides — composing + privilege changes with namespace crossing or remote login would + hide the effective UID. +- The session-start audit event records the **effective** UID at + spawn time (not the configured value), so post-hoc review shows + the actual outcome. + +### Audit-log evasion + +- Audit events fire from the handler outer loop, **after** session + cleanup but **before** the WS goroutine returns. A handler crash + inside the loop is still followed by the deferred conn.Close, + which preserves the EventBus emission as a deferred call. (If a + process abort happens — `SIGKILL` to the agent — no event fires, + but no shell was active either.) +- The audit event includes session ID, byte counts in both + directions, exit code (when known), and the close reason as a + short tag (`client_close`, `idle_timeout`, `exit`, `protocol_violation`). + +### Session leakage between users / boxes + +- The dashboard proxy resolves boxID → agent at the start of the + request and binds the WS proxy to that single agent. There's no + shared state between concurrent sessions that could leak. +- The agent's session ID is freshly generated per upgrade; no caller + controls it. + +### Container escape via nsenter / SSH bridge + +- nsenter mode is opt-in via `HAPROXY_AGENT_HOST_EXEC=nsenter` and + requires the operator to have *already* granted `pid:host + + privileged` on the container — i.e. the escape capability is + granted explicitly at deploy time, not by the console code. +- SSH bridge mode uses a dedicated keypair (not the operator's + personal key); the host's authorized_keys entry has the + `gearbox-agent` comment for easy audit. +- Both modes refuse `run-as` overrides, so the only way to drop to + a less-privileged user is via the SSH login user + (`HAPROXY_AGENT_CONSOLE_SSH_USER`) or, for nsenter, by the + operator running the agent itself as non-root. + +### Filesystem traversal via box names in recordings + +- `sanitizeForFilename` replaces non-portable chars with `_` and + strips leading dots. Even though box names are operator-supplied + (so this is defense-in-depth), the cost of defending is zero. + +## Residual risks + +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 + 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 + +| Posture | Risk | Mitigation | +|----------------------------------|---------------------------------------------------------|---------------------------------------------| +| Agent on host (Mode A) | Console = remote root on that box | Permission boundary; audit log | +| Agent in container w/ nsenter | Container has `pid:host + privileged` | Audit access to deploy that container | +| Agent in container w/ SSH bridge | Agent has SSH access to the host | Dedicated key, host-key pinned, perms 0600 | +| Recording on | Disk holds full session transcripts | FDE / KMS; restrict recordings dir | +| Recording off | No post-hoc replay of "what did Joe type" | Operator chooses | diff --git a/gearbox-agent/cmd/gearbox-agent/main.go b/gearbox-agent/cmd/gearbox-agent/main.go index 8919ba3..e0f3245 100644 --- a/gearbox-agent/cmd/gearbox-agent/main.go +++ b/gearbox-agent/cmd/gearbox-agent/main.go @@ -21,6 +21,9 @@ package main import ( "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" "flag" "fmt" "log/slog" @@ -28,6 +31,8 @@ import ( "os" "os/signal" "strings" + + "golang.org/x/crypto/ssh" "syscall" "time" @@ -74,6 +79,7 @@ func main() { generateWebhookSecret := flag.Bool("generate-webhook-secret", false, "Generate webhook secret (if not exists) and display it") showVersion := flag.Bool("version", false, "Show version and exit") syncOnce := flag.Bool("sync-once", false, "Run one sync cycle and exit") + generateConsoleKey := flag.Bool("generate-console-key", false, "Generate an ed25519 SSH key pair under HAPROXY_AGENT_DATA_DIR for the console ssh_bridge mode and print the public key for authorized_keys") flag.Parse() if *showVersion { @@ -177,6 +183,51 @@ func main() { os.Exit(0) } + if *generateConsoleKey { + // Generate an ed25519 keypair under DataDir/console-ssh/agent. + // Print the public key so the operator can paste it into the + // host's authorized_keys. Refuses to overwrite an existing + // key — rotation is a deliberate "delete the old one then + // re-run" step, not an implicit clobber. + keyDir := cfg.DataDir + "/console-ssh" + privPath := keyDir + "/agent" + pubPath := privPath + ".pub" + if _, err := os.Stat(privPath); err == nil { + fmt.Fprintf(os.Stderr, "Console SSH key already exists at %s — delete it first if you want to rotate.\n", privPath) + os.Exit(1) + } + if err := os.MkdirAll(keyDir, 0o700); err != nil { + logger.Error("Failed to create console-ssh dir", "error", err) + os.Exit(1) + } + pub, priv, err := generateConsoleEd25519() + if err != nil { + logger.Error("Failed to generate console SSH key", "error", err) + os.Exit(1) + } + if err := os.WriteFile(privPath, priv, 0o600); err != nil { + logger.Error("Failed to write console SSH private key", "error", err) + os.Exit(1) + } + if err := os.WriteFile(pubPath, pub, 0o644); err != nil { + logger.Error("Failed to write console SSH public key", "error", err) + os.Exit(1) + } + fmt.Printf("Console SSH key pair generated:\n private: %s (mode 0600)\n public: %s\n\n", privPath, pubPath) + fmt.Println("Install the public key on the host's authorized_keys (typically /root/.ssh/authorized_keys),") + fmt.Println("then set the following env vars on the agent:") + fmt.Println("") + fmt.Println(" HAPROXY_AGENT_HOST_EXEC=ssh-bridge") + fmt.Println(" HAPROXY_AGENT_CONSOLE_SSH_HOST=127.0.0.1:22") + fmt.Println(" HAPROXY_AGENT_CONSOLE_SSH_USER=root # or whatever user owns the authorized_keys") + fmt.Printf(" HAPROXY_AGENT_CONSOLE_SSH_KEY=%s\n", privPath) + fmt.Println(" HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY=/path/to/expected/host.pub # ssh-keyscan -t ed25519 ") + fmt.Println("") + fmt.Println("Public key (paste into authorized_keys):") + fmt.Println(string(pub)) + os.Exit(0) + } + // Normal startup logger.Info("Starting gearbox-agent", "version", Version, @@ -338,6 +389,15 @@ 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)") + } + // Create and start API server serverCfg := api.ServerConfig{ ListenAddr: cfg.ListenAddr, @@ -347,6 +407,7 @@ 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) @@ -601,3 +662,28 @@ func buildSourceOverrides(cfg *config.Config) map[gear.MetricCategory]string { } return out } + +// generateConsoleEd25519 mints an ed25519 keypair encoded in the +// formats sshd expects: OpenSSH PEM for the private side, single-line +// authorized_keys format for the public side. Used only by the +// --generate-console-key one-shot flag, so we keep the implementation +// inline rather than scattering it across the framework. +func generateConsoleEd25519() (pub, priv []byte, err error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate ed25519: %w", err) + } + sshPub, err := ssh.NewPublicKey(publicKey) + if err != nil { + return nil, nil, fmt.Errorf("marshal public: %w", err) + } + // "gearbox-agent" comment makes the key easy to identify on the + // host (`grep gearbox-agent ~/.ssh/authorized_keys`). + pub = append(ssh.MarshalAuthorizedKey(sshPub)[:len(ssh.MarshalAuthorizedKey(sshPub))-1], []byte(" gearbox-agent\n")...) + pemBlock, err := ssh.MarshalPrivateKey(privateKey, "gearbox-agent console bridge") + if err != nil { + return nil, nil, fmt.Errorf("marshal private: %w", err) + } + priv = pem.EncodeToMemory(pemBlock) + return pub, priv, nil +} diff --git a/gearbox-agent/go.mod b/gearbox-agent/go.mod index d1a2a64..d3fb53f 100644 --- a/gearbox-agent/go.mod +++ b/gearbox-agent/go.mod @@ -14,6 +14,8 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/UserExistsError/conpty v0.1.4 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/spec v0.22.3 // indirect @@ -26,8 +28,10 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/swaggo/files v1.0.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/tools v0.41.0 // indirect ) diff --git a/gearbox-agent/go.sum b/gearbox-agent/go.sum index f79f047..4e0c747 100644 --- a/gearbox-agent/go.sum +++ b/gearbox-agent/go.sum @@ -1,5 +1,9 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to= +github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -52,6 +56,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= @@ -61,6 +67,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -71,6 +79,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/gearbox-agent/internal/api/console/audit.go b/gearbox-agent/internal/api/console/audit.go new file mode 100644 index 0000000..c7a5c0d --- /dev/null +++ b/gearbox-agent/internal/api/console/audit.go @@ -0,0 +1,64 @@ +package console + +import ( + "time" + + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +// auditPublisher is the narrow surface we need from events.Bus. Allows +// tests to capture audit emissions without standing up a real bus. +type auditPublisher interface { + Publish(events.Event) +} + +// emitSessionStart records the opening of a console session. +// +// Every session — including echo-mode sessions in Phase 1a — emits one +// of these. The event is the load-bearing record for "who got a shell on +// what box and when." Fields are intentionally flat (no nested map) so +// log-forwarders can index them without a JSON unmarshal step. +func emitSessionStart(bus auditPublisher, sessionID, remoteAddr, mode string, effectiveUID int, startedAt time.Time) { + if bus == nil { + return + } + bus.Publish(events.Event{ + Type: events.EventConsoleSessionStart, + Timestamp: startedAt, + Data: map[string]any{ + "session_id": sessionID, + "remote_addr": remoteAddr, + "mode": mode, + "effective_uid": effectiveUID, + }, + }) +} + +// emitSessionEnd records the close of a console session. reason is a +// short tag ("client_close", "idle_timeout", "exit", "error") rather than +// a free-form string so dashboards can group sessions by close cause. +// bytesIn / bytesOut count payload bytes (decoded data frames, not the +// JSON envelopes) so operators can spot "user pasted the entire log" +// outliers without recording session content. +// +// exitCode is the child process's exit status when a real PTY was +// attached; -1 for echo mode (no child), -1 when the child was killed +// before producing a code. The dashboard distinguishes "operator +// closed the tab" from "shell exited" by combining reason + exit code. +func emitSessionEnd(bus auditPublisher, sessionID, reason string, bytesIn, bytesOut int64, duration time.Duration, exitCode int) { + if bus == nil { + return + } + bus.Publish(events.Event{ + Type: events.EventConsoleSessionEnd, + Timestamp: time.Now(), + Data: map[string]any{ + "session_id": sessionID, + "reason": reason, + "bytes_in": bytesIn, + "bytes_out": bytesOut, + "duration_ms": duration.Milliseconds(), + "exit_code": exitCode, + }, + }) +} diff --git a/gearbox-agent/internal/api/console/capabilities.go b/gearbox-agent/internal/api/console/capabilities.go new file mode 100644 index 0000000..c20fa5e --- /dev/null +++ b/gearbox-agent/internal/api/console/capabilities.go @@ -0,0 +1,124 @@ +package console + +import ( + "encoding/json" + "net/http" + "os" + "runtime" +) + +// Mode names what the agent will actually exec when a session opens. +// Stable string contract for the dashboard to switch on. +const ( + // ModeEcho — the handler echoes data frames back to the client + // without spawning a shell. Phase 1a default, Windows fallback, + // and what tests run with when no Spawner is wired. + ModeEcho = "echo" + + // ModeHostPTY — direct PTY on the host the agent runs on. + // Phase 1b default on Linux/macOS host installs. + ModeHostPTY = "host_pty" + + // ModeNsenter — Phase 1d. Container agent crossing into the host's + // namespaces via nsenter (requires pid:host + privileged). + ModeNsenter = "nsenter" + + // ModeSSHBridge — Phase 2. Container agent connecting to the host's + // sshd over a private channel using an agent-managed key. + ModeSSHBridge = "ssh_bridge" +) + +// CapabilitiesResponse describes what the console endpoint can actually +// do on this agent. The dashboard reads this before exposing any +// affordance; if Enabled is false, or HostConsole is false in a context +// 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 bool `json:"enabled" example:"true"` + + // Mode is the exec strategy the agent will use when a session + // opens. See Mode* constants. + Mode string `json:"mode" example:"host_pty"` + + // HostConsole is true when a session lands on the host the + // operator thinks of as "this box" — direct PTY on a host + // install, nsenter or SSH bridge from a container install. + // False in echo mode and in any container deployment that + // lacks both bridges. + HostConsole bool `json:"host_console" example:"true"` + + // DefaultUID is the UID a session will run as if the dashboard + // doesn't override it. Equals the agent process's effective UID + // (geteuid). On almost every box in this fleet the agent runs + // as root, so this is typically 0 — surfaced so operators + // reading the box-settings UI see, unambiguously, that the + // default shell is a root shell. The agent never escalates + // above this value; it can drop below it via the run-as setting. + // + // -1 on Windows where the UID concept doesn't apply (Phase 3). + DefaultUID int `json:"default_uid" example:"0"` + + // OS is the runtime.GOOS the agent is built for. Lets the + // dashboard pick a sensible default shell ("/bin/bash -l" on + // linux, "pwsh" on windows). + OS string `json:"os" example:"linux"` + + // Shell is the argv the agent will exec when a session opens. + // Surfaced so the dashboard can show "Console: /bin/bash -l" + // next to the run-as field rather than leaving operators + // guessing what they're about to get. + Shell []string `json:"shell,omitempty" example:"[\"/bin/bash\", \"-l\"]"` +} + +// HandleCapabilities reports what this agent's console surface can do. +// +// @Summary Console surface capabilities +// @Description Reports whether the remote-console surface is enabled, which execution mode it will use, the UID a session will run as by default, and the runtime OS. Dashboard reads this before exposing the console affordance. +// @Tags Console +// @Produce json +// @Security BearerAuth +// @Success 200 {object} CapabilitiesResponse "Console capabilities" +// @Failure 401 {string} string "Unauthorized" +// @Router /api/v1/console/capabilities [get] +func (h *Handler) HandleCapabilities(w http.ResponseWriter, r *http.Request) { + mode := h.Mode + if mode == "" { + mode = ModeEcho + } + resp := CapabilitiesResponse{ + Enabled: true, + Mode: mode, + HostConsole: hostConsoleForMode(mode), + DefaultUID: effectiveUID(), + OS: runtime.GOOS, + Shell: h.Shell, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// hostConsoleForMode reports whether a session in the given mode lands +// on the host the operator thinks of as "this box." Echo mode is the +// only one that doesn't. +func hostConsoleForMode(mode string) bool { + switch mode { + case ModeHostPTY, ModeNsenter, ModeSSHBridge: + return true + } + return false +} + +// effectiveUID returns os.Geteuid on POSIX, -1 on Windows. Keeps the +// capabilities response honest about what "default UID" means across +// platforms without dragging in a syscall package. +func effectiveUID() int { + uid := os.Geteuid() + if uid < 0 { + return -1 + } + return uid +} diff --git a/gearbox-agent/internal/api/console/capabilities_test.go b/gearbox-agent/internal/api/console/capabilities_test.go new file mode 100644 index 0000000..c6595cf --- /dev/null +++ b/gearbox-agent/internal/api/console/capabilities_test.go @@ -0,0 +1,96 @@ +package console + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "runtime" + "testing" +) + +// echoHandler returns a Handler with no Spawner — capabilities should +// reflect echo mode regardless of platform. +func echoHandler() *Handler { + return &Handler{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Mode: ModeEcho, + } +} + +func TestCapabilities_EchoMode(t *testing.T) { + h := echoHandler() + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/capabilities", nil) + rr := httptest.NewRecorder() + h.HandleCapabilities(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp CapabilitiesResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Enabled { + t.Error("enabled = false; handler running ⇒ enabled") + } + if resp.Mode != ModeEcho { + t.Errorf("mode = %q, want %q", resp.Mode, ModeEcho) + } + if resp.HostConsole { + t.Error("host_console = true in echo mode; want false") + } + if resp.OS != runtime.GOOS { + t.Errorf("os = %q, want %q", resp.OS, runtime.GOOS) + } +} + +func TestCapabilities_HostPTYMode(t *testing.T) { + // When a Spawner is set and Mode is HostPTY, the dashboard sees + // host_console=true and learns the actual shell it will get. + h := &Handler{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Mode: ModeHostPTY, + Shell: []string{"/bin/bash", "-l"}, + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/capabilities", nil) + rr := httptest.NewRecorder() + h.HandleCapabilities(rr, req) + + var resp CapabilitiesResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Mode != ModeHostPTY { + t.Errorf("mode = %q, want %q", resp.Mode, ModeHostPTY) + } + if !resp.HostConsole { + t.Error("host_console = false in host_pty mode; want true") + } + if len(resp.Shell) != 2 || resp.Shell[0] != "/bin/bash" { + t.Errorf("shell = %v, want [/bin/bash -l]", resp.Shell) + } + if runtime.GOOS == "windows" { + if resp.DefaultUID != -1 { + t.Errorf("default_uid = %d on windows, want -1", resp.DefaultUID) + } + } else if resp.DefaultUID != os.Geteuid() { + t.Errorf("default_uid = %d, want %d", resp.DefaultUID, os.Geteuid()) + } +} + +func TestCapabilities_NsenterAndSSHBridgeAreHostConsole(t *testing.T) { + // These modes don't ship in Phase 1b but the host_console + // classifier must already report them correctly so the dashboard + // can wire its conditional UI ahead of time. + for _, mode := range []string{ModeNsenter, ModeSSHBridge} { + if !hostConsoleForMode(mode) { + t.Errorf("hostConsoleForMode(%q) = false, want true", mode) + } + } + if hostConsoleForMode(ModeEcho) { + t.Error("hostConsoleForMode(echo) = true, want false") + } +} diff --git a/gearbox-agent/internal/api/console/handler.go b/gearbox-agent/internal/api/console/handler.go new file mode 100644 index 0000000..0f662d2 --- /dev/null +++ b/gearbox-agent/internal/api/console/handler.go @@ -0,0 +1,770 @@ +package console + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "github.com/sarg3nt/gearbox-agent/internal/api/console/pty" + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +// Handler owns the runtime state for the /api/v1/console/* surface — +// the token manager, the audit event bus, and the PTY spawner. +// Construct one per Server. +type Handler struct { + Tokens *TokenManager + Logger *slog.Logger + + // Audit is where session-start / session-end events go. In + // production this is an *events.Bus; tests substitute a capture. + // The interface is intentionally narrow — Handler doesn't + // subscribe, it only publishes. + Audit auditPublisher + + // Shell is the command run inside the PTY. Defaults to + // /bin/bash -l on linux/darwin; the agent reads + // HAPROXY_AGENT_CONSOLE_SHELL to override for non-bash hosts + // (alpine, BSD, etc.). The split into argv slots avoids ever + // running through a parent shell — no opportunity for word + // splitting / glob expansion of operator-supplied strings. + Shell []string + + // RunAsUID is an optional numeric UID the spawned shell drops + // to before exec. Empty (default) means inherit the agent's UID + // — which on a root agent yields a root shell, by design. + // See [#89] for the privilege model. + RunAsUID string + + // Spawner is the function used to attach a PTY to the WS. nil + // means "echo mode" — frames are bounced back to the client + // without a real shell. This is the Phase 1a path; production + // installs set Spawner to pty.SpawnUnix (or its container/SSH + // equivalents from later phases). Tests inject directly to + // avoid spawning real processes. + Spawner pty.Spawner + + // Mode is the string value reported in the capabilities envelope + // and in audit events. Defaults to ModeEcho when Spawner is nil, + // ModeHostPTY when Spawner is set. Container/SSH wiring in later + // phases overrides this at construction. + Mode string + + // IdleTimeout bounds how long a session may sit silent before the + // server hangs it up. Defaults to 15 minutes — shells left open in + // a forgotten browser tab are the most common source of long-lived + // sessions, and a 15-minute timeout is short enough to bound + // exposure without disrupting interactive work. + IdleTimeout time.Duration + + // MaxFrameBytes caps the size of a single inbound frame (after JSON + // decode + base64 decode of Data). The default 64 KiB matches a + // generous paste from a terminal; legitimate interactive use is + // many orders of magnitude smaller. + MaxFrameBytes int + + // ReadBufBytes sizes the buffer used to pump PTY stdout to the WS. + // 4 KiB is a sensible default for character-at-a-time interactive + // traffic — a single screen redraw fits in two or three frames. + ReadBufBytes int + + // RecordSessions, when true, writes an NDJSON transcript of every + // session under DataDir/console-sessions/--.ndjson + // (mode 0600, parent dir 0700). Off by default — recording shells + // is sensitive and the user shouldn't be surprised by it. + // Operators opt in via HAPROXY_AGENT_CONSOLE_RECORD=true. + RecordSessions bool + + // DataDir is where session recordings live. Sourced from + // HAPROXY_AGENT_DATA_DIR via main wiring; passed in explicitly + // so this package stays independent of the config package. + DataDir string +} + +// NewHandler constructs a Handler with sensible defaults. Pass the +// production event bus as audit — tests construct the Handler directly +// with an injected capture instead of calling this. +// +// Mode selection cascade (first match wins): +// +// 1. Agent in a container with HAPROXY_AGENT_HOST_EXEC=nsenter AND +// nsenter is usable → Spawner = SpawnNsenter, Mode = nsenter. +// This is the TrueNAS/docker-host path; requires pid:host + +// privileged on the container. +// 2. Agent in a container with HAPROXY_AGENT_HOST_EXEC=ssh-bridge +// AND the four SSH env vars validate → Spawner = SSHBridgeSpawner, +// Mode = ssh_bridge. The TrueNAS-friendly fallback. +// 3. Agent on host (or container without a configured bridge) → +// Spawner = SpawnUnix (POSIX) or SpawnUnix's ConPTY-backed Windows +// equivalent (same exported name across builds), Mode = host_pty. +// +// Notes: +// - No "platform unsupported → echo" case exists today; every +// platform Go builds for has a PTY backend. If a future platform +// genuinely lacks one, hostSpawnerAvailable() returns false and +// we degrade to echo. +// +// Operators set the shell + run-as via HAPROXY_AGENT_CONSOLE_SHELL +// and HAPROXY_AGENT_CONSOLE_RUN_AS regardless of mode. +func NewHandler(bus *events.Bus, logger *slog.Logger) *Handler { + h := &Handler{ + Tokens: NewTokenManager(), + Logger: logger, + IdleTimeout: 15 * time.Minute, + MaxFrameBytes: 64 * 1024, + ReadBufBytes: 4 * 1024, + Shell: defaultShell(), + } + if bus != nil { + h.Audit = bus + } + switch pickHostExecMode() { + case modeUnavailable: + // No PTY backend on this platform. Drops to echo mode so + // the dashboard's UI still works for protocol prototyping. + h.Mode = ModeEcho + case modeNsenter: + h.Spawner = pty.SpawnNsenter + h.Mode = ModeNsenter + if logger != nil { + logger.Info("console: nsenter host-exec selected (container → host via PID 1 namespaces)") + } + case modeSSHBridge: + cfg, err := pty.LoadSSHBridgeConfigFromEnv() + if err != nil { + if logger != nil { + logger.Error("console: ssh_bridge requested but config is invalid; degrading to echo mode", "error", err) + } + h.Mode = ModeEcho + } else { + h.Spawner = pty.SSHBridgeSpawner(cfg) + h.Mode = ModeSSHBridge + if logger != nil { + logger.Info("console: ssh_bridge host-exec selected", "host", cfg.Host, "user", cfg.User) + } + } + default: + h.Spawner = pty.SpawnUnix + h.Mode = ModeHostPTY + } + if v := os.Getenv("HAPROXY_AGENT_CONSOLE_SHELL"); v != "" { + h.Shell = strings.Fields(v) + } + if v := os.Getenv("HAPROXY_AGENT_CONSOLE_RUN_AS"); v != "" { + h.RunAsUID = v + } + // HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT lets operators override the + // 15-minute default. Format is a Go duration string ("30m", "2h", + // "168h" to effectively disable for a week). Invalid values fall + // back to the default with a warning so a typo doesn't silently + // leave sessions vulnerable. We don't accept "0" — a zero deadline + // would cause every read to fail immediately; if you need + // no-effective-timeout, set a very large duration. + if v := os.Getenv("HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + switch { + case err != nil: + if logger != nil { + logger.Warn("console: invalid HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT, falling back to default", + "value", v, "default", h.IdleTimeout, "error", err) + } + case d <= 0: + if logger != nil { + logger.Warn("console: HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT must be positive, falling back to default", + "value", v, "default", h.IdleTimeout) + } + default: + h.IdleTimeout = d + if logger != nil { + logger.Info("console: idle timeout overridden", "value", d) + } + } + } + if os.Getenv("HAPROXY_AGENT_CONSOLE_RECORD") == "true" { + h.RecordSessions = true + h.DataDir = os.Getenv("HAPROXY_AGENT_DATA_DIR") + if h.DataDir == "" { + h.DataDir = "/var/lib/gearbox-agent" + } + if logger != nil { + logger.Warn("Console session recording ENABLED — transcripts written to "+h.DataDir+"/console-sessions", + "format", "ndjson", "perms", "0600") + } + } + return h +} + +// pickHostExecMode collapses the platform + host-exec detection into a +// single tag used by NewHandler's switch. Pure function for testability +// (the pty subpackage's detector is platform-specific and hard to mock). +type internalMode int + +const ( + modeHostDirect internalMode = iota + modeUnavailable + modeNsenter + modeSSHBridge +) + +func pickHostExecMode() internalMode { + if !hostSpawnerAvailable() { + return modeUnavailable + } + switch pty.HostExecDetect() { + case pty.HostExecNsenter: + return modeNsenter + case pty.HostExecSSHBridge: + return modeSSHBridge + default: + return modeHostDirect + } +} + +// hostSpawnerAvailable reports whether pty.SpawnUnix on this platform +// actually returns a usable session. Phase 1b shipped Linux + macOS +// (unix build tag); Phase 3 added a ConPTY-backed Windows +// implementation (also exported as SpawnUnix for handler symmetry). +// So today the answer is "yes" on every platform Go builds for — +// kept as a function so a future platform that genuinely has no PTY +// support (plan9?) can opt out. +func hostSpawnerAvailable() bool { + return true +} + +// 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: + return []string{"/bin/bash", "-l"} + } +} + +// Close releases the token manager's cleanup goroutine. +func (h *Handler) Close() { + if h.Tokens != nil { + h.Tokens.Close() + } +} + +// WebSocket protocol timing — borrowed verbatim from api/websocket.go so +// the events and console channels behave identically to operators +// watching connection lifecycles. If these ever diverge it should be +// because the console channel needs *tighter* deadlines, not looser. +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 +) + +// upgrader is the console-specific WebSocket upgrader. CheckOrigin +// matches the api package's logic — same canonicalization, same +// AGENT_ALLOWED_ORIGINS allowlist — but lives here so the console +// package can be developed and tested without an import cycle on the +// outer api package. +// +// Buffers are sized for character-at-a-time interactive traffic, not +// throughput. A 4 KiB read buffer comfortably holds the largest +// reasonable single keystroke burst (e.g. a pasted command line); a +// 32 KiB write buffer holds a screen-clearing redraw without +// fragmenting. Larger sizes only help bulk transfers, which a console +// is not. +var upgrader = websocket.Upgrader{ + ReadBufferSize: 4 * 1024, + WriteBufferSize: 32 * 1024, + CheckOrigin: checkOrigin, +} + +// checkOrigin mirrors the events-channel CheckOrigin: same-origin by +// default, override via AGENT_ALLOWED_ORIGINS comma list with "*" for +// wildcard (development only). Lives here instead of importing from the +// api package so the console subpackage stays free of upward imports. +// +// See api/websocket.go for the canonicalization rationale (2026-05 +// security audit P1-5). This is a deliberate copy — diverging the two +// origin-check policies would be a latent footgun. If a third +// WebSocket channel appears, extract this to a shared helper. +func checkOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Non-browser client (curl, websocat, Go) — Origin only + // matters for browser-driven CSWSH. + return true + } + canon, ok := canonicalOrigin(origin) + if !ok { + return false + } + allowed := os.Getenv("AGENT_ALLOWED_ORIGINS") + if allowed != "" { + for _, entry := range strings.Split(allowed, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if entry == "*" { + return true + } + if c, ok := canonicalOrigin(entry); ok && c == canon { + return true + } + } + return false + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" { + scheme = proto + } + return canon == canonicalHost(scheme, r.Host) +} + +func canonicalOrigin(s string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(s)) + if err != nil || u == nil || u.Scheme == "" || u.Host == "" { + return "", false + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + port := u.Port() + if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") { + port = "" + } + if port == "" { + return scheme + "://" + host, true + } + return scheme + "://" + host + ":" + port, true +} + +func canonicalHost(scheme, hostPort string) string { + scheme = strings.ToLower(scheme) + host, port, err := net.SplitHostPort(strings.ToLower(hostPort)) + if err != nil { + host = strings.Trim(strings.ToLower(hostPort), "[]") + return scheme + "://" + host + } + if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") { + return scheme + "://" + host + } + return scheme + "://" + host + ":" + port +} + +// HandleWS handles the WebSocket upgrade at GET /api/v1/console/ws. +// Token is required via the ?token= query parameter; only single-use +// console tokens issued via POST /api/v1/console/token are accepted. +// +// When Spawner is set (the production path on Linux/macOS), the +// handler attaches a real PTY to the WS: stdin/stdout flow through +// FrameTypeData, resize requests reach the kernel, signals reach the +// process group, and audit events record the child's exit code. When +// Spawner is nil (Phase 1a fallback, Windows pre-Phase-3), data +// frames are echoed back to the client. +// +// @Summary Console WebSocket +// @Description Upgrades to a WebSocket carrying a JSON-framed console session. Pass a valid token from POST /api/v1/console/token in the ?token= query parameter. When the agent has a PTY backend, a real shell is attached; otherwise data frames are echoed. +// @Tags Console +// @Produce json +// @Param token query string true "Single-use console token from /api/v1/console/token" +// @Success 101 "Switching Protocols" +// @Failure 401 {string} string "Unauthorized" +// @Router /api/v1/console/ws [get] +func (h *Handler) HandleWS(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" || h.Tokens == nil || !h.Tokens.Validate(token) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + if h.Logger != nil { + h.Logger.Warn("console: WS upgrade failed", "remote_addr", r.RemoteAddr, "error", err) + } + return + } + defer func() { _ = conn.Close() }() + + sessionID := newSessionID() + startedAt := time.Now() + uid := os.Geteuid() + mode := h.Mode + if mode == "" { + mode = ModeEcho + } + + emitSessionStart(h.Audit, sessionID, r.RemoteAddr, mode, uid, startedAt) + if h.Logger != nil { + h.Logger.Info("console: session opened", + "session_id", sessionID, + "remote_addr", r.RemoteAddr, + "mode", mode, + "effective_uid", uid, + "run_as", h.RunAsUID, + ) + } + + // Optional NDJSON transcript. Open lazily so a recorder failure + // (disk full, perms wrong on the data dir) doesn't block the + // session — we log the open error and keep going. The session + // audit record on the bus is always written regardless. + var recorder *Recorder + if h.RecordSessions && h.DataDir != "" { + // Box-name isn't known to the agent (the dashboard is the + // thing that knows boxes); use the remote IP as the + // per-file disambiguator. Operators correlate file → box + // via the matching audit event on the dashboard side. + rec, err := OpenRecorder(h.DataDir, r.RemoteAddr, sessionID) + if err != nil { + if h.Logger != nil { + h.Logger.Warn("console: recorder open failed; session continues unrecorded", "error", err) + } + } else { + recorder = rec + recorder.LogOpen(sessionID, mode, uid) + } + } + + var bytesIn, bytesOut atomic.Int64 + var reason string + var exitCode int + if h.Spawner != nil { + reason, exitCode = h.ptyLoop(r.Context(), conn, &bytesIn, &bytesOut, recorder) + } else { + reason = h.echoLoop(conn, &bytesIn, &bytesOut, recorder) + } + + if recorder != nil { + _ = recorder.Close(reason, exitCode) + } + + emitSessionEnd(h.Audit, sessionID, reason, bytesIn.Load(), bytesOut.Load(), time.Since(startedAt), exitCode) + if h.Logger != nil { + h.Logger.Info("console: session closed", + "session_id", sessionID, + "reason", reason, + "exit_code", exitCode, + "bytes_in", bytesIn.Load(), + "bytes_out", bytesOut.Load(), + "duration_ms", time.Since(startedAt).Milliseconds(), + ) + } +} + +// ptyLoop is the production path. It spawns a shell via h.Spawner, +// then runs three goroutines: WS reader (client → PTY), PTY reader +// (PTY → client), and ping ticker. The first one to error wins and +// drives the close reason. +// +// Returns (reason, exitCode). exitCode is the child's exit status +// when reason == "exit"; -1 otherwise. +func (h *Handler) ptyLoop(parentCtx context.Context, conn *websocket.Conn, bytesIn, bytesOut *atomic.Int64, rec *Recorder) (string, int) { + conn.SetReadLimit(int64(h.MaxFrameBytes) * 2) + _ = conn.SetReadDeadline(time.Now().Add(h.IdleTimeout)) + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(h.IdleTimeout)) + return nil + }) + + // Wire context cancellation to PTY child termination — when the + // outer request context cancels (server shutdown, client drop), + // the SpawnContext-backed exec.Cmd kills the child for us. + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + sess, err := h.Spawner(ctx, h.Shell, h.RunAsUID, 80, 24) + if err != nil { + h.writeErr(conn, ErrCodeInternal, "failed to start shell") + if h.Logger != nil { + h.Logger.Error("console: spawner failed", "error", err) + } + return "spawn_error", -1 + } + defer func() { _ = sess.Close() }() + + // reasonCh is buffered to 1 so the first writer wins and the + // others drop their reason silently. Each goroutine that can + // terminate the session writes here and then returns. + reasonCh := make(chan string, 1) + + // PTY → WS pump. When the child exits (PTY EOF) or the master + // FD goes away, we close the WS connection — that wakes up the + // WS reader's blocking ReadMessage so the outer loop can collect + // the reason and emit the session-end audit event. + go func() { + defer func() { _ = conn.Close() }() + buf := make([]byte, h.ReadBufBytes) + for { + n, err := sess.Reader().Read(buf) + if n > 0 { + out := Frame{ + Type: FrameTypeData, + Data: base64.StdEncoding.EncodeToString(buf[:n]), + } + if werr := h.writeFrame(conn, out); werr != nil { + select { + case reasonCh <- "write_error": + default: + } + return + } + bytesOut.Add(int64(n)) + if rec != nil { + rec.LogOut(buf[:n]) + } + } + if err != nil { + if errors.Is(err, io.EOF) { + select { + case reasonCh <- "exit": + default: + } + } else { + select { + case reasonCh <- "pty_read_error": + default: + } + } + return + } + } + }() + + // Ping ticker (keep-alive) + pingDone := make(chan struct{}) + defer close(pingDone) + go func() { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _ = conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-pingDone: + return + } + } + }() + + // WS → PTY pump (this goroutine, since we need to block until + // some terminal condition). + wsReason := h.pumpWStoPTY(conn, sess, bytesIn, rec) + // The PTY-side goroutine writes its reason to reasonCh *before* + // it closes the WS conn. So if the WS pump just returned because + // of a conn close initiated by the PTY pump, reasonCh already + // has the real reason. Prefer it over the WS pump's + // generic "client_close". A short timeout absorbs the rare + // scheduling race where this goroutine wakes up before the + // reasonCh write commits. + var reason string + select { + case reason = <-reasonCh: + case <-time.After(50 * time.Millisecond): + reason = wsReason + } + + // Kill the child and wait for the reaper to settle so the exit + // code is populated. + cancel() + exitCode := sess.Wait() + return reason, exitCode +} + +// pumpWStoPTY reads frames from the WS and dispatches them to the +// PTY. Returns the close reason, or "" if the WS pump terminated +// without owning the close (PTY-side goroutine got there first). +func (h *Handler) pumpWStoPTY(conn *websocket.Conn, sess pty.Session, bytesIn *atomic.Int64, rec *Recorder) string { + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if isIdleTimeout(err) { + return "idle_timeout" + } + return "client_close" + } + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + h.writeErr(conn, ErrCodeProtocolViolation, "invalid frame") + return "protocol_violation" + } + switch f.Type { + case FrameTypeData: + payload, err := base64.StdEncoding.DecodeString(f.Data) + if err != nil { + h.writeErr(conn, ErrCodeProtocolViolation, "invalid base64 in data frame") + return "protocol_violation" + } + if len(payload) > h.MaxFrameBytes { + h.writeErr(conn, ErrCodeProtocolViolation, "data frame exceeds max size") + return "protocol_violation" + } + if _, werr := sess.Write(payload); werr != nil { + return "pty_write_error" + } + bytesIn.Add(int64(len(payload))) + if rec != nil { + rec.LogIn(payload) + } + case FrameTypeResize: + if f.Cols > 0 && f.Rows > 0 && f.Cols < 1<<16 && f.Rows < 1<<16 { + _ = sess.Resize(uint16(f.Cols), uint16(f.Rows)) + if rec != nil { + rec.LogResize(f.Cols, f.Rows) + } + } + case FrameTypeSignal: + if f.Signal != "" { + _ = sess.Signal(f.Signal) + } + case FrameTypePing: + if err := h.writeFrame(conn, Frame{Type: FrameTypePong}); err != nil { + return "write_error" + } + default: + h.writeErr(conn, ErrCodeProtocolViolation, "unknown frame type") + return "protocol_violation" + } + } +} + +// echoLoop is the test/fallback path. Behavior matches Phase 1a: data +// frames are echoed, resize/signal are no-ops, ping → pong. +// +// The loop terminates when: +// - the client closes the WS (reason "client_close"), +// - no message arrives within IdleTimeout (reason "idle_timeout"), +// - a malformed frame arrives (reason "protocol_violation"), +// - a write fails (reason "write_error"). +func (h *Handler) echoLoop(conn *websocket.Conn, bytesIn, bytesOut *atomic.Int64, rec *Recorder) string { + conn.SetReadLimit(int64(h.MaxFrameBytes) * 2) + deadline := time.Now().Add(h.IdleTimeout) + _ = conn.SetReadDeadline(deadline) + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(h.IdleTimeout)) + return nil + }) + + pingDone := make(chan struct{}) + defer close(pingDone) + go func() { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _ = conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-pingDone: + return + } + } + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if isIdleTimeout(err) { + return "idle_timeout" + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + return "client_close" + } + return "client_close" + } + + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + h.writeErr(conn, ErrCodeProtocolViolation, "invalid frame") + return "protocol_violation" + } + + switch f.Type { + case FrameTypeData: + payload, err := base64.StdEncoding.DecodeString(f.Data) + if err != nil { + h.writeErr(conn, ErrCodeProtocolViolation, "invalid base64 in data frame") + return "protocol_violation" + } + if len(payload) > h.MaxFrameBytes { + h.writeErr(conn, ErrCodeProtocolViolation, "data frame exceeds max size") + return "protocol_violation" + } + bytesIn.Add(int64(len(payload))) + if rec != nil { + rec.LogIn(payload) + } + out := Frame{Type: FrameTypeData, Data: f.Data} + if err := h.writeFrame(conn, out); err != nil { + return "write_error" + } + bytesOut.Add(int64(len(payload))) + if rec != nil { + rec.LogOut(payload) + } + case FrameTypePing: + if err := h.writeFrame(conn, Frame{Type: FrameTypePong}); err != nil { + return "write_error" + } + case FrameTypeResize, FrameTypeSignal: + // Echo mode: accept and ignore. + default: + h.writeErr(conn, ErrCodeProtocolViolation, "unknown frame type") + return "protocol_violation" + } + } +} + +// writeFrame JSON-encodes and sends a single frame with the standard +// write deadline. +func (h *Handler) writeFrame(conn *websocket.Conn, f Frame) error { + _ = conn.SetWriteDeadline(time.Now().Add(writeWait)) + return conn.WriteJSON(f) +} + +// writeErr sends a FrameTypeErr frame; errors writing it are dropped +// because the caller is about to close the connection anyway. +func (h *Handler) writeErr(conn *websocket.Conn, code, msg string) { + _ = h.writeFrame(conn, Frame{Type: FrameTypeErr, Reason: code, Msg: msg}) +} + +// isIdleTimeout reports whether an error from ReadMessage was caused by +// the read deadline. gorilla/websocket wraps os.ErrDeadlineExceeded — +// errors.Is unwraps the chain. +func isIdleTimeout(err error) bool { + return errors.Is(err, os.ErrDeadlineExceeded) +} + +// newSessionID returns a short hex ID used in audit events. 8 bytes is +// enough to disambiguate sessions in a per-host log without becoming +// noise; collisions are not security-sensitive because the audit log is +// already authenticated by who wrote it. +func newSessionID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return time.Now().UTC().Format("20060102T150405.000000") + } + return hex.EncodeToString(b) +} diff --git a/gearbox-agent/internal/api/console/handler_pty_test.go b/gearbox-agent/internal/api/console/handler_pty_test.go new file mode 100644 index 0000000..921b3ea --- /dev/null +++ b/gearbox-agent/internal/api/console/handler_pty_test.go @@ -0,0 +1,274 @@ +//go:build unix + +package console + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/sarg3nt/gearbox-agent/internal/api/console/pty" + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +func newPTYTestHandler(spawner pty.Spawner) *Handler { + return &Handler{ + Tokens: NewTokenManager(), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Mode: ModeHostPTY, + Spawner: spawner, + Shell: []string{"/bin/cat"}, // cat is a deterministic echo we can drive from tests + IdleTimeout: 5 * time.Second, + MaxFrameBytes: 64 * 1024, + ReadBufBytes: 4 * 1024, + } +} + +// TestPTYLoop_RealCatRoundTrip spawns `cat` in a real PTY, sends bytes +// in via the WS, and confirms the PTY echoes them back to the WS. cat +// is the canonical "input == output" process — its termcap echo + +// line-buffered read make this a tight integration test of the whole +// stack: WS frame → base64 decode → PTY write → cat echo → PTY read +// → base64 encode → WS frame. +func TestPTYLoop_RealCatRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("skipping PTY integration test in -short mode") + } + bus := &captureBus{} + h := newPTYTestHandler(pty.SpawnUnix) + h.Audit = bus + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + // cat echoes its stdin to stdout (with PTY echo on top, so we + // see each character twice — once from PTY echo, once from cat + // writing it back). The presence of our payload anywhere in the + // next 2 seconds of frames is sufficient. + payload := []byte("hello-pty\n") + out, _ := json.Marshal(Frame{Type: FrameTypeData, Data: base64.StdEncoding.EncodeToString(payload)}) + if err := conn.WriteMessage(websocket.TextMessage, out); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + var got strings.Builder + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + continue + } + if f.Type == FrameTypeData { + decoded, _ := base64.StdEncoding.DecodeString(f.Data) + got.Write(decoded) + if strings.Contains(got.String(), "hello-pty") { + break + } + } + } + if !strings.Contains(got.String(), "hello-pty") { + t.Fatalf("did not see payload echoed; got %q", got.String()) + } + + // Close the WS — the deferred sess.Close in ptyLoop should kill + // cat. Audit event should fire with a non-error reason. + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + _ = conn.Close() + + endDeadline := time.Now().Add(3 * time.Second) + var snapshot []events.Event + for time.Now().Before(endDeadline) { + snapshot = bus.snapshot() + if len(snapshot) >= 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + if len(snapshot) < 2 { + t.Fatalf("expected 2 audit events, got %d", len(snapshot)) + } + if snapshot[0].Type != events.EventConsoleSessionStart { + t.Errorf("event[0].Type = %q, want %q", snapshot[0].Type, events.EventConsoleSessionStart) + } + if snapshot[1].Type != events.EventConsoleSessionEnd { + t.Errorf("event[1].Type = %q, want %q", snapshot[1].Type, events.EventConsoleSessionEnd) + } + if mode, _ := snapshot[0].Data["mode"].(string); mode != ModeHostPTY { + t.Errorf("start.mode = %q, want %q", mode, ModeHostPTY) + } + if _, hasExit := snapshot[1].Data["exit_code"]; !hasExit { + t.Error("session-end event missing exit_code field") + } +} + +// TestPTYLoop_ExitFromShellEndsSession runs `/bin/true`, which exits +// immediately with code 0. The handler should observe the EOF on the +// PTY, report reason="exit" and exit_code=0 in the audit event. +func TestPTYLoop_ExitFromShellEndsSession(t *testing.T) { + if testing.Short() { + t.Skip("skipping PTY integration test in -short mode") + } + bus := &captureBus{} + h := newPTYTestHandler(pty.SpawnUnix) + h.Audit = bus + h.Shell = []string{"/bin/sh", "-c", "exit 7"} + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + // Read until the WS closes (the PTY EOF should drive that). + go func() { + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + deadline := time.Now().Add(3 * time.Second) + var snapshot []events.Event + for time.Now().Before(deadline) { + snapshot = bus.snapshot() + if len(snapshot) >= 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + _ = conn.Close() + if len(snapshot) < 2 { + t.Fatalf("expected 2 audit events, got %d", len(snapshot)) + } + exit, _ := snapshot[1].Data["exit_code"].(int) + if exit != 7 { + t.Errorf("exit_code = %v, want 7", exit) + } +} + +// TestPTYLoop_ResizeReachesPTY drives a resize frame and confirms the +// PTY's reported window matches via `stty size`. The shell prints its +// stty output, which we read back through the WS. +func TestPTYLoop_ResizeReachesPTY(t *testing.T) { + if testing.Short() { + t.Skip("skipping PTY integration test in -short mode") + } + h := newPTYTestHandler(pty.SpawnUnix) + h.Shell = []string{"/bin/sh"} + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + // Resize to a memorable non-default geometry. + r, _ := json.Marshal(Frame{Type: FrameTypeResize, Cols: 132, Rows: 50}) + if err := conn.WriteMessage(websocket.TextMessage, r); err != nil { + t.Fatalf("WriteMessage resize: %v", err) + } + // stty size prints " " + cmd, _ := json.Marshal(Frame{Type: FrameTypeData, Data: base64.StdEncoding.EncodeToString([]byte("stty size; exit\n"))}) + if err := conn.WriteMessage(websocket.TextMessage, cmd); err != nil { + t.Fatalf("WriteMessage cmd: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + var got strings.Builder + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + continue + } + if f.Type == FrameTypeData { + decoded, _ := base64.StdEncoding.DecodeString(f.Data) + got.Write(decoded) + if strings.Contains(got.String(), "50 132") { + return + } + } + } + t.Fatalf("did not see resized geometry in output; got %q", got.String()) +} + +// TestPTYLoop_ContextCancelKillsChild verifies that cancelling the +// request context terminates the PTY child. Important for server +// shutdown — we don't want zombie shells after the agent exits. +func TestPTYLoop_ContextCancelKillsChild(t *testing.T) { + if testing.Short() { + t.Skip("skipping PTY integration test in -short mode") + } + h := newPTYTestHandler(pty.SpawnUnix) + h.Shell = []string{"/bin/sh", "-c", "sleep 30"} + defer h.Close() + + ctx, cancel := context.WithCancel(context.Background()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.HandleWS(w, r.WithContext(ctx)) + })) + defer srv.Close() + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + // Cancel before the sleep finishes — the shell should die. + time.Sleep(100 * time.Millisecond) + cancel() + + // The WS should close shortly after. + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + select { + case <-done: + // good + case <-time.After(3 * time.Second): + t.Fatal("WS did not close after context cancel") + } +} diff --git a/gearbox-agent/internal/api/console/handler_test.go b/gearbox-agent/internal/api/console/handler_test.go new file mode 100644 index 0000000..e5e18f4 --- /dev/null +++ b/gearbox-agent/internal/api/console/handler_test.go @@ -0,0 +1,264 @@ +package console + +import ( + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +// captureBus is a test double for events.Bus used by audit.go. The real +// bus's Publish has a non-blocking semantic; the capture keeps an +// ordered list so tests can assert on session-start / session-end pairs. +type captureBus struct { + mu sync.Mutex + events []events.Event +} + +func (c *captureBus) Publish(e events.Event) { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, e) +} + +func (c *captureBus) snapshot() []events.Event { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]events.Event, len(c.events)) + copy(out, c.events) + return out +} + +func newTestHandler() *Handler { + return &Handler{ + Tokens: NewTokenManager(), + Audit: nil, // auth/echo tests don't care; audit-specific tests inject explicitly + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + IdleTimeout: 2 * time.Second, + MaxFrameBytes: 64 * 1024, + } +} + +func TestHandleWS_RejectsMissingToken(t *testing.T) { + // No token query param → 401, no upgrade. + h := newTestHandler() + defer h.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/ws", nil) + rr := httptest.NewRecorder() + h.HandleWS(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestHandleWS_RejectsUnknownToken(t *testing.T) { + h := newTestHandler() + defer h.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/ws?token=garbage", nil) + rr := httptest.NewRecorder() + h.HandleWS(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestHandleWS_RejectsReplayedToken(t *testing.T) { + // A token validated by an earlier call must not work a second + // time, even on a different connection. + h := newTestHandler() + defer h.Close() + + tok, err := h.Tokens.Create() + if err != nil { + t.Fatalf("Create: %v", err) + } + // First validation consumes the token. + if !h.Tokens.Validate(tok) { + t.Fatal("first Validate = false") + } + // Second attempt against the handler must 401. + req := httptest.NewRequest(http.MethodGet, "/api/v1/console/ws?token="+tok, nil) + rr := httptest.NewRecorder() + h.HandleWS(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestHandleWS_EchoRoundTripAndAudit(t *testing.T) { + // Full happy path: stand up an httptest server, mint a token, + // open a WS, send a data frame, expect the same payload echoed + // back. Then close and confirm both audit events fired with + // matching session_id and a non-zero byte count. + bus := &captureBus{} + h := &Handler{ + Tokens: NewTokenManager(), + Audit: bus, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + IdleTimeout: 2 * time.Second, + MaxFrameBytes: 64 * 1024, + } + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + + tok, err := h.Tokens.Create() + if err != nil { + t.Fatalf("Create: %v", err) + } + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + + payload := []byte("hello world\n") + out, _ := json.Marshal(Frame{Type: FrameTypeData, Data: base64.StdEncoding.EncodeToString(payload)}) + if err := conn.WriteMessage(websocket.TextMessage, out); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + var got Frame + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Type != FrameTypeData { + t.Errorf("echoed type = %q, want data", got.Type) + } + decoded, err := base64.StdEncoding.DecodeString(got.Data) + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + if string(decoded) != string(payload) { + t.Errorf("echo payload = %q, want %q", decoded, payload) + } + + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + _ = conn.Close() + + // The session-end audit fires after the server-side loop returns. + // Poll briefly rather than racing. + deadline := time.Now().Add(2 * time.Second) + var snapshot []events.Event + for time.Now().Before(deadline) { + snapshot = bus.snapshot() + if len(snapshot) >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(snapshot) < 2 { + t.Fatalf("expected 2 audit events, got %d: %+v", len(snapshot), snapshot) + } + if snapshot[0].Type != events.EventConsoleSessionStart { + t.Errorf("event[0].Type = %q, want %q", snapshot[0].Type, events.EventConsoleSessionStart) + } + if snapshot[1].Type != events.EventConsoleSessionEnd { + t.Errorf("event[1].Type = %q, want %q", snapshot[1].Type, events.EventConsoleSessionEnd) + } + startID, _ := snapshot[0].Data["session_id"].(string) + endID, _ := snapshot[1].Data["session_id"].(string) + if startID == "" || startID != endID { + t.Errorf("session_id mismatch: start=%q end=%q", startID, endID) + } + if bin, _ := snapshot[1].Data["bytes_in"].(int64); bin != int64(len(payload)) { + t.Errorf("bytes_in = %v, want %d", snapshot[1].Data["bytes_in"], len(payload)) + } + if bout, _ := snapshot[1].Data["bytes_out"].(int64); bout != int64(len(payload)) { + t.Errorf("bytes_out = %v, want %d", snapshot[1].Data["bytes_out"], len(payload)) + } +} + +func TestHandleWS_PingFrameGetsPong(t *testing.T) { + // Ping/pong is the application-layer keep-alive (separate from + // the WS protocol ping). Useful for the dashboard to confirm the + // session is still wired up to the agent end-to-end. + h := newTestHandler() + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + out, _ := json.Marshal(Frame{Type: FrameTypePing}) + if err := conn.WriteMessage(websocket.TextMessage, out); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + var got Frame + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Type != FrameTypePong { + t.Errorf("type = %q, want pong", got.Type) + } +} + +func TestHandleWS_MalformedFrameClosesConnectionWithError(t *testing.T) { + // Garbage in → ErrCodeProtocolViolation frame, then close. A + // strict parser is the cheap defense against half-deployed + // clients sending the wrong wire shape. + h := newTestHandler() + defer h.Close() + + srv := httptest.NewServer(http.HandlerFunc(h.HandleWS)) + defer srv.Close() + + tok, _ := h.Tokens.Create() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + tok + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + // Not valid JSON. + if err := conn.WriteMessage(websocket.TextMessage, []byte("{not-json")); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + var got Frame + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Type != FrameTypeErr { + t.Errorf("type = %q, want err", got.Type) + } + if got.Reason != ErrCodeProtocolViolation { + t.Errorf("reason = %q, want %q", got.Reason, ErrCodeProtocolViolation) + } +} diff --git a/gearbox-agent/internal/api/console/idle_timeout_test.go b/gearbox-agent/internal/api/console/idle_timeout_test.go new file mode 100644 index 0000000..a29f9e8 --- /dev/null +++ b/gearbox-agent/internal/api/console/idle_timeout_test.go @@ -0,0 +1,47 @@ +package console + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +func newSilentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// TestNewHandler_IdleTimeoutEnvOverride checks the three branches of +// the env knob: valid value → applied, invalid value → keep default, +// non-positive → keep default. Important for operators tuning +// long-running sessions (e.g. a long apt upgrade) — a regression here +// silently caps every session at 15 minutes. +func TestNewHandler_IdleTimeoutEnvOverride(t *testing.T) { + cases := []struct { + name string + env string + wantChange bool + want time.Duration + }{ + {"valid_hours", "2h", true, 2 * time.Hour}, + {"valid_minutes", "45m", true, 45 * time.Minute}, + {"empty_keeps_default", "", false, 15 * time.Minute}, + {"garbage_keeps_default", "ten minutes", false, 15 * time.Minute}, + {"zero_keeps_default", "0", false, 15 * time.Minute}, + {"negative_keeps_default", "-5m", false, 15 * time.Minute}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HAPROXY_AGENT_CONSOLE_IDLE_TIMEOUT", tc.env) + bus := events.NewBus() + defer bus.Close() + h := NewHandler(bus, newSilentLogger()) + defer h.Close() + if h.IdleTimeout != tc.want { + t.Errorf("IdleTimeout = %v, want %v", h.IdleTimeout, tc.want) + } + }) + } +} diff --git a/gearbox-agent/internal/api/console/protocol.go b/gearbox-agent/internal/api/console/protocol.go new file mode 100644 index 0000000..6e38a46 --- /dev/null +++ b/gearbox-agent/internal/api/console/protocol.go @@ -0,0 +1,63 @@ +package console + +// Frame is the on-the-wire envelope for every console WebSocket message. +// JSON-tagged so the wire format stays self-describing even as we add new +// types in later phases (resize, signal, exit, error). Binary stdout/stdin +// rides as base64 in the Data field — text-framed WebSocket is easier to +// proxy through HTTP/2 intermediaries than the binary opcode. +// +// Phase 1a uses only "data" and "ping"/"pong"; the other variants are +// reserved so dashboard code written against this protocol now keeps +// working when 1b/1c land. +type Frame struct { + // Type is the frame discriminator. See FrameType* constants. + Type string `json:"t"` + + // Data carries base64-encoded stdin (client→agent) or stdout + // (agent→client) bytes. Set only for FrameTypeData. + Data string `json:"d,omitempty"` + + // Cols / Rows set the terminal size on FrameTypeResize. Wired in + // Phase 1b once a real PTY is attached; Phase 1a accepts and + // ignores the frame so dashboard test harnesses can prototype. + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + + // Signal carries a named POSIX signal on FrameTypeSignal + // (e.g. "INT", "TERM"). Reserved — Ctrl-C as a normal data byte + // is the recommended path. Phase 1a accepts and ignores. + Signal string `json:"s,omitempty"` + + // Code / Reason populate FrameTypeExit (process exit) and + // FrameTypeErr (protocol- or session-level error). + Code int `json:"code,omitempty"` + Reason string `json:"reason,omitempty"` + + // Msg populates FrameTypeErr — a short human-readable string. + Msg string `json:"msg,omitempty"` +} + +// Frame type constants. The full set is defined now so the protocol is +// stable across phases — code that doesn't recognize a type should drop +// the frame rather than crash. +const ( + FrameTypeData = "data" + FrameTypeResize = "resize" + FrameTypeSignal = "signal" + FrameTypePing = "ping" + FrameTypePong = "pong" + FrameTypeExit = "exit" + FrameTypeErr = "err" +) + +// 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" + ErrCodeInternal = "INTERNAL" +) diff --git a/gearbox-agent/internal/api/console/pty/nsenter_linux.go b/gearbox-agent/internal/api/console/pty/nsenter_linux.go new file mode 100644 index 0000000..eebdbed --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/nsenter_linux.go @@ -0,0 +1,137 @@ +//go:build linux + +package pty + +import ( + "context" + "errors" + "fmt" + "os" +) + +// HostExecMode names the strategy the agent uses to cross from a +// container into the host. Reported via the capabilities envelope so +// the dashboard can branch on it. +type HostExecMode string + +const ( + HostExecDirect HostExecMode = "direct" // not in a container — Mode A + HostExecNsenter HostExecMode = "nsenter" // Mode B.1 + HostExecSSHBridge HostExecMode = "ssh_bridge" // Mode B.2 (placeholder, real impl Phase 2) + HostExecNone HostExecMode = "none" // container, no bridge available +) + +// HostExecDetect returns the best available host-exec strategy for +// this agent process. Detection is conservative — if we can't *prove* +// nsenter will work, we don't claim it does. The dashboard would +// rather show "console unavailable on this box" than hand a user a +// shell that lands somewhere unexpected. +// +// Detection rules: +// - If we're not in a container (no /.dockerenv, no /proc/1/cgroup +// hint of containerd/docker), the agent is on the host → HostExecDirect. +// - Else, if HAPROXY_AGENT_HOST_EXEC=nsenter is set AND /proc/1/ns/mnt +// differs from our own mount namespace AND `nsenter` is on PATH AND +// the host's bash binary is reachable through /host or directly, +// → HostExecNsenter. +// - Else, if HAPROXY_AGENT_HOST_EXEC=ssh-bridge is set, → HostExecSSHBridge +// (real wiring lands in Phase 2; this returns the mode for the +// capabilities envelope today). +// - Else, HostExecNone — capabilities will report host_console=false. +// +// All filesystem probes use the agent's own UID; we don't try to +// detect "could we nsenter if we had more privileges" because the +// honest answer there is "ask the operator to grant them and re-probe." +func HostExecDetect() HostExecMode { + if !runningInContainer() { + return HostExecDirect + } + switch os.Getenv("HAPROXY_AGENT_HOST_EXEC") { + case "nsenter": + if nsenterUsable() { + return HostExecNsenter + } + case "ssh-bridge", "ssh_bridge": + return HostExecSSHBridge + } + return HostExecNone +} + +// runningInContainer is a best-effort container detection. The signals +// we trust most: /.dockerenv (Docker), /run/.containerenv (Podman), and +// the absence of a host-style /proc layout. We don't trust /proc/1/cgroup +// strings alone — they're brittle across runtimes. +func runningInContainer() bool { + for _, p := range []string{"/.dockerenv", "/run/.containerenv"} { + if _, err := os.Stat(p); err == nil { + return true + } + } + // Heuristic: a container's PID 1 is the entrypoint binary, not + // systemd/init. If /proc/1/comm contains our own command, we're + // almost certainly the container's PID 1 — i.e. in a container. + if data, err := os.ReadFile("/proc/1/comm"); err == nil { + comm := string(data) + for _, marker := range []string{"gearbox-agent", "haproxy-agent"} { + if len(comm) >= len(marker) && comm[:len(marker)] == marker { + return true + } + } + } + return false +} + +// nsenterUsable reports whether nsenter into PID 1's namespaces would +// work right now. Requires: +// - the nsenter binary somewhere on PATH (checked by Spawner at exec +// time; here we just verify the rest) +// - /proc/1/ns/mnt readable (proves we have access to the host's mount +// ns reference) +// - that ns differs from our own (proves there's actually a host to +// cross into) +func nsenterUsable() bool { + hostMnt, err := os.Readlink("/proc/1/ns/mnt") + if err != nil { + return false + } + selfMnt, err := os.Readlink("/proc/self/ns/mnt") + if err != nil { + return false + } + if hostMnt == selfMnt { + // Same namespace — nsenter would be a no-op and we'd + // land in the agent container's shell (which doesn't + // exist because distroless). + return false + } + return true +} + +// SpawnNsenter wraps SpawnUnix in an nsenter invocation. The argv +// becomes: nsenter --target 1 --mount --uts --ipc --net --pid -- +// . The host shell is whatever the operator configured +// (or /bin/bash by default), resolved at the host's mount namespace. +// +// Requires the container to be run with pid:host + (privileged OR +// CAP_SYS_ADMIN + CAP_SYS_PTRACE). The agent doesn't verify those +// capabilities directly — if nsenter fails at exec, the resulting +// audit event captures the error and the dashboard shows it. +func SpawnNsenter(ctx context.Context, command []string, runAs string, cols, rows uint16) (Session, error) { + if len(command) == 0 { + return nil, errors.New("nsenter: empty command") + } + // runAs through nsenter is a request to su to that UID *inside* + // the host namespace, which we don't currently implement. If + // someone passes one, fail loud rather than silently giving a + // root shell. + if runAs != "" { + return nil, fmt.Errorf("nsenter: run-as UID override not supported in this mode (got %q)", runAs) + } + argv := append([]string{ + "nsenter", + "--target", "1", + "--mount", "--uts", "--ipc", "--net", "--pid", + "--", + }, command...) + return SpawnUnix(ctx, argv, "", cols, rows) +} diff --git a/gearbox-agent/internal/api/console/pty/nsenter_linux_test.go b/gearbox-agent/internal/api/console/pty/nsenter_linux_test.go new file mode 100644 index 0000000..20e35d8 --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/nsenter_linux_test.go @@ -0,0 +1,61 @@ +//go:build linux + +package pty + +import ( + "context" + "strings" + "testing" +) + +// TestHostExecDetect_DefaultsToDirect verifies that on a developer +// machine (not in a container, no env override) the detector picks +// HostExecDirect. The CI matrix runs both bare-metal Linux and +// Linux-in-Docker, so this is the discrimination test. +func TestHostExecDetect_DefaultsToDirect(t *testing.T) { + t.Setenv("HAPROXY_AGENT_HOST_EXEC", "") + if runningInContainer() { + t.Skip("running in a container; this test asserts the host-mode default") + } + if got := HostExecDetect(); got != HostExecDirect { + t.Errorf("HostExecDetect() = %q, want %q", got, HostExecDirect) + } +} + +// TestHostExecDetect_SSHBridgeRequiresOptIn — even in a container, +// SSH bridge mode never auto-selects. The env var is the only path. +func TestHostExecDetect_SSHBridgeRequiresOptIn(t *testing.T) { + if !runningInContainer() { + t.Skip("only meaningful inside a container") + } + t.Setenv("HAPROXY_AGENT_HOST_EXEC", "") + if got := HostExecDetect(); got == HostExecSSHBridge { + t.Errorf("HostExecDetect() = ssh_bridge without env opt-in; got %q", got) + } + t.Setenv("HAPROXY_AGENT_HOST_EXEC", "ssh-bridge") + if got := HostExecDetect(); got != HostExecSSHBridge { + t.Errorf("HostExecDetect() = %q with env opt-in, want ssh_bridge", got) + } +} + +// TestSpawnNsenter_RejectsRunAs — nsenter doesn't compose with a +// run-as drop. We surface that as a clear error rather than silently +// giving a root shell. +func TestSpawnNsenter_RejectsRunAs(t *testing.T) { + _, err := SpawnNsenter(context.Background(), []string{"/bin/true"}, "1000", 80, 24) + if err == nil { + t.Fatal("SpawnNsenter with run-as = nil err; want explicit refusal") + } + if !strings.Contains(err.Error(), "run-as") { + t.Errorf("error = %q; want one mentioning run-as", err.Error()) + } +} + +// TestSpawnNsenter_EmptyCommand — defensive: empty argv is a caller +// bug, fail fast. +func TestSpawnNsenter_EmptyCommand(t *testing.T) { + _, err := SpawnNsenter(context.Background(), nil, "", 80, 24) + if err == nil { + t.Fatal("SpawnNsenter with empty command = nil err; want error") + } +} diff --git a/gearbox-agent/internal/api/console/pty/nsenter_other.go b/gearbox-agent/internal/api/console/pty/nsenter_other.go new file mode 100644 index 0000000..ed6cb43 --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/nsenter_other.go @@ -0,0 +1,35 @@ +//go:build unix && !linux + +package pty + +import ( + "context" + "errors" +) + +// HostExecMode names the host-exec strategy. On non-Linux POSIX +// (macOS, BSD), only HostExecDirect is meaningful — there's no +// nsenter equivalent in production use, and the agent typically runs +// directly on the host. +type HostExecMode string + +const ( + HostExecDirect HostExecMode = "direct" + HostExecNsenter HostExecMode = "nsenter" + HostExecSSHBridge HostExecMode = "ssh_bridge" + HostExecNone HostExecMode = "none" +) + +// HostExecDetect on non-Linux always reports direct — we trust the +// agent is on the host rather than guessing about container-like +// environments (macOS containers run under a Linux VM and the agent +// would be inside that VM, where the linux build kicks in). +func HostExecDetect() HostExecMode { + return HostExecDirect +} + +// SpawnNsenter is not available on non-Linux POSIX. Returns a clear +// error so the caller can fall through to direct host PTY. +func SpawnNsenter(_ context.Context, _ []string, _ string, _, _ uint16) (Session, error) { + return nil, errors.New("nsenter: not supported on this platform") +} diff --git a/gearbox-agent/internal/api/console/pty/nsenter_windows.go b/gearbox-agent/internal/api/console/pty/nsenter_windows.go new file mode 100644 index 0000000..7c0defc --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/nsenter_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package pty + +import ( + "context" + "errors" +) + +// HostExecMode names the host-exec strategy. On Windows we have no +// container-to-host bridge story yet — Phase 3+ may revisit. +type HostExecMode string + +const ( + HostExecDirect HostExecMode = "direct" + HostExecNsenter HostExecMode = "nsenter" + HostExecSSHBridge HostExecMode = "ssh_bridge" + HostExecNone HostExecMode = "none" +) + +// HostExecDetect on Windows reports direct — host installs only, no +// container support today. +func HostExecDetect() HostExecMode { + return HostExecDirect +} + +// SpawnNsenter is a Windows stub. +func SpawnNsenter(_ context.Context, _ []string, _ string, _, _ uint16) (Session, error) { + return nil, errors.New("nsenter: not supported on windows") +} diff --git a/gearbox-agent/internal/api/console/pty/pty.go b/gearbox-agent/internal/api/console/pty/pty.go new file mode 100644 index 0000000..b266f45 --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/pty.go @@ -0,0 +1,59 @@ +// Package pty wraps platform-specific pseudo-terminal allocation behind +// a single interface so the console handler can stay OS-agnostic. +// +// The contract is small on purpose: spawn a child process with its +// stdio attached to a PTY, read/write bytes on the master side, change +// terminal size, send signals to the child's process group, and wait +// for exit. Each backend file (pty_unix.go, pty_windows.go) implements +// the OS specifics; the handler never touches `os/exec` or `syscall` +// directly. +package pty + +import ( + "context" + "io" +) + +// Session is a running shell attached to a PTY. The implementation is +// platform-specific; the interface is what console/handler.go consumes. +type Session interface { + // Reader returns the master-side reader (stdout + stderr merged, + // which is the standard PTY contract — the kernel already + // combines them on the slave side). + Reader() io.Reader + + // Write sends bytes to the child's stdin via the master side. + Write(p []byte) (int, error) + + // Resize updates the child terminal's window size. cols and rows + // match the WS protocol field names; ws_xpixel / ws_ypixel are + // always zero because xterm.js doesn't report them. + Resize(cols, rows uint16) error + + // Signal sends a POSIX signal to the child's process group. On + // Windows the implementation maps the well-known names to ConPTY + // control sequences as best it can; unsupported signals return + // ErrSignalUnsupported. + Signal(name string) error + + // Wait blocks until the child exits and returns its exit code. + // 0 = clean exit; -1 = killed by signal before producing a code. + // Subsequent calls return the cached value. + Wait() int + + // Close terminates the child if it's still running and releases + // the master FD. Idempotent. Calling Close before Wait drains + // returns -1 from Wait. + Close() error +} + +// Spawn launches a shell attached to a fresh PTY. ctx is wired to +// child cancellation — when ctx is cancelled, the child is killed and +// Wait unblocks with -1. +// +// The `runAs` field, when non-empty, asks the implementation to drop +// to a less-privileged UID before exec. An empty string means "inherit +// the parent's UID" — which on a root-running agent means the spawned +// shell is root. This is intentional; see [#89] for the privilege +// discussion. On Windows the field is ignored (Phase 3 may revisit). +type Spawner func(ctx context.Context, cmd []string, runAs string, cols, rows uint16) (Session, error) diff --git a/gearbox-agent/internal/api/console/pty/pty_unix.go b/gearbox-agent/internal/api/console/pty/pty_unix.go new file mode 100644 index 0000000..8126eb6 --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/pty_unix.go @@ -0,0 +1,180 @@ +//go:build unix + +package pty + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "sync" + "syscall" + + creackpty "github.com/creack/pty" +) + +// ErrSignalUnsupported is returned by Session.Signal when the named +// signal has no equivalent on this platform. +var ErrSignalUnsupported = errors.New("pty: unsupported signal name") + +// unixSession is the POSIX implementation of Session: the child runs +// under a real PTY (creack/pty), Signal dispatches via syscall.Kill on +// the negated PID so it reaches the whole process group. +type unixSession struct { + cmd *exec.Cmd + ptmx *os.File // master side of the PTY + + mu sync.Mutex + exitCode int + exited bool + exitCh chan struct{} // closed when Wait completes +} + +// SpawnUnix is the Spawner implementation for POSIX systems. Always +// returns a Session backed by /dev/ptmx (Linux) or /dev/ptyXX (BSD / +// macOS) via creack/pty. +// +// If runAs is non-empty, the child is forked with a Credential set: +// - numeric uid → that UID (and same GID) +// - "user:uid" form rejected; the caller should resolve to a numeric +// UID before calling. Keeps this layer free of /etc/passwd parsing. +// +// An empty runAs means inherit — on a root agent the child is root. +// This is the documented Phase 1b behavior; the dashboard surfaces it +// via the box-settings UI. +func SpawnUnix(ctx context.Context, command []string, runAs string, cols, rows uint16) (Session, error) { + if len(command) == 0 { + return nil, fmt.Errorf("pty: empty command") + } + + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + + // New session + controlling TTY: required so the child's process + // group is distinct from the agent's, which is what makes + // signal-by-pgid work without nuking the agent. + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + Setctty: true, + } + + if runAs != "" { + uid, err := strconv.ParseUint(runAs, 10, 32) + if err != nil { + return nil, fmt.Errorf("pty: runAs must be a numeric UID, got %q: %w", runAs, err) + } + cmd.SysProcAttr.Credential = &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(uid), + } + } + + // Match the dashboard's idea of a sensible shell environment. + // PATH is the only one we set deliberately — the rest of the + // environment falls through from the agent process so things like + // TZ, LANG, and HOME work as the operator expects. + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + ptmx, err := creackpty.StartWithSize(cmd, &creackpty.Winsize{Cols: cols, Rows: rows}) + if err != nil { + return nil, fmt.Errorf("pty: start failed: %w", err) + } + + s := &unixSession{ + cmd: cmd, + ptmx: ptmx, + exitCh: make(chan struct{}), + } + + // Reap in a goroutine — Wait() reads the cached value. + go s.reap() + + return s, nil +} + +func (s *unixSession) Reader() io.Reader { return s.ptmx } + +func (s *unixSession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } + +func (s *unixSession) Resize(cols, rows uint16) error { + return creackpty.Setsize(s.ptmx, &creackpty.Winsize{Cols: cols, Rows: rows}) +} + +// nameToSignal maps the small set of WS-protocol signal names we +// understand to syscall.Signal values. The list is intentionally short — +// Ctrl-C / Ctrl-D / Ctrl-Z are all just bytes on the wire; this is the +// out-of-band path for explicit "kill the session" cases. +func nameToSignal(name string) (syscall.Signal, error) { + switch name { + case "INT", "SIGINT": + return syscall.SIGINT, nil + case "TERM", "SIGTERM": + return syscall.SIGTERM, nil + case "HUP", "SIGHUP": + return syscall.SIGHUP, nil + case "QUIT", "SIGQUIT": + return syscall.SIGQUIT, nil + case "KILL", "SIGKILL": + return syscall.SIGKILL, nil + } + return 0, ErrSignalUnsupported +} + +func (s *unixSession) Signal(name string) error { + sig, err := nameToSignal(name) + if err != nil { + return err + } + if s.cmd.Process == nil { + return fmt.Errorf("pty: child not started") + } + // Negative PID delivers to the process group — needed so Ctrl-C + // reaches the child of the shell (e.g. a running `top`), not just + // the shell itself. + return syscall.Kill(-s.cmd.Process.Pid, sig) +} + +func (s *unixSession) Wait() int { + <-s.exitCh + s.mu.Lock() + defer s.mu.Unlock() + return s.exitCode +} + +func (s *unixSession) Close() error { + // Try graceful first — TERM then KILL after a short window if + // the child ignores it. The reap goroutine handles the actual + // wait; this just nudges the child toward exit. + if s.cmd.Process != nil { + _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGTERM) + } + // Closing the PTY master propagates SIGHUP to the slave, which + // any well-behaved shell will respect. + err := s.ptmx.Close() + // Don't block on Wait here — Close should be fast. The reap + // goroutine will populate exitCode when the child exits. + return err +} + +func (s *unixSession) reap() { + defer close(s.exitCh) + + err := s.cmd.Wait() + s.mu.Lock() + defer s.mu.Unlock() + s.exited = true + if err == nil { + s.exitCode = 0 + return + } + var ee *exec.ExitError + if errors.As(err, &ee) { + s.exitCode = ee.ExitCode() + return + } + // Non-exit-error means killed-before-exit or some other oddity; + // surface as -1. + s.exitCode = -1 +} diff --git a/gearbox-agent/internal/api/console/pty/pty_windows.go b/gearbox-agent/internal/api/console/pty/pty_windows.go new file mode 100644 index 0000000..5773bcf --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/pty_windows.go @@ -0,0 +1,126 @@ +//go:build windows + +package pty + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/UserExistsError/conpty" +) + +// ErrSignalUnsupported is returned by Session.Signal when the named +// signal has no equivalent on this platform. +var ErrSignalUnsupported = errors.New("pty: signal not supported on windows") + +// SpawnUnix on Windows backs the Spawner interface with a ConPTY-based +// session. The function name is kept for cross-platform compatibility — +// the handler picks the spawner based on runtime.GOOS, and giving the +// Windows implementation the same name avoids handler-side branching. +// (A future renaming pass could call it SpawnPlatform on both sides.) +// +// runAs is currently ignored on Windows; the child runs under the +// agent's account. Phase 3 follow-up: STARTUPINFOEX with a +// PROC_THREAD_ATTRIBUTE_HANDLE_LIST + LogonUser for under-the-agent +// privilege drop. +// +// NOTE: this code path has been compiled but not run-tested in CI as +// of the Phase 3 commit. The fleet this ships for is Linux/macOS; +// Windows support is the "doesn't break the build, ready for the +// first operator to try it" tier, not "battle-tested." A real +// Windows install should expect to find at least one rough edge. +func SpawnUnix(ctx context.Context, command []string, _ string, cols, rows uint16) (Session, error) { + if len(command) == 0 { + return nil, fmt.Errorf("pty: empty command") + } + // conpty.Start takes a single command-line string. Join argv + // with spaces — that's the standard Windows convention, and + // operators who need quoting should set HAPROXY_AGENT_CONSOLE_SHELL + // to something like `cmd /c "C:\\path with spaces\\app.exe" --flag`. + cmd := strings.Join(command, " ") + cpty, err := conpty.Start( + cmd, + conpty.ConPtyDimensions(int(cols), int(rows)), + conpty.ConPtyEnv(nil), // inherit + ) + if err != nil { + return nil, fmt.Errorf("pty: ConPTY start failed: %w", err) + } + + s := &windowsSession{ + cpty: cpty, + exitCh: make(chan struct{}), + } + go s.reap(ctx) + return s, nil +} + +// windowsSession wraps a *conpty.ConPty as a pty.Session. ConPTY +// exposes a single io.ReadWriteCloser combining stdin and stdout/stderr, +// which is exactly the contract we want. +type windowsSession struct { + cpty *conpty.ConPty + + mu sync.Mutex + exited bool + exitCode int + exitCh chan struct{} +} + +func (s *windowsSession) Reader() io.Reader { return s.cpty } +func (s *windowsSession) Write(p []byte) (int, error) { return s.cpty.Write(p) } +func (s *windowsSession) Resize(cols, rows uint16) error { return s.cpty.Resize(int(cols), int(rows)) } + +// Signal on Windows is best-effort. SIGINT maps to writing Ctrl-C +// (0x03) to the input stream — ConPTY translates that to a console +// control event. SIGTERM/KILL fall through to Close, which terminates +// the child via ConPTY's lifecycle. Anything else returns +// ErrSignalUnsupported so the dashboard sees a clear "not for this +// platform" rather than silent failure. +func (s *windowsSession) Signal(name string) error { + switch name { + case "INT", "SIGINT": + _, err := s.cpty.Write([]byte{0x03}) + return err + case "TERM", "SIGTERM", "KILL", "SIGKILL": + return s.cpty.Close() + } + return ErrSignalUnsupported +} + +func (s *windowsSession) Wait() int { + <-s.exitCh + s.mu.Lock() + defer s.mu.Unlock() + return s.exitCode +} + +func (s *windowsSession) Close() error { return s.cpty.Close() } + +func (s *windowsSession) reap(ctx context.Context) { + defer close(s.exitCh) + // conpty.ConPty.Wait blocks until the child exits, returning the + // exit code. The context isn't directly honored by the upstream + // API; if cancellation comes in while we're still waiting, we + // close the ConPty to force exit. + doneCh := make(chan uint32, 1) + go func() { + code, _ := s.cpty.Wait(ctx) + doneCh <- code + }() + var code uint32 + select { + case code = <-doneCh: + case <-ctx.Done(): + _ = s.cpty.Close() + code = <-doneCh + } + s.mu.Lock() + defer s.mu.Unlock() + s.exited = true + s.exitCode = int(code) +} diff --git a/gearbox-agent/internal/api/console/pty/ssh_bridge.go b/gearbox-agent/internal/api/console/pty/ssh_bridge.go new file mode 100644 index 0000000..435910d --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/ssh_bridge.go @@ -0,0 +1,310 @@ +package pty + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "sync/atomic" + + "golang.org/x/crypto/ssh" +) + +// SSHBridgeConfig captures the operator-supplied wiring for mode B.2: +// the agent (running inside a container) connects out to the host's +// sshd over a private channel using a dedicated agent key. None of +// these are guessable defaults — the operator must produce the key, +// install the public half on the host, and tell the agent where to +// find the private half and what user to log in as. +// +// Loaded from env at handler construction: +// - HAPROXY_AGENT_CONSOLE_SSH_HOST "127.0.0.1:22" (or path to UNIX socket) +// - HAPROXY_AGENT_CONSOLE_SSH_USER "root" or whatever the operator wants +// - HAPROXY_AGENT_CONSOLE_SSH_KEY path to private key (mode 0600) +// - HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY path to expected host pubkey +// +// HostKey is *not* optional. We refuse to fall back to +// ssh.InsecureIgnoreHostKey() — a bridge that ignores host keys +// would silently land sessions anywhere a MITM redirected the +// connection, which is exactly the thing the bridge is supposed to +// avoid. +type SSHBridgeConfig struct { + Host string // "host:port" — TCP only for now; UNIX-socket support is Phase 2b + User string + PrivateKey string // path + HostKey string // path to expected ssh public key in authorized_keys format +} + +// LoadSSHBridgeConfigFromEnv reads the bridge config from env. Returns +// nil + error if any required field is missing or unreadable — the +// caller should treat that as "ssh_bridge mode misconfigured" and +// surface it to the operator rather than silently falling through to +// another mode. +func LoadSSHBridgeConfigFromEnv() (*SSHBridgeConfig, error) { + cfg := &SSHBridgeConfig{ + Host: os.Getenv("HAPROXY_AGENT_CONSOLE_SSH_HOST"), + User: os.Getenv("HAPROXY_AGENT_CONSOLE_SSH_USER"), + PrivateKey: os.Getenv("HAPROXY_AGENT_CONSOLE_SSH_KEY"), + HostKey: os.Getenv("HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY"), + } + missing := []string{} + if cfg.Host == "" { + missing = append(missing, "HAPROXY_AGENT_CONSOLE_SSH_HOST") + } + if cfg.User == "" { + missing = append(missing, "HAPROXY_AGENT_CONSOLE_SSH_USER") + } + if cfg.PrivateKey == "" { + missing = append(missing, "HAPROXY_AGENT_CONSOLE_SSH_KEY") + } + if cfg.HostKey == "" { + missing = append(missing, "HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY") + } + if len(missing) > 0 { + return nil, fmt.Errorf("ssh_bridge: missing required env: %v", missing) + } + // Verify the private key file is readable and 0600 — sloppy + // permissions on an SSH key in the data dir are the kind of + // foot-gun we'd rather catch at startup than at first session. + st, err := os.Stat(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("ssh_bridge: private key %q: %w", cfg.PrivateKey, err) + } + if st.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("ssh_bridge: private key %q has too-open permissions %o (want 0600)", + cfg.PrivateKey, st.Mode().Perm()) + } + if _, err := os.Stat(cfg.HostKey); err != nil { + return nil, fmt.Errorf("ssh_bridge: host key %q: %w", cfg.HostKey, err) + } + return cfg, nil +} + +// SSHBridgeSpawner closes over an SSHBridgeConfig and returns a +// Spawner that satisfies the pty.Spawner contract. Construction is +// separated from spawning so the dashboard can verify the bridge is +// well-configured at agent startup (LoadSSHBridgeConfigFromEnv) and +// fail loud, rather than only discovering the misconfiguration when +// a user tries to open a session. +func SSHBridgeSpawner(cfg *SSHBridgeConfig) Spawner { + return func(ctx context.Context, command []string, runAs string, cols, rows uint16) (Session, error) { + if cfg == nil { + return nil, errors.New("ssh_bridge: nil config") + } + if runAs != "" { + // Same reasoning as nsenter mode: composing a UID + // drop with the SSH login user is more confusing + // than useful. The operator picks the login user via + // HAPROXY_AGENT_CONSOLE_SSH_USER. + return nil, fmt.Errorf("ssh_bridge: run-as UID override not supported (got %q); use HAPROXY_AGENT_CONSOLE_SSH_USER instead", runAs) + } + if len(command) == 0 { + return nil, errors.New("ssh_bridge: empty command") + } + return dialAndStart(ctx, cfg, command, cols, rows) + } +} + +// dialAndStart opens a fresh SSH connection, allocates a PTY, and +// starts the requested command. A new connection per session is +// deliberately simple — we don't multiplex; the agent is a single +// process serving a small number of concurrent operators, and +// multiplexing would force us to deal with connection-level +// failure modes leaking into multiple sessions. +func dialAndStart(ctx context.Context, cfg *SSHBridgeConfig, command []string, cols, rows uint16) (Session, error) { + keyBytes, err := os.ReadFile(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("ssh_bridge: read key: %w", err) + } + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("ssh_bridge: parse key: %w", err) + } + hostKeyBytes, err := os.ReadFile(cfg.HostKey) + if err != nil { + return nil, fmt.Errorf("ssh_bridge: read host key: %w", err) + } + hostKey, _, _, _, err := ssh.ParseAuthorizedKey(hostKeyBytes) + if err != nil { + // Try ParsePublicKey as a fallback for raw public-key files + // (the operator may have used `ssh-keyscan` output directly). + hostKey, err = ssh.ParsePublicKey(hostKeyBytes) + if err != nil { + return nil, fmt.Errorf("ssh_bridge: parse host key (%q): %w", filepath.Base(cfg.HostKey), err) + } + } + + clientCfg := &ssh.ClientConfig{ + User: cfg.User, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.FixedHostKey(hostKey), + } + + // We don't use context.DialContext directly — golang.org/x/crypto/ssh + // doesn't accept it. Wrap the synchronous Dial in a goroutine and + // honor cancellation by closing the connection if it returns after + // the context expires. This is the standard ssh.Dial pattern. + type dialResult struct { + client *ssh.Client + err error + } + res := make(chan dialResult, 1) + go func() { + c, err := ssh.Dial("tcp", cfg.Host, clientCfg) + res <- dialResult{c, err} + }() + var client *ssh.Client + select { + case r := <-res: + if r.err != nil { + return nil, fmt.Errorf("ssh_bridge: dial %s: %w", cfg.Host, r.err) + } + client = r.client + case <-ctx.Done(): + return nil, ctx.Err() + } + + sess, err := client.NewSession() + if err != nil { + _ = client.Close() + return nil, fmt.Errorf("ssh_bridge: new session: %w", err) + } + + if err := sess.RequestPty("xterm-256color", int(rows), int(cols), ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + }); err != nil { + _ = sess.Close() + _ = client.Close() + return nil, fmt.Errorf("ssh_bridge: request pty: %w", err) + } + + stdin, err := sess.StdinPipe() + if err != nil { + _ = sess.Close() + _ = client.Close() + return nil, fmt.Errorf("ssh_bridge: stdin pipe: %w", err) + } + stdoutR, stdoutW := io.Pipe() + sess.Stdout = stdoutW + sess.Stderr = stdoutW + + // Build the remote command string. Quoting is intentionally + // minimal — the agent's Shell field comes from operator-set env + // and is expected to already be a sensible argv. If they want + // quoting, they wrap in `sh -c 'whatever'`. + cmdStr := "" + for i, a := range command { + if i > 0 { + cmdStr += " " + } + cmdStr += a + } + if err := sess.Start(cmdStr); err != nil { + _ = stdoutW.Close() + _ = sess.Close() + _ = client.Close() + return nil, fmt.Errorf("ssh_bridge: start %q: %w", cmdStr, err) + } + + s := &sshSession{ + client: client, + sess: sess, + stdin: stdin, + stdoutR: stdoutR, + stdoutW: stdoutW, + exitCh: make(chan struct{}), + exitCode: -1, + } + go s.reap() + return s, nil +} + +// sshSession adapts an *ssh.Session to the pty.Session interface. +// Combining stdout+stderr via the pipe matches the local PTY contract +// (the kernel already merges them on the slave side); SSH separates +// them at protocol level, so we merge them here. +type sshSession struct { + client *ssh.Client + sess *ssh.Session + + stdin io.WriteCloser + stdoutR *io.PipeReader + stdoutW *io.PipeWriter + + exitCode int32 + exited atomic.Bool + exitCh chan struct{} + closeMu sync.Mutex + closed bool +} + +func (s *sshSession) Reader() io.Reader { return s.stdoutR } +func (s *sshSession) Write(p []byte) (int, error) { return s.stdin.Write(p) } +func (s *sshSession) Resize(cols, rows uint16) error { return s.sess.WindowChange(int(rows), int(cols)) } + +// Signal maps the same WS-protocol names as the local PTY backend. +// SSH's signal handling depends on sshd honoring the request (OpenSSH +// historically did not; recent versions do). We return nil even when +// the request was sent but possibly ignored — partial support is +// closer to "works" than "fails." +func (s *sshSession) Signal(name string) error { + var sig ssh.Signal + switch name { + case "INT", "SIGINT": + sig = ssh.SIGINT + case "TERM", "SIGTERM": + sig = ssh.SIGTERM + case "HUP", "SIGHUP": + sig = ssh.SIGHUP + case "QUIT", "SIGQUIT": + sig = ssh.SIGQUIT + case "KILL", "SIGKILL": + sig = ssh.SIGKILL + default: + return ErrSignalUnsupported + } + return s.sess.Signal(sig) +} + +func (s *sshSession) Wait() int { + <-s.exitCh + return int(atomic.LoadInt32(&s.exitCode)) +} + +func (s *sshSession) Close() error { + s.closeMu.Lock() + defer s.closeMu.Unlock() + if s.closed { + return nil + } + s.closed = true + _ = s.stdin.Close() + _ = s.sess.Close() + _ = s.client.Close() + return nil +} + +func (s *sshSession) reap() { + defer close(s.exitCh) + defer func() { _ = s.stdoutW.Close() }() + + err := s.sess.Wait() + s.exited.Store(true) + if err == nil { + atomic.StoreInt32(&s.exitCode, 0) + return + } + var ee *ssh.ExitError + if errors.As(err, &ee) { + atomic.StoreInt32(&s.exitCode, int32(ee.ExitStatus())) + return + } + atomic.StoreInt32(&s.exitCode, -1) +} diff --git a/gearbox-agent/internal/api/console/pty/ssh_bridge_test.go b/gearbox-agent/internal/api/console/pty/ssh_bridge_test.go new file mode 100644 index 0000000..536b08a --- /dev/null +++ b/gearbox-agent/internal/api/console/pty/ssh_bridge_test.go @@ -0,0 +1,101 @@ +package pty + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadSSHBridgeConfigFromEnv_RejectsMissingFields(t *testing.T) { + // All four env vars are required. Confirm each missing field + // produces a clear "missing" error rather than silently using + // a zero value (which would surface as a confusing dial failure + // at first session). + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOST", "") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_USER", "") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_KEY", "") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY", "") + _, err := LoadSSHBridgeConfigFromEnv() + if err == nil { + t.Fatal("LoadSSHBridgeConfigFromEnv with all empty = nil err; want error") + } + for _, want := range []string{"HOST", "USER", "KEY", "HOSTKEY"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing mention of %s", err.Error(), want) + } + } +} + +func TestLoadSSHBridgeConfigFromEnv_RejectsWorldReadableKey(t *testing.T) { + // Loose perms on an SSH key in the data dir are a foot-gun. + // Catch them at startup, not at first session. + dir := t.TempDir() + keyPath := filepath.Join(dir, "agent.key") + if err := os.WriteFile(keyPath, []byte("not really a key"), 0o644); err != nil { + t.Fatal(err) + } + hostKeyPath := filepath.Join(dir, "host.pub") + if err := os.WriteFile(hostKeyPath, []byte("ssh-ed25519 AAAA fake"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOST", "127.0.0.1:22") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_USER", "agent") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_KEY", keyPath) + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY", hostKeyPath) + _, err := LoadSSHBridgeConfigFromEnv() + if err == nil { + t.Fatal("LoadSSHBridgeConfigFromEnv with 0644 key = nil err; want refusal") + } + if !strings.Contains(err.Error(), "permissions") { + t.Errorf("error %q missing 'permissions'", err.Error()) + } +} + +func TestLoadSSHBridgeConfigFromEnv_AcceptsTightKey(t *testing.T) { + // Sanity: a properly-protected key (mode 0600) passes the + // validation step. We don't try to actually parse it here — + // dialAndStart does that at session time. + dir := t.TempDir() + keyPath := filepath.Join(dir, "agent.key") + if err := os.WriteFile(keyPath, []byte("placeholder"), 0o600); err != nil { + t.Fatal(err) + } + hostKeyPath := filepath.Join(dir, "host.pub") + if err := os.WriteFile(hostKeyPath, []byte("ssh-ed25519 AAAA fake"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOST", "127.0.0.1:22") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_USER", "agent") + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_KEY", keyPath) + t.Setenv("HAPROXY_AGENT_CONSOLE_SSH_HOSTKEY", hostKeyPath) + cfg, err := LoadSSHBridgeConfigFromEnv() + if err != nil { + t.Fatalf("LoadSSHBridgeConfigFromEnv = %v, want nil err", err) + } + if cfg.Host != "127.0.0.1:22" || cfg.User != "agent" { + t.Errorf("cfg = %+v, want host=127.0.0.1:22 user=agent", cfg) + } +} + +func TestSSHBridgeSpawner_RejectsRunAs(t *testing.T) { + cfg := &SSHBridgeConfig{Host: "x", User: "y", PrivateKey: "z", HostKey: "w"} + sp := SSHBridgeSpawner(cfg) + _, err := sp(context.Background(), []string{"/bin/true"}, "1000", 80, 24) + if err == nil { + t.Fatal("SSHBridgeSpawner with run-as = nil err; want explicit refusal") + } + if !strings.Contains(err.Error(), "HAPROXY_AGENT_CONSOLE_SSH_USER") { + t.Errorf("error %q should suggest the env var", err.Error()) + } +} + +func TestSSHBridgeSpawner_RejectsEmptyCommand(t *testing.T) { + cfg := &SSHBridgeConfig{Host: "x", User: "y", PrivateKey: "z", HostKey: "w"} + sp := SSHBridgeSpawner(cfg) + _, err := sp(context.Background(), nil, "", 80, 24) + if err == nil { + t.Fatal("SSHBridgeSpawner with empty cmd = nil err; want refusal") + } +} diff --git a/gearbox-agent/internal/api/console/recorder.go b/gearbox-agent/internal/api/console/recorder.go new file mode 100644 index 0000000..60c36d4 --- /dev/null +++ b/gearbox-agent/internal/api/console/recorder.go @@ -0,0 +1,173 @@ +package console + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Recorder writes a session's wire traffic to disk as newline-delimited +// JSON, one record per frame, both directions. Off by default — +// recording shells is sensitive and the operator opts in via +// HAPROXY_AGENT_CONSOLE_RECORD=true. +// +// File format (one JSON object per line): +// +// {"t":"open", "ts":"...", "session":"...", "uid":0, "mode":"host_pty"} +// {"t":"in", "ts":"...", "d":""} // bytes from client → PTY +// {"t":"out", "ts":"...", "d":""} // bytes from PTY → client +// {"t":"resize","ts":"...", "cols":120, "rows":40} +// {"t":"close", "ts":"...", "reason":"...", "exit_code":N} +// +// Files live at `${DataDir}/console-sessions/--.ndjson` +// with mode 0600 (owner read/write only). The Recorder is +// goroutine-safe; the handler can drive in/out pumps concurrently. +type Recorder struct { + mu sync.Mutex + f *os.File + closed bool +} + +// recorderRoot returns the directory recordings live under, creating +// it if necessary. We mkdir 0700 so the directory tree is as +// restrictive as the files inside it. +func recorderRoot(dataDir string) (string, error) { + if dataDir == "" { + return "", fmt.Errorf("recorder: empty data dir") + } + dir := filepath.Join(dataDir, "console-sessions") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("recorder: mkdir %s: %w", dir, err) + } + return dir, nil +} + +// OpenRecorder creates a new file for the given session. boxName is +// folded into the filename so operators browsing /var/lib/gearbox-agent +// can see at a glance which box each recording is from; non-filename- +// safe characters are sanitized so a malicious box name (unlikely since +// the operator owns the names) can't escape the directory. +func OpenRecorder(dataDir, boxName, sessionID string) (*Recorder, error) { + dir, err := recorderRoot(dataDir) + if err != nil { + return nil, err + } + stamp := time.Now().UTC().Format("20060102T150405") + name := fmt.Sprintf("%s-%s-%s.ndjson", sanitizeForFilename(boxName), stamp, sessionID) + path := filepath.Join(dir, name) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("recorder: open %s: %w", path, err) + } + return &Recorder{f: f}, nil +} + +// LogOpen writes the session-open record. Best-effort — recording +// failures must not break the session, so all write errors are +// swallowed; the on-disk record is the source of truth for "was this +// session recorded" and operators who care should `ls` the directory. +func (r *Recorder) LogOpen(sessionID, mode string, uid int) { + r.writeRecord(map[string]any{ + "t": "open", + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "session": sessionID, + "mode": mode, + "uid": uid, + }) +} + +// LogIn records bytes flowing client → PTY (stdin). +func (r *Recorder) LogIn(data []byte) { + r.writeRecord(map[string]any{ + "t": "in", + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "d": base64.StdEncoding.EncodeToString(data), + }) +} + +// LogOut records bytes flowing PTY → client (stdout/stderr). +func (r *Recorder) LogOut(data []byte) { + r.writeRecord(map[string]any{ + "t": "out", + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "d": base64.StdEncoding.EncodeToString(data), + }) +} + +// LogResize records a window-change event. +func (r *Recorder) LogResize(cols, rows int) { + r.writeRecord(map[string]any{ + "t": "resize", + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "cols": cols, + "rows": rows, + }) +} + +// Close writes the session-close record and releases the file. +func (r *Recorder) Close(reason string, exitCode int) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil + } + r.closed = true + if r.f != nil { + _ = r.writeRecordLocked(map[string]any{ + "t": "close", + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "reason": reason, + "exit_code": exitCode, + }) + err := r.f.Close() + r.f = nil + return err + } + return nil +} + +func (r *Recorder) writeRecord(rec map[string]any) { + r.mu.Lock() + defer r.mu.Unlock() + _ = r.writeRecordLocked(rec) +} + +func (r *Recorder) writeRecordLocked(rec map[string]any) error { + if r.f == nil { + return nil + } + return json.NewEncoder(r.f).Encode(rec) +} + +// sanitizeForFilename replaces anything outside the conservative +// portable filename set with '_'. Box names are operator-controlled, +// but defending against a literal '/' in a name keeps the directory +// traversal threat at zero rather than "trust the operator." +func sanitizeForFilename(s string) string { + if s == "" { + return "box" + } + b := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', + c >= 'A' && c <= 'Z', + c >= '0' && c <= '9', + c == '-' || c == '_' || c == '.': + b = append(b, c) + default: + b = append(b, '_') + } + } + out := strings.TrimLeft(string(b), ".") // no hidden files + if out == "" { + return "box" + } + return out +} diff --git a/gearbox-agent/internal/api/console/recorder_test.go b/gearbox-agent/internal/api/console/recorder_test.go new file mode 100644 index 0000000..cfbf26c --- /dev/null +++ b/gearbox-agent/internal/api/console/recorder_test.go @@ -0,0 +1,149 @@ +package console + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRecorder_WritesAllFrameTypes(t *testing.T) { + // Smoke test: open a recorder, log every kind of record, close, + // then parse the file back and confirm each line decodes to the + // expected envelope. This pins the on-disk format so a future + // refactor that drops a field is caught here. + dir := t.TempDir() + rec, err := OpenRecorder(dir, "test-box", "abcdef12") + if err != nil { + t.Fatalf("OpenRecorder: %v", err) + } + rec.LogOpen("abcdef12", ModeHostPTY, 0) + rec.LogIn([]byte("hello\n")) + rec.LogOut([]byte("world\n")) + rec.LogResize(132, 50) + if err := rec.Close("client_close", 0); err != nil { + t.Fatalf("Close: %v", err) + } + + // Find the file (name includes a timestamp we don't predict). + entries, _ := os.ReadDir(filepath.Join(dir, "console-sessions")) + if len(entries) != 1 { + t.Fatalf("want 1 recording file, got %d", len(entries)) + } + if !strings.HasPrefix(entries[0].Name(), "test-box-") { + t.Errorf("filename = %q, want test-box-... prefix", entries[0].Name()) + } + if !strings.HasSuffix(entries[0].Name(), "-abcdef12.ndjson") { + t.Errorf("filename = %q, want session-id suffix", entries[0].Name()) + } + + path := filepath.Join(dir, "console-sessions", entries[0].Name()) + + // File mode must be 0600 — recording shells is sensitive. + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Errorf("file mode = %o, want 0600", st.Mode().Perm()) + } + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + want := []string{"open", "in", "out", "resize", "close"} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var got []string + var inRec, outRec map[string]any + for scanner.Scan() { + var rec map[string]any + if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { + t.Fatalf("malformed line %q: %v", scanner.Text(), err) + } + t, _ := rec["t"].(string) + got = append(got, t) + if t == "in" { + inRec = rec + } + if t == "out" { + outRec = rec + } + } + if scanner.Err() != nil { + t.Fatalf("scan: %v", scanner.Err()) + } + if len(got) != len(want) { + t.Fatalf("frame count = %d, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i] != w { + t.Errorf("frame[%d] = %q, want %q", i, got[i], w) + } + } + + // Verify base64 round-trip on the in/out payloads. + inData, _ := inRec["d"].(string) + dec, err := base64.StdEncoding.DecodeString(inData) + if err != nil { + t.Fatalf("in.d not base64: %v", err) + } + if string(dec) != "hello\n" { + t.Errorf("in.d decoded = %q, want %q", dec, "hello\n") + } + outData, _ := outRec["d"].(string) + dec, err = base64.StdEncoding.DecodeString(outData) + if err != nil { + t.Fatalf("out.d not base64: %v", err) + } + if string(dec) != "world\n" { + t.Errorf("out.d decoded = %q, want %q", dec, "world\n") + } +} + +func TestRecorder_RefusesEmptyDataDir(t *testing.T) { + _, err := OpenRecorder("", "box", "sid") + if err == nil { + t.Fatal("OpenRecorder with empty dataDir = nil err; want error") + } +} + +func TestRecorder_CloseIsIdempotent(t *testing.T) { + dir := t.TempDir() + rec, err := OpenRecorder(dir, "box", "sid") + if err != nil { + t.Fatal(err) + } + if err := rec.Close("a", 0); err != nil { + t.Errorf("first Close: %v", err) + } + if err := rec.Close("b", 0); err != nil { + t.Errorf("second Close: %v", err) + } +} + +func TestRecorder_SanitizeForFilename(t *testing.T) { + // Operators control box names but we still defend against + // path-traversal characters slipping into the filename. + cases := []struct { + in, want string + }{ + {"", "box"}, + {"prod-01", "prod-01"}, + {"box/with/slashes", "box_with_slashes"}, + {"../escape", "_escape"}, // '/' → '_', then leading dots trimmed + {".hidden", "hidden"}, + {"weird:chars*", "weird_chars_"}, + } + for _, c := range cases { + if got := sanitizeForFilename(c.in); got != c.want { + t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/gearbox-agent/internal/api/console/token.go b/gearbox-agent/internal/api/console/token.go new file mode 100644 index 0000000..2be24a3 --- /dev/null +++ b/gearbox-agent/internal/api/console/token.go @@ -0,0 +1,152 @@ +// Package console implements the remote-console surface — a token-gated +// WebSocket endpoint that lets the dashboard open an interactive shell on +// 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). +package console + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "sync" + "time" +) + +// tokenLength is the byte length of console session tokens. 32 bytes → +// 64 hex chars, matches the wstoken pattern used for the events channel. +const tokenLength = 32 + +// tokenExpiry is how long an unredeemed token is valid. Short by design — +// the dashboard exchanges the API key for a token and immediately opens +// the WebSocket, so 60s is comfortable; anything longer just widens the +// replay window. +const tokenExpiry = 60 * time.Second + +// token holds a single in-flight console token. +type token struct { + value string + expiresAt time.Time +} + +// TokenManager mints and consumes short-lived single-use tokens for +// console WebSocket upgrades. +// +// Parallel to api.WSTokenManager (events channel). Kept in a separate +// type — and a separate map — so the two namespaces can't leak into each +// other: a token minted for /events cannot be replayed against /console, +// and vice versa. If a third such channel ever appears, extract the +// common code to a shared wstoken package; two callers don't justify the +// refactor yet. +type TokenManager struct { + mu sync.RWMutex + tokens map[string]token + stopCleanup chan struct{} +} + +// NewTokenManager constructs a TokenManager and starts its background +// cleanup goroutine. Call Close to stop it. +func NewTokenManager() *TokenManager { + mgr := &TokenManager{ + tokens: make(map[string]token), + stopCleanup: make(chan struct{}), + } + go mgr.cleanupLoop() + return mgr +} + +// Close stops the cleanup goroutine. Safe to call once. +func (m *TokenManager) Close() { + close(m.stopCleanup) +} + +// Create mints a fresh token with full TTL and returns its hex value. +func (m *TokenManager) Create() (string, error) { + b := make([]byte, tokenLength) + if _, err := rand.Read(b); err != nil { + return "", err + } + v := hex.EncodeToString(b) + + m.mu.Lock() + defer m.mu.Unlock() + m.tokens[v] = token{value: v, expiresAt: time.Now().Add(tokenExpiry)} + return v, nil +} + +// Validate consumes the token if it exists and is unexpired. Returns +// true exactly once per minted token. Expired tokens are still removed +// (so a slow attacker can't keep them alive past their TTL by failing to +// redeem them in time) but never validate. +func (m *TokenManager) Validate(v string) bool { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.tokens[v] + if !ok { + return false + } + // Delete first to make replay impossible even if a goroutine races + // the post-delete check below. + delete(m.tokens, v) + return !time.Now().After(t.expiresAt) +} + +// cleanupLoop sweeps expired tokens every 30s. Cheap — only runs when +// the manager is alive, exits on Close. +func (m *TokenManager) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + m.mu.Lock() + now := time.Now() + for v, t := range m.tokens { + if now.After(t.expiresAt) { + delete(m.tokens, v) + } + } + m.mu.Unlock() + case <-m.stopCleanup: + return + } + } +} + +// TokenResponse is the body returned by POST /api/v1/console/token. +type TokenResponse struct { + Token string `json:"token" example:"a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"` + ExpiresIn int `json:"expires_in" example:"60"` +} + +// HandleTokenExchange handles POST /api/v1/console/token. Caller must be +// behind APIKeyAuth — this handler trusts that auth has already happened. +// +// @Summary Exchange API key for console WebSocket token +// @Description Exchanges a valid API key (Bearer auth) for a 60-second single-use token. Use the returned token in the `?token=` query parameter when opening the WebSocket at /api/v1/console/ws. +// @Tags Console +// @Produce json +// @Security BearerAuth +// @Success 200 {object} TokenResponse "Console WebSocket token" +// @Failure 401 {string} string "Unauthorized" +// @Failure 405 {string} string "Method not allowed" +// @Failure 500 {string} string "Failed to generate token" +// @Router /api/v1/console/token [post] +func (m *TokenManager) HandleTokenExchange(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + t, err := m.Create() + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TokenResponse{ + Token: t, + ExpiresIn: int(tokenExpiry.Seconds()), + }) +} diff --git a/gearbox-agent/internal/api/console/token_test.go b/gearbox-agent/internal/api/console/token_test.go new file mode 100644 index 0000000..66c5dd3 --- /dev/null +++ b/gearbox-agent/internal/api/console/token_test.go @@ -0,0 +1,159 @@ +package console + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestTokenManager_CreateProducesUniqueHexOfExpectedLength(t *testing.T) { + // Tokens are the only thing standing between an API-key-bearing + // caller and a shell session — uniqueness is non-negotiable. The + // hex-length check pins the wire format (64 chars) so a future + // "let's shorten this" change has to update the test deliberately. + m := NewTokenManager() + defer m.Close() + + const N = 100 + seen := make(map[string]struct{}, N) + for i := 0; i < N; i++ { + v, err := m.Create() + if err != nil { + t.Fatalf("Create() err = %v", err) + } + if got, want := len(v), tokenLength*2; got != want { + t.Errorf("token hex length = %d, want %d", got, want) + } + if _, err := hex.DecodeString(v); err != nil { + t.Errorf("token not valid hex: %v", err) + } + if _, dup := seen[v]; dup { + t.Errorf("duplicate token after %d iterations: %s", i, v) + } + seen[v] = struct{}{} + } +} + +func TestTokenManager_ValidateSucceedsOnceThenFails(t *testing.T) { + // Single-use is the entire reason these tokens exist — a leaked + // token must be useless after the first redemption. + m := NewTokenManager() + defer m.Close() + + v, err := m.Create() + if err != nil { + t.Fatalf("Create() err = %v", err) + } + if !m.Validate(v) { + t.Fatal("first Validate() = false, want true") + } + if m.Validate(v) { + t.Fatal("second Validate() = true, want false (replay)") + } +} + +func TestTokenManager_ValidateFailsForUnknownToken(t *testing.T) { + m := NewTokenManager() + defer m.Close() + if m.Validate("not-a-real-token") { + t.Fatal("Validate(unknown) = true, want false") + } +} + +func TestTokenManager_ValidateFailsForExpired(t *testing.T) { + // Forge an entry directly so we don't have to wait the real 60s + // TTL in tests. The cleanupLoop's eviction is incidental — what + // matters is that Validate refuses a token whose expiresAt is in + // the past, even though the entry physically exists in the map. + m := NewTokenManager() + defer m.Close() + m.mu.Lock() + v := "deadbeef" + m.tokens[v] = token{value: v, expiresAt: time.Now().Add(-time.Second)} + m.mu.Unlock() + if m.Validate(v) { + t.Fatal("Validate(expired) = true, want false") + } +} + +func TestTokenManager_ConcurrentCreateAndValidate(t *testing.T) { + // Race detector test — `go test -race` will catch the bug if the + // map mutex is dropped. The mutex protects the entire map; a fast + // concurrent caller minting and validating shouldn't see races. + m := NewTokenManager() + defer m.Close() + + var wg sync.WaitGroup + wg.Add(20) + for i := 0; i < 20; i++ { + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + v, err := m.Create() + if err != nil { + t.Errorf("Create() err = %v", err) + return + } + if !m.Validate(v) { + t.Errorf("Validate(just-created) = false") + } + } + }() + } + wg.Wait() +} + +func TestHandleTokenExchange_ReturnsJSONWithExpiry(t *testing.T) { + m := NewTokenManager() + defer m.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/console/token", nil) + rr := httptest.NewRecorder() + + m.HandleTokenExchange(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %q", rr.Code, rr.Body.String()) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var resp TokenResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Token == "" { + t.Error("token field is empty") + } + if resp.ExpiresIn != int(tokenExpiry.Seconds()) { + t.Errorf("expires_in = %d, want %d", resp.ExpiresIn, int(tokenExpiry.Seconds())) + } + // The returned token should be redeemable exactly once. + if !m.Validate(resp.Token) { + t.Error("returned token does not validate") + } + if m.Validate(resp.Token) { + t.Error("returned token validated twice") + } +} + +func TestHandleTokenExchange_RejectsNonPost(t *testing.T) { + m := NewTokenManager() + defer m.Close() + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + req := httptest.NewRequest(method, "/api/v1/console/token", nil) + rr := httptest.NewRecorder() + m.HandleTokenExchange(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: status = %d, want 405", method, rr.Code) + } + if !strings.Contains(rr.Body.String(), "Method not allowed") { + t.Errorf("%s: body = %q, want 'Method not allowed'", method, rr.Body.String()) + } + } +} diff --git a/gearbox-agent/internal/api/server.go b/gearbox-agent/internal/api/server.go index 29c55bb..be6ad8e 100644 --- a/gearbox-agent/internal/api/server.go +++ b/gearbox-agent/internal/api/server.go @@ -16,6 +16,7 @@ import ( httpSwagger "github.com/swaggo/http-swagger" _ "github.com/sarg3nt/gearbox-agent/docs" // Swagger docs + "github.com/sarg3nt/gearbox-agent/internal/api/console" "github.com/sarg3nt/gearbox-agent/internal/framework/events" frameworkmiddleware "github.com/sarg3nt/gearbox-agent/internal/framework/middleware" ) @@ -53,6 +54,14 @@ 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 @@ -128,6 +137,21 @@ 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) + } + // Protected API routes (require API key auth) r.Group(func(r chi.Router) { r.Use(frameworkmiddleware.RateLimitMiddleware(rateLimiter)) @@ -149,6 +173,14 @@ func NewServer(cfg ServerConfig) *Server { r.Get("/api/v1/events/info", wsHandler.HandleWSInfo) } } + + // 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) + } }) return &Server{ diff --git a/gearbox-agent/internal/api/server_test.go b/gearbox-agent/internal/api/server_test.go new file mode 100644 index 0000000..6e820fc --- /dev/null +++ b/gearbox-agent/internal/api/server_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sarg3nt/gearbox-agent/internal/framework/events" +) + +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) { + 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 + }) + + 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. + 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) + if err != nil { + t.Fatalf("capabilities request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + 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). + req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/console/capabilities", nil) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("capabilities (no auth) request: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("capabilities (no auth) status = %d, want 401", resp2.StatusCode) + } + + // Token endpoint must also be auth-gated. + req3, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/console/token", nil) + resp3, err := http.DefaultClient.Do(req3) + if err != nil { + t.Fatalf("token (no auth) request: %v", err) + } + defer resp3.Body.Close() + if resp3.StatusCode != http.StatusUnauthorized { + t.Errorf("token (no auth) status = %d, want 401", resp3.StatusCode) + } +} diff --git a/gearbox-agent/internal/framework/config/config.go b/gearbox-agent/internal/framework/config/config.go index 576f045..a7cd3f0 100644 --- a/gearbox-agent/internal/framework/config/config.go +++ b/gearbox-agent/internal/framework/config/config.go @@ -84,6 +84,15 @@ type Config struct { // 2026-05 security audit P3-2. SwaggerEnabled bool + // ConsoleEnabled, when true, exposes the remote-console endpoints + // (POST /api/v1/console/token, GET /api/v1/console/ws, GET + // /api/v1/console/capabilities). Off by default — the surface only + // exists on agents an operator has explicitly opted in via the + // HAPROXY_AGENT_CONSOLE_ENABLED env var. Belt-and-suspenders with the + // per-box opt-in on the dashboard side; both must be true for a + // session to open. See [#89] for design and [#117] for Phase 1a scope. + ConsoleEnabled bool + // Metric-source overrides. Each one points a single metric category // at a specific gear, bypassing auto-detection. Empty = pure // auto-detection (built-in preference order). Lowercased at load @@ -225,6 +234,10 @@ func Load() (*Config, error) { // Swagger UI off by default; opt in for dev / API debugging. cfg.SwaggerEnabled = os.Getenv("HAPROXY_AGENT_SWAGGER_ENABLED") == "true" + // Remote console off by default. When unset, /api/v1/console/* returns + // 404 — the surface doesn't exist. See [#89] for the security rationale. + cfg.ConsoleEnabled = os.Getenv("HAPROXY_AGENT_CONSOLE_ENABLED") == "true" + // Metric-source overrides. Lowercased + trimmed so 'HAProxy ' and // 'haproxy' both match the gear's Info().Name. Empty = auto-detect. cfg.HTTPSource = normaliseSourceOverride(os.Getenv("GEARBOX_AGENT_HTTP_SOURCE")) diff --git a/gearbox-agent/internal/framework/events/bus.go b/gearbox-agent/internal/framework/events/bus.go index d7184e1..802b9af 100644 --- a/gearbox-agent/internal/framework/events/bus.go +++ b/gearbox-agent/internal/framework/events/bus.go @@ -39,6 +39,23 @@ const ( EventAptCompleted EventType = "apt.completed" // EventAptFailed is emitted when a package manager operation fails. EventAptFailed EventType = "apt.failed" + + // EventConsoleSessionStart is emitted when a remote-console session + // opens (after token validation + WebSocket upgrade). Payload includes + // the session ID, effective UID, remote address, mode, and start time. + // This is the load-bearing audit record — every shell session must + // emit one. See [#89]. + EventConsoleSessionStart EventType = "console.session.start" + // EventConsoleSessionEnd is emitted when a remote-console session + // closes for any reason (client disconnect, exit, error, idle + // timeout). Payload includes the matching session ID, byte counts in + // both directions, exit reason, and duration. + EventConsoleSessionEnd EventType = "console.session.end" + // EventConsoleConfigChange is emitted when console settings are + // modified at the agent (currently env-driven; reserved for future + // API-driven config). Operators rely on this to detect surprise + // console-enabling. + EventConsoleConfigChange EventType = "console.config.change" ) // Event represents an event in the system. diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index a5297f0..190b90c 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -775,6 +775,13 @@ func main() { r.Get("/{boxID}/firewall/config/backups", h.APIFirewallConfigBackups) r.Post("/{boxID}/firewall/config/restore", h.APIFirewallConfigRestore) + // Remote console API (per #89). Capabilities is a JSON + // proxy; the WS endpoint pipes the JSON-framed shell + // session between the browser and the agent. Both gate + // on the box_console component permissions. + r.Get("/console/{boxID}/capabilities", h.APIConsoleCapabilities) + r.Get("/console/{boxID}/ws", h.APIConsoleWS) + // User permissions API r.Route("/users", func(r chi.Router) { r.Get("/{userID}/permissions", h.APIGetUserPermissions) diff --git a/gearbox/internal/framework/agent/client.go b/gearbox/internal/framework/agent/client.go index 54f912c..87072fa 100644 --- a/gearbox/internal/framework/agent/client.go +++ b/gearbox/internal/framework/agent/client.go @@ -98,6 +98,20 @@ func NewClientWithTimeout(baseURL, apiKey string, timeout time.Duration) *Client } } +// BuildTLSConfig is the exported alias for createTLSConfig — used by +// code paths outside the HTTP client (e.g. the dashboard's console +// WebSocket proxy in handler/api_console.go) that need to dial agents +// with the same trust policy as a regular API call. +// +// Keeping a single implementation behind one exported entry point +// means a future operator who sets AGENT_CA_CERT_PATH gets both REST +// and WebSocket dials pinned in one place, instead of "REST is +// pinned but the WS proxy quietly accepts anything." See #89 +// follow-up. +func BuildTLSConfig() *tls.Config { + return createTLSConfig() +} + // createTLSConfig creates a TLS configuration with certificate verification. // Supports three modes via environment variables: // 1. AGENT_CA_CERT_PATH: Path to CA certificate for validation (RECOMMENDED) diff --git a/gearbox/internal/framework/agent/console_client.go b/gearbox/internal/framework/agent/console_client.go new file mode 100644 index 0000000..adf73df --- /dev/null +++ b/gearbox/internal/framework/agent/console_client.go @@ -0,0 +1,74 @@ +package agent + +import ( + "encoding/json" + "fmt" +) + +// ConsoleCapabilitiesResponse mirrors the agent's CapabilitiesResponse — +// kept in sync by hand because the two repos are sibling Go modules +// without a shared types package. If they drift, the dashboard logs +// a JSON-unmarshal warning and shows the console as unavailable for +// that box, which is the safer failure mode than guessing. +type ConsoleCapabilitiesResponse struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + HostConsole bool `json:"host_console"` + DefaultUID int `json:"default_uid"` + OS string `json:"os"` + Shell []string `json:"shell,omitempty"` +} + +// ConsoleTokenResponse mirrors the agent's TokenResponse. +type ConsoleTokenResponse struct { + Token string `json:"token"` + ExpiresIn int `json:"expires_in"` +} + +// GetConsoleCapabilities asks the agent what its console surface can +// do. Returns an APIError with status 404 when the operator hasn't +// enabled console on this agent — the caller should treat that as +// "console not available for this box" rather than a hard error. +func (c *Client) GetConsoleCapabilities() (*ConsoleCapabilitiesResponse, error) { + body, err := c.doRequest("GET", "/api/v1/console/capabilities", nil) + if err != nil { + return nil, err + } + var resp ConsoleCapabilitiesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse console capabilities: %w", err) + } + return &resp, nil +} + +// GetConsoleToken exchanges the API key for a 60-second single-use +// token suitable for opening /api/v1/console/ws. The token namespace +// is separate from the events token — they cannot be cross-replayed. +func (c *Client) GetConsoleToken() (*ConsoleTokenResponse, error) { + body, err := c.doRequest("POST", "/api/v1/console/token", nil) + if err != nil { + return nil, err + } + var resp ConsoleTokenResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse console token: %w", err) + } + return &resp, nil +} + +// BaseURL exposes the agent's base URL so the dashboard's WebSocket +// proxy can build the agent-side `wss://.../api/v1/console/ws` URL. +// Kept on the Client (not duplicated in the proxy) so URL parsing +// stays in one place; any change to how URLs are normalized lives +// here too. +func (c *Client) BaseURL() string { + return c.baseURL +} + +// APIKey exposes the API key for code paths that need to authenticate +// directly against the agent (the WebSocket proxy uses it to fetch a +// fresh console token before establishing the upstream WS). Treat as +// a credential — never log, never include in error messages. +func (c *Client) APIKey() string { + return c.apiKey +} diff --git a/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.down.sql b/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.down.sql new file mode 100644 index 0000000..c400301 --- /dev/null +++ b/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.down.sql @@ -0,0 +1,7 @@ +-- SQLite < 3.35 can't ALTER TABLE ... DROP COLUMN. Most modern +-- distros have 3.35+ but to stay portable across older deployments +-- this down migration is a no-op; rollback leaves the column in +-- place with all rows defaulting to 0 (= console disabled), which is +-- functionally equivalent to "not migrated yet" for any code path +-- that uses the value. +SELECT 1; diff --git a/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.up.sql b/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.up.sql new file mode 100644 index 0000000..84283c1 --- /dev/null +++ b/gearbox/internal/framework/database/migrations/files/000002_add_box_console_enabled.up.sql @@ -0,0 +1,6 @@ +-- #89 Phase 2c: per-box opt-in for the remote console feature. +-- Defaults to 0 so the column rollout doesn't accidentally enable +-- console on every box; operators flip it on per box via the box +-- settings UI. The agent-side HAPROXY_AGENT_CONSOLE_ENABLED flag is +-- still required — both must be true for a session to open. +ALTER TABLE boxes ADD COLUMN console_enabled INTEGER NOT NULL DEFAULT 0; diff --git a/gearbox/internal/framework/database/servers.go b/gearbox/internal/framework/database/servers.go index 0232298..6262f12 100644 --- a/gearbox/internal/framework/database/servers.go +++ b/gearbox/internal/framework/database/servers.go @@ -19,9 +19,15 @@ type BoxDB struct { APIKeyEncrypted []byte Enabled bool AutoDiscovery bool - CreatedAt time.Time - UpdatedAt time.Time - CreatedBy *string // UUID + // ConsoleEnabled is the per-box opt-in for the remote console + // feature (see #89). Default 0 — operator flips it on per box from + // the box settings UI. Belt-and-suspenders with the agent-side + // HAPROXY_AGENT_CONSOLE_ENABLED env var: both must be true for the + // proxy to open a session, so revoking either kills access. + ConsoleEnabled bool + CreatedAt time.Time + UpdatedAt time.Time + CreatedBy *string // UUID } // UsesAgentAPI returns true if this box has valid Agent API configuration. @@ -38,8 +44,8 @@ func (d *DB) CreateBox(box *BoxDB) error { query := ` INSERT INTO boxes ( box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_by, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + enabled, auto_discovery, console_enabled, created_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` result, err := d.db.Exec(query, @@ -51,6 +57,7 @@ func (d *DB) CreateBox(box *BoxDB) error { box.APIKeyEncrypted, box.Enabled, box.AutoDiscovery, + box.ConsoleEnabled, box.CreatedBy, ) if err != nil { @@ -73,7 +80,7 @@ func (d *DB) GetBoxes() ([]*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, console_enabled, created_at, updated_at, created_by FROM boxes ORDER BY name ASC ` @@ -97,6 +104,7 @@ func (d *DB) GetBoxes() ([]*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.ConsoleEnabled, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -122,7 +130,7 @@ func (d *DB) GetEnabledBoxes() ([]*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, console_enabled, created_at, updated_at, created_by FROM boxes WHERE enabled = 1 ORDER BY name ASC @@ -147,6 +155,7 @@ func (d *DB) GetEnabledBoxes() ([]*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.ConsoleEnabled, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -172,7 +181,7 @@ func (d *DB) GetBoxByID(id int64) (*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, console_enabled, created_at, updated_at, created_by FROM boxes WHERE id = ? ` @@ -188,6 +197,7 @@ func (d *DB) GetBoxByID(id int64) (*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.ConsoleEnabled, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -209,7 +219,7 @@ func (d *DB) GetBoxByBoxID(boxID string) (*BoxDB, error) { query := ` SELECT id, box_id, name, location, notes, agent_url, api_key_encrypted, - enabled, auto_discovery, created_at, updated_at, created_by + enabled, auto_discovery, console_enabled, created_at, updated_at, created_by FROM boxes WHERE box_id = ? ` @@ -225,6 +235,7 @@ func (d *DB) GetBoxByBoxID(boxID string) (*BoxDB, error) { &box.APIKeyEncrypted, &box.Enabled, &box.AutoDiscovery, + &box.ConsoleEnabled, &box.CreatedAt, &box.UpdatedAt, &box.CreatedBy, @@ -254,6 +265,7 @@ func (d *DB) UpdateBox(box *BoxDB) error { api_key_encrypted = ?, enabled = ?, auto_discovery = ?, + console_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ` @@ -267,6 +279,7 @@ func (d *DB) UpdateBox(box *BoxDB) error { box.APIKeyEncrypted, box.Enabled, box.AutoDiscovery, + box.ConsoleEnabled, box.ID, ) if err != nil { diff --git a/gearbox/internal/framework/database/servers_console_test.go b/gearbox/internal/framework/database/servers_console_test.go new file mode 100644 index 0000000..a15d58d --- /dev/null +++ b/gearbox/internal/framework/database/servers_console_test.go @@ -0,0 +1,122 @@ +package database + +import ( + "testing" +) + +// TestBoxConsoleEnabled_DefaultsFalse confirms the migration's +// DEFAULT 0 actually reaches Create/Read code paths. Important because +// the column was added late in the box-table evolution; a regression +// that defaults it to true would silently enable console on every box +// after a restart, bypassing the per-box opt-in that's the whole +// point of #89 Phase 2c. +func TestBoxConsoleEnabled_DefaultsFalse(t *testing.T) { + db := setupTestDB(t) + box := &BoxDB{ + BoxID: "box-default", + Name: "Default Console Box", + AgentURL: "https://example.invalid:8405", + APIKeyEncrypted: []byte("k"), + Enabled: true, + // ConsoleEnabled intentionally omitted — zero value. + } + if err := db.CreateBox(box); err != nil { + t.Fatalf("CreateBox: %v", err) + } + got, err := db.GetBoxByBoxID("box-default") + if err != nil { + t.Fatalf("GetBoxByBoxID: %v", err) + } + if got == nil { + t.Fatal("GetBoxByBoxID returned nil") + } + if got.ConsoleEnabled { + t.Errorf("ConsoleEnabled = true, want false (per-box opt-in must default off)") + } +} + +// TestBoxConsoleEnabled_PersistsExplicitTrue covers the create + read +// path with an operator who deliberately turned it on. +func TestBoxConsoleEnabled_PersistsExplicitTrue(t *testing.T) { + db := setupTestDB(t) + box := &BoxDB{ + BoxID: "box-explicit", + Name: "Explicit Console Box", + AgentURL: "https://example.invalid:8405", + APIKeyEncrypted: []byte("k"), + Enabled: true, + ConsoleEnabled: true, + } + if err := db.CreateBox(box); err != nil { + t.Fatalf("CreateBox: %v", err) + } + got, _ := db.GetBoxByBoxID("box-explicit") + if got == nil || !got.ConsoleEnabled { + t.Fatalf("ConsoleEnabled lost across CreateBox/GetBoxByBoxID: got %+v", got) + } +} + +// TestBoxConsoleEnabled_UpdateToggle proves an operator can flip the +// flag on, then back off, via UpdateBox. This is the load-bearing +// path for revoking access — if the off-toggle doesn't round-trip, +// a user revokes via the UI and is surprised when sessions still open. +func TestBoxConsoleEnabled_UpdateToggle(t *testing.T) { + db := setupTestDB(t) + box := &BoxDB{ + BoxID: "box-toggle", + Name: "Toggleable Box", + AgentURL: "https://example.invalid:8405", + APIKeyEncrypted: []byte("k"), + Enabled: true, + } + if err := db.CreateBox(box); err != nil { + t.Fatalf("CreateBox: %v", err) + } + box.ConsoleEnabled = true + if err := db.UpdateBox(box); err != nil { + t.Fatalf("UpdateBox on: %v", err) + } + got, _ := db.GetBoxByBoxID("box-toggle") + if !got.ConsoleEnabled { + t.Fatal("after UpdateBox(on), ConsoleEnabled = false") + } + got.ConsoleEnabled = false + if err := db.UpdateBox(got); err != nil { + t.Fatalf("UpdateBox off: %v", err) + } + got2, _ := db.GetBoxByBoxID("box-toggle") + if got2.ConsoleEnabled { + t.Fatal("after UpdateBox(off), ConsoleEnabled = true (revoke didn't persist)") + } +} + +// TestBoxConsoleEnabled_GetEnabledBoxesIncludesFlag verifies the +// list query (used by the Bx fleet page) carries the flag through. +// A regression here would mean the per-box toggle works on the edit +// page but the Bx grid still tries to show the console icon on +// boxes that have it off. +func TestBoxConsoleEnabled_GetEnabledBoxesIncludesFlag(t *testing.T) { + db := setupTestDB(t) + for _, b := range []*BoxDB{ + {BoxID: "a", Name: "A", AgentURL: "https://x:8405", APIKeyEncrypted: []byte("k"), Enabled: true, ConsoleEnabled: true}, + {BoxID: "b", Name: "B", AgentURL: "https://x:8405", APIKeyEncrypted: []byte("k"), Enabled: true, ConsoleEnabled: false}, + } { + if err := db.CreateBox(b); err != nil { + t.Fatalf("CreateBox %s: %v", b.BoxID, err) + } + } + got, err := db.GetEnabledBoxes() + if err != nil { + t.Fatalf("GetEnabledBoxes: %v", err) + } + byID := map[string]*BoxDB{} + for _, b := range got { + byID[b.BoxID] = b + } + if byID["a"] == nil || !byID["a"].ConsoleEnabled { + t.Errorf("box a: ConsoleEnabled = false in GetEnabledBoxes, want true") + } + if byID["b"] == nil || byID["b"].ConsoleEnabled { + t.Errorf("box b: ConsoleEnabled = true in GetEnabledBoxes, want false") + } +} diff --git a/gearbox/internal/framework/handler/api_console.go b/gearbox/internal/framework/handler/api_console.go new file mode 100644 index 0000000..da5d1d9 --- /dev/null +++ b/gearbox/internal/framework/handler/api_console.go @@ -0,0 +1,281 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + + "github.com/sarg3nt/gearbox/internal/framework/agent" + "github.com/sarg3nt/gearbox/internal/framework/models" +) + +// consoleUpgrader handles browser → dashboard WebSocket upgrades for +// console sessions. CheckOrigin is permissive because the request is +// already gated by a session cookie + permission check upstream; if +// you got here, you're already authenticated as the user who's +// connecting. (Cross-Site WebSocket Hijacking only matters for +// unauthenticated endpoints or those relying solely on the cookie for +// access — we don't.) +var consoleUpgrader = websocket.Upgrader{ + ReadBufferSize: 4 * 1024, + WriteBufferSize: 32 * 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// APIConsoleCapabilities proxies the agent's /api/v1/console/capabilities +// response to the dashboard caller. Returns 404 with a small JSON +// envelope when the agent has console disabled, so the dashboard's UI +// can branch on that rather than a generic error. +// +// Permission: box_console:view. +func (h *Handler) APIConsoleCapabilities(w http.ResponseWriter, r *http.Request) { + if !h.authManager.HasPermission(r, models.ComponentBoxConsole, models.PermissionView) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + boxID := chi.URLParam(r, "boxID") + if boxID == "" { + http.Error(w, "Box ID is required", http.StatusBadRequest) + return + } + server, err := h.db.GetBoxByBoxID(boxID) + if err != nil || server == nil { + http.Error(w, "Box not found", http.StatusNotFound) + return + } + // Per-box opt-in (#89 Phase 2c). Both this and the agent's + // HAPROXY_AGENT_CONSOLE_ENABLED must be true for a session to + // open. Returning the same envelope shape the agent uses for + // "disabled" so the dashboard JS branches identically. + if !server.ConsoleEnabled { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "enabled": false, + "reason": "console not enabled for this box (toggle on the box settings page)", + }) + return + } + client, err := h.getAgentClient(server) + if err != nil { + http.Error(w, "Failed to connect to agent", http.StatusBadGateway) + return + } + 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 + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(caps) +} + +// APIConsoleWS proxies a browser WebSocket to the agent's +// /api/v1/console/ws. The dashboard handles the agent-side token +// exchange so the browser never sees the agent's API key — it just +// rides the user's existing session cookie. +// +// Flow: +// 1. Validate cookie + box_console:connect permission (middleware + +// this handler). +// 2. Resolve box → agent client. +// 3. POST /api/v1/console/token against the agent for a fresh +// single-use token. +// 4. Dial wss://agent/api/v1/console/ws?token=… as the upstream +// side of the proxy. +// 5. Upgrade the client-side WS. +// 6. Pump messages in both directions until either side closes. +// +// Audit recording lives on the agent (it sees the actual session +// open/close); the dashboard logs a short "session proxied" line so +// support can correlate by box+user when needed. +func (h *Handler) APIConsoleWS(w http.ResponseWriter, r *http.Request) { + if !h.authManager.HasPermission(r, models.ComponentBoxConsole, models.PermissionConnect) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + boxID := chi.URLParam(r, "boxID") + if boxID == "" { + http.Error(w, "Box ID is required", http.StatusBadRequest) + return + } + server, err := h.db.GetBoxByBoxID(boxID) + if err != nil || server == nil { + http.Error(w, "Box not found", http.StatusNotFound) + return + } + // Per-box opt-in (#89 Phase 2c). Refuse before even reaching the + // agent so a misconfigured box doesn't leak the agent's token + // endpoint to the audit log on every refused click. + if !server.ConsoleEnabled { + http.Error(w, "Console disabled for this box", http.StatusForbidden) + return + } + client, err := h.getAgentClient(server) + if err != nil { + h.logger.Error("console proxy: agent client", "box", boxID, "error", err) + http.Error(w, "Failed to connect to agent", http.StatusBadGateway) + return + } + + tokenResp, err := client.GetConsoleToken() + if err != nil { + h.logger.Error("console proxy: token exchange", "box", boxID, "error", err) + http.Error(w, "Failed to authorize console session", http.StatusBadGateway) + return + } + + // Build wss:// upstream URL. The agent client's BaseURL is + // https://... — flipping the scheme to wss:// is the standard + // way to address a WebSocket on the same TLS listener. + wsBase, err := url.Parse(client.BaseURL()) + if err != nil { + http.Error(w, "Invalid agent URL", http.StatusInternalServerError) + return + } + scheme := "wss" + if wsBase.Scheme == "http" { + scheme = "ws" + } + upstreamURL := scheme + "://" + wsBase.Host + "/api/v1/console/ws?token=" + url.QueryEscape(tokenResp.Token) + + // Dial the agent. Share the same TLS trust policy as the HTTP + // client (AGENT_CA_CERT_PATH for pinning, GEARBOX_INSECURE_TLS + // for explicit opt-out, system pool otherwise) so a deployment + // that hardens REST against MITM gets the WebSocket dial + // hardened too — no quiet "REST is pinned, WS isn't" gap. + // See #89 follow-up. + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + TLSClientConfig: agent.BuildTLSConfig(), + } + upstream, upstreamResp, err := dialer.Dial(upstreamURL, nil) + if err != nil { + code := http.StatusBadGateway + if upstreamResp != nil { + code = upstreamResp.StatusCode + } + h.logger.Error("console proxy: upstream dial", "box", boxID, "status", code, "error", err) + http.Error(w, "Failed to open console session: "+err.Error(), code) + return + } + defer func() { _ = upstream.Close() }() + + // Now upgrade the browser side. If this fails after the + // upstream dial succeeded, close the upstream cleanly to free + // the agent's session slot. + downstream, err := consoleUpgrader.Upgrade(w, r, nil) + if err != nil { + _ = upstream.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseGoingAway, "dashboard upgrade failed")) + h.logger.Warn("console proxy: downstream upgrade failed", "box", boxID, "error", err) + return + } + defer func() { _ = downstream.Close() }() + + h.logger.Info("console proxy: session opened", "box", boxID) + startedAt := time.Now() + + // Bidirectional message pump. We use the channel-of-error + // pattern: whichever side errors first closes both. No need to + // inspect message contents — the wire format is the agent's + // JSON frame shape, opaque to the proxy. + pumpErr := make(chan error, 2) + var once sync.Once + closeBoth := func() { + once.Do(func() { + _ = upstream.Close() + _ = downstream.Close() + }) + } + + // Browser → agent + go func() { + for { + mt, data, err := downstream.ReadMessage() + if err != nil { + pumpErr <- err + closeBoth() + return + } + if err := upstream.WriteMessage(mt, data); err != nil { + pumpErr <- err + closeBoth() + return + } + } + }() + + // Agent → browser + go func() { + for { + mt, data, err := upstream.ReadMessage() + if err != nil { + pumpErr <- err + closeBoth() + return + } + if err := downstream.WriteMessage(mt, data); err != nil { + pumpErr <- err + closeBoth() + return + } + } + }() + + first := <-pumpErr + closeBoth() + // Drain the second error so the goroutine can exit cleanly. + select { + case <-pumpErr: + case <-time.After(time.Second): + } + + reason := "client_close" + if !isExpectedCloseErr(first) { + reason = "upstream_error" + } + h.logger.Info("console proxy: session closed", + "box", boxID, + "reason", reason, + "duration_ms", time.Since(startedAt).Milliseconds(), + ) +} + +// isExpectedCloseErr distinguishes "client/agent hung up cleanly" +// from "something went wrong." Used only for the proxy's +// info-level log line so support can spot anomalies; the audit +// record on the agent side has the authoritative reason. +func isExpectedCloseErr(err error) bool { + if err == nil { + return true + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + return true + } + var ce *websocket.CloseError + if errors.As(err, &ce) { + return true + } + if strings.Contains(err.Error(), "use of closed network connection") { + return true + } + return false +} diff --git a/gearbox/internal/framework/handler/haproxy_config.go b/gearbox/internal/framework/handler/haproxy_config.go index fe1bb3c..8c12333 100644 --- a/gearbox/internal/framework/handler/haproxy_config.go +++ b/gearbox/internal/framework/handler/haproxy_config.go @@ -95,13 +95,14 @@ func (h *Handler) HAProxyBoxCreatePost(w http.ResponseWriter, r *http.Request) { // Parse form into server struct server := &database.BoxDB{ - BoxID: strings.TrimSpace(r.FormValue("box_id")), - Name: strings.TrimSpace(r.FormValue("name")), - Location: strings.TrimSpace(r.FormValue("location")), - Notes: strings.TrimSpace(r.FormValue("notes")), - AgentURL: strings.TrimSpace(r.FormValue("agent_url")), - Enabled: r.FormValue("enabled") == "on", - CreatedBy: &user.ID, + BoxID: strings.TrimSpace(r.FormValue("box_id")), + Name: strings.TrimSpace(r.FormValue("name")), + Location: strings.TrimSpace(r.FormValue("location")), + Notes: strings.TrimSpace(r.FormValue("notes")), + AgentURL: strings.TrimSpace(r.FormValue("agent_url")), + Enabled: r.FormValue("enabled") == "on", + ConsoleEnabled: r.FormValue("console_enabled") == "on", + CreatedBy: &user.ID, } // Validate required fields @@ -230,6 +231,7 @@ func (h *Handler) HAProxyBoxUpdatePost(w http.ResponseWriter, r *http.Request) { server.Notes = strings.TrimSpace(r.FormValue("notes")) server.AgentURL = strings.TrimSpace(r.FormValue("agent_url")) server.Enabled = r.FormValue("enabled") == "on" + server.ConsoleEnabled = r.FormValue("console_enabled") == "on" // Update API key if provided apiKey := strings.TrimSpace(r.FormValue("api_key")) diff --git a/gearbox/internal/framework/models/permissions.go b/gearbox/internal/framework/models/permissions.go index 5399f99..3e6cae1 100644 --- a/gearbox/internal/framework/models/permissions.go +++ b/gearbox/internal/framework/models/permissions.go @@ -26,6 +26,7 @@ const ( ComponentSecurity Component = "security" // Security dashboard, IP blocking, fail2ban ComponentAlerts Component = "alerts" // Alert management and configuration ComponentOSUpdates Component = "os_updates" // OS updates and package management + ComponentBoxConsole Component = "box_console" // Remote shell sessions per box; see #89 ) const ( @@ -37,6 +38,11 @@ const ( PermissionDownload Permission = "download" // Download files (certificates, etc.) (implies view) PermissionApproveUsers Permission = "approve_users" // Approve new user accounts PermissionManageBoxes Permission = "manage_boxes" // Add/edit/delete monitored boxes + // 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" ) // PermissionGrant represents a permission granted to a user for a specific component. @@ -213,6 +219,7 @@ func AllComponents() []Component { ComponentSecurity, ComponentAlerts, ComponentOSUpdates, + ComponentBoxConsole, } } @@ -233,6 +240,7 @@ func GetComponentDisplayName(c Component) string { ComponentSecurity: "Security", ComponentAlerts: "Alerts", ComponentOSUpdates: "OS Updates", + ComponentBoxConsole: "Box Console", } if name, exists := names[c]; exists { @@ -251,6 +259,7 @@ func GetPermissionDisplayName(p Permission) string { PermissionDownload: "Download", PermissionApproveUsers: "Approve Users", PermissionManageBoxes: "Manage Boxes", + PermissionConnect: "Connect (Open Session)", } if name, exists := names[p]; exists { @@ -269,6 +278,7 @@ func GetPermissionDescription(p Permission) string { PermissionDownload: "Download certificate files", PermissionApproveUsers: "Approve or deny new user account requests", PermissionManageBoxes: "Add, edit, or remove monitored box connections", + PermissionConnect: "Open an interactive shell session on a box via the dashboard", } if desc, exists := descriptions[p]; exists { @@ -359,6 +369,12 @@ func GetAvailablePermissionsForComponent(c Component) []Permission { PermissionConfigure, // Configure automatic updates settings PermissionAction, // Install updates, manage packages, reboot } + case ComponentBoxConsole: + return []Permission{ + PermissionView, // See that console is available for a box + PermissionConfigure, // Toggle per-box console enable + edit run-as / shell + PermissionConnect, // Open an actual shell session (the load-bearing one) + } default: return defaultPerms } diff --git a/gearbox/internal/framework/templates/components/console.templ b/gearbox/internal/framework/templates/components/console.templ new file mode 100644 index 0000000..7ce76ad --- /dev/null +++ b/gearbox/internal/framework/templates/components/console.templ @@ -0,0 +1,61 @@ +package components + +// ConsoleDrawer renders the markup for the remote-console drawer +// (one per layout, opened on demand by window.openConsole). The +// xterm.js terminal mounts into #console-xterm; the title bar shows +// which box the session targets so the user can't mix them up if +// they have multiple browser tabs open against different boxes. +// +// Hidden by default. Opened by static/js/console/console.js. The +// fullscreen overlay sits at z-[200] so it lands above existing +// modals (z-[100]); a console session is the most foreground thing +// the UI can show. +templ ConsoleDrawer() { + +} + +// ConsoleAssets pulls in the xterm.js bundle, fit addon, and CSS. +// Place once near the bottom of the layout (after the existing +// vendor scripts) so window.Terminal exists before console.js runs. +// +// The actual wiring code is in static/js/console/console.js — kept +// in JS so the bundler-free workflow stays simple and so tests can +// stub window.openConsole without recompiling Go. +templ ConsoleAssets() { + + + + +} diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index f89b4e0..809045e 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -4,6 +4,7 @@ import "context" import "encoding/json" import "github.com/sarg3nt/gearbox/internal/framework/auth" import "github.com/sarg3nt/gearbox/internal/framework/models" +import "github.com/sarg3nt/gearbox/internal/framework/templates/components" import "github.com/sarg3nt/gearbox/internal/framework/ui" import "github.com/sarg3nt/gearbox/internal/framework/middleware" import "strings" @@ -1449,6 +1450,8 @@ templ Base(title string, user *models.User, currentPath ...string) { @PromptDialog() @AlertDialog() @ui.Toast() + @components.ConsoleDrawer() + @components.ConsoleAssets() } diff --git a/gearbox/internal/framework/templates/pages/haproxy_settings.templ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ index 141a29d..265ef51 100644 --- a/gearbox/internal/framework/templates/pages/haproxy_settings.templ +++ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ @@ -471,6 +471,24 @@ templ haProxyBoxForm(user *models.User, server *database.BoxDB, isEdit bool, err

Box will be monitored when enabled

+
+ +

+ Allow opening a shell on this box from the dashboard. + Off by default. Requires HAPROXY_AGENT_CONSOLE_ENABLED=true on the agent and + box_console:connect on the user. See #89. +

+