The current project is a collection of scripts that are intended to be run under cloud-init on a newly provisioned vm. They install and configure certain software such as docker and k3s. The idea is that when provisioning a machine a set of these scripts may be specified (in turn run by the "combine.sh" script) and the result will be a machine running that set of software. I would like some help with a new script that will install a web remote access service but first perhaps you could take a look at what's already here.
{
"subagent_type": "Explore",
"prompt": "Explore this machine provisioning project thoroughly. I need to understand:\n1. The overall directory structure and all scripts present\n2. How combine.sh works (read it fully)\n3. The pattern used by existing install scripts (read 2-3 of them to understand conventions)\n4. Any README or documentation\n\nReport back with a clear summary of the project structure, how scripts are organized, and the conventions/patterns they follow (e.g. how they handle arguments, logging, error handling, dependencies)."
}Perfect! Now I have a complete picture of the project. Let me compile a comprehensive summary.
## Machine Provisioning Project Summary
### Project Overview
This is a collection of bash provisioning scripts designed for automated machine setup via cloud-init and similar tools. The scripts are modular and can be executed individually or combined using the `combine.sh` orchestrator. They're designed to work with the [machine](https://github.com/stirlingbridge/machine) utility for Linux machine provisioning.
### Directory Structure
```
/home/david/projects/stirlingbridge/machine-provisioning/
├── README.md (Documentation and examples)
└── scripts/ (All executable bash scripts)
├── combine.sh (Orchestrator - runs multiple scripts)
├── docker.sh (Docker installation)
├── k3s-node.sh (Kubernetes single-node cluster setup)
├── podman.sh (Podman container runtime)
├── packages.sh (Generic package installer)
├── stack.sh (Stack application deployment utility)
├── health.sh (Cloud-init health check server)
├── fqdn.sh (FQDN configuration)
└── error.sh (Test error script)
```
### Script Organization & Conventions
All scripts follow these consistent patterns:
#### 1. **Debug Mode & Environment Setup**
```bash
if [[ -n "$MACHINE_SCRIPT_DEBUG" ]]; then
set -x
fi
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
```
- Respects `MACHINE_SCRIPT_DEBUG` environment variable for debug output
- Sets non-interactive Debian frontend to avoid prompts
- Uses `NEEDRESTART_MODE=a` to auto-restart services
#### 2. **Error Handling**
- Most scripts use `set -eo pipefail` (or `-euo pipefail`)
- This ensures scripts exit on first error and fail if pipes break
- k3s-node.sh delays `set -e` to allow checking installed packages before enabling strict mode
#### 3. **APT Installation Pattern**
```bash
APT_INSTALL="sudo --preserve-env=DEBIAN_FRONTEND,NEEDRESTART_MODE apt -y install"
```
All scripts use a standardized APT install alias that:
- Preserves environment variables for non-interactive mode
- Uses `-y` flag for automatic yes to prompts
- Prefixed with `sudo --preserve-env=...`
#### 4. **Argument Handling**
Scripts use different patterns depending on complexity:
**Simple scripts** (packages.sh): Accept positional arguments directly
```bash
sudo apt update
$APT_INSTALL $*
```
**Complex scripts** use `getopts` (stack.sh) or while-loop parsing (combine.sh, k3s-node.sh):
```bash
while (( "$#" )); do
case $1 in
--flag)
shift&&VALUE="$1"||die
;;
esac
shift
done
```
#### 5. **Pre-Installation Checks**
Scripts check if software is already installed:
```bash
which docker >/dev/null && rc=$? || rc=$?
if [[ $rc -eq 0 ]]; then
echo "docker already installed."
if [[ "$1" != "-f" ]]; then
exit 0
fi
fi
```
- Allows forcing reinstallation with `-f` flag
- Avoids redundant installs
#### 6. **Helper Functions**
**maybe_install()** - Used in combine.sh, k3s-node.sh, health.sh, and stack.sh:
```bash
function maybe_install {
local todo=""
while (( "$#" )); do
which $1 >/dev/null && exists=true || exists=false
if [[ "true" != "$exists" ]]; then
todo="$todo $1"
fi
shift
done
if [[ ! -z "$todo" ]]; then
sudo apt -y update
sudo --preserve-env=DEBIAN_FRONTEND,NEEDRESTART_MODE apt -y install $todo
fi
}
```
Installs only packages that don't already exist.
**retry()** - Used in k3s-node.sh:
```bash
function retry {
local try=0
local max=5
local delay=10
while [ $try -lt $max ]; do
try=$((try + 1))
$* && RC=$? || RC=$?
if [ $RC -eq 0 ]; then
return 0
else
sleep $delay
fi
done
return 1
}
```
Retries commands up to 5 times with 10-second delays.
#### 7. **Logging & Output**
- All scripts echo their progress with separator lines (`*` and `#` lines)
- Echo arguments at the start: `echo "$0 called with $*"`
- Clear section headers make multi-script output easy to follow
---
### How combine.sh Works
**Purpose**: Orchestrates execution of multiple provisioning scripts in sequence, handling both local and remote (HTTP) scripts.
**Key Features**:
1. **Script Source Detection** (lines 66-77):
- Local paths (starting with `/`): Execute directly
- HTTP(S) URLs: Download with wget to `/tmp/combine.step.$N`
- Relative names: Prepend `DEFAULT_SCRIPT_URL_PREFIX` (defaults to GitHub repo)
2. **Argument Parsing** (lines 21-34):
- `--script-url SCRIPT`: Adds script to execution queue
- `--script-args ARGS`: Associates args with the most recent script
- Uses associative array: `ARGS[$(( ${#SCRIPTS[@]} ))]` to map step number to args
3. **Execution Flow** (lines 59-90):
```bash
for script in "${SCRIPTS[@]}"; do
# Download if needed
# Run: $cmd ${ARGS["$step"]}
# Check return code
# Break on first failure
done
```
- Executes sequentially
- Stops on first script failure (rc != 0)
- Cleans up temp files after completion
4. **Error Handling**:
- Captures return code of each script
- Prints "FAILED rc=X" on error
- Exits with final script's return code
**Example Usage** (from README):
```bash
combine.sh \
--script-url packages.sh --script-args "build-essential" \
--script-url podman.sh \
--script-url k3s-node.sh --script-args "-y --letsencrypt-email user@example.com"
```
---
### Individual Script Summaries
| Script | Purpose | Args | Dependencies |
|--------|---------|------|--------------|
| **docker.sh** | Install Docker from official repo | `-f` (force reinstall) | wget, apt |
| **podman.sh** | Install podman container runtime | `-f` (force reinstall) | apt |
| **packages.sh** | Generic package installer | Any apt package names | apt |
| **stack.sh** | Install stack deployment tool | `-f` (force), `-b` (build from source), `-v VERSION` | git, python3 (if building) |
| **k3s-node.sh** | Install single-node k3s cluster + nginx + cert-manager | `-y` (skip warning), `--letsencrypt-email`, `--image-registry`, `--do-dns-access-token` | curl, kubectl |
| **health.sh** | Start health check HTTP server | `--port PORT` | python3 |
| **fqdn.sh** | Configure FQDN from env var | Uses `$MACHINE_FQDN` env var | none |
| **error.sh** | Intentional error for testing | none | none |
---
### Key Patterns & Best Practices Used
1. **Idempotency**: Scripts check if software already exists before installing
2. **Defensive bash**: Uses `set -eo pipefail` for safer script execution
3. **Non-interactive**: All apt operations set `DEBIAN_FRONTEND=noninteractive`
4. **Modular design**: Each script has a single responsibility
5. **Composability**: Scripts can be chained via combine.sh
6. **Temporary file safety**: Uses `$$` (PID) for unique temp files in `/tmp`
7. **Comprehensive logging**: Clear progress markers and section headers
8. **Platform detection**: k3s-node.sh detects Debian vs Ubuntu
9. **Conditional features**: k3s-node.sh conditionally configures cert-manager and DNS based on provided args
10. **User notifications**: Provides next steps and helpful messages at end of scriptsHere's what the project contains:
Structure: All scripts live in scripts/ and are designed to be run standalone or composed via combine.sh.
Existing scripts:
| Script | Purpose |
|--------|---------|
| combine.sh | Orchestrator — chains scripts with per-script args |
| docker.sh | Docker from official repo |
| podman.sh | Podman container runtime |
| packages.sh | Generic apt package installer |
| k3s-node.sh | Single-node k3s + nginx ingress + cert-manager |
| stack.sh | Stack deployment tool |
| health.sh | Cloud-init health check HTTP server |
| fqdn.sh | Set FQDN from env var |
| error.sh | Test script (intentional failure) |
Conventions the scripts follow:
- set -eo pipefail for error handling
- MACHINE_SCRIPT_DEBUG env var enables set -x
- DEBIAN_FRONTEND=noninteractive + NEEDRESTART_MODE=a
- Standard APT_INSTALL alias with sudo --preserve-env=...
- maybe_install() helper to skip already-installed packages
- Idempotency checks (which <tool> before installing, -f to force)
- Clear logging with separator lines and echo "$0 called with $*"
- getopts or while/case for argument parsing
Now — what web remote access service did you have in mind? (e.g. Apache Guacamole, Cockpit, Teleport, Meshcentral, something else?)
I'm not sure. That's one thing I'd like help with. I want to add a remote shell access capability. I think it's a given that this needs to support a websocket interface so browser code can use it. It needs to have strong authentication, which I believe in all cases needs to be added with a reverse proxy. Authentication should use some public key scheme so we don't end up exposing a shared secret through the cloud-init chain of custody. The facility needs to be able to support a full "ssh experience" in the browser where a user can see shell session output and type commands. The session should run in the context of a user (not root). It also needs to support command injection and stdout capture for commands run on the remote system at the behest of JS/TS code in a browser app. That is we want to be able to manually type and see output, but also have browser code "orchestrate" the running of remote commands and capture of their output. I'd like the Linux side of this to be as simple and lightweight as practical (e.g. better to not need Node.JS on the machine). When I search for solutions in this space the results are confusing because documentation and articles tends to be form the perspective of a system administrator feature for end users, whereas I am in a sense "embedding" the functionality. Therefore I don't need ancillary functionality such as filesystem browsing, but I do need to be concerned about security and the set of dependencies required.
Good set of requirements. Let me break down the landscape and give you a recommendation.
| Solution | Language | Interactive Shell | Programmatic Exec | Weight | Notes |
|---|---|---|---|---|---|
| ttyd | C | Yes | Via WebSocket | ~500KB binary | Bundles xterm.js, clean WS protocol |
| gotty | Go | Yes | Via WebSocket | Single binary | Unmaintained since ~2017 |
| wetty | Node.js | Yes | Limited | Heavy | Ruled out (Node dep) |
| shellinabox | C | Yes | No | Light | Unmaintained, no WebSocket |
| Cockpit/Guacamole | Mixed | Yes | Overkill | Heavy | Admin-oriented, not embeddable |
ttyd is the strongest fit for your requirements:
0 + data for stdin/stdout, resize messages, etc.sudo -u appuser ttyd bash), or you can use --credential to map to a login session.Caddy as reverse proxy handles:
Browser (xterm.js / JS code)
│
│ wss:// + client cert
▼
Caddy (mTLS termination, reverse proxy)
│
│ ws://localhost:7681
▼
ttyd (runs bash as non-root user)
Interactive shell: User opens the page, xterm.js connects via WebSocket, full terminal experience.
Programmatic command exec: Browser JS opens a WebSocket directly to the same endpoint. ttyd's protocol is:
- Send: 0 + command + \n (stdin)
- Receive: 0 + output (stdout)
The challenge is that stdout includes prompts and ANSI escape codes. Two mitigations:
1. Set TERM=dumb to suppress most escapes
2. Use sentinel markers — e.g. wrap commands as echo __START__; yourcommand; echo __END__ $? — so your JS can extract clean output and exit codes
This isn't as clean as a dedicated exec API, but it means one component on the Linux side instead of two, and the same auth infrastructure covers both paths.
Want me to go ahead and write this script following the project's conventions? I'd also want to confirm: are you okay with Caddy, or do you have a preference for nginx (which is already pulled in by k3s-node.sh)?
Use of Caddy is fine. This sounds like a good approach but I have some questions: is mTLS the same as client certificates? I thought those were very hard to provision and basically not used any longer, so I'm curious how that works. We may be able to use Let's Encrypt support in Caddy to issue a public CA-signed certificate.
You're right to question this — let me clarify.
mTLS and client certificates are the same thing, yes. And you're right that client certs have significant friction:
Client certs work well for service-to-service auth (where both ends are automated), but for a browser-based tool where humans need access, they're a poor fit. I should have thought that through more carefully.
Let's Encrypt handles the server certificate (so the browser trusts the server). That's the easy part and Caddy does it automatically.
For client authentication without shared secrets, the practical options are:
JWT signed with an asymmetric key pair (e.g. Ed25519 or RS256): During provisioning, you install only the public key on the machine. Your browser app signs JWTs with the private key (which never leaves your app/infrastructure). Caddy verifies the JWT signature using the public key before proxying to ttyd. No secret passes through cloud-init.
OAuth2/OIDC: Caddy can validate tokens from an external identity provider (e.g. your own service, or something like Auth0). The machine only needs the provider's public JWKS endpoint URL — again, no secrets on the machine.
Option 1 is simpler and self-contained — no external IdP dependency. The flow would be:
Your app (has private key)
│
│ Signs a JWT
▼
Browser opens wss://machine.example.com/ws?token=<JWT>
│
▼
Caddy (has public key, verifies JWT signature)
│
│ Valid → proxy to ttyd
▼
ttyd
During cloud-init you'd only provision: - The Ed25519/RSA public key (safe to expose) - Caddy config that references it
Does this approach work for your use case? If so I'll write the script using JWT verification with an asymmetric key pair, with Let's Encrypt for the server TLS.
Before I write the script, one design detail worth surfacing: Caddy doesn't include JWT verification natively. Building a custom Caddy binary with a plugin would require Go on the machine, which is heavy. Instead, the clean approach is:
python3-jwt + python3-cryptography (apt packages, no pip) as a JWT verifierforward_auth directive calls it on WebSocket requests onlyBrowser → wss://host/ws?token=<JWT>
│
Caddy (Let's Encrypt TLS)
│
forward_auth → Python JWT verifier (localhost:9222)
│ extracts token from X-Forwarded-Uri
│ verifies signature with public key
▼
reverse_proxy → ttyd (localhost:7681, runs bash as shell user)
Let me write it.
{
"file_path": "/home/david/projects/stirlingbridge/machine-provisioning/scripts/docker.sh"
}1 #!/usr/bin/env bash
2 if [[ -n "$MACHINE_SCRIPT_DEBUG" ]]; then
3 set -x
4 fi
5
6 export DEBIAN_FRONTEND=noninteractive
7 export NEEDRESTART_MODE=a
8
9 APT_INSTALL="sudo --preserve-env=DEBIAN_FRONTEND,NEEDRESTART_MODE apt -y install"
10
11 set -eo pipefail ## https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
12
13 which docker >/dev/null && rc=$? || rc=$?
14 if [[ $rc -eq 0 ]]; then
15 echo "docker already installed."
16 if [[ "$1" != "-f" ]]; then
17 exit 0
18 fi
19 fi
20
21 sudo apt update
22 $APT_INSTALL apt-transport-https ca-certificates curl software-properties-common curl
23
24 if [[ ! -f "/usr/share/keyrings/docker-archive-keyring.gpg" ]]; then
25 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
26 fi
27
28 echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
29
30 sudo apt update
31 $APT_INSTALL docker-ce
32
33 sudo usermod -aG docker ${USER}
34 {
"file_path": "/home/david/projects/stirlingbridge/machine-provisioning/scripts/combine.sh"
}1 #!/usr/bin/env bash
2 if [[ -n "$MACHINE_SCRIPT_DEBUG" ]]; then
3 set -x
4 fi
5
6 DEFAULT_SCRIPT_URL_PREFIX="$(dirname ${MACHINE_SCRIPT_URL})"
7 if [[ -z "$DEFAULT_SCRIPT_URL_PREFIX" ]]; then
8 DEFAULT_SCRIPT_URL_PREFIX="https://raw.githubusercontent.com/bozemanpass/machine-provisioning/refs/heads/main/scripts"
9 fi
10
11 export DEBIAN_FRONTEND=noninteractive
12 export NEEDRESTART_MODE=a
13
14 echo "$0 called with $*"
15
16 set -eo pipefail ## https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
17
18 SCRIPTS=()
19 declare -A ARGS
20
21 while (( "$#" )); do
22 case $1 in
23 --script-url)
24 shift&&SCRIPTS+=("$1")||die
25 ;;
26 --script-args)
27 shift&&ARGS[$(( ${#SCRIPTS[@]} ))]="$1"||die
28 ;;
29 *)
30 echo "Unrecognized argument: $1"
31 ;;
32 esac
33 shift
34 done
35
36 function maybe_install {
37 local todo=""
38 while (( "$#" )); do
39 local exists=false
40 which $1 >/dev/null && exists=true || exists=false
41 if [[ "true" != "$exists" ]]; then
42 todo="$todo $1"
43 fi
44 shift
45 done
46 if [[ ! -z "$todo" ]]; then
47 echo "**************************************************************************************"
48 echo "Installing required packages"
49 sudo apt -y update
50 sudo --preserve-env=DEBIAN_FRONTEND,NEEDRESTART_MODE apt -y install $todo
51 fi
52 }
53
54 maybe_install wget
55
56 step=0
57 rc=0
58
59 for script in "${SCRIPTS[@]}"; do
60 step=$((step + 1))
61 echo "**************************************************************************************"
62 echo "$script BEGIN"
63 cmd=""
64
65 # Local path
66 if [[ $script == /* ]]; then
67 cmd="$script"
68 else
69 script_url="$script"
70 if [[ $script_url != http* ]]; then
71 script_url="${DEFAULT_SCRIPT_URL_PREFIX}/${script}"
72 fi
73 echo "Downloading $script_url to /tmp/combine.script.$step ..."
74 wget -q -O /tmp/combine.step.$step "$script_url"
75 chmod 700 /tmp/combine.step.$step
76 cmd=/tmp/combine.step.$step
77 fi
78
79 echo "Running: $cmd ${ARGS["$step"]}"
80 $cmd ${ARGS["$step"]} && rc=$? || rc=$?
81 if [[ $rc -ne 0 ]]; then
82 echo "$script FAILED rc=$rc"
83 fi
84 echo "$script END"
85 echo "#####################################################################################"
86
87 if [[ $rc != 0 ]]; then
88 break
89 fi
90 done
91
92 rm -f /tmp/combine.step.*
93
94 if [[ $rc -eq 0 ]]; then
95 echo "All scripts completed successfully."
96 fi
97
98 exit $rc
99 #!/usr/bin/env bash
if [[ -n "$MACHINE_SCRIPT_DEBUG" ]]; then
set -x
fi
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
APT_INSTALL="sudo --preserve-env=DEBIAN_FRONTEND,NEEDRESTART_MODE apt -y install"
set -eo pipefail ## https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
echo "***********************************************************************"
echo "* web-shell.sh"
echo "***********************************************************************"
echo "$0 called with $*"
# Defaults
TTYD_PORT=7681
VERIFY_PORT=9222
SHELL_USER="webshell"
FQDN=""
JWT_PUBLIC_KEY_FILE=""
FORCE=false
while (( "$#" )); do
case $1 in
--fqdn)
shift&&FQDN="$1"||{ echo "Missing --fqdn value"; exit 1; }
;;
--jwt-public-key-file)
shift&&JWT_PUBLIC_KEY_FILE="$1"||{ echo "Missing --jwt-public-key-file value"; exit 1; }
;;
--shell-user)
shift&&SHELL_USER="$1"||{ echo "Missing --shell-user value"; exit 1; }
;;
-f)
FORCE=true
;;
*)
echo "Unrecognized argument: $1"
;;
esac
shift
done
# Validate required arguments
if [[ -z "$FQDN" ]]; then
echo "Error: --fqdn is required (for Let's Encrypt TLS)"
exit 1
fi
if [[ -z "$JWT_PUBLIC_KEY_FILE" ]]; then
echo "Error: --jwt-public-key-file is required (PEM-encoded public key for JWT verification)"
exit 1
fi
if [[ ! -f "$JWT_PUBLIC_KEY_FILE" ]]; then
echo "Error: JWT public key file not found: $JWT_PUBLIC_KEY_FILE"
exit 1
fi
# Check if already installed
if [[ "$FORCE" != "true" ]]; then
which ttyd >/dev/null 2>&1 && rc=$? || rc=$?
if [[ $rc -eq 0 ]]; then
echo "ttyd already installed. Use -f to force reinstall."
exit 0
fi
fi
echo "***********************************************************************"
echo "* Installing packages"
echo "***********************************************************************"
sudo apt update
$APT_INSTALL ca-certificates curl python3-jwt python3-cryptography
# Install Caddy from official repository
if ! which caddy >/dev/null 2>&1; then
echo "***********************************************************************"
echo "* Installing Caddy"
echo "***********************************************************************"
$APT_INSTALL debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
$APT_INSTALL caddy
fi
# Install ttyd binary
echo "***********************************************************************"
echo "* Installing ttyd"
echo "***********************************************************************"
TTYD_VERSION="1.7.7"
ARCH=$(uname -m)
case $ARCH in
x86_64) TTYD_ARCH="x86_64" ;;
aarch64) TTYD_ARCH="aarch64" ;;
armv7l) TTYD_ARCH="armhf" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -fsSL -o /tmp/ttyd.$$ "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${TTYD_ARCH}"
sudo install -m 755 /tmp/ttyd.$$ /usr/local/bin/ttyd
rm -f /tmp/ttyd.$$
# Create shell user if needed
if ! id "$SHELL_USER" >/dev/null 2>&1; then
echo "***********************************************************************"
echo "* Creating user: $SHELL_USER"
echo "***********************************************************************"
sudo useradd -m -s /bin/bash "$SHELL_USER"
fi
# Set up config directory
echo "***********************************************************************"
echo "* Writing configuration"
echo "***********************************************************************"
sudo mkdir -p /etc/web-shell
sudo cp "$JWT_PUBLIC_KEY_FILE" /etc/web-shell/public.pem
sudo chmod 644 /etc/web-shell/public.pem
# Write JWT verification service
sudo tee /etc/web-shell/jwt-verify.py > /dev/null << 'PYEOF'
#!/usr/bin/env python3
"""Tiny JWT verification service for Caddy forward_auth."""
import sys, http.server
from urllib.parse import urlparse, parse_qs
import jwt
PUBLIC_KEY_PATH = sys.argv[1]
LISTEN_PORT = int(sys.argv[2])
with open(PUBLIC_KEY_PATH, 'rb') as f:
PUBLIC_KEY = f.read()
ALGORITHMS = ["EdDSA", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
class AuthHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
uri = self.headers.get('X-Forwarded-Uri', self.path)
params = parse_qs(urlparse(uri).query)
token = params.get('token', [None])[0]
if token:
try:
jwt.decode(token, PUBLIC_KEY, algorithms=ALGORITHMS)
self.send_response(200)
self.end_headers()
return
except jwt.exceptions.PyJWTError:
pass
self.send_response(401)
self.end_headers()
def log_message(self, format, *args):
pass
http.server.HTTPServer(('127.0.0.1', LISTEN_PORT), AuthHandler).serve_forever()
PYEOF
sudo chmod 644 /etc/web-shell/jwt-verify.py
# Write Caddyfile
sudo tee /etc/caddy/Caddyfile > /dev/null << CADDYEOF
${FQDN} {
@ws path /ws
forward_auth @ws 127.0.0.1:${VERIFY_PORT} {
uri /verify
}
reverse_proxy 127.0.0.1:${TTYD_PORT}
}
CADDYEOF
# Write systemd unit for ttyd
sudo tee /etc/systemd/system/ttyd.service > /dev/null << SVCEOF
[Unit]
Description=ttyd web terminal
After=network.target
[Service]
Type=simple
User=${SHELL_USER}
ExecStart=/usr/local/bin/ttyd --port ${TTYD_PORT} --interface lo --ping-interval 30 bash -l
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVCEOF
# Write systemd unit for JWT verifier
sudo tee /etc/systemd/system/jwt-verify.service > /dev/null << SVCEOF
[Unit]
Description=JWT verification service for web-shell
After=network.target
[Service]
Type=simple
User=nobody
ExecStart=/usr/bin/python3 /etc/web-shell/jwt-verify.py /etc/web-shell/public.pem ${VERIFY_PORT}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVCEOF
# Enable and start services
echo "***********************************************************************"
echo "* Starting services"
echo "***********************************************************************"
sudo systemctl daemon-reload
sudo systemctl enable --now jwt-verify.service
sudo systemctl enable --now ttyd.service
sudo systemctl restart caddy
echo "***********************************************************************"
echo "* web-shell.sh complete"
echo "***********************************************************************"
echo " FQDN: ${FQDN}"
echo " Shell user: ${SHELL_USER}"
echo " ttyd: 127.0.0.1:${TTYD_PORT} (localhost only)"
echo " JWT verify: 127.0.0.1:${VERIFY_PORT} (localhost only)"
echo " Caddy: https://${FQDN} (Let's Encrypt TLS)"
echo ""
echo " Interactive: https://${FQDN}/?token=<JWT>"
echo " WebSocket: wss://${FQDN}/ws?token=<JWT>"
File created successfully at: /home/david/projects/stirlingbridge/machine-provisioning/scripts/web-shell.sh (file state is current in your context — no need to Read it back)
chmod +x /home/david/projects/stirlingbridge/machine-provisioning/scripts/web-shell.sh
Here's what the script does:
Components installed: - ttyd (1.7.7 binary from GitHub) — C-based terminal server, ~500KB, serves xterm.js UI and WebSocket - Caddy (from official apt repo) — reverse proxy with automatic Let's Encrypt - python3-jwt + python3-cryptography (apt packages) — for the JWT verifier
Three systemd services:
- ttyd.service — runs bash -l as the shell user, listens on localhost:7681 only
- jwt-verify.service — 25-line Python script, listens on localhost:9222
- caddy — TLS termination, forward_auth on /ws path, reverse proxy to ttyd
Arguments:
- --fqdn DOMAIN (required) — for Let's Encrypt cert
- --jwt-public-key-file PATH (required) — PEM public key (Ed25519, RSA, or ECDSA)
- --shell-user NAME (default: webshell) — user context for shell sessions
- -f — force reinstall
Auth flow: Only the WebSocket path (/ws) requires JWT auth. The static UI assets (xterm.js, CSS) are served without auth since they're inert without a valid WebSocket connection. The token goes as a query parameter (?token=<JWT>) since the browser WebSocket API doesn't support custom headers.
Usage via combine.sh:
combine.sh \
--script-url web-shell.sh \
--script-args "--fqdn shell.example.com --jwt-public-key-file /path/to/public.pem"
A few things to consider:
openssl genpkey -algorithm ed25519 -out private.pem then openssl pkey -in private.pem -pubonly -out public.pem. Only public.pem goes to the machine.exp). We could add audience/issuer checks if you want tighter scoping.--once (one-shot) or we could explore alternatives.Want me to adjust anything — e.g. add claim validation, handle multi-session, or tweak the auth flow?
Thanks this looks promising. A couple of things: we need to be able to run all client code in the browser, so the use of the openssl CLI utility is not allowed. Presumably there are equivalent web crypto (and associated libraries) capabilities that can be used. Thinking further, in order to both test and show how to use this web shell facility we probably need to provide a minimal example web app showing how it is done.
Good point — the Web Crypto API supports Ed25519 (in modern browsers) and RS256/ES256 broadly. A minimal example app would serve as both a test harness and reference implementation.
Let me think about what this needs:
The example app should be a single HTML file (or small set of static files) that a developer can open locally or serve from anywhere — no build step, no server-side runtime.
Let me also think about the key provisioning workflow from the browser perspective:
1. User generates Ed25519 key pair in browser (Web Crypto)
2. Exports public key as PEM → provides it during machine provisioning
3. Private key stays in browser (localStorage or downloaded)
4. To connect: browser signs a JWT with private key, opens WebSocket with ?token=JWT
Let me build this out.
{
"subagent_type": "Explore",
"prompt": "Quick check: in /home/david/projects/stirlingbridge/machine-provisioning, is there an existing examples/ or demo/ directory, or any HTML/JS files? Also check if there's anything in the README about example usage patterns. Just report what exists, keep it brief."
}**Summary:** - **No examples/ or demo/ directories** found - **No HTML/JS files** in the repo (this is a shell script provisioning repo) - **README example usage:** Yes - contains a YAML config example showing how to use the scripts via the `machine` utility, demonstrating how to chain multiple provisioning scripts together with `combine.sh` The repo is straightforward: shell scripts for machine provisioning with usage documented in the README via a YAML config example.
ls /home/david/projects/stirlingbridge/machine-provisioning/
I'll create an examples/web-shell/ directory with a minimal static web app — no build step, no dependencies beyond what's loaded from CDN. It'll demonstrate key generation, interactive terminal, and programmatic command execution.
mkdir -p /home/david/projects/stirlingbridge/machine-provisioning/examples/web-shell
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Shell Example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
h1 { font-size: 1.3em; margin-bottom: 16px; color: #a0c4ff; }
h2 { font-size: 1.1em; margin-bottom: 8px; color: #a0c4ff; }
.panel {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
}
.row { display: flex; gap: 16px; margin-bottom: 16px; }
.row > * { flex: 1; }
label { display: block; font-size: 0.85em; color: #999; margin-bottom: 4px; }
input, textarea {
width: 100%;
background: #0a0a1a;
border: 1px solid #0f3460;
color: #e0e0e0;
padding: 8px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
textarea { resize: vertical; min-height: 60px; }
button {
background: #0f3460;
color: #e0e0e0;
border: 1px solid #1a5276;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
margin-right: 8px;
margin-top: 8px;
}
button:hover { background: #1a5276; }
button:disabled { opacity: 0.4; cursor: default; }
#terminal-container {
height: 400px;
background: #000;
border-radius: 4px;
overflow: hidden;
}
#exec-output {
background: #0a0a1a;
border: 1px solid #0f3460;
color: #b0f0b0;
padding: 12px;
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
min-height: 80px;
max-height: 300px;
overflow-y: auto;
border-radius: 4px;
}
.status { font-size: 0.85em; margin-top: 8px; }
.status.ok { color: #4caf50; }
.status.err { color: #f44336; }
.status.info { color: #ffa726; }
</style>
</head>
<body>
<h1>Web Shell Example</h1>
<!-- Key Management -->
<div class="panel">
<h2>1. Key Pair</h2>
<p style="font-size:0.85em; color:#999; margin-bottom:12px;">
Generate an Ed25519 key pair in the browser. The public key (PEM) is provisioned onto the
machine; the private key stays here and signs JWTs.
</p>
<button id="btn-generate">Generate Key Pair</button>
<button id="btn-export-pub">Copy Public Key PEM</button>
<button id="btn-save-keys">Save Keys to localStorage</button>
<button id="btn-load-keys">Load Saved Keys</button>
<div id="key-status" class="status"></div>
<div style="margin-top: 12px;">
<label>Public Key (PEM) — provision this on the machine</label>
<textarea id="public-key-pem" readonly rows="4"></textarea>
</div>
</div>
<!-- Connection -->
<div class="panel">
<h2>2. Connect</h2>
<div class="row">
<div>
<label>Machine Host (e.g. shell.example.com)</label>
<input id="host" type="text" placeholder="shell.example.com">
</div>
<div>
<label>JWT Lifetime (seconds)</label>
<input id="jwt-lifetime" type="number" value="3600">
</div>
</div>
<button id="btn-connect">Connect Terminal</button>
<button id="btn-disconnect" disabled>Disconnect</button>
<div id="conn-status" class="status"></div>
</div>
<!-- Interactive Terminal -->
<div class="panel">
<h2>3. Interactive Terminal</h2>
<div id="terminal-container"></div>
</div>
<!-- Programmatic Execution -->
<div class="panel">
<h2>4. Programmatic Command Execution</h2>
<p style="font-size:0.85em; color:#999; margin-bottom:12px;">
Runs a command on a separate WebSocket connection and captures output.
Uses sentinel markers to extract clean output and exit code.
</p>
<div class="row">
<div>
<label>Command</label>
<input id="exec-cmd" type="text" placeholder="uname -a" value="uname -a">
</div>
</div>
<button id="btn-exec">Run Command</button>
<div id="exec-status" class="status"></div>
<div style="margin-top: 8px;">
<label>Output</label>
<div id="exec-output">(no output yet)</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="web-shell.js"></script>
</body>
</html>
File created successfully at: /home/david/projects/stirlingbridge/machine-provisioning/examples/web-shell/index.html (file state is current in your context — no need to Read it back)
/**
* Web Shell Example — browser-side key management, JWT signing, interactive
* terminal, and programmatic command execution over ttyd WebSocket.
*
* Dependencies: xterm.js and xterm-addon-fit (loaded from CDN in index.html).
* No build step required.
*/
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let keyPair = null; // { publicKey: CryptoKey, privateKey: CryptoKey }
let publicPem = '';
let terminal = null;
let termSocket = null;
const STORAGE_KEY = 'web-shell-keypair';
// ---------------------------------------------------------------------------
// DOM refs
// ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const ui = {
btnGenerate: $('btn-generate'),
btnExportPub: $('btn-export-pub'),
btnSaveKeys: $('btn-save-keys'),
btnLoadKeys: $('btn-load-keys'),
keyStatus: $('key-status'),
publicKeyPem: $('public-key-pem'),
host: $('host'),
jwtLifetime: $('jwt-lifetime'),
btnConnect: $('btn-connect'),
btnDisconnect: $('btn-disconnect'),
connStatus: $('conn-status'),
termContainer: $('terminal-container'),
execCmd: $('exec-cmd'),
btnExec: $('btn-exec'),
execStatus: $('exec-status'),
execOutput: $('exec-output'),
};
// ---------------------------------------------------------------------------
// Utility: status helpers
// ---------------------------------------------------------------------------
function setStatus(el, cls, msg) {
el.textContent = msg;
el.className = 'status ' + cls;
}
// ---------------------------------------------------------------------------
// 1. Key Management (Web Crypto — Ed25519)
// ---------------------------------------------------------------------------
/**
* Generate an Ed25519 key pair using the Web Crypto API.
* Ed25519 support: Chrome 113+, Firefox 130+, Safari 17+.
*/
async function generateKeyPair() {
try {
keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
publicPem = await exportPublicKeyPem(keyPair.publicKey);
ui.publicKeyPem.value = publicPem;
setStatus(ui.keyStatus, 'ok', 'Key pair generated.');
} catch (e) {
setStatus(ui.keyStatus, 'err', 'Failed: ' + e.message +
'. Ed25519 requires Chrome 113+, Firefox 130+, or Safari 17+.');
}
}
/** Export a CryptoKey (public, Ed25519) to PEM format. */
async function exportPublicKeyPem(key) {
const spki = await crypto.subtle.exportKey('spki', key);
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
const lines = b64.match(/.{1,64}/g).join('\n');
return '-----BEGIN PUBLIC KEY-----\n' + lines + '\n-----END PUBLIC KEY-----';
}
/** Save both keys to localStorage as exportable JWK. */
async function saveKeys() {
if (!keyPair) { setStatus(ui.keyStatus, 'err', 'No key pair to save.'); return; }
const pub = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const priv = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ pub, priv }));
setStatus(ui.keyStatus, 'ok', 'Keys saved to localStorage.');
}
/** Load keys from localStorage. */
async function loadKeys() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) { setStatus(ui.keyStatus, 'err', 'No saved keys found.'); return; }
const { pub, priv } = JSON.parse(stored);
const publicKey = await crypto.subtle.importKey('jwk', pub, 'Ed25519', true, ['verify']);
const privateKey = await crypto.subtle.importKey('jwk', priv, 'Ed25519', true, ['sign']);
keyPair = { publicKey, privateKey };
publicPem = await exportPublicKeyPem(publicKey);
ui.publicKeyPem.value = publicPem;
setStatus(ui.keyStatus, 'ok', 'Keys loaded from localStorage.');
}
function copyPublicKey() {
if (!publicPem) { setStatus(ui.keyStatus, 'err', 'No public key to copy.'); return; }
navigator.clipboard.writeText(publicPem);
setStatus(ui.keyStatus, 'ok', 'Public key PEM copied to clipboard.');
}
// ---------------------------------------------------------------------------
// 2. JWT Signing (Web Crypto)
// ---------------------------------------------------------------------------
/** Base64url encode a Uint8Array or ArrayBuffer. */
function b64url(buf) {
const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* Create and sign a JWT using the Ed25519 private key.
* Claims: iat, exp. Add sub/aud/iss as needed.
*/
async function createJwt(lifetimeSec) {
if (!keyPair) throw new Error('No key pair — generate or load keys first.');
const header = { alg: 'EdDSA', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now, exp: now + lifetimeSec };
const enc = new TextEncoder();
const signingInput = b64url(enc.encode(JSON.stringify(header))) + '.' +
b64url(enc.encode(JSON.stringify(payload)));
const sig = await crypto.subtle.sign('Ed25519', keyPair.privateKey, enc.encode(signingInput));
return signingInput + '.' + b64url(sig);
}
// ---------------------------------------------------------------------------
// 3. Interactive Terminal (xterm.js + ttyd WebSocket)
// ---------------------------------------------------------------------------
/**
* ttyd WebSocket protocol:
* Client → Server: 0 + input bytes (stdin)
* 1 + JSON resize (e.g. {"columns":80,"rows":24})
* Server → Client: 0 + output bytes (stdout)
* 1 + JSON config (title etc.)
* 2 + JSON title
*/
async function connectTerminal() {
const host = ui.host.value.trim();
if (!host) { setStatus(ui.connStatus, 'err', 'Enter a host.'); return; }
const lifetime = parseInt(ui.jwtLifetime.value) || 3600;
let token;
try {
token = await createJwt(lifetime);
} catch (e) {
setStatus(ui.connStatus, 'err', e.message);
return;
}
// Clean up previous connection
disconnectTerminal();
// Create terminal
terminal = new Terminal({ cursorBlink: true, fontSize: 14 });
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(ui.termContainer);
fitAddon.fit();
// Resize on window resize
const onResize = () => fitAddon.fit();
window.addEventListener('resize', onResize);
terminal._onResizeCleanup = () => window.removeEventListener('resize', onResize);
// Connect WebSocket
const wsUrl = 'wss://' + host + '/ws?token=' + encodeURIComponent(token);
setStatus(ui.connStatus, 'info', 'Connecting to ' + host + '...');
termSocket = new WebSocket(wsUrl);
termSocket.binaryType = 'arraybuffer';
termSocket.onopen = () => {
setStatus(ui.connStatus, 'ok', 'Connected.');
ui.btnConnect.disabled = true;
ui.btnDisconnect.disabled = false;
// Send initial terminal size
const msg = '1' + JSON.stringify({ columns: terminal.cols, rows: terminal.rows });
termSocket.send(new TextEncoder().encode(msg));
};
termSocket.onmessage = (ev) => {
const data = new Uint8Array(ev.data);
const msgType = data[0];
const payload = data.slice(1);
if (msgType === 0) {
// stdout
terminal.write(payload);
}
// msgType 1 = config, 2 = title — ignored for simplicity
};
termSocket.onclose = () => {
setStatus(ui.connStatus, 'info', 'Disconnected.');
ui.btnConnect.disabled = false;
ui.btnDisconnect.disabled = true;
};
termSocket.onerror = () => {
setStatus(ui.connStatus, 'err', 'WebSocket error — check host, token, and that the machine is running.');
};
// Send keystrokes
terminal.onData((data) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
const bytes = new TextEncoder().encode(data);
const msg = new Uint8Array(1 + bytes.length);
msg[0] = 0; // stdin type
msg.set(bytes, 1);
termSocket.send(msg);
}
});
// Send resize events
terminal.onResize(({ cols, rows }) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
const msg = '1' + JSON.stringify({ columns: cols, rows: rows });
termSocket.send(new TextEncoder().encode(msg));
}
});
}
function disconnectTerminal() {
if (termSocket) {
termSocket.close();
termSocket = null;
}
if (terminal) {
if (terminal._onResizeCleanup) terminal._onResizeCleanup();
terminal.dispose();
terminal = null;
}
ui.btnConnect.disabled = false;
ui.btnDisconnect.disabled = true;
}
// ---------------------------------------------------------------------------
// 4. Programmatic Command Execution
// ---------------------------------------------------------------------------
/**
* Execute a command on a fresh WebSocket connection and capture output.
*
* Strategy: send a wrapped command with unique sentinel markers, then collect
* everything between the markers. This avoids parsing prompts or escape codes.
*
* Returns: { stdout: string, exitCode: number }
*/
async function execCommand(command) {
const host = ui.host.value.trim();
if (!host) throw new Error('Enter a host.');
const lifetime = parseInt(ui.jwtLifetime.value) || 3600;
const token = await createJwt(lifetime);
const wsUrl = 'wss://' + host + '/ws?token=' + encodeURIComponent(token);
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
let output = '';
let settled = false;
const sentinel = '__EXEC_' + Math.random().toString(36).slice(2, 10) + '__';
const startMarker = sentinel + '_START';
const endMarker = sentinel + '_END';
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
ws.close();
reject(new Error('Command timed out after 30s'));
}
}, 30000);
ws.onopen = () => {
// Wait briefly for the shell prompt, then send the wrapped command.
// TERM=dumb suppresses most escape sequences in command output.
setTimeout(() => {
const wrapped =
'export TERM=dumb\n' +
'echo ' + startMarker + '\n' +
command + '\n' +
'echo ' + endMarker + ' $?\n';
const bytes = new TextEncoder().encode(wrapped);
const msg = new Uint8Array(1 + bytes.length);
msg[0] = 0;
msg.set(bytes, 1);
ws.send(msg);
}, 500);
};
ws.onmessage = (ev) => {
const data = new Uint8Array(ev.data);
if (data[0] === 0) {
output += new TextDecoder().decode(data.slice(1));
// Check if we have both markers
const startIdx = output.indexOf(startMarker + '\n');
const endIdx = output.indexOf(endMarker + ' ');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
settled = true;
clearTimeout(timeout);
const body = output.slice(startIdx + startMarker.length + 1, endIdx);
// Extract exit code from the line containing the end marker
const afterEnd = output.slice(endIdx + endMarker.length + 1);
const exitCode = parseInt(afterEnd.trim().split(/\s/)[0]) || 0;
ws.close();
resolve({ stdout: body.trim(), exitCode });
}
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(new Error('WebSocket error'));
}
};
ws.onclose = () => {
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(new Error('Connection closed before command completed'));
}
};
});
}
async function onExec() {
const cmd = ui.execCmd.value.trim();
if (!cmd) { setStatus(ui.execStatus, 'err', 'Enter a command.'); return; }
setStatus(ui.execStatus, 'info', 'Running...');
ui.execOutput.textContent = '';
ui.btnExec.disabled = true;
try {
const result = await execCommand(cmd);
ui.execOutput.textContent = result.stdout;
setStatus(ui.execStatus, 'ok', 'Exit code: ' + result.exitCode);
} catch (e) {
setStatus(ui.execStatus, 'err', e.message);
} finally {
ui.btnExec.disabled = false;
}
}
// ---------------------------------------------------------------------------
// Event wiring
// ---------------------------------------------------------------------------
ui.btnGenerate.addEventListener('click', generateKeyPair);
ui.btnExportPub.addEventListener('click', copyPublicKey);
ui.btnSaveKeys.addEventListener('click', saveKeys);
ui.btnLoadKeys.addEventListener('click', loadKeys);
ui.btnConnect.addEventListener('click', connectTerminal);
ui.btnDisconnect.addEventListener('click', disconnectTerminal);
ui.btnExec.addEventListener('click', onExec);
// Try loading saved keys on startup
loadKeys().catch(() => {});
File created successfully at: /home/david/projects/stirlingbridge/machine-provisioning/examples/web-shell/web-shell.js (file state is current in your context — no need to Read it back)
Here's what the example app provides:
Section 1 — Key Pair Management
- Generate Ed25519 key pair entirely in-browser (Web Crypto API)
- Export public key as PEM to copy/paste into machine provisioning
- Save/load keys from localStorage for persistence across sessions
Section 2 — Connection - Enter the machine's FQDN, set JWT lifetime - JWT is signed client-side with the Ed25519 private key — no server needed
Section 3 — Interactive Terminal
- xterm.js connected via wss://host/ws?token=JWT
- Full terminal experience: handles stdin/stdout and resize events
- Speaks ttyd's binary WebSocket protocol (type-byte prefixed messages)
Section 4 — Programmatic Command Execution
- Opens a separate WebSocket connection per command
- Wraps commands with random sentinel markers (echo __START__ / echo __END__ $?)
- Sets TERM=dumb to suppress escape sequences
- Extracts clean stdout and exit code between markers
- Demonstrates the orchestration pattern for browser JS/TS code
To use it: just open index.html in a browser (or serve it with any static file server). No build step, no Node.js, no dependencies beyond xterm.js from CDN.
Browser requirements: Ed25519 Web Crypto needs Chrome 113+, Firefox 130+, or Safari 17+. The app shows a clear error if the browser doesn't support it.
The web-shell.js file also serves as a reference for how to integrate the WebSocket protocol into any application — the key functions are createJwt(), connectTerminal(), and execCommand().