Internals
View as Markdown0002. Agent transport: outbound WebSocket, Ed25519 identity
- Status: accepted
- Date: 2026-10-25
Context#
Spec §6.3 suggests agent-initiated, strongly authenticated connections with explicit identity, revocation, replay protection, protocol versioning, durable commands and honest handling of uncertain state. Homelab hosts are often behind NAT, so the control plane cannot dial them. Open question §19.5 asks gRPC stream vs WebSocket over TLS.
Decision#
Transport. The agent always dials outbound to wss://<control-plane>/agent/v1/connect using github.com/coder/websocket. A plain ws:// URL is refused unless the agent runs with --insecure-dev. One socket per server carries commands, results, heartbeats, events, stats and log streams in both directions. A newer connection from the same server replaces the older one.
Enrollment.
- The UI creates a one-time token
nct_enroll_<32 random bytes, base32>. Only its SHA-256 hash is stored; it expires after 1 hour, is single-use and revocable. The server row is created at this point with statuspending. - The agent generates an Ed25519 keypair and calls
POST /agent/v1/enroll {token, publicKey, hostname, facts}; the response is{serverId, agentId}. - The agent stores its identity in bbolt at
<state-dir>/agent.db(mode 0600). The--tokenis used only while no identity is stored.
Connect handshake.
- The server sends
challenge{nonce, serverTime}. - The agent replies
auth{agentId, signature}where the signature coversnonce || agentId || serverTime. - The server verifies the signature against the stored public key, rejects a nonce older than 30 seconds (replay protection), and rejects any identity with
revoked_atset. Then the agent sendshello. - A revoked agent is closed with status 4401; the agent exits non-zero with "agent revoked; re-enroll". An unsupported protocol major version is closed with 4400.
Envelope. Every frame is JSON {"v":1,"type":…,"id":…,…}. pkg/agentprotocol exports ProtocolVersion = 1. Agent to server: hello, heartbeat (every 10 s), ack, result, event, log, log_end, stats, observed. Server to agent: challenge, command, result_ack, reconcile. Command kinds are a closed set: deploy, restart_service, stop_service, remove_service, sync_routes, logs_subscribe, logs_unsubscribe. The agent rejects anything else; it never runs a shell string from the wire.
Command durability and uncertainty.
- The control plane inserts an
agent_commandsrow (statuspending) before sending, with a unique idempotency key and anexpires_at15 minutes ahead. It sends immediately when the server is online and on every connect. - The agent ACKs on receipt and records the command ID in bbolt. A redelivered command returns the stored result instead of running again, which makes commands idempotent.
- The agent keeps each result until it receives
result_ackand resends unacknowledged results inhello.unackedResultsafter a reconnect. - A periodic job moves
pending/sentcommands pastexpires_attoexpired; the deployment then fails with "Not delivered: server offline. No changes were made." This is safe because the agent never saw the command. - Acked commands never time out to a terminal state. After an ACK the outcome is genuinely unknown while the agent is offline. The deployment stays active and the UI shows "Waiting for <server> (offline since …)" until the result arrives or an operator revokes the server.
- Secrets travel only inside the outgoing frame (
secrets), attached at send time and never persisted inagent_commands.payload.
Liveness. A server is offline after 30 s without a heartbeat; observations older than 30 s render as stale. On hello the control plane sends reconcile with the active deployments for that server (see the reconciler in the plan).
Consequences#
- Works behind NAT and strict firewalls: nothing listens on the agent host for the control plane. Log streaming and commands share one authenticated, encrypted connection.
- Agent identity is a keypair rather than a bearer secret; compromise of the control-plane database does not yield agent credentials, and revocation is immediate (close 4401).
- Authentication happens at the application layer inside TLS, not mTLS. It gives equivalent identity properties without managing a CA; certificate rotation is replaced by re-enrollment.
- The control plane must keep one live socket per server in process memory (see ADR 0001 on single-instance scope).
- The protocol is hand-rolled JSON over WebSocket, so versioning and frame compatibility are our responsibility. The
vfield and close code 4400 are the compatibility gate.
Alternatives considered#
- gRPC bidirectional stream: strong typing and flow control, but heavier toolchain, more friction through HTTP proxies, and no extra capability needed for this message set.
- Control plane dials agents (SSH or HTTPS): needs inbound ports on every host; breaks homelab NAT; SSH is a general shell, contradicting spec §6.2.
- Polling agent over HTTPS: simple, but no push for logs and higher command latency.
- mTLS with a private CA: rejected for P0 to avoid certificate lifecycle code; the Ed25519 challenge provides identity, replay protection and revocation.