|
|
||
|---|---|---|
| .. | ||
| lib | ||
| install.sh | ||
| README.md | ||
| register.sh | ||
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 registerto get an assigned port and FQDN - SSH forced commands: Uses
restrict,command="..."authorized_keys entries — no new HTTP daemon - Hot-patched Caddy routing:
<project>.disinto.ai→127.0.0.1:<port>via Caddy admin API - Port allocator: Manages ports in
20000-29999range 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:<port>" │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Installation
Prerequisites
- Fresh Debian 12 (Bookworm) system
- Root or sudo access
- Domain
disinto.aihosted at Gandi with API token
One-Click Install
# 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
-
Creates users:
disinto-register— owns registry, runs Caddy admin API callsdisinto-tunnel— no password, no shell, only receives reverse tunnels
-
Creates data directory:
/var/lib/disinto/withregistry.json,registry.lock,allowlist.json- Permissions:
root:disinto-register 0750
-
Installs Caddy:
- Backs up any pre-existing
/etc/caddy/Caddyfileto/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.aivia DNS-01 - Creates
/etc/caddy/extra.d/for operator-owned site blocks - Emitted Caddyfile ends with
import /etc/caddy/extra.d/*.caddy
- Backs up any pre-existing
-
Sets up SSH:
- Creates
disinto-registerauthorized_keys with forced command - Creates
disinto-tunnelauthorized_keys (rebuildable from registry)
- Creates
-
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 <project>.<DOMAIN_SUFFIX> 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/:
# 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 <path> flag overrides the default import glob (/etc/caddy/extra.d/*.caddy) if needed.
Usage
Register a Tunnel (from dev box)
# First-time setup (generates tunnel keypair)
disinto edge register myproject
# Subsequent runs are idempotent
disinto edge register myproject # returns same port/FQDN
Response:
{"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
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
disinto edge status
Shows all registered tunnels with their ports and FQDNs.
Registry Schema
/var/lib/disinto/registry.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.shseeds 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.jsonhas registered projects butallowlist.jsondoes not exist,install.shauto-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):
{
"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
- Admin edits
/var/lib/disinto/allowlist.json(via ops repo PR, or directssh root@edge). - File is
root:root 0644—disinto-registeronly reads it;register.shnever mutates it. - Callers run
registeras 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:
# 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:
ssh disinto-register@edge.disinto.ai '
/opt/disinto-edge/lib/rebuild-authorized-keys.sh
'
Rotating Admin Key
To rotate the disinto-register admin pubkey:
# 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:
- Run
install.shon the second host - Configure Caddy to use the same registry (NFS or shared storage)
- Update
EDGE_HOSTin.envto load-balance between hosts - 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-tunnelcannot shell in, only receive reverse tunnels - Port validation: Tunnel connections outside allocated ports are refused
- Forced command:
disinto-registercan only executeregister.sh
Certificate Strategy
- Single wildcard
*.disinto.aicert 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
# 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
# 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 boxregister.sh— Forced-command handler (dispatches toregister|deregister|list)lib/ports.sh— Port allocator over20000-29999, jq-based, flockdlib/authorized_keys.sh— Deterministic rebuild ofdisinto-tunnelauthorized_keyslib/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 bashjq— JSON parsing for registryflock— Concurrency control for registry updatescaddy— Web server with admin API and Gandi DNS pluginssh— OpenSSH for forced commands and reverse tunnels