Here's my analysis of the best approaches, given your architecture:
The Core Problem
Your browser has the SSH private key (in SecretStore). The VM has the corresponding public key (via cloud-init). But the browser can't speak SSH. You need a WebSocket-accessible proxy on the VM, and you want to avoid shared secrets for auth.
Two Viable Approaches
Approach 1: ttyd (Recommended — simplest)
ttyd is a ~3MB C binary that wraps a shell in a WebSocket terminal with built-in xterm.js. It spawns a PTY directly — no SSH involved at all. Cloud-init is trivial:
runcmd:
- apt-get update && apt-get install -y ttyd
- ttyd -p 7681 --writable bash
The auth question: Since ttyd itself only supports basic auth (a shared secret), you'd put it behind a reverse proxy. For asymmetric auth without shared secrets, you have two clean options:
Option A — Challenge-response using the existing SSH key:
- Cloud-init installs a small auth proxy (nginx + a tiny auth service)
- Browser requests a challenge nonce from the proxy
- Browser signs the nonce with the SSH private key using WebCrypto (RSA/Ed25519 keys can be imported)
- Proxy verifies the signature against the SSH public key already in ~/.ssh/authorized_keys
- On success, proxy issues a short-lived session cookie and proxies WebSocket to ttyd on localhost
This is the most elegant approach — it reuses the SSH keypair for auth without any new secrets. The private key never leaves the browser.
Option B — JWT signed by the orchestrator: - At VM creation time, generate a keypair; put the public key on the VM via cloud-init - The orchestrator signs a short-lived JWT with the private key - Browser presents JWT to the proxy, which validates against the provisioned public key - Downside: requires the orchestrator to hold a signing key (but it's not a shared secret)
Approach 2: wetty with cloud-init-generated keypair
wetty (Node.js) is an SSH-over-WebSocket proxy. Run it with --sshkey so it connects to localhost sshd using a key that never leaves the VM:
runcmd:
- apt-get update && apt-get install -y nodejs npm
- npm install -g wetty
- ssh-keygen -t ed25519 -f /etc/wetty/local-key -N ""
- cat /etc/wetty/local-key.pub >> /home/deploy/.ssh/authorized_keys
- wetty --sshkey /etc/wetty/local-key --sshuser deploy --sshhost localhost -p 7681
Same auth proxy question applies for browser-to-wetty authentication. Heavier install (~60MB for Node.js) but gives you proper SSH session semantics (PAM, audit log, user switching).
My Recommendation
ttyd + challenge-response auth using the SSH keypair is the best fit because:
- No shared secrets — the SSH keypair you already provision does double duty
- Private key never transits — browser signs a challenge locally via WebCrypto
- Lightest weight — single binary, 3 lines of cloud-init for ttyd itself
- No SSH overhead — direct PTY, no localhost SSH hop
- Built-in xterm.js — or you can use your own xterm.js instance and connect via the WebSocket API directly
Architecture Sketch
Browser (xterm.js)
|
| 1. GET /auth/challenge → returns nonce
| 2. Sign nonce with SSH private key (WebCrypto)
| 3. POST /auth/verify { signature, pubkey_fingerprint } → session cookie
| 4. WSS /terminal (cookie auth) → proxied to ttyd on localhost:7681
|
▼
[Caddy or nginx on VM, port 443 with auto-TLS]
|
├── /auth/* → small auth service (validates signatures against authorized_keys)
└── /terminal → reverse proxy to ttyd (localhost:7681, bound to 127.0.0.1)
The auth service can be a small Go or Python binary (~100 lines) provisioned via cloud-init. It reads the public key from ~/.ssh/authorized_keys, verifies the challenge signature, and sets a session cookie.
Browser-Side Integration
On the browser side, you'd add: - An xterm.js terminal component (or use ttyd's built-in one in an iframe) - WebCrypto code to import the SSH private key and sign challenges - A new UI panel/modal for the terminal session
The main complexity is the WebCrypto part — SSH keys need to be parsed from PEM/OpenSSH format into a format WebCrypto can import. Libraries like sshpk (JS) handle this conversion.
Want me to dive deeper into any of these areas — the cloud-init setup, the auth service design, the WebCrypto key signing, or the browser-side terminal integration?