Sounds good. A few notes that may help with testing:
ttyd bash directly on the machine and hit http://localhost:7681 to verify the terminal works in isolation.web-shell.sh --jwt-public-key-file /path/to/public.pem --fqdn ....Let me know what you find — happy to iterate on any of it.
Will do. Perhaps you could write some documentation explaining how all this works and how to use it?
{
"file_path": "/home/david/projects/stirlingbridge/machine-provisioning/README.md"
}1 # Provisioning scripts for the machine utility 2 3 These provisioning scripts are designed to be used in conjunction with any Linux machine provisioning 4 tool that can execute a script after first boot (typically via the `cloud-init` mechanism), for example 5 the [machine](https://github.com/stirlingbridge/machine) utility. 6 7 ## Scripts 8 9 ### combine.sh 10 Supports the execution of several other scripts together (useful because machine provisioning only allows one script to be executed). 11 ### docker.sh 12 Installs Docker and performs associated system configuration. 13 ### k3s-node.sh 14 Installs a single-node k8s cluster using k3s. 15 ### podman.sh 16 Installs podman (only install one of: Docker and podman). 17 ### stack.sh 18 Installs the [stack](https://github.com/bozemanpass/stack) application deployment utility. 19 20 ## Example 21 Scripts can be used individually, or together to provision more complex machine configurations specifying arguments as shown in the following `~/.machine/config.yaml` example. It provisions a machine that has the `build-essential` package installed, then podman, the stack utility and finally a single node k8s cluster, with appropriate configuration for hosting applications with TLS: 22 ```yaml 23 machines: 24 k8s-stack-host: 25 new-user-name: bpi 26 script-dir: /opt/bpi 27 script-url: https://raw.githubusercontent.com/stirlingbridge/machine-provisioning/refs/heads/main/scripts/combine.sh 28 script-path: /opt/bpi/combine.sh 29 script-args: >- 30 --script-url packages.sh --script-args "build-essential" 31 --script-url podman.sh 32 --script-url stack.sh 33 --script-url k3s-node.sh 34 --script-args "-y --letsencrypt-email user@example.com --do-dns-access-token ZZZZ --image-registry registry.digitalocean.com --image-registry-username user@example.com --image-registry-password YYYY" 35 ``` 36
# Web Shell — Browser-Based Remote Shell Access
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).
## Architecture
```
┌─────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────┘
```
### Components on the machine
| 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.
### Authentication flow
1. During provisioning, an Ed25519 (or RSA/ECDSA) **public key** is placed on the machine. The corresponding private key never leaves the client.
2. The browser signs a JWT using the private key (via the Web Crypto API).
3. The JWT is sent as a query parameter: `wss://host/ws?token=<JWT>`.
4. Caddy's `forward_auth` passes the request to `jwt-verify.py`, which verifies the signature and checks token expiry.
5. 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.
### Why a query parameter for the token?
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.
## Setup
### Prerequisites
- A machine with a public IP and a DNS record pointing to it (required for Let's Encrypt).
- The provisioning scripts from this repository.
### Step 1: Generate a key pair
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:
```bash
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.)
### Step 2: Provision the machine
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:
```bash
web-shell.sh --fqdn shell.example.com --jwt-public-key-file /path/to/public.pem
```
Or via `combine.sh` alongside other provisioning scripts:
```bash
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`:
```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"
```
### Step 3: Connect
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.
### Script arguments
| 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. |
## Usage
### Interactive terminal
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.
### Programmatic command execution
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:
1. Open a fresh WebSocket connection (separate from the interactive terminal).
2. Wrap the command with unique sentinel markers:
```
export TERM=dumb
echo __START_abc123__
your-command-here
echo __END_abc123__ $?
```
3. Collect WebSocket output until both markers appear.
4. 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.
#### Using `execCommand()` in your own code
The `execCommand()` function in `web-shell.js` implements this pattern and can be adapted for any application:
```javascript
const result = await execCommand('ls -la /tmp');
console.log(result.stdout); // clean text output
console.log(result.exitCode); // 0
```
It returns a `{ stdout: string, exitCode: number }` object. Commands time out after 30 seconds by default.
#### Key functions in web-shell.js
| 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 WebSocket protocol
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 |
## Security considerations
- **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 `exp` claim. 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.
## Browser requirements
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.
## Installed services
After provisioning, three systemd services run on the machine:
```bash
# 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-verify
```
Configuration 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 |
## Troubleshooting
**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.
File created successfully at: /home/david/projects/stirlingbridge/machine-provisioning/examples/web-shell/README.md (file state is current in your context — no need to Read it back)
### stack.sh Installs the [stack](https://github.com/bozemanpass/stack) application deployment utility. ## Example
### stack.sh Installs the [stack](https://github.com/bozemanpass/stack) application deployment utility. ### web-shell.sh Installs browser-based remote shell access using ttyd, Caddy (with Let's Encrypt TLS), and JWT authentication with asymmetric keys. Supports interactive terminal sessions and programmatic command execution from browser JavaScript. See [examples/web-shell/](examples/web-shell/) for a complete example app and detailed documentation. ## Example
The file /home/david/projects/stirlingbridge/machine-provisioning/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Done. Here's what was created:
examples/web-shell/README.md covers:
- Architecture diagram and component overview
- Authentication flow explanation (why public key only, why query parameter for token)
- Step-by-step setup: key generation → provisioning → connecting
- Script argument reference
- Interactive terminal usage
- Programmatic command execution pattern with code examples
- ttyd WebSocket protocol reference table
- Security considerations
- Browser requirements
- Systemd service management and config file locations
- Troubleshooting guide
README.md (project root) — added a web-shell.sh entry to the scripts list with a pointer to the detailed docs.