wanctl — Architecture
Control another device across the public internet from a terminal (or an AI agent's shell), over an end-to-end-encrypted, relayed channel. A web portal issues tokens and manages sharing; a Postgres-backed relay authenticates and brokers; devices enforce a local, approval-based permission policy.
Devices use persistent UUIDs for routing and associations, independently of their display names and TLS fingerprints. See device identity and upgrading.
controller (you/agent) ──┐ ┌── device (wanctl agent)
wanctl exec/push/logs │ relay (public broker) │ policy engine + approval
├── byte-pipe + registry ───┤ JSONL event log
│ token auth + ACL + audit│
E2E mutual-TLS ════════╪════════ over the pipe ════╪════ (relay sees only ciphertext)
│ │
portal (web, SSO) ─────┘ issues tokens, ACL ──────┘ (thin proxy to relay /admin/*)
Components
One Go binary; the role is chosen at runtime:
wanctl relay— the public broker. Postgres-backed (DATABASE_URL): hashed-token resolve, cross-namespace ACL, metadata audit, device registry. Serves the signed-manifest/dl/<artifact>distribution and the/skillsdocument for AI controllers. Falls back to a staticWANCTL_TOKENSenv map when no database is attached. The secret-gated admin API/admin/*backs the portal.wanctl portal— the web app. No database of its own: a thin proxy to the relay's/admin/*using a sharedWANCTL_ADMIN_SECRET, scoped to the namespace resolved from the authenticated identity. The portal is also a first-class controller: it holds its own Ed25519 identity and a privileged dial token, and drives a live device console over the same E2E tunnel.wanctl agent— the controlled device. Outbound-only to the relay; each session does a server-side TLS handshake, TOFU authorization, then policy-gated exec/file serving. Writes a JSONL event log.wanctl exec|push|pull|peers|id|trust|rules|logs|net|docs|update— controller commands.
Package map: transport (Ed25519 identity, mutual TLS, TOFU), protocol
(framed wire format), wsconn (WebSocket↔net.Conn), httpconn (HTTP
long-poll↔net.Conn — the proxy-agnostic carrier), relay (broker + http
transport + pgstore + admin + dist), agent, client, server (shell+files),
policy (rules+approver), console (transport-neutral approval queue),
portal, eventlog, sessionauth (relay-issued capability grants).
The opt-in WebFetch adapter adds URL-only AI clients. Its owner approval and short-lived device scopes live in the existing portal/token store; the adapter is an ordinary E2E controller with a distinct identity per grant. Unlike the opaque broker, this explicitly enabled controller component sees the plaintext it sends and receives. Device-local action policy remains the authority. Delegated metadata is preserved across admission and both carriers; namespace-only legacy paths refuse delegated credentials.
Transports
Two carriers speak the same TLS + framed protocol:
- WebSocket (
wsconn) — for deployments whose ingress preserves WebSocket upgrades (direct exposure, or a reverse proxy configured for upgrades). - HTTP long-poll (
httpconn) — the proxy-agnostic default:WANCTL_TRANSPORT=http. Up-channel is one POST per write; down-channel is a long-poll GET that returns available bytes / 204 retry / 410 EOF. Every response is finite, which survives edges that stripConnection: Upgradeand buffer streaming responses (a plain streaming GET deadlocks behind such an edge — this was measured, not theorized).
Measured on an 8 MB payload, WebSocket buys 4–5× push and ~2× pull throughput; interactive exec latency and line-by-line streaming spacing are equivalent on long-poll. Pick WebSocket for file-transfer-heavy fleets, long-poll for maximum ingress compatibility.
Trust model
Three independent layers; compromising one does not collapse the others:
- Relay admission — a bearer token (
wanctl_<40 hex>) maps to a namespace. Tokens are stored as SHA-256 hashes, may carry labels and expiry, and are revocable. The relay authorizes dials by namespace and ACL; it cannot see inside sessions. - End-to-end identity — controller and device each hold a self-signed Ed25519 certificate; sessions run mutual TLS 1.3 over the relayed pipe with fingerprint pinning (TOFU on both sides). A relay or database compromise leaks metadata only. Devices additionally refuse pairing prompts from controllers that do not identify themselves with a label.
- Device-local policy — a rules engine gated by human approval
(Claude-Code-style: yes / always-this-dir / always-global / no). Command
rules match a single simple command only — anything with shell operators,
substitutions, or redirects requires an exact match. File rules bind the
actual opens to a directory root (no symlink escape).
bypassmode auto-allows but still logs; elevated exec is its own class, never covered by an ordinary exec rule, and covered by bypass only on a device whose elevation channel is switched on as well — two separate opt-ins, both off by default. Headless with no approver subscribed means deny, not hang.
Cross-namespace sharing is a relay-side ACL grant (owner, device, grantee,
manage). A share inherits the owner's use of the device: the relay stamps a
grantee's session with UseCapabilities — exec, read, write, logs — over the
authenticated agent control channel. The acl.perms column still exists and is
written as the constant full, but nothing reads it (ADR 0007). A controlled
device runs one agent bound to one account, so sharing is how a machine gets a
second user; a permission matrix here would be a second, weaker model layered
over the device's own, and it is the device's model that decides each request.
What constrains a grantee is therefore the device, not the grant. Its single mode and rule set apply to everyone: left on per-request approval, the owner answers for the grantee's commands; put into bypass, the grantee is bypassed too, elevated exec included if the device's elevation channel is on.
Management — the device console: approvals, rules, mode, trusted controllers —
is the one thing a share varies, through acl.manage (migration 008), off
unless the owner turns it on. It adds Console to that grantee's sessions and
opens the portal's console for them. Two independent checks still guard it: the
relay's switch says whether the owner agreed, and the device's own
portal_admins set says which controller identity may act, so a grantee's own
controller cannot administer the device directly even with the switch on.
Unbinding, renaming, revoking a share, and the owner's notification settings
are never granted.
/peers reports the grantee's own online devices under devices, unchanged,
and the devices granted to them under shared, each with its owner namespace,
route, label and online state — a grantee has no other way to learn the owner
namespace a --target needs. A --target with no namespace resolves in the
caller's own namespace first and then, if exactly one grant matches by route,
name or alias, against the shared devices; a label two owners both use is
refused rather than guessed. A refusal names the target only when it was asked
for in the caller's own namespace; a target in a namespace the caller holds no
grant on gets a bare forbidden, which reveals nothing about what is there.
Remote device console
The portal opens a live console to a device over the E2E tunnel: pending
approvals, rules, mode, and the activity log, pushed in real time
(KindApprovalNotif carries the full console state). Approval decisions from
a local TTY and from the portal feed one queue; first answer wins. The device
pre-trusts portal identities through an explicit portal_admins set, seeded
at enrollment and manageable with wanctl portal-admins add|list|remove
(removing the last one is refused). Rotating a portal identity is an overlap
migration: seed old+new, deploy, verify, then remove old.
Because the platform edge may buffer streams, the portal's browser-facing event channel is a 25-second long-poll rather than SSE.
Server logs
Relay and portal tee process logs to a bounded in-memory ring (2,000 lines /
2 MiB) and expose the secret-gated GET /admin/logs. The portal serves its own
ring and proxies the relay's, so it is the single entry point:
WANCTL_ADMIN_SECRET=... wanctl logs --service portal
WANCTL_ADMIN_SECRET=... wanctl logs --service relay --since 30m --grep <term>
Both services redact token/secret/password/API-key labelled values, bearer
credentials, and base64-like strings ≥ 32 chars before applying grep, so a
secret passed as a grep term cannot confirm its own presence. --follow is
deliberately unsupported; the command errors instead of pretending to stream.
Release distribution
/dl is an allowlist, not a directory listing: the relay serves exactly the
artifacts named by an offline-signed manifest (Ed25519), verified again on
every read; anything else 404s. Installers are the trust-bootstrap exception —
they are served unsigned by necessity, so prefer fetching them from the
project's release page for machines that matter, and compare SHA-256
out-of-band. Install scripts verify an RSA signature instead of Ed25519
because a stock macOS LibreSSL and PowerShell 5.1 cannot verify Ed25519 — see
docs/release-signing.md.
wanctl update verifies the manifest signature, swaps the binary, and — on
platforms where a supervisor may own the running agent under another account —
checks whether it can actually terminate the old process instead of reporting
success while the old version keeps serving. After any upgrade, trust the
running process's start time against the binary's mtime, not the version on
disk.
Satellite relays
A relay without a database can resolve tokens against the main relay
(WANCTL_UPSTREAM_RELAY + WANCTL_ADMIN_SECRET, 5-minute cache), so
portal-issued tokens work on every relay without sharing the DB. That is how a
self-hoster gets intranet latency: run a relay inside the network and point the
devices and controllers there with wanctl config set relay=…. A ws:// relay
is acceptable only inside an encrypted overlay (WireGuard or similar);
otherwise use wss://.
Field notes (hard-won, don't relearn)
- PaaS edges lie twice: the same edge that strips WebSocket upgrades will
also buffer streaming responses and ignore
X-Accel-Buffering. Long-poll (every response finite) is the shape that survives both. /healthzmay be shadowed by a platform edge that answers it with its own JSON; probe a real application route to test reachability.- When a platform hides a secret (masked
DATABASE_URL, write-only env editors), change the architecture instead of fishing for it — the portal-as-proxy design exists because the portal could not be given the DSN. - The SSO identity header is an assertion from the proxy. The proxy must strip any client-supplied value for that header; the app cannot authenticate it by itself.
- Detaching a process over ssh:
setsid CMD & exit 0sometimes dies. Usenohup setsid CMD >log 2>&1 </dev/null & disown. - Passwordless sudo changes installer semantics: a root-owned
/usr/local/bin/wanctlfrom an earlier sudo install makes a later non-sudo reinstall silently fall back to~/.local/bin. Pin withWANCTL_BIN. - A retry loop that logs nothing produces the least debuggable failure there is. The agent's poll loop logs the first failure, then ~one per minute, then the recovery. Apply the same rule to any loop that retries forever.
- A "verified on the device" claim is only worth the invocation form it used. Verify a new platform by running the published installer and then the bare command off PATH — not a hand-copied binary.
- Android breaks four Unix assumptions (resolver, HOME, /bin/sh, traversal
permissions) and Termux breaks two more (linker argv duplication, no exec
from an app's private dir). The full account, including why the APK's
lib/<abi>/directory is the one place an app may exec from, lives indocs/android.md.
Local smoke test (no external services)
go build -o /tmp/wanctl .
WANCTL_TOKENS="tk1:teamA" /tmp/wanctl relay --addr :18080 &
WANCTL_CONFIG_DIR=/tmp/wc-agent /tmp/wanctl agent --relay ws://127.0.0.1:18080 --token tk1 --name lab-pc --yes &
WANCTL_CONFIG_DIR=/tmp/wc-ctl WANCTL_RELAY=ws://127.0.0.1:18080 WANCTL_TOKEN=tk1 /tmp/wanctl peers
WANCTL_CONFIG_DIR=/tmp/wc-ctl WANCTL_RELAY=ws://127.0.0.1:18080 WANCTL_TOKEN=tk1 /tmp/wanctl exec --target lab-pc "echo ok"