From 6d047e25ab7abfd9bf63d3e6d5554725d9342808 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 29 Jul 2026 12:16:45 +0100 Subject: [PATCH] docs: Design for agent-relayed console proxy --- .../2026-07-29-agent-console-proxy-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md diff --git a/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md b/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md new file mode 100644 index 0000000..1321f13 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md @@ -0,0 +1,201 @@ +# Agent-relayed console proxy + +Date: 2026-07-29 +Status: approved, not yet implemented + +## Problem + +`consoleTunnel` builds guacamole parameters from `srv.IPAddress` and hands them +to guacd, which then dials the target itself. On a self-hosted deployment the +control plane and the managed servers share a network, so that works. On Vantage +Cloud they do not: guacd runs on the cloud host and the customer's server is on +an RFC1918 address behind their NAT. Every cloud console session to a private +address fails, for SSH, RDP and VNC alike. + +Agents already hold an outbound gRPC connection to the control plane. The fix is +to carry the console's TCP bytes over that existing path rather than asking guacd +to route somewhere it cannot reach. + +## Decisions + +**Self-relay only.** The agent relays to its own host and nowhere else. It is +never told a hostname; the host is hardcoded to `127.0.0.1` on the agent side and +only the port comes from the server. A jump-host mode (reaching agentless devices +through a neighbouring agent) was rejected: it would give an agent the power to +dial arbitrary addresses on the customer's LAN, and the console today can only +target servers that run an agent anyway. + +**A dedicated bidirectional RPC, one stream per TCP connection.** Multiplexing +console bytes onto the existing `CommandStream` was rejected — that stream +already carries control commands and workflow stdout, and an RDP framebuffer +would introduce head-of-line blocking against key sync and step output. A +separate stream also gets connection lifetime, flow control and close semantics +for free instead of needing a hand-rolled connection-ID demux. + +**Always proxy, both deployments.** Direct dial is deleted rather than kept as a +self-hosted fast path or a fallback. One code path means one tested code path, +and the cloud path is the one no developer can reproduce locally. A +try-direct-then-fall-back design was rejected outright: it puts a timeout in +front of every private-network session and makes "which path did this session +use" unanswerable from the audit log. + +The cost is that the console now requires a live agent, where a self-hosted +deployment could previously reach a server whose agent was down. In practice an +offline agent almost always means an offline host, and the failure is now an +immediate, explicit refusal instead of a hang. + +## Architecture + +Three parties rendezvous on a single `proxy_id`. Neither guacd nor the agent +changes which direction it dials: guacd still makes an outbound TCP connection, +the agent still only connects outbound to the control plane. + +``` +consoleTunnel (server) + 1. proxy.Open(instance, server_id, port) -> proxy_id + ephemeral listener :N + 2. push OpenProxyCmd{proxy_id, port} down the existing CommandStream + 3. agent dials 127.0.0.1:port locally, then opens ProxyStream and sends + ProxyOpen{server_id, agent_token, proxy_id} + 4. guacd dials PROXY_ADVERTISE_HOST:N (the params it was handed in step 1) + 5. registry holds both halves -> io.Copy in both directions + 6. either side EOFs -> close listener, close stream, drop the registry entry +``` + +Steps 3 and 4 race, so a registry entry has two slots and starts piping when the +second one arrives. Both waits share a single 10 second deadline; expiry closes +everything and frees the entry. + +The agent dials locally *before* opening the stream, so a refused connection +arrives as an explicit `ProxyClose{reason}` rather than as a hang. + +`BuildGuacParams` stops reading `srv.IPAddress` and takes the relay host and port +instead. `IPAddress` remains in use for display and for monitors. + +## Wire protocol + +Additive only; no existing message changes shape. + +```protobuf +rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg); + +message OpenProxyCmd { // ServerCommand oneof field 8 + string proxy_id = 1; + uint32 port = 2; +} + +message ProxyClientMsg { + oneof payload { + ProxyOpen open = 1; // first message only + bytes data = 2; + ProxyClose close = 3; + } +} +message ProxyOpen { string server_id = 1; string agent_token = 2; string proxy_id = 3; } +message ProxyServerMsg { oneof payload { bytes data = 1; ProxyClose close = 2; } } +message ProxyClose { string reason = 1; } +``` + +## Security + +**The agent only ever dials `127.0.0.1`.** The port is the only field it takes +from the server; the host is hardcoded agent-side. A compromised control plane +cannot use an agent to reach anything else on the customer's network. This is the +strongest property in the design and the reason self-relay was chosen. + +**`proxy_id` is 32 random bytes, single-use and scoped.** On `ProxyOpen` the +server checks three things together: the agent token hash matches that +`server_id`, the `proxy_id` exists in the registry, and the entry's `server_id` +and `instance_id` match the authenticated agent. Any mismatch closes the stream +without revealing which check failed. + +**The listener is the exposed surface and is narrowed four ways.** It binds an +ephemeral port; it lives at most 10 seconds unclaimed; it accepts exactly one +connection and closes immediately afterwards; and the accepted connection's +remote address must resolve to a host named in `GUACD_ADDR`. Without that last +check, any other container on the Docker network could claim the session during +the window. + +**Agent-offline is refused early.** `consoleConnect` checks +`srv.Status == "active"` and returns 409 `agent_offline`, rather than letting the +browser open a WebSocket that dies on a deadline. + +**Audit.** `console.opened` gains the relay port and `proxy_id`. A relay that +expires or is refused writes `console.proxy_failed` with a reason, so a failed +console session stops being invisible. + +Credentials are unchanged. Private keys and RDP passwords travel from the server +to guacd inside the guacamole handshake and never reach the agent. The SSH and +RDP sessions are negotiated end-to-end between guacd and the target daemon, so +the agent relays bytes it cannot read. + +## Components + +New, server: + +| Unit | Responsibility | +| --- | --- | +| `server/internal/proxy/registry.go` | `Open`, `AttachAgent`, `AttachTCP`, expiry sweep. Pure state — no net, no gRPC, testable alone | +| `server/internal/proxy/session.go` | One relay: listener, deadline, the `io.Copy` pair, teardown-once | +| `server/internal/grpc/proxystream.go` | The `ProxyStream` handler: authenticate, then hand the stream to the registry. No relay logic of its own | + +New, agent: + +| Unit | Responsibility | +| --- | --- | +| `agent/internal/proxy/proxy.go` | `Open(ctx, client, proxyID, port)` — dial loopback, open the stream, pump bytes. No build tags; Linux and Windows share it | + +Changed: + +- `proto/vantage/v1/vantage.proto`, and both generated pb trees +- `server/internal/services/console.go` — `BuildGuacParams(srv, relayHost, relayPort, …)` +- `server/internal/api/console.go` — offline pre-check in `consoleConnect`; open the relay before the guacd handshake in `consoleTunnel` and close it in `OnDisconnect` +- `agent/internal/sync/sync.go` — handle `OpenProxyCmd`, one goroutine per proxy +- `deploy/docker-compose.yml`, `deploy/docker-compose.site.yml` — `PROXY_ADVERTISE_HOST=server` + +Two new optional environment variables on the server: `PROXY_ADVERTISE_HOST` +(default `server`, the name guacd resolves the control plane by) and +`PROXY_LISTEN_HOST` (default `0.0.0.0`). + +Nothing new is opened on the customer's firewall — the relay rides the agent's +existing outbound gRPC connection. +`docsite/docs/reference/ports-and-networking.md` and +`docsite/docs/vantage/browser-console.md` must say so, and must state the new +requirement that the agent be online. + +A secondary benefit beyond cloud: a VNC or RDP service bound only to `127.0.0.1` +is now reachable, where a direct dial from guacd never could be. + +## Failure modes + +| Failure | Behaviour | +| --- | --- | +| Agent offline at connect | 409 `agent_offline` from `consoleConnect`, before any WebSocket is opened | +| Agent never opens the stream | 10s deadline; listener closed; `console.proxy_failed{reason:"agent_timeout"}`; WebSocket closed with a message the UI surfaces | +| Local dial refused (daemon down, wrong port) | `ProxyClose{reason}` relayed up as the same audit event, reason `dial_refused` | +| guacd never dials | Same deadline path, reason `guacd_timeout` | +| Bad token, unknown or foreign `proxy_id` | Stream closed with no detail leaked; `console.proxy_failed{reason:"rejected"}` | +| Agent process dies mid-session | Stream EOF, relay torn down, console shows a disconnect | +| CommandStream reconnects mid-session | No effect on live sessions — the relay is on its own stream. Only a new `OpenProxyCmd` needs the control stream | + +Teardown is guarded by `sync.Once` on both sides: both `io.Copy` goroutines +finish, and whichever finishes second must not double-close. + +## Testing + +Written test-first. + +- `server/internal/proxy/registry_test.go` — the two halves pair in either + order; expiry frees the entry; a second claim on a used `proxy_id` is + rejected; a mismatched `instance_id` is rejected. No network. +- `server/internal/proxy/session_test.go` — two `net.Pipe` halves; bytes flow + both ways; EOF in each direction tears down; double-close is safe. +- `server/internal/grpc/proxystream_test.go` — the authentication matrix: valid, + wrong token, unknown `proxy_id`, `proxy_id` belonging to another instance. +- `agent/internal/proxy` — a refused dial emits `ProxyClose`; the happy path + echoes bytes. +- End-to-end in `server`: a fake agent plus a `net.Listen` echo server, asserting + bytes traverse listener → registry → stream → echo and back. This is the test + that would have caught the original bug. + +Manual verification, in this order: self-hosted SSH (proves no regression), +cloud SSH to a private-network host, cloud RDP to a Windows agent.