# Edge Control Plane SSH-forced-command control plane for managing reverse tunnels to edge hosts. ## Overview This control plane runs on the public edge host (Debian DO box) and provides: - **Self-service tunnel registration**: Projects run `disinto edge register` to get an assigned port and FQDN - **SSH forced commands**: Uses `restrict,command="..."` authorized_keys entries — no new HTTP daemon - **Hot-patched Caddy routing**: `.disinto.ai` → `127.0.0.1:` via Caddy admin API - **Port allocator**: Manages ports in `20000-29999` range with flock-based concurrency control ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Edge Host (Debian DO) │ │ │ │ ┌──────────────────┐ ┌───────────────────────────────────────────────┐ │ │ │ disinto-register│ │ /var/lib/disinto/ │ │ │ │ (authorized_keys│ │ ├── registry.json (source of truth) │ │ │ │ forced cmd) │ │ ├── registry.lock (flock) │ │ │ │ │ │ └── allowlist.json (admin-approved names) │ │ │ │ │ │ └── authorized_keys (rebuildable) │ │ │ └────────┬─────────┘ └───────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ register.sh (forced command handler) │ │ │ │ ────────────────────────────────────────────────────────────────── │ │ │ │ • Parses SSH_ORIGINAL_COMMAND │ │ │ │ • Dispatches to register|deregister|list │ │ │ │ • Returns JSON on stdout │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ lib/ │ │ ├─ ports.sh → port allocator (20000-29999) │ │ ├─ authorized_keys.sh → rebuild authorized_keys from registry │ │ └─ caddy.sh → Caddy admin API (127.0.0.1:2019) │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Caddy (with Gandi DNS plugin) │ │ │ │ ────────────────────────────────────────────────────────────────── │ │ │ │ • Admin API on 127.0.0.1:2019 │ │ │ │ • Wildcard *.disinto.ai cert (DNS-01 via Gandi) │ │ │ │ • Site blocks hot-patched via admin API │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ disinto-tunnel (no shell, no password) │ │ │ │ ────────────────────────────────────────────────────────────────── │ │ │ │ • Receives reverse tunnels only │ │ │ │ • authorized_keys: permitlisten="127.0.0.1:" │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Installation ### Prerequisites - Fresh Debian 12 (Bookworm) system - Root or sudo access - Domain `disinto.ai` hosted at Gandi with API token ### One-Click Install ```bash # Download and run installer curl -sL https://raw.githubusercontent.com/disinto-admin/disinto/fix/issue-621/tools/edge-control/install.sh | bash -s -- --gandi-token YOUR_GANDI_API_TOKEN # You'll be prompted to paste your admin pubkey for the disinto-register user ``` ### What install.sh Does 1. **Creates users**: - `disinto-register` — owns registry, runs Caddy admin API calls - `disinto-tunnel` — no password, no shell, only receives reverse tunnels 2. **Creates data directory**: - `/var/lib/disinto/` with `registry.json`, `registry.lock`, `allowlist.json` - Permissions: `root:disinto-register 0750` 3. **Installs Caddy**: - Backs up any pre-existing `/etc/caddy/Caddyfile` to `/etc/caddy/Caddyfile.pre-disinto` - Download Caddy with Gandi DNS plugin - Enable admin API on `127.0.0.1:2019` - Configure wildcard cert for `*.disinto.ai` via DNS-01 - Creates `/etc/caddy/extra.d/` for operator-owned site blocks - Emitted Caddyfile ends with `import /etc/caddy/extra.d/*.caddy` 4. **Sets up SSH**: - Creates `disinto-register` authorized_keys with forced command - Creates `disinto-tunnel` authorized_keys (rebuildable from registry) 5. **Installs control plane scripts**: - `/opt/disinto-edge/register.sh` — forced command handler - `/opt/disinto-edge/lib/*.sh` — helper libraries ## Operator-Owned Site Blocks Edge-control owns the top-level `/etc/caddy/Caddyfile` and dynamic `.` routes injected via the Caddy admin API. Operators own everything under `/etc/caddy/extra.d/`. To serve non-tunnel content (apex domain, www redirect, static sites), drop `.caddy` files into `/etc/caddy/extra.d/`: ```bash # Example: /etc/caddy/extra.d/landing.caddy disinto.ai { root * /home/debian/disinto-site file_server } # Example: /etc/caddy/extra.d/www-redirect.caddy www.disinto.ai { redir https://disinto.ai{uri} permanent } ``` These files survive across `install.sh` re-runs. The `--extra-caddyfile ` flag overrides the default import glob (`/etc/caddy/extra.d/*.caddy`) if needed. ## Usage ### Register a Tunnel (from dev box) ```bash # First-time setup (generates tunnel keypair) disinto edge register myproject # Subsequent runs are idempotent disinto edge register myproject # returns same port/FQDN ``` Response: ```json {"port":23456,"fqdn":"myproject.disinto.ai"} ``` These values are written to `.env` as: ``` EDGE_TUNNEL_HOST=edge.disinto.ai EDGE_TUNNEL_PORT=23456 EDGE_TUNNEL_FQDN=myproject.disinto.ai ``` ### Deregister a Tunnel ```bash disinto edge deregister myproject ``` This: - Removes the authorized_keys entry for the tunnel - Removes the Caddy site block - Frees the port in the registry ### Check Status ```bash disinto edge status ``` Shows all registered tunnels with their ports and FQDNs. ## Registry Schema `/var/lib/disinto/registry.json`: ```json { "version": 1, "projects": { "myproject": { "port": 23456, "fqdn": "myproject.disinto.ai", "pubkey": "ssh-ed25519 AAAAC3Nza... operator@devbox", "registered_at": "2026-04-10T14:30:00Z" } } } ``` ## Allowlist The allowlist prevents project name squatting by requiring admin approval before a name can be registered. It is **opt-in**: when `allowlist.json` does not exist, registration is unrestricted. When the file exists, only project names listed in the `allowed` map can be registered. ### Install-time behavior - **Fresh install**: `install.sh` seeds an empty allowlist (`{"version":1,"allowed":{}}`) and prints a warning that registration is now gated until entries are added. - **Upgrade onto an existing box**: if `registry.json` has registered projects but `allowlist.json` does not exist, `install.sh` auto-populates the allowlist with each existing project name (unbound — `pubkey_fingerprint: ""`). This preserves current behavior so existing tunnels keep working. The operator can tighten pubkey bindings later. ### Format `/var/lib/disinto/allowlist.json` (root-owned, `0644`): ```json { "version": 1, "allowed": { "myproject": { "pubkey_fingerprint": "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "open-project": { "pubkey_fingerprint": "" } } } ``` - **With `pubkey_fingerprint`** (non-empty): only the SSH key with that exact SHA256 fingerprint can register this project name. - **With empty `pubkey_fingerprint`**: any caller may register this project name (name reservation without key binding). - **Not listed in `allowed`**: registration is refused with `{"error":"name not approved"}`. ### Workflow 1. Admin edits `/var/lib/disinto/allowlist.json` (via ops repo PR, or direct `ssh root@edge`). 2. File is `root:root 0644` — `disinto-register` only reads it; `register.sh` never mutates it. 3. Callers run `register` as usual. The allowlist is checked transparently. ### Security - The allowlist is a **first-come-first-serve defense**: once a name is approved for a key, no one else can claim it. - It does **not** replace per-operation ownership checks (sibling issue #1094) — it only prevents the initial race. ## Recovery ### After State Loss If `registry.json` is lost but Caddy config persists: ```bash # Rebuild from existing Caddy config ssh disinto-register@edge.disinto.ai ' /opt/disinto-edge/lib/rebuild-registry-from-caddy.sh ' ``` ### Rebuilding authorized_keys If `authorized_keys` is corrupted: ```bash ssh disinto-register@edge.disinto.ai ' /opt/disinto-edge/lib/rebuild-authorized-keys.sh ' ``` ### Rotating Admin Key To rotate the `disinto-register` admin pubkey: ```bash # On edge host, remove old pubkey from authorized_keys # Add new pubkey: echo "new-pubkey" >> /home/disinto-register/.ssh/authorized_keys # Trigger rebuild: /opt/disinto-edge/lib/rebuild-authorized-keys.sh ``` ### Adding a Second Edge Host For high availability, add a second edge host: 1. Run `install.sh` on the second host 2. Configure Caddy to use the same registry (NFS or shared storage) 3. Update `EDGE_HOST` in `.env` to load-balance between hosts 4. Use a reverse proxy (HAProxy, Traefik) in front of both edge hosts ## Security ### What's Protected - **No new attack surface**: sshd is already the only listener; control plane is a forced command - **Restricted tunnel user**: `disinto-tunnel` cannot shell in, only receive reverse tunnels - **Port validation**: Tunnel connections outside allocated ports are refused - **Forced command**: `disinto-register` can only execute `register.sh` ### Certificate Strategy - Single wildcard `*.disinto.ai` cert via DNS-01 through Gandi - Caddy handles automatic renewal - No per-project cert work needed ### Future Considerations - Long-term "shop" vision could layer an HTTP API on top - forward_auth / OAuth is out of scope (handled per-project inside edge container) ## Testing ### Verify Tunnel User Restrictions ```bash # Should hang (no command given) ssh -i tunnel_key disinto-tunnel@edge.disinto.ai # Should fail (port outside allocation) ssh -R 127.0.0.1:9999:localhost:80 disinto-tunnel@edge.disinto.ai # Should succeed (port within allocation) ssh -R 127.0.0.1:23456:localhost:80 disinto-tunnel@edge.disinto.ai ``` ### Verify Admin User Restrictions ```bash # Should fail (not a valid command) ssh disinto-register@edge.disinto.ai "random command" # Should succeed (valid command) ssh disinto-register@edge.disinto.ai "register myproject $(cat ~/.ssh/id_ed25519.pub)" ``` ## Files - `install.sh` — One-shot installer for fresh Debian DO box - `register.sh` — Forced-command handler (dispatches to `register|deregister|list`) - `lib/ports.sh` — Port allocator over `20000-29999`, jq-based, flockd - `lib/authorized_keys.sh` — Deterministic rebuild of `disinto-tunnel` authorized_keys - `lib/caddy.sh` — POST to Caddy admin API for route mapping - `/var/lib/disinto/allowlist.json` — Admin-approved project name allowlist (root-owned, read-only by register.sh) ## Dependencies - `bash` — All scripts are bash - `jq` — JSON parsing for registry - `flock` — Concurrency control for registry updates - `caddy` — Web server with admin API and Gandi DNS plugin - `ssh` — OpenSSH for forced commands and reverse tunnels