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.go coverage. The remaining slices are the end-to-end wiring: the standalone WS relay deployment and the browser terminal UI (the Access dashboard 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 dedicated server for 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 specific session_id + host + principal; the relay verifies it before bridging to the agent. Gate the mint on access.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.ts shows the Upgrade: websocket detection + routing pattern.

Slices

  1. 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.
  2. 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.
  3. Agent PTYinternal/access: creack/pty + x/crypto/ssh cert-only, the tunnel dialer from the secure-access spec (Phase 3 native reverse-tunnel).
  4. 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.