web-ssh
Web-SSH — architecture (slice plan)
Browser terminal to any managed host over HTTPS + auth, no inbound ports, no standing credentials. Built in slices; this doc is the blueprint so the terminal slice is de-risked before it's written.
Status (2026-07-01). The agent side is implemented and tested in
agent/internal/access/: the PTY host (pty_linux.go), the WebSocket transport (ws.go), the session model + hash-chained recording (session.go,recording.go), and the relay dialer (connect.go,client.go) — all with_test.gocoverage. The remaining slices are the end-to-end wiring: the standalone WS relay deployment and the browser terminal UI (theAccessdashboard page). Treat the tiers below as the blueprint; the transport primitives already exist.
Why a separate relay (not Next.js)
Next's app-router/standalone server can't host long-lived WebSocket upgrades.
AltoHost already solved this exact problem for its real-time product: it runs
a dedicated WS engine as its own service (altohost/ws-engine, port 6001) behind
nginx/cloudflared, not inside Next — see altohost/docs/dev/websocket.md and
altohost/deploy/nginx-tunnel.conf. We adopt the same shape. (AltoHost's engine
is a Pusher-style channels pub/sub engine — reusable as an architecture +
proxy/auth pattern, not as a drop-in PTY stream.)
xterm.js (browser)
│ wss://<host>.<tenant>.hostssh.app (session token in the connect query/subprotocol)
▼
edge: Cloudflare Tunnel (cloudflared) OR Traefik — TLS, no inbound port on the box
▼
control-plane WS relay (separate process/container — NOT the Next app)
│ verifies the session token (HMAC, minted by the control plane), then opens
▼
agent PTY over the secure tunnel (internal/access; the 4th agent goroutine in the secure-access spec)
▼
shell on the host (cert-only, ephemeral key from lib/access, ≤TTL)
Reuse from AltoHost (concrete)
- WS proxy config — copy the upgrade block from
nginx-tunnel.conf: a dedicatedserverfor the ws host,proxy_http_version 1.1,Upgrade $http_upgrade,Connection "upgrade",proxy_read_timeout 86400s,limit_conn. Traefik equivalent: a router with the websocket entrypoint + long timeouts. With cloudflared, TLS terminates at the edge and the box opens no inbound port. - HMAC session auth — AltoHost's channel-auth flow (client → app auth endpoint
→
HMAC-SHA256(secret, socket_id:channel)→ engine verifies) maps directly: the control plane mints a short-lived signed token for a specificsession_id + host + principal; the relay verifies it before bridging to the agent. Gate the mint onaccess.ssh+ the impersonation/WebAuthn step-up. - Client resilience — reuse the reconnect/ping-pong shape (exp backoff to 30s, 120s ping / 30s pong) for the xterm client.
- Edge routing — if we front with Cloudflare,
altohost/deploy/workers/anycast-router/worker.tsshows theUpgrade: websocketdetection + routing pattern.
Slices
- Ephemeral SSH keys — ✅ DONE (
lib/access— real ed25519 issue/revoke, OpenSSH public + fingerprint, private shown once, TTL/expiry,access.keys-gated). The credential the terminal + CLI consume. - WS relay service — a small Node
ws(MIT) server (or extend the agent) that verifies the control-plane session token and proxies a byte stream. Deployed as its own container/process, proxied per the nginx/Traefik upgrade block above. - Agent PTY —
internal/access:creack/pty+x/crypto/sshcert-only, the tunnel dialer from the secure-access spec (Phase 3 native reverse-tunnel). - xterm.js frontend — the Access page "Open Web-SSH" opens a terminal bound to the relay; session recording → hash-chained audit on heartbeat.
Security spine (from secure-access.md): SSH-CA (per-tenant), WebAuthn step-up for
root, ≤5-min single-host certs, kill-switch (revoke serial + reap PGID), Origin
allow-list, off-box session recording. Non-root principal by default.
Licenses: permissive only — ws (MIT), creack/pty (MIT), coder/websocket (MIT),
@xterm/xterm (MIT). No GPL/AGPL.