I would like to understand the best way to add the following feature: there should be a "login terminal" window that can be opened onto a newly created rmeote machine/vm. As-is new machines will be listening for ssh connections, and provisioned with whatever ssh key was specified at creation time. However, a user of this application can only interact with browser-hosted code, which precludes running a real ssh client, nor making tcp connections on port 22 (because browser code can't make non-http connections). I am aware that there are various options for support for "web ssh" that use a browser-side component in conjunction with some sort of server-hosted proxy. I'd like to use one of those, but with the specific requirement that the "server side proxy" component be running on the new VM itself. We can provision the installation of packages and even complex scripted setup on the new machine, via cloud-init. Somehow however the private key has to be provisioned. I'd prefer to avoid using shared secret authentication as a workaround, if possible.
{
"subagent_type": "general-purpose",
"prompt": "Research the current landscape of web-based SSH terminal solutions that work with a browser-side terminal component and a server-side proxy. I need to understand:\n\n1. What are the main open-source options? (e.g., ttyd, webssh, gotty, Apache Guacamole, wetty, sshwifty, etc.)\n2. Which ones can run the proxy component directly on the target VM (not a separate bastion)?\n3. Which ones support WebSocket-based connections from the browser?\n4. Which ones are lightweight enough to install via cloud-init on a fresh VM?\n5. For each option, how does authentication work — can the browser send an SSH private key, or does the proxy need the key locally?\n6. Are there any solutions where the proxy connects to localhost SSH using a local key, and the browser just authenticates to the proxy via a token or certificate?\n\nFocus especially on:\n- **ttyd** - can it just expose a shell directly without SSH?\n- **wetty** - Node.js based, WebSocket to SSH\n- **sshwifty** - Go binary, supports SSH via browser\n- **Apache Guacamole** - heavier but full-featured\n\nAlso research: is there a pattern where the VM runs a WebSocket-to-SSH proxy that holds the private key locally, and the browser authenticates to the WebSocket endpoint using a short-lived token or TLS client cert? This would avoid sending private keys through the browser.\n\nReturn a structured comparison with pros/cons for each approach, focusing on: ease of cloud-init setup, security model, browser-side library requirements, and whether private keys need to transit through the browser."
}{
"subagent_type": "Explore",
"prompt": "Explore this codebase to understand:\n1. How VMs/machines are currently created (look for cloud-init, SSH key provisioning, machine creation code)\n2. The existing runtime/worker architecture (how does the browser communicate with external services)\n3. Any existing terminal UI code that could be extended\n4. The types and message protocol used\n\nBe thorough - check src/, any config files, and the CLI tool integration."
}
# Web-Based SSH Terminal Solutions: Structured Comparison
## 1. Overview of Main Open-Source Options
### ttyd
- **Language:** C (tiny binary, ~3MB)
- **How it works:** Wraps any command (e.g., `bash`, `login`) in a WebSocket-based terminal accessible from a browser. It does **not** use SSH at all — it directly spawns a process on the host and exposes it over HTTP/WebSocket with an embedded xterm.js frontend.
- **WebSocket support:** Yes, native.
- **Run on target VM:** Yes — this is its primary use case. You run `ttyd bash` on the VM and connect from a browser.
- **Cloud-init friendly:** Extremely. Single static binary, available in most package managers (`apt install ttyd`), or downloadable from GitHub releases. A cloud-init script can install and start it in 3-4 lines.
- **Authentication model:** ttyd itself supports HTTP basic auth (`-c user:pass`), or you can put it behind a reverse proxy with token/cert auth. Since it spawns a local process directly (not SSH), **no SSH keys are involved at all**. The process runs as whatever user starts ttyd (or you can use `ttyd login` to get a PAM login prompt).
- **Key transit through browser:** No. No SSH involved. The browser connects to the ttyd HTTP/WebSocket endpoint; ttyd spawns a shell locally.
- **Pros:**
- Lightest weight option — single binary, minimal dependencies
- No SSH overhead; direct PTY allocation
- Built-in xterm.js client, no browser-side library needed beyond a browser
- TLS support built in (`--ssl`)
- Can expose any command, not just shell (e.g., `ttyd htop`)
- **Cons:**
- No SSH — so you get a shell as whatever user runs ttyd, not a multi-user SSH experience
- Basic auth is rudimentary; for production you need a reverse proxy for proper auth
- No built-in session recording or audit logging
- If ttyd process is compromised, attacker has a shell
---
### wetty (Web + TTY)
- **Language:** Node.js (TypeScript)
- **How it works:** Runs an HTTP/WebSocket server that, on connection, opens an SSH session to a target host (default: localhost). The browser gets an xterm.js terminal. The SSH connection is from the wetty process to the SSH daemon.
- **WebSocket support:** Yes, native.
- **Run on target VM:** Yes. Commonly deployed on the same machine, connecting to `localhost:22`.
- **Cloud-init friendly:** Moderate. Requires Node.js runtime (~60-100MB installed). Install via `npm install -g wetty` or use the Docker image. Cloud-init needs to install Node.js first, then wetty. More steps than ttyd but still manageable (~10 lines of cloud-init).
- **Authentication model:** By default, wetty prompts for SSH username/password in the terminal (the SSH auth happens server-side between wetty and sshd). It can also be configured with `--sshkey` to use a local private key for the SSH connection, in which case the browser user never touches a key. You can put it behind an OAuth proxy or use URL-based auth tokens.
- **Key transit through browser:** **No** (when using `--sshkey`). The private key stays on the VM. The browser only authenticates to the wetty HTTP endpoint (via whatever auth layer you add) and then gets a pre-authenticated SSH session.
- **Pros:**
- Mature project, well-maintained
- SSH connection means proper PAM auth, audit trail, session accounting
- `--sshkey` flag enables the exact pattern of "proxy holds key, browser uses token"
- Supports custom base paths for reverse proxy setups
- Docker image available
- **Cons:**
- Requires Node.js runtime — heavier install than ttyd or Go binaries
- Node.js can have memory overhead for idle sessions
- npm dependency tree is a supply-chain surface
---
### sshwifty
- **Language:** Go (single static binary)
- **How it works:** WebSocket-based SSH (and Telnet) client. The Go server acts as a proxy — the browser UI sends SSH connection parameters (host, port, credentials) to the server, which opens the actual SSH connection. The browser-side is a custom JS/WASM client (not xterm.js).
- **WebSocket support:** Yes, native.
- **Run on target VM:** Yes, can run on the target and connect to localhost.
- **Cloud-init friendly:** Very. Single Go binary, download from GitHub releases. Similar to ttyd in ease — 3-5 lines of cloud-init.
- **Authentication model:** This is where sshwifty differs. By default, the **browser user provides SSH credentials** (password or private key) through the web UI, and those are sent to the sshwifty server over WebSocket, which then uses them to connect via SSH. There is a "preset" feature where you can pre-configure SSH connections with credentials stored in a server-side config file — in this mode, the browser user does not provide keys.
- **Key transit through browser:** **Yes by default** (keys are sent from browser to proxy). **No with presets** (keys stored in server config).
- **Pros:**
- Single binary, easy deployment
- Supports both SSH and Telnet
- Presets allow key-on-server pattern
- Built-in SharedKey authentication for the web interface itself
- Actively maintained
- **Cons:**
- Default mode sends credentials through the browser (less secure)
- Custom UI (not xterm.js) — less familiar, less extensible
- Preset configuration is JSON file based, less dynamic than API-driven approaches
- Fewer configuration options than wetty for reverse proxy integration
---
### Apache Guacamole
- **Language:** Java (server) + C (guacd proxy daemon)
- **How it works:** Full remote desktop gateway. The architecture has three components: (1) browser-side JS client, (2) Java web app (guacamole-client), (3) guacd daemon (C, handles the actual SSH/RDP/VNC protocol). The browser speaks the Guacamole protocol over WebSocket to the Java server, which relays to guacd.
- **WebSocket support:** Yes.
- **Run on target VM:** Technically possible but impractical. Requires Java + Tomcat + guacd + database (MySQL/PostgreSQL for connection storage). This is designed as a centralized gateway, not a per-VM agent.
- **Cloud-init friendly:** **No.** Heavy installation: Java/Tomcat, the guacamole WAR file, guacd compilation or package, database setup. Typically deployed via Docker Compose with 3+ containers. Not suitable for ephemeral VM setup.
- **Authentication model:** Very rich — supports LDAP, SAML, OpenID Connect, TOTP, CAS, RADIUS, database auth. SSH keys are stored in the Guacamole database/config, not sent from the browser. The browser authenticates to Guacamole (the gateway), and Guacamole handles the SSH session with pre-configured credentials.
- **Key transit through browser:** **No.** Keys are managed server-side in the connection configuration.
- **Pros:**
- Enterprise-grade: session recording, audit logging, concurrent access, clipboard sharing
- Supports SSH, RDP, VNC, Telnet, Kubernetes
- Rich auth integration (SAML, OIDC, LDAP)
- Connection sharing and monitoring
- **Cons:**
- Very heavy — not suitable for per-VM deployment or cloud-init
- Complex architecture (3+ components)
- High resource usage (Java + database)
- Overkill for single-VM shell access
---
### GoTTY
- **Language:** Go (single binary)
- **How it works:** Very similar to ttyd — wraps a command in a WebSocket terminal. `gotty bash` exposes a shell over HTTP.
- **WebSocket support:** Yes.
- **Run on target VM:** Yes.
- **Cloud-init friendly:** Yes — single binary.
- **Authentication model:** Basic auth via flags. No SSH involvement (same as ttyd). However, GoTTY is **largely unmaintained** (last significant commit was ~2017, archived on GitHub).
- **Key transit through browser:** No (no SSH).
- **Note:** ttyd was created as an actively-maintained alternative to GoTTY. **Use ttyd instead.**
---
### webssh / webssh2
- **Language:** Python (webssh) / Node.js (webssh2)
- **How it works:** Browser-based SSH client. The browser provides SSH credentials (host, user, password/key) through a web form, sends them to the server, which opens an SSH connection.
- **WebSocket support:** Yes.
- **Cloud-init friendly:** Moderate (Python/Node.js dependency).
- **Authentication model:** By default, credentials are sent from browser to server. webssh2 (used by ManageEngine, etc.) can be configured with host-side keys.
- **Key transit through browser:** **Yes by default.**
- **Pros:** Simple, focused on SSH specifically
- **Cons:** Default model requires browser to send keys; less maintained than alternatives
---
## 2. Structured Comparison Table
| Feature | ttyd | wetty | sshwifty | Guacamole | GoTTY |
|---|---|---|---|---|---|
| **Language** | C | Node.js | Go | Java+C | Go |
| **Binary size** | ~3MB | ~60MB (with Node) | ~15MB | ~500MB+ | ~10MB |
| **Uses SSH** | No (direct PTY) | Yes | Yes | Yes | No (direct PTY) |
| **WebSocket** | Yes | Yes | Yes | Yes | Yes |
| **On-VM deployment** | Excellent | Good | Excellent | Poor | Excellent |
| **Cloud-init lines** | 3-5 | 8-12 | 3-5 | 30+ | 3-5 (but unmaintained) |
| **Key in browser** | N/A | No (with --sshkey) | Yes (default) / No (presets) | No | N/A |
| **Auth options** | Basic/reverse proxy | SSH + proxy layer | SharedKey + SSH | LDAP/SAML/OIDC/DB | Basic |
| **Maintained** | Active | Active | Active | Active | Archived |
| **xterm.js** | Yes (built-in) | Yes (built-in) | Custom UI | Custom protocol | Yes (built-in) |
---
## 3. The "Proxy Holds Key, Browser Uses Token" Pattern
This is the most security-conscious architecture, and yes, it is a well-established pattern. Here is how it works:
### Architecture
```
Browser --[HTTPS/WSS + auth token]--> WebSocket Proxy (on VM)
|
[local SSH key on disk]
|
SSH to localhost:22
```
### Which tools support this natively:
1. **ttyd** — achieves this without SSH entirely. Run `ttyd -c token:secret bash` or put it behind a reverse proxy that validates a short-lived JWT. The shell is direct, no SSH key involved at all.
2. **wetty** — run with `wetty --sshkey /path/to/key --sshuser deploy --sshhost localhost`. The SSH key lives on disk, wetty uses it to connect to local sshd. Put wetty behind an auth proxy (e.g., oauth2-proxy) that validates a token. The browser never sees the SSH key.
3. **sshwifty** — use presets in the config JSON:
```json
{
"Presets": [{
"Type": "SSH",
"Host": "localhost:22",
"User": "deploy",
"PrivateKey": "/path/to/key"
}]
}
```
The web interface uses SharedKey authentication (a shared secret/token). Browser never sees the SSH key.
4. **Custom approach** — run a lightweight WebSocket server (e.g., using xterm.js addon-attach on the browser side) that authenticates via a signed token and then opens an SSH connection or PTY locally. Libraries like `ssh2` (Node.js) or Go's `x/crypto/ssh` make this straightforward.
### Short-lived token implementation patterns:
- **Pre-shared token in cloud-init:** Generate a random token during VM provisioning, pass it as an env var or config file to the proxy. Share the token with the user via your control plane.
- **TLS client certificates:** Generate a short-lived client cert during provisioning, provide it to the user's browser. The proxy validates the cert. This is the strongest option but more complex to implement.
- **JWT signed by control plane:** Your orchestrator signs a JWT with a short expiry. The proxy validates the signature against a known public key. No shared secret needed.
---
## 4. Recommendations by Use Case
### For this project (cloud-init on fresh VMs, browser-side terminal):
**Best option: ttyd**
- Rationale: Lightest weight, no SSH needed (direct shell), trivial cloud-init setup, built-in xterm.js. Put it behind a token-validated reverse proxy or use its basic auth with a generated secret.
- Cloud-init example:
```yaml
runcmd:
- apt-get update && apt-get install -y ttyd
- ttyd -p 7681 --writable -c "token:$(cat /etc/ttyd-secret)" bash &
```
**Runner-up: wetty with --sshkey**
- Rationale: If you need SSH semantics (PAM accounting, user switching, audit logs), wetty with a local key is the best option. Slightly heavier install but proven pattern.
**Avoid for this use case:**
- Guacamole — too heavy for per-VM deployment
- GoTTY — unmaintained
- sshwifty default mode — sends credentials through browser
- webssh — sends credentials through browser
### If you need maximum security:
Use **ttyd** or **wetty** behind a reverse proxy (e.g., Caddy or nginx) that validates JWTs issued by your control plane. The proxy terminates TLS with a Let's Encrypt cert (Caddy does this automatically). The ttyd/wetty process binds only to localhost. This gives you:
- TLS encryption in transit
- Short-lived token authentication
- No SSH keys in the browser
- No SSH keys in transit at all (with ttyd, no SSH period)agentId: af4ee28fab40e34e0 (use SendMessage with to: 'af4ee28fab40e34e0' to continue this agent) <usage>total_tokens: 12371 tool_uses: 0 duration_ms: 84170</usage>
Perfect! Now let me create a comprehensive summary document based on my exploration:
## Comprehensive Codebase Analysis Report
I've completed a thorough exploration of the Stirlingbridge local-orchestrator project and related infrastructure. Here's what I found:
---
## 1. VM/Machine Creation Architecture
**Location:** `/home/david/projects/stirlingbridge/machine/` (separate repository)
### Cloud-Init & SSH Key Provisioning
The machine tool uses **cloud-init** for VM provisioning:
**Key Files:**
- `/home/david/projects/stirlingbridge/machine/machine/cloud_config.py` - Generates cloud-config YAML with:
- User creation with sudo access (`new_user_name`)
- SSH public key injection from the provider's SSH key store
- Optional `runcmd` section that downloads and executes setup scripts
- Environment variable substitution via `expandvars`
**Cloud-Init Features:**
- Creates a dedicated user with passwordless sudo
- Injects SSH public key for authentication
- Downloads and executes setup scripts if `script_url` is provided
- Passes environment variables to the initialization script via cloud-config's `runcmd`
### VM Creation Process
**DigitalOcean Provider** (`/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py`):
1. Looks up SSH key by name from DigitalOcean account
2. Creates a `digitalocean.Droplet` object with:
- `name` - VM name
- `region` - Geographic region (NYC1, SFO3, AMS3, etc.)
- `image` - OS image (Ubuntu 24.04, Debian 12, CentOS, AlmaLinux, etc.)
- `size_slug` - Machine size (s-1vcpu-1gb, s-4vcpu-8gb, etc.)
- `ssh_keys` - List containing the SSH key object
- `tags` - For tracking (session ID, machine type, creation tag)
- `user_data` - The cloud-init config from `cloud_config.py`
3. Calls `droplet.create()` which triggers DigitalOcean's API
4. Polls for IP address assignment (up to 60 seconds)
5. Optionally creates DNS A records if DNS zone configured
**Create Command** (`/home/david/projects/stirlingbridge/machine/machine/subcommands/create.py`):
- Supports flags: `--name`, `--region`, `--image`, `--machine-size`, `--type`, `--tag`
- Validates SSH key exists before creation
- Supports `--wait-for-ip` and `--update-dns` flags
- Returns droplet ID and IP address
---
## 2. Runtime/Worker Architecture
**Location:** `/home/david/projects/stirlingbridge/local-orchestrator/src/`
### Browser-to-External Communication Pattern
The architecture uses **Pyodide (WASM Python)** with custom HTTP modules to bypass WASM socket limitations:
**Key Components:**
1. **BrowserRuntime** (`runtime.ts`):
- Public API class managing worker communication
- Methods: `init()`, `exec()`, `registerCommand()`, `installPackages()`, `writeFile()`, `readFile()`
- Implements request/response correlation with unique IDs
- Tracks installed dependencies to avoid reinstalling
2. **Web Worker** (`worker.ts`):
- Hosts Pyodide instance (Python interpreter compiled to WASM)
- Manages in-memory virtual filesystem (`/tools/` for Python scripts)
- Provides HTTP client shims:
- **`http_client` module**: Uses synchronous XMLHttpRequest via Pyodide FFI
- **`requests` shim**: Wraps http_client to provide requests API compatibility
- Handles message types: `init`, `exec`, `install`, `write-file`, `read-file`
3. **Bootstrap Python**:
- Runs `_run_command(script_path, args_json)` helper
- Captures stdout/stderr/exitCode
- Properly handles `sys.argv`, environment variables, and SystemExit
### Message Protocol (Types in `types.ts`)
```typescript
WorkerRequest =
| { id, type: "init" }
| { id, type: "exec", scriptPath, args, env? }
| { id, type: "install", packages }
| { id, type: "write-file", path, content }
| { id, type: "read-file", path }
WorkerResponse =
| { id, type: "ready" }
| { id, type: "exec-result", stdout, stderr, exitCode }
| { id, type: "install-result", success, error? }
| { id, type: "write-file-result", success, error? }
| { id, type: "read-file-result", success, content?, error? }
| { id, type: "error", error }
```
### Command Registration System
- **Simple commands**: Python source code written to `/tools/{name}.py`, registered via `registerCommand()`
- **Package commands**: Multi-file packages with entry point via `registerPackageCommand()`
- **GitHub commands**: Auto-discovered from GitHub repos via `registerGitHubCommand()` using GitHub API
- **Repository cloning**: Full GitHub repos cloned to virtual filesystem via `cloneRepo()`
### Environment & Secrets
**SecretStore** (`secrets.ts`):
- AES-GCM encrypted storage using Web Crypto API
- Non-extractable CryptoKey stored in IndexedDB
- Ciphertext persisted in localStorage (origin-scoped)
- All secrets injected as environment variables to every command execution
---
## 3. Terminal UI Code
**Location:** `/home/david/projects/stirlingbridge/local-orchestrator/`
### Terminal Interface (`main.ts`)
**DOM Structure** (from `index.html`):
- Header with title and Secrets button
- Status bar showing runtime state (Loading, Ready, Error)
- Output area (scrollable, pre-formatted)
- Input bar with command prompt
- Example buttons for quick commands
- Secrets modal (encrypted key/value storage)
**Terminal Features:**
- Command parsing with quote handling (`parseCommand()`)
- Line coloring by type:
- `line-stdout`: Default text
- `line-stderr`: Red errors
- `line-cmd`: Green for executed commands
- `line-info`: Blue for info messages
- `line-exit-ok`/`line-exit-err`: Exit codes
- Status bar with state indicators (ready=green, error=red)
**Built-in Commands** (`commands.ts`):
- `hello [--name NAME] [--shout]` - Argparse demo
- `sysinfo` - Python/platform info
- `fetch URL [--headers]` - HTTP fetch with custom http_client
- `env [NAME] [--filter STR]` - Environment variables
- `files [PATH] [-r]` - Virtual filesystem listing
**Extensible Commands**:
- `test-tool` - Loaded from `bozemanpass/test-python-cli-tool` on startup
- `machine` - Loaded from `stirlingbridge/machine` on startup (see below)
- Custom commands via `runtime.registerCommand()`
### Secrets Modal UI
- Modal overlay with key/value input fields
- Password field for secret values
- Delete button for each secret
- "Add" button to encrypt and store
- All encrypted client-side, never sent to backend
**Styling**: Tokyonight dark theme (colors from `index.html` inline styles)
---
## 4. Types & Message Protocol Summary
### Public API Types (`types.ts`)
```typescript
// Command registration
CommandDef {
source: string; // Python source code
dependencies?: string[]; // Packages to install
scriptPath?: string; // Override default /tools/{name}.py
}
PackageCommandDef {
files: Record<string, string>; // File paths -> contents
entryPoint: string; // "module.path:function"
dependencies?: string[];
}
GitHubCommandOptions {
repo: string; // "owner/repo"
branch?: string; // Default: "main"
subdir?: string; // Subdirectory in repo
entryPoint: string; // Entry point in package
dependencies?: string[];
}
// Execution
ExecOptions {
stdin?: string;
env?: Record<string, string>;
cwd?: string;
}
ProcessResult {
stdout: string;
stderr: string;
exitCode: number;
}
```
### Machine Tool Integration
The `machine` command is registered on startup (`main.ts` lines 307-326):
```typescript
runtime.registerGitHubCommand("machine", {
repo: "stirlingbridge/machine",
entryPoint: "machine.main:main",
dependencies: ["click", "python-digitalocean", "ruamel.yaml", "expandvars"],
});
// Default config written to virtual filesystem
await runtime.writeFile("/home/pyodide/.machine/config.yml",
"digital-ocean:\n access-token: ${DIGITALOCEAN_ACCESS_TOKEN}\n..."
);
```
The machine tool's Click CLI exposes subcommands:
- `machine create --name <NAME> --type <TYPE>` - Creates VM
- `machine list` - Lists VMs
- `machine destroy <VM-ID>` - Destroys VM
- `machine ssh-keys` - Lists SSH keys
- `machine domains` - Lists DNS zones
- `machine status <VM-ID>` - Gets VM status
---
## Key Design Patterns
1. **Worker Isolation**: Python execution in Web Worker keeps UI responsive
2. **HTTP Bypass**: Custom http_client module uses browser's XHR instead of Python sockets to work in WASM
3. **Requests Compatibility**: Shim layer lets `python-digitalocean` work unchanged
4. **Virtual Filesystem**: In-memory, no disk I/O, scripts and packages stored as strings
5. **Lazy Loading**: Pyodide and external tools loaded on demand, not bundled
6. **Environment Injection**: Secrets stored securely, injected as env vars at execution time
---
## File Paths Reference
**Local Orchestrator:**
- `/home/david/projects/stirlingbridge/local-orchestrator/src/main.ts` - UI wiring
- `/home/david/projects/stirlingbridge/local-orchestrator/src/runtime.ts` - Browser runtime API
- `/home/david/projects/stirlingbridge/local-orchestrator/src/worker.ts` - Pyodide worker & HTTP modules
- `/home/david/projects/stirlingbridge/local-orchestrator/src/types.ts` - Type definitions
- `/home/david/projects/stirlingbridge/local-orchestrator/src/commands.ts` - Built-in demo commands
- `/home/david/projects/stirlingbridge/local-orchestrator/src/secrets.ts` - Encrypted secret store
- `/home/david/projects/stirlingbridge/local-orchestrator/index.html` - UI template
**Machine Tool:**
- `/home/david/projects/stirlingbridge/machine/machine/main.py` - Click CLI entry point
- `/home/david/projects/stirlingbridge/machine/machine/cloud_config.py` - Cloud-init generation
- `/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py` - DO API wrapper
- `/home/david/projects/stirlingbridge/machine/machine/subcommands/create.py` - VM creation logic
- `/home/david/projects/stirlingbridge/machine/machine/config.py` - Config file parsingHere's my analysis of the best approaches, given your architecture:
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.
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)
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).
ttyd + challenge-response auth using the SSH keypair is the best fit because:
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.
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?
Thanks, very interesting. Reading this I wondered about another approach: could we use Webauthn to make a strongly secure authentication mechanism for the reverse proxy? User either uses a Yubikey, or passkey support in the browser. The newly installed reverse proxy on the target server somehow onboards them via Webauthn and subsequently authentication works like any web site that uses passkeys?
Interesting idea. It can work, but there are some structural challenges worth understanding before committing to it.
Orchestrator UI (origin: app.example.com)
|
| 1. User registers passkey (one-time, RP ID = app.example.com)
| 2. Orchestrator stores credential ID + public key
| 3. At VM creation, cloud-init provisions the credential public key
|
| --- on "connect terminal" ---
|
| 4. Browser fetches challenge from VM proxy
| 5. navigator.credentials.get() → user taps Yubikey / uses passkey
| 6. Signed assertion sent to VM proxy
| 7. VM proxy verifies signature against provisioned credential public key
| 8. Session established → WebSocket terminal opens
|
▼
[VM proxy verifies WebAuthn assertions, proxies to ttyd]
WebAuthn is origin-bound. The credential is tied to the Relying Party ID (RP ID), which is a domain. This has several implications:
1. The RP must be your orchestrator's origin, not the VM's. Since each VM is a different host (IP or subdomain), you can't register a passkey per-VM — the user would need to tap their Yubikey for a new registration on every VM creation. Instead, the passkey is registered once at the orchestrator's origin, and the VM proxy accepts assertions scoped to that origin.
2. The assertion ceremony must happen in the orchestrator's browsing context.
navigator.credentials.get() checks that the RP ID matches the current page's origin. So the WebAuthn challenge-response must be triggered from your orchestrator UI, then the resulting assertion is forwarded to the VM. This works fine — the assertion is just a signed blob you can send anywhere.
3. The VM proxy needs to know the orchestrator's origin.
When verifying the assertion, the proxy checks that clientDataJSON.origin matches an expected value. This is straightforward to provision via cloud-init.
This is the main wrinkle. WebAuthn registration itself must be authenticated — otherwise anyone could register their passkey with your orchestrator. So you still need an initial auth mechanism to gate registration:
After that first enrollment, all subsequent VM connections use the passkey. The credential public key gets provisioned to every new VM via cloud-init.
What it gives you over the SSH-key challenge-response approach: - Hardware-bound keys (Yubikey's private key is non-extractable) - Biometric option (fingerprint/face via platform passkeys) - Phishing resistance (browser enforces origin binding) - Standardized protocol with broad library support
What it costs you:
- Registration flow in the orchestrator UI (one-time, but still needs building)
- WebAuthn server-side verification library on the VM (Python: py_webauthn ~50KB; Go: go-webauthn ~5MB binary; Node: @simplewebauthn/server)
- The bootstrapping problem above
- More moving parts than raw signature verification
Compared to the SSH key approach:
| | SSH key challenge-response | WebAuthn |
|---|---|---|
| Key material | SSH private key in SecretStore (software) | Yubikey/passkey (can be hardware-bound) |
| Extractability | Key is in JS memory | Non-extractable from hardware authenticator |
| Browser API | WebCrypto (import PEM, sign) | navigator.credentials.get() (standardized) |
| VM-side verification | Parse authorized_keys, verify sig (~100 lines) | WebAuthn library (~same complexity) |
| Provisioning data | SSH public key (already there) | Credential ID + public key (new data path) |
| Registration | None (key already exists) | One-time enrollment flow needed |
WebAuthn is a solid choice if you value hardware-bound keys (Yubikey) or want the standard passkey UX. The orchestrator already has a natural place for the one-time registration, and the credential public key is small enough to provision via cloud-init alongside the SSH public key.
However, if your users are already managing SSH private keys in the browser's SecretStore, the SSH-key challenge-response approach is simpler and reuses infrastructure you already have — no new registration flow, no new credential to provision, and the VM already has the public key.
The two approaches aren't mutually exclusive either — you could support both, letting users choose passkey auth if they have a Yubikey, or SSH key auth as a fallback.
Want me to sketch out the implementation plan for either (or both) approaches?
No let me think about the options for a bit. I was aware that the RP ID is a domain, but we assume the new VM will be provisioned with a DNS hostname that we know. However I hadn't considered that the RP ID has to match the orchestrator origin domain. It won't! Perhaps there's a way around that by opening a new browser page directly to the proxy on the target VM, via a URL specifying its host name. There would need to be a way to pass some nonce between the orchestrator and that new page. Also while the user would be able to interact manually with that new page in a terminal session, if we wanted to send shell commands programatically to the target VM from the orchestrator application, that wouldn't work because auth would be bound to the other page.
You're thinking through this well. Let me address both points.
This does solve the RP ID problem — if the browser navigates to https://vm-abc.vms.example.com, the WebAuthn ceremony can use that domain (or a parent like vms.example.com) as the RP ID. The user registers a passkey scoped to vms.example.com, and it works across all VMs under that domain.
Passing a nonce from orchestrator to the new page: Several options:
- URL fragment (#nonce=...) — not sent to server, readable by JS on the new page
- window.open() + postMessage() — orchestrator opens the page and sends the nonce cross-origin
- Short-lived token in the URL path/query — the VM proxy validates it once to bootstrap the WebAuthn registration
But this leads to your second point, which is the real problem.
Exactly right. If auth is bound to a page on the VM's origin, the orchestrator app (different origin) can't use that session to send commands. You'd have two isolated contexts:
You could bridge them with postMessage() between the two windows, but that's fragile — the user could close the VM tab, and you're building a cross-origin RPC layer on top of a hack.
WebAuthn is fundamentally designed to bind auth to an origin. That's its strength for preventing phishing, but it works against you here because you have two origins that both need authenticated access to the same resource.
This brings it back to the SSH key challenge-response approach, which doesn't have the origin-binding constraint. The SSH private key is just bytes in your SecretStore — you can sign a challenge from any page, send the signature to any server, and it verifies the same way. You get:
The tradeoff is software keys vs. hardware-bound keys. If hardware binding is important to you, there might be a hybrid: use WebAuthn at the orchestrator origin purely to gate access to the SSH private key in SecretStore (i.e., "tap your Yubikey to unlock the terminal feature"), then use the SSH key for the actual VM auth. That gives you a physical confirmation step without the origin-binding problem.