- Redirect all status messages in caddy.sh to stderr (add_route, remove_route, reload_caddy) - Redirect status message in authorized_keys.sh to stderr (rebuild_authorized_keys) - Fix install.sh to source authorized_keys.sh library and call rebuild_authorized_keys directly |
||
|---|---|---|
| .. | ||
| 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) │ │
│ │ │ │ └── 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- Permissions:
root:disinto-register 0750
-
Installs Caddy:
- Download Caddy with Gandi DNS plugin
- Enable admin API on
127.0.0.1:2019 - Configure wildcard cert for
*.disinto.aivia DNS-01
-
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
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"
}
}
}
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
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