Web Shell provides secure, browser-based terminal access to a provisioned machine. It supports both interactive shell sessions and programmatic command execution from JavaScript, authenticated using asymmetric cryptography (no shared secrets).
┌─────────────────────────────────────────┐
│ Browser │
│ │
│ Web Crypto API ──► JWT (signed) │
│ xterm.js ◄──► WebSocket │
│ JS/TS app code ──► command exec │
└──────────────┬──────────────────────────┘
│ wss://host/ws?token=<JWT>
▼
┌──────────────────────────────────────────┐
│ Machine │
│ │
│ Caddy (Let's Encrypt TLS) │
│ │ forward_auth on /ws │
│ ▼ │
│ jwt-verify.py (validates JWT signature) │
│ │ 200 OK → proxy │
│ ▼ │
│ ttyd (terminal server, localhost only) │
│ │ runs bash as shell user │
│ ▼ │
│ bash -l │
└──────────────────────────────────────────┘
| Component | Role | Listens on |
|---|---|---|
| Caddy | TLS termination (Let's Encrypt), reverse proxy, auth gating | Port 443 (public) |
| jwt-verify.py | Validates JWT signature against the provisioned public key | localhost:9222 |
| ttyd | Terminal server — connects browser WebSocket to a bash session | localhost:7681 |
Only Caddy is exposed to the network. ttyd and the JWT verifier listen on localhost only.
- During provisioning, an Ed25519 (or RSA/ECDSA) public key is placed on the machine. The corresponding private key never leaves the client.
- The browser signs a JWT using the private key (via the Web Crypto API).
- The JWT is sent as a query parameter:
wss://host/ws?token=<JWT>. - Caddy's
forward_authpasses the request tojwt-verify.py, which verifies the signature and checks token expiry. - If valid, Caddy proxies the WebSocket connection to ttyd.
This means no shared secret (password, API key, etc.) passes through the cloud-init chain of custody — only the public key is provisioned onto the machine.
The browser WebSocket API does not support setting custom HTTP headers. The token must be passed as a query parameter or a cookie. A query parameter is simplest and avoids session/cookie management.
- A machine with a public IP and a DNS record pointing to it (required for Let's Encrypt).
- The provisioning scripts from this repository.
Open the example app (examples/web-shell/index.html) in a browser and click Generate Key Pair. This creates an Ed25519 key pair entirely in the browser using the Web Crypto API.
Copy the Public Key (PEM) from the text area and save it to a file (e.g. public.pem). Click Save Keys to localStorage to persist the private key in the browser for later use.
Alternatively, generate keys with OpenSSL on any machine you control:
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -pubonly -out public.pem(If using OpenSSL, you would need to import the private key into your browser application for JWT signing.)
The public key file must be accessible on the machine during provisioning. Place it via cloud-init write_files, a shared volume, or any other mechanism.
Run web-shell.sh directly:
web-shell.sh --fqdn shell.example.com --jwt-public-key-file /path/to/public.pemOr via combine.sh alongside other provisioning scripts:
combine.sh \
--script-url packages.sh --script-args "build-essential" \
--script-url web-shell.sh \
--script-args "--fqdn shell.example.com --jwt-public-key-file /path/to/public.pem --shell-user myuser"Example ~/.machine/config.yaml:
machines:
dev-box:
new-user-name: dev
script-url: https://raw.githubusercontent.com/stirlingbridge/machine-provisioning/refs/heads/main/scripts/combine.sh
script-args: >-
--script-url web-shell.sh
--script-args "--fqdn shell.example.com --jwt-public-key-file /opt/keys/public.pem --shell-user dev"Open the example app in a browser, enter the machine's FQDN, and click Connect Terminal. If you saved keys to localStorage in Step 1, they will be loaded automatically.
| Argument | Required | Default | Description |
|---|---|---|---|
--fqdn DOMAIN |
Yes | — | Domain name for the machine. Caddy uses this for the Let's Encrypt certificate. |
--jwt-public-key-file PATH |
Yes | — | Path to a PEM-encoded public key file (Ed25519, RSA, or ECDSA) on the machine. |
--shell-user NAME |
No | webshell |
Linux user that shell sessions run as. Created if it doesn't exist. |
-f |
No | — | Force reinstall even if ttyd is already present. |
The example app's Interactive Terminal section provides a full terminal experience using xterm.js. It connects via WebSocket to ttyd, which runs bash -l as the configured shell user. You can type commands, use tab completion, run interactive programs (vim, top, etc.), and see output in real time — the same experience as SSH in a terminal emulator.
The Programmatic Command Execution section demonstrates running commands from JavaScript and capturing their output. This is the pattern to use when browser application code needs to orchestrate work on the remote machine.
The approach:
- Open a fresh WebSocket connection (separate from the interactive terminal).
- Wrap the command with unique sentinel markers:
export TERM=dumb echo __START_abc123__ your-command-here echo __END_abc123__ $? - Collect WebSocket output until both markers appear.
- Extract the text between the markers (clean stdout) and the exit code.
TERM=dumb suppresses most terminal escape sequences so the captured output is clean text.
The execCommand() function in web-shell.js implements this pattern and can be adapted for any application:
const result = await execCommand('ls -la /tmp');
console.log(result.stdout); // clean text output
console.log(result.exitCode); // 0It returns a { stdout: string, exitCode: number } object. Commands time out after 30 seconds by default.
| Function | Purpose |
|---|---|
generateKeyPair() |
Generate Ed25519 key pair (Web Crypto API) |
exportPublicKeyPem(key) |
Export public CryptoKey to PEM string |
createJwt(lifetimeSec) |
Sign a JWT with the private key |
connectTerminal() |
Open interactive terminal session |
execCommand(command) |
Run a command and capture output |
ttyd uses a simple binary WebSocket protocol with a one-byte message type prefix:
| Direction | Type byte | Payload | Meaning |
|---|---|---|---|
| Client → Server | 0 |
UTF-8 text | stdin (keystrokes or commands) |
| Client → Server | 1 |
JSON | Terminal resize: {"columns":80,"rows":24} |
| Server → Client | 0 |
UTF-8 text | stdout (terminal output) |
| Server → Client | 1 |
JSON | Configuration |
| Server → Client | 2 |
JSON | Window title |
- No shared secrets on the machine. Only the public key is provisioned. The private key exists only in the browser (or wherever the client application runs).
- ttyd and jwt-verify only listen on localhost. They are not reachable from the network — all access goes through Caddy.
- JWT expiry. Tokens include an
expclaim. The verifier rejects expired tokens. Use short lifetimes appropriate to your use case. - TLS everywhere. Caddy automatically obtains and renews a Let's Encrypt certificate. WebSocket connections use
wss://. - Shell user isolation. Sessions run as a dedicated non-root user. Configure this user's permissions, PATH, and environment to limit what can be done through the shell.
- Single session model. By default, ttyd shares one shell session across all connections. If you need isolated per-connection sessions, consider running ttyd with different configuration or multiple instances.
Ed25519 support in the Web Crypto API requires:
- Chrome 113+
- Firefox 130+
- Safari 17+
If your browser doesn't support Ed25519, the example app will show an error when generating keys. As a fallback, you could generate RSA or ECDSA keys instead — the JWT verifier on the machine accepts RS256, RS384, RS512, ES256, ES384, and ES512 in addition to EdDSA.
After provisioning, three systemd services run on the machine:
# Check status
sudo systemctl status caddy
sudo systemctl status ttyd
sudo systemctl status jwt-verify
# View logs
sudo journalctl -u ttyd -f
sudo journalctl -u jwt-verify -f
sudo journalctl -u caddy -f
# Restart after config changes
sudo systemctl restart caddy
sudo systemctl restart ttyd
sudo systemctl restart jwt-verifyConfiguration files:
| File | Purpose |
|---|---|
/etc/caddy/Caddyfile |
Caddy reverse proxy and TLS configuration |
/etc/web-shell/jwt-verify.py |
JWT verification service |
/etc/web-shell/public.pem |
Provisioned public key |
/etc/systemd/system/ttyd.service |
ttyd systemd unit |
/etc/systemd/system/jwt-verify.service |
JWT verifier systemd unit |
Caddy won't start / no TLS certificate: Ensure the FQDN resolves to the machine's public IP and that port 443 (and port 80 for the ACME HTTP challenge) are open.
WebSocket connection refused (401): The JWT is invalid or expired. Generate a new one. Check that the public key on the machine matches the private key used to sign the JWT.
WebSocket connection refused (502): ttyd or jwt-verify isn't running. Check systemctl status ttyd and systemctl status jwt-verify.
Terminal connects but no prompt appears: Check that the shell user exists and has a valid shell: getent passwd webshell.
Ed25519 not supported in browser: Use a recent browser version (see Browser Requirements above) or switch to RSA/ECDSA keys.