Merge pull request 'fix: feat: disinto edge command + SSH-forced-command control plane in tools/edge-control/ (#621)' (#640) from fix/issue-621 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
dev-qwen 2026-04-10 19:49:51 +00:00
commit 29cbbcb7de
7 changed files with 1503 additions and 0 deletions

View file

@ -54,6 +54,12 @@ Usage:
disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--model <name>]
Hire a new agent (create user + .profile repo)
disinto agent <subcommand> Manage agent state (enable/disable)
disinto edge <verb> [options] Manage edge tunnel registrations
Edge subcommands:
register [project] Register a new tunnel (generates keypair if needed)
deregister <project> Remove a tunnel registration
status Show registered tunnels
Agent subcommands:
disable <agent> Remove state file to disable agent
@ -1613,6 +1619,232 @@ EOF
esac
}
# ── edge command ──────────────────────────────────────────────────────────────
# Manage edge tunnel registrations (reverse SSH tunnels to edge hosts)
# Usage: disinto edge <verb> [options]
# register [project] Register a new tunnel (generates keypair if needed)
# deregister <project> Remove a tunnel registration
# status Show registered tunnels
disinto_edge() {
local subcmd="${1:-}"
local EDGE_HOST="${EDGE_HOST:-}"
local env_file="${FACTORY_ROOT}/.env"
# Determine edge host (flag > env var > default)
local edge_host="${EDGE_HOST:-edge.disinto.ai}"
shift || true
case "$subcmd" in
register)
local project="${1:-}"
local env_file="${FACTORY_ROOT}/.env"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--edge-host)
edge_host="$2"
shift 2
;;
*)
if [ -z "$project" ]; then
project="$1"
fi
shift
;;
esac
done
if [ -z "$project" ]; then
echo "Error: project name required" >&2
echo "Usage: disinto edge register [project] [--edge-host <fqdn>]" >&2
exit 1
fi
# Validate project name
if ! [[ "$project" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "Error: invalid project name (use alphanumeric, hyphens, underscores)" >&2
exit 1
fi
# Determine edge host (flag > env > default)
if [ -z "$edge_host" ]; then
edge_host="${EDGE_HOST:-edge.disinto.ai}"
fi
# Check for tunnel keypair
local secrets_dir="${FACTORY_ROOT}/secrets"
local tunnel_key="${secrets_dir}/tunnel_key"
local tunnel_pubkey="${tunnel_key}.pub"
if [ ! -f "$tunnel_pubkey" ]; then
echo "Generating tunnel keypair..."
mkdir -p "$secrets_dir"
chmod 700 "$secrets_dir"
ssh-keygen -t ed25519 -f "$tunnel_key" -N "" -C "edge-tunnel@${project}" 2>/dev/null
chmod 600 "$tunnel_key" "$tunnel_pubkey"
echo "Generated: ${tunnel_pubkey}"
fi
# Read pubkey (single line, remove trailing newline)
local pubkey
pubkey=$(tr -d '\n' < "$tunnel_pubkey")
# SSH to edge host and register
echo "Registering tunnel for ${project} on ${edge_host}..."
local response
response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \
"disinto-register@${edge_host}" \
"register ${project} ${pubkey}" 2>&1) || {
echo "Error: failed to register tunnel" >&2
echo "Response: ${response}" >&2
exit 1
}
# Parse response and write to .env
local port fqdn
port=$(echo "$response" | jq -r '.port // empty' 2>/dev/null) || port=""
fqdn=$(echo "$response" | jq -r '.fqdn // empty' 2>/dev/null) || fqdn=""
if [ -z "$port" ] || [ -z "$fqdn" ]; then
echo "Error: invalid response from edge host" >&2
echo "Response: ${response}" >&2
exit 1
fi
# Write to .env (replace existing entries to avoid duplicates)
local tmp_env
tmp_env=$(mktemp)
grep -Ev "^EDGE_TUNNEL_(HOST|PORT|FQDN)=" "$env_file" > "$tmp_env" 2>/dev/null || true
mv "$tmp_env" "$env_file"
echo "EDGE_TUNNEL_HOST=${edge_host}" >> "$env_file"
echo "EDGE_TUNNEL_PORT=${port}" >> "$env_file"
echo "EDGE_TUNNEL_FQDN=${fqdn}" >> "$env_file"
echo "Registered: ${project}"
echo " Port: ${port}"
echo " FQDN: ${fqdn}"
echo " Saved to: ${env_file}"
;;
deregister)
local project="${1:-}"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--edge-host)
edge_host="$2"
shift 2
;;
*)
if [ -z "$project" ]; then
project="$1"
fi
shift
;;
esac
done
if [ -z "$project" ]; then
echo "Error: project name required" >&2
echo "Usage: disinto edge deregister <project> [--edge-host <fqdn>]" >&2
exit 1
fi
# Determine edge host
if [ -z "$edge_host" ]; then
edge_host="${EDGE_HOST:-edge.disinto.ai}"
fi
# SSH to edge host and deregister
echo "Deregistering tunnel for ${project} on ${edge_host}..."
local response
response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \
"disinto-register@${edge_host}" \
"deregister ${project}" 2>&1) || {
echo "Error: failed to deregister tunnel" >&2
echo "Response: ${response}" >&2
exit 1
}
# Remove from .env if present
if [ -f "$env_file" ]; then
local tmp_env
tmp_env=$(mktemp)
grep -Ev "^EDGE_TUNNEL_(HOST|PORT|FQDN)=" "$env_file" > "$tmp_env" 2>/dev/null || true
mv "$tmp_env" "$env_file"
fi
echo "Deregistered: ${project}"
;;
status)
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--edge-host)
edge_host="$2"
shift 2
;;
*)
shift
;;
esac
done
# Determine edge host
if [ -z "$edge_host" ]; then
edge_host="${EDGE_HOST:-edge.disinto.ai}"
fi
# SSH to edge host and get status
echo "Checking tunnel status on ${edge_host}..."
local response
response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \
"disinto-register@${edge_host}" \
"list" 2>&1) || {
echo "Error: failed to get status" >&2
echo "Response: ${response}" >&2
exit 1
}
# Parse and display
local tunnels
tunnels=$(echo "$response" | jq -r '.tunnels // [] | length' 2>/dev/null) || tunnels="0"
if [ "$tunnels" = "0" ]; then
echo "No tunnels registered"
else
echo "Registered tunnels:"
echo "$response" | jq -r '.tunnels[] | " \(.name): port=\(.port) fqdn=\(.fqdn)"'
fi
;;
*)
cat <<EOF >&2
Usage: disinto edge <verb> [options]
Manage edge tunnel registrations:
register [project] Register a new tunnel (generates keypair if needed)
deregister <project> Remove a tunnel registration
status Show registered tunnels
Options:
--edge-host <fqdn> Edge host FQDN (default: edge.disinto.ai or EDGE_HOST env)
Examples:
disinto edge register myproject
disinto edge register myproject --edge-host custom.example.com
disinto edge deregister myproject
disinto edge status
EOF
exit 1
;;
esac
}
# ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-}" in
@ -1628,6 +1860,7 @@ case "${1:-}" in
release) shift; disinto_release "$@" ;;
hire-an-agent) shift; disinto_hire_an_agent "$@" ;;
agent) shift; disinto_agent "$@" ;;
edge) shift; disinto_edge "$@" ;;
-h|--help) usage ;;
*) usage ;;
esac

View file

@ -0,0 +1,260 @@
# 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**: `<project>.disinto.ai``127.0.0.1:<port>` 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) │ │
│ │ │ │ └── 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.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`
- Permissions: `root:disinto-register 0750`
3. **Installs Caddy**:
- Download Caddy with Gandi DNS plugin
- Enable admin API on `127.0.0.1:2019`
- Configure wildcard cert for `*.disinto.ai` via DNS-01
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
## 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"
}
}
}
```
## 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
## 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

375
tools/edge-control/install.sh Executable file
View file

@ -0,0 +1,375 @@
#!/usr/bin/env bash
# =============================================================================
# install.sh — One-shot installer for edge control plane on Debian DO box
#
# Usage:
# curl -sL https://raw.githubusercontent.com/disinto-admin/disinto/fix/issue-621/tools/edge-control/install.sh | bash -s -- --gandi-token YOUR_TOKEN
#
# What it does:
# 1. Creates users: disinto-register, disinto-tunnel
# 2. Creates /var/lib/disinto/ with registry.json, registry.lock
# 3. Installs Caddy with Gandi DNS plugin
# 4. Sets up SSH authorized_keys for both users
# 5. Installs control plane scripts to /opt/disinto-edge/
#
# Requirements:
# - Fresh Debian 12 (Bookworm)
# - Root or sudo access
# - Gandi API token (for wildcard cert)
# =============================================================================
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Parse arguments
GANDI_TOKEN=""
INSTALL_DIR="/opt/disinto-edge"
REGISTRY_DIR="/var/lib/disinto"
CADDY_VERSION="2.8.4"
DOMAIN_SUFFIX="disinto.ai"
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--gandi-token <token> Gandi API token for wildcard cert (required)
--install-dir <dir> Install directory (default: /opt/disinto-edge)
--registry-dir <dir> Registry directory (default: /var/lib/disinto)
--caddy-version <ver> Caddy version to install (default: ${CADDY_VERSION})
--domain-suffix <suffix> Domain suffix for tunnels (default: disinto.ai)
-h, --help Show this help
Example:
$0 --gandi-token YOUR_GANDI_API_TOKEN
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case $1 in
--gandi-token)
GANDI_TOKEN="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
--registry-dir)
REGISTRY_DIR="$2"
shift 2
;;
--caddy-version)
CADDY_VERSION="$2"
shift 2
;;
--domain-suffix)
DOMAIN_SUFFIX="$2"
shift 2
;;
-h|--help)
usage
;;
*)
log_error "Unknown option: $1"
usage
;;
esac
done
# Validate required arguments
if [ -z "$GANDI_TOKEN" ]; then
log_error "Gandi API token is required (--gandi-token)"
usage
fi
log_info "Starting edge control plane installation..."
# =============================================================================
# Step 1: Create users
# =============================================================================
log_info "Creating users..."
# Create disinto-register user
if ! id "disinto-register" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /home/disinto-register "disinto-register" 2>/dev/null || true
log_info "Created user: disinto-register"
else
log_info "User already exists: disinto-register"
fi
# Create disinto-tunnel user
if ! id "disinto-tunnel" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -M "disinto-tunnel" 2>/dev/null || true
log_info "Created user: disinto-tunnel"
else
log_info "User already exists: disinto-tunnel"
fi
# =============================================================================
# Step 2: Create registry directory
# =============================================================================
log_info "Creating registry directory..."
mkdir -p "$REGISTRY_DIR"
chown root:disinto-register "$REGISTRY_DIR"
chmod 0750 "$REGISTRY_DIR"
# Initialize registry.json
REGISTRY_FILE="${REGISTRY_DIR}/registry.json"
if [ ! -f "$REGISTRY_FILE" ]; then
echo '{"version":1,"projects":{}}' > "$REGISTRY_FILE"
chmod 0644 "$REGISTRY_FILE"
log_info "Initialized registry: ${REGISTRY_FILE}"
fi
# Create lock file
LOCK_FILE="${REGISTRY_DIR}/registry.lock"
touch "$LOCK_FILE"
chmod 0644 "$LOCK_FILE"
# =============================================================================
# Step 3: Install Caddy with Gandi DNS plugin
# =============================================================================
log_info "Installing Caddy ${CADDY_VERSION} with Gandi DNS plugin..."
# Create Caddy config directory
CADDY_CONFIG_DIR="/etc/caddy"
CADDY_DATA_DIR="/var/lib/caddy"
mkdir -p "$CADDY_CONFIG_DIR" "$CADDY_DATA_DIR"
chmod 755 "$CADDY_CONFIG_DIR" "$CADDY_DATA_DIR"
# Download Caddy binary with Gandi plugin
CADDY_BINARY="/usr/bin/caddy"
# Build Caddy with Gandi plugin using caddy build command
if ! command -v caddy &>/dev/null; then
log_info "Installing Caddy builder..."
go install github.com/caddyserver/caddy/v2/cmd/caddy@latest 2>/dev/null || {
log_warn "Go not available, trying system package..."
if apt-get update -qq && apt-get install -y -qq caddy 2>/dev/null; then
:
fi || true
}
fi
# Download Caddy with Gandi DNS plugin using Caddy's download API
# The API returns a binary with specified plugins baked in
CADDY_DOWNLOAD_API="https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com/caddy-dns/gandi"
log_info "Downloading Caddy with Gandi DNS plugin..."
curl -sL "$CADDY_DOWNLOAD_API" -o /tmp/caddy
chmod +x /tmp/caddy
# Verify it works
if ! /tmp/caddy version &>/dev/null; then
log_error "Caddy binary verification failed"
exit 1
fi
# Check for Gandi plugin
if ! /tmp/caddy version 2>&1 | grep -qi gandi; then
log_warn "Gandi plugin not found in Caddy binary - DNS-01 challenge will fail"
fi
mv /tmp/caddy "$CADDY_BINARY"
log_info "Installed Caddy: $CADDY_BINARY"
# Create Caddy systemd service
CADDY_SERVICE="/etc/systemd/system/caddy.service"
cat > "$CADDY_SERVICE" <<EOF
[Unit]
Description=Caddy HTTP/HTTPS web server
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Wants=network-online.target
[Service]
User=nobody
Group=nogroup
Type=notify
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable caddy 2>/dev/null || true
# Create Gandi environment file
GANDI_ENV="/etc/caddy/gandi.env"
cat > "$GANDI_ENV" <<EOF
GANDI_API_KEY=${GANDI_TOKEN}
EOF
chmod 600 "$GANDI_ENV"
# Create Caddyfile with admin API and wildcard cert
CADDYFILE="/etc/caddy/Caddyfile"
cat > "$CADDYFILE" <<EOF
# Caddy configuration for edge control plane
# Admin API enabled on 127.0.0.1:2019
{
admin localhost:2019
}
# Default site (reverse proxy for edge tunnels will be added dynamically)
:80, :443 {
tls {
dns gandi {env.GANDI_API_KEY}
}
}
EOF
# Start Caddy
systemctl restart caddy 2>/dev/null || {
log_warn "Could not start Caddy service (may need manual start)"
# Try running directly for testing
/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
sleep 2
}
log_info "Caddy configured with admin API on 127.0.0.1:2019"
# =============================================================================
# Step 4: Install control plane scripts
# =============================================================================
log_info "Installing control plane scripts to ${INSTALL_DIR}..."
mkdir -p "${INSTALL_DIR}/lib"
# Copy scripts (overwrite existing to ensure idempotent updates)
cp "${BASH_SOURCE%/*}/register.sh" "${INSTALL_DIR}/"
cp "${BASH_SOURCE%/*}/lib/ports.sh" "${INSTALL_DIR}/lib/"
cp "${BASH_SOURCE%/*}/lib/authorized_keys.sh" "${INSTALL_DIR}/lib/"
cp "${BASH_SOURCE%/*}/lib/caddy.sh" "${INSTALL_DIR}/lib/"
chmod +x "${INSTALL_DIR}/register.sh"
chmod +x "${INSTALL_DIR}/lib/"*.sh
chown -R root:disinto-register "${INSTALL_DIR}"
chmod 750 "${INSTALL_DIR}"
chmod 750 "${INSTALL_DIR}/lib"
log_info "Control plane scripts installed"
# =============================================================================
# Step 5: Set up SSH authorized_keys
# =============================================================================
log_info "Setting up SSH authorized_keys..."
# Create .ssh directories
mkdir -p /home/disinto-register/.ssh
mkdir -p /home/disinto-tunnel/.ssh
# Set permissions
chmod 700 /home/disinto-register/.ssh
chmod 700 /home/disinto-tunnel/.ssh
chown -R disinto-register:disinto-register /home/disinto-register/.ssh
chown -R disinto-tunnel:disinto-tunnel /home/disinto-tunnel/.ssh
# Prompt for admin pubkey (for disinto-register user)
log_info "Please paste your admin SSH public key for the disinto-register user."
log_info "Paste the entire key (e.g., 'ssh-ed25519 AAAAC3Nza... user@host') and press Enter."
log_info "Paste key (or press Enter to skip): "
read -r ADMIN_PUBKEY
if [ -n "$ADMIN_PUBKEY" ]; then
echo "$ADMIN_PUBKEY" > /home/disinto-register/.ssh/authorized_keys
chmod 600 /home/disinto-register/.ssh/authorized_keys
chown disinto-register:disinto-register /home/disinto-register/.ssh/authorized_keys
# Add forced command restriction
# We'll update this after the first register call
log_info "Admin pubkey added to disinto-register"
else
log_warn "No admin pubkey provided - SSH access will be restricted"
echo "# No admin pubkey configured" > /home/disinto-register/.ssh/authorized_keys
chmod 600 /home/disinto-register/.ssh/authorized_keys
fi
# Create initial authorized_keys for tunnel user
# Source the library and call the function directly (not as subprocess)
source "${INSTALL_DIR}/lib/ports.sh"
source "${INSTALL_DIR}/lib/authorized_keys.sh"
rebuild_authorized_keys
# =============================================================================
# Step 6: Configure forced command for disinto-register
# =============================================================================
log_info "Configuring forced command for disinto-register..."
# Update authorized_keys with forced command
# Note: This replaces the pubkey line with a restricted version
if [ -n "$ADMIN_PUBKEY" ]; then
# Extract key type and key
KEY_TYPE="${ADMIN_PUBKEY%% *}"
KEY_DATA="${ADMIN_PUBKEY#* }"
# Create forced command entry
FORCED_CMD="restrict,command=\"${INSTALL_DIR}/register.sh\" ${KEY_TYPE} ${KEY_DATA}"
# Replace the pubkey line
echo "$FORCED_CMD" > /home/disinto-register/.ssh/authorized_keys
chmod 600 /home/disinto-register/.ssh/authorized_keys
chown disinto-register:disinto-register /home/disinto-register/.ssh/authorized_keys
log_info "Forced command configured: ${INSTALL_DIR}/register.sh"
fi
# =============================================================================
# Step 7: Final configuration
# =============================================================================
log_info "Configuring domain suffix: ${DOMAIN_SUFFIX}"
# Reload systemd if needed
systemctl daemon-reload 2>/dev/null || true
# =============================================================================
# Summary
# =============================================================================
echo ""
log_info "Installation complete!"
echo ""
echo "Edge control plane is now running on this host."
echo ""
echo "Configuration:"
echo " Install directory: ${INSTALL_DIR}"
echo " Registry: ${REGISTRY_FILE}"
echo " Caddy admin API: http://127.0.0.1:2019"
echo ""
echo "Users:"
echo " disinto-register - SSH forced command (runs ${INSTALL_DIR}/register.sh)"
echo " disinto-tunnel - Reverse tunnel receiver (no shell)"
echo ""
echo "Next steps:"
echo " 1. Verify Caddy is running: systemctl status caddy"
echo " 2. Test SSH access: ssh disinto-register@localhost 'list'"
echo " 3. From a dev box, register a tunnel:"
echo " disinto edge register <project>"
echo ""
echo "To test:"
echo " ssh disinto-register@$(hostname) 'list'"
echo ""

View file

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# =============================================================================
# lib/authorized_keys.sh — Rebuild authorized_keys from registry
#
# Rebuilds disinto-tunnel's authorized_keys file from the registry.
# Each entry has:
# - restrict flag (no shell, no X11 forwarding, etc.)
# - permitlisten for allowed reverse tunnel ports
# - command="/bin/false" to prevent arbitrary command execution
#
# Functions:
# rebuild_authorized_keys → rebuilds /home/disinto-tunnel/.ssh/authorized_keys
# get_tunnel_authorized_keys → prints the generated authorized_keys content
# =============================================================================
set -euo pipefail
# Source ports library (SCRIPT_DIR is this file's directory, so lib/ports.sh is adjacent)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/ports.sh"
# Tunnel user home directory
TUNNEL_USER="disinto-tunnel"
TUNNEL_SSH_DIR="/home/${TUNNEL_USER}/.ssh"
TUNNEL_AUTH_KEYS="${TUNNEL_SSH_DIR}/authorized_keys"
# Ensure tunnel user exists
_ensure_tunnel_user() {
if ! id "$TUNNEL_USER" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -M "$TUNNEL_USER" 2>/dev/null || true
mkdir -p "$TUNNEL_SSH_DIR"
chmod 700 "$TUNNEL_SSH_DIR"
fi
}
# Generate the authorized_keys content from registry
# Output: one authorized_keys line per registered project
generate_authorized_keys_content() {
local content=""
local first=true
# Get all projects from registry
while IFS= read -r line; do
[ -z "$line" ] && continue
local project port pubkey
# shellcheck disable=SC2034
project=$(echo "$line" | jq -r '.name')
port=$(echo "$line" | jq -r '.port')
pubkey=$(echo "$line" | jq -r '.pubkey')
# Skip if missing required fields
{ [ -z "$port" ] || [ -z "$pubkey" ]; } && continue
# Build the authorized_keys line
# Format: restrict,port-forwarding,permitlisten="127.0.0.1:<port>",command="/bin/false" <key-type> <key>
local auth_line="restrict,port-forwarding,permitlisten=\"127.0.0.1:${port}\",command=\"/bin/false\" ${pubkey}"
if [ "$first" = true ]; then
content="$auth_line"
first=false
else
content="${content}
${auth_line}"
fi
done < <(list_ports)
if [ -z "$content" ]; then
# No projects registered, create empty file
echo "# No tunnels registered"
else
echo "$content"
fi
}
# Rebuild authorized_keys file
# Usage: rebuild_authorized_keys
rebuild_authorized_keys() {
_ensure_tunnel_user
local content
content=$(generate_authorized_keys_content)
# Write to file
echo "$content" > "$TUNNEL_AUTH_KEYS"
chmod 600 "$TUNNEL_AUTH_KEYS"
chown -R "$TUNNEL_USER":"$TUNNEL_USER" "$TUNNEL_SSH_DIR"
echo "Rebuilt authorized_keys for ${TUNNEL_USER} (entries: $(echo "$content" | grep -c 'ssh-' || echo 0))" >&2
}
# Get the current authorized_keys content (for verification)
# Usage: get_tunnel_authorized_keys
get_tunnel_authorized_keys() {
if [ -f "$TUNNEL_AUTH_KEYS" ]; then
cat "$TUNNEL_AUTH_KEYS"
else
generate_authorized_keys_content
fi
}

143
tools/edge-control/lib/caddy.sh Executable file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env bash
# =============================================================================
# lib/caddy.sh — Caddy admin API wrapper
#
# Interacts with Caddy admin API on 127.0.0.1:2019 to:
# - Add site blocks for <project>.disinto.ai → reverse_proxy 127.0.0.1:<port>
# - Remove site blocks when deregistering
#
# Functions:
# add_route <project> <port> → adds Caddy site block
# remove_route <project> → removes Caddy site block
# reload_caddy → sends POST /reload to apply changes
# =============================================================================
set -euo pipefail
# Caddy admin API endpoint
CADDY_ADMIN_URL="${CADDY_ADMIN_URL:-http://127.0.0.1:2019}"
# Domain suffix for projects
DOMAIN_SUFFIX="${DOMAIN_SUFFIX:-disinto.ai}"
# Add a route for a project
# Usage: add_route <project> <port>
add_route() {
local project="$1"
local port="$2"
local fqdn="${project}.${DOMAIN_SUFFIX}"
# Build the route configuration (partial config)
local route_config
route_config=$(cat <<EOF
{
"match": [
{
"host": ["${fqdn}"]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "127.0.0.1:${port}"
}
]
}
]
}
]
}
]
}
EOF
)
# Append route using POST /config/apps/http/servers/edge/routes
local response
response=$(curl -s -X POST \
"${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes" \
-H "Content-Type: application/json" \
-d "$route_config" 2>&1) || {
echo "Error: failed to add route for ${fqdn}" >&2
echo "Response: ${response}" >&2
return 1
}
echo "Added route: ${fqdn} → 127.0.0.1:${port}" >&2
}
# Remove a route for a project
# Usage: remove_route <project>
remove_route() {
local project="$1"
local fqdn="${project}.${DOMAIN_SUFFIX}"
# First, get current routes
local routes_json
routes_json=$(curl -s "${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes" 2>&1) || {
echo "Error: failed to get current routes" >&2
return 1
}
# Find the route index that matches our fqdn using jq
local route_index
route_index=$(echo "$routes_json" | jq -r "to_entries[] | select(.value.match[]?.host[]? == \"${fqdn}\") | .key" 2>/dev/null | head -1)
if [ -z "$route_index" ] || [ "$route_index" = "null" ]; then
echo "Warning: route for ${fqdn} not found" >&2
return 0
fi
# Delete the route at the found index
local response
response=$(curl -s -X DELETE \
"${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes/${route_index}" \
-H "Content-Type: application/json" 2>&1) || {
echo "Error: failed to remove route for ${fqdn}" >&2
echo "Response: ${response}" >&2
return 1
}
echo "Removed route: ${fqdn}" >&2
}
# Reload Caddy to apply configuration changes
# Usage: reload_caddy
reload_caddy() {
local response
response=$(curl -s -X POST \
"${CADDY_ADMIN_URL}/reload" 2>&1) || {
echo "Error: failed to reload Caddy" >&2
echo "Response: ${response}" >&2
return 1
}
echo "Caddy reloaded" >&2
}
# Get Caddy config for debugging
# Usage: get_caddy_config
get_caddy_config() {
curl -s "${CADDY_ADMIN_URL}/config"
}
# Check if Caddy admin API is reachable
# Usage: check_caddy_health
check_caddy_health() {
local response
response=$(curl -s -o /dev/null -w "%{http_code}" \
"${CADDY_ADMIN_URL}/" 2>/dev/null) || response="000"
if [ "$response" = "200" ]; then
return 0
else
echo "Caddy admin API not reachable (HTTP ${response})" >&2
return 1
fi
}

202
tools/edge-control/lib/ports.sh Executable file
View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# =============================================================================
# lib/ports.sh — Port allocator for edge control plane
#
# Manages port allocation in the range 20000-29999.
# Uses flock-based concurrency control over registry.json.
#
# Functions:
# allocate_port <project> <pubkey> <fqdn> → writes to registry, returns port
# free_port <project> → removes project from registry
# get_port <project> → returns assigned port or empty
# list_ports → prints all projects with port/FQDN
# =============================================================================
set -euo pipefail
# Directory containing registry files
REGISTRY_DIR="${REGISTRY_DIR:-/var/lib/disinto}"
REGISTRY_FILE="${REGISTRY_DIR}/registry.json"
LOCK_FILE="${REGISTRY_DIR}/registry.lock"
# Port range
PORT_MIN=20000
PORT_MAX=29999
# Ensure registry directory exists
_ensure_registry_dir() {
if [ ! -d "$REGISTRY_DIR" ]; then
mkdir -p "$REGISTRY_DIR"
chmod 0750 "$REGISTRY_DIR"
chown root:disinto-register "$REGISTRY_DIR"
fi
if [ ! -f "$LOCK_FILE" ]; then
touch "$LOCK_FILE"
chmod 0644 "$LOCK_FILE"
fi
}
# Read current registry, returns JSON or empty string
_registry_read() {
if [ -f "$REGISTRY_FILE" ]; then
cat "$REGISTRY_FILE"
else
echo '{"version":1,"projects":{}}'
fi
}
# Write registry atomically (write to temp, then mv)
_registry_write() {
local tmp_file
tmp_file=$(mktemp "${REGISTRY_DIR}/registry.XXXXXX")
echo "$1" > "$tmp_file"
mv -f "$tmp_file" "$REGISTRY_FILE"
chmod 0644 "$REGISTRY_FILE"
}
# Allocate a port for a project
# Usage: allocate_port <project> <pubkey> <fqdn>
# Returns: port number on stdout
# Writes: registry.json with project entry
allocate_port() {
local project="$1"
local pubkey="$2"
local fqdn="$3"
_ensure_registry_dir
# Use flock for concurrency control
exec 200>"$LOCK_FILE"
flock -x 200
local registry
registry=$(_registry_read)
# Check if project already has a port assigned
local existing_port
existing_port=$(echo "$registry" | jq -r ".projects[\"$project\"].port // empty" 2>/dev/null) || existing_port=""
if [ -n "$existing_port" ]; then
# Project already registered, return existing port
echo "$existing_port"
return 0
fi
# Find an available port
local port assigned=false
local used_ports
used_ports=$(echo "$registry" | jq -r '.projects | to_entries | map(.value.port) | .[]' 2>/dev/null) || used_ports=""
for candidate in $(seq $PORT_MIN $PORT_MAX); do
# Check if port is already used
local in_use=false
if echo "$used_ports" | grep -qx "$candidate"; then
in_use=true
fi
if [ "$in_use" = false ]; then
port=$candidate
assigned=true
break
fi
done
if [ "$assigned" = false ]; then
echo "Error: no available ports in range ${PORT_MIN}-${PORT_MAX}" >&2
return 1
fi
# Get current timestamp
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Add project to registry
local new_registry
new_registry=$(echo "$registry" | jq --arg project "$project" \
--argjson port "$port" \
--arg pubkey "$pubkey" \
--arg fqdn "$fqdn" \
--arg timestamp "$timestamp" \
'.projects[$project] = {
"port": $port,
"fqdn": $fqdn,
"pubkey": $pubkey,
"registered_at": $timestamp
}')
_registry_write "$new_registry"
echo "$port"
}
# Free a port (remove project from registry)
# Usage: free_port <project>
# Returns: 0 on success, 1 if project not found
free_port() {
local project="$1"
_ensure_registry_dir
# Use flock for concurrency control
exec 200>"$LOCK_FILE"
flock -x 200
local registry
registry=$(_registry_read)
# Check if project exists
local existing_port
existing_port=$(echo "$registry" | jq -r ".projects[\"$project\"].port // empty" 2>/dev/null) || existing_port=""
if [ -z "$existing_port" ]; then
echo "Error: project '$project' not found in registry" >&2
return 1
fi
# Remove project from registry
local new_registry
new_registry=$(echo "$registry" | jq --arg project "$project" 'del(.projects[$project])')
_registry_write "$new_registry"
echo "$existing_port"
}
# Get the port for a project
# Usage: get_port <project>
# Returns: port number or empty string
get_port() {
local project="$1"
_ensure_registry_dir
local registry
registry=$(_registry_read)
echo "$registry" | jq -r ".projects[\"$project\"].port // empty" 2>/dev/null || echo ""
}
# List all registered projects with their ports and FQDNs
# Usage: list_ports
# Returns: JSON array of projects
list_ports() {
_ensure_registry_dir
local registry
registry=$(_registry_read)
echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn}) | .[] | @json' 2>/dev/null
}
# Get full project info from registry
# Usage: get_project_info <project>
# Returns: JSON object with project details
get_project_info() {
local project="$1"
_ensure_registry_dir
local registry
registry=$(_registry_read)
echo "$registry" | jq -c ".projects[\"$project\"] // empty" 2>/dev/null || echo ""
}

191
tools/edge-control/register.sh Executable file
View file

@ -0,0 +1,191 @@
#!/usr/bin/env bash
# =============================================================================
# register.sh — SSH forced-command handler for edge control plane
#
# This script runs as a forced command for the disinto-register SSH user.
# It parses SSH_ORIGINAL_COMMAND and dispatches to register|deregister|list.
#
# Usage (via SSH):
# ssh disinto-register@edge "register <project> <pubkey>"
# ssh disinto-register@edge "deregister <project>"
# ssh disinto-register@edge "list"
#
# Output: JSON on stdout
# =============================================================================
set -euo pipefail
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source libraries
source "${SCRIPT_DIR}/lib/ports.sh"
source "${SCRIPT_DIR}/lib/caddy.sh"
source "${SCRIPT_DIR}/lib/authorized_keys.sh"
# Domain suffix
DOMAIN_SUFFIX="${DOMAIN_SUFFIX:-disinto.ai}"
# Print usage
usage() {
cat <<EOF
Usage:
register <project> <pubkey> Register a new tunnel
deregister <project> Remove a tunnel
list List all registered tunnels
Example:
ssh disinto-register@edge "register myproject ssh-ed25519 AAAAC3..."
EOF
exit 1
}
# Register a new tunnel
# Usage: do_register <project> <pubkey>
do_register() {
local project="$1"
local pubkey="$2"
# Validate project name (alphanumeric, hyphens, underscores)
if ! [[ "$project" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo '{"error":"invalid project name"}'
exit 1
fi
# Extract key type and key from pubkey (format: "ssh-ed25519 AAAAC3...")
local key_type key
key_type=$(echo "$pubkey" | awk '{print $1}')
key=$(echo "$pubkey" | awk '{$1=""; print $0}' | tr -d ' ')
if [ -z "$key_type" ] || [ -z "$key" ]; then
echo '{"error":"invalid pubkey format"}'
exit 1
fi
# Validate key type
if ! [[ "$key_type" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)$ ]]; then
echo '{"error":"unsupported key type"}'
exit 1
fi
# Full pubkey for registry
local full_pubkey="${key_type} ${key}"
# Allocate port (idempotent - returns existing if already registered)
local port
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")
# Add Caddy route
add_route "$project" "$port"
# Rebuild authorized_keys for tunnel user
rebuild_authorized_keys
# Reload Caddy
reload_caddy
# Return JSON response
echo "{\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\"}"
}
# Deregister a tunnel
# Usage: do_deregister <project>
do_deregister() {
local project="$1"
# Get current port before removing
local port
port=$(get_port "$project")
if [ -z "$port" ]; then
echo '{"error":"project not found"}'
exit 1
fi
# Remove from registry
free_port "$project" >/dev/null
# Remove Caddy route
remove_route "$project"
# Rebuild authorized_keys for tunnel user
rebuild_authorized_keys
# Reload Caddy
reload_caddy
# Return JSON response
echo "{\"removed\":true,\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\"}"
}
# List all registered tunnels
# Usage: do_list
do_list() {
local result='{"tunnels":['
local first=true
while IFS= read -r line; do
[ -z "$line" ] && continue
if [ "$first" = true ]; then
first=false
else
result="${result},"
fi
result="${result}${line}"
done < <(list_ports)
result="${result}]}"
echo "$result"
}
# Main dispatch
main() {
# Get the SSH_ORIGINAL_COMMAND
local command="${SSH_ORIGINAL_COMMAND:-}"
if [ -z "$command" ]; then
echo '{"error":"no command provided"}'
exit 1
fi
# Parse command
local cmd="${command%% *}"
local args="${command#* }"
# Handle commands
case "$cmd" in
register)
# register <project> <pubkey>
local project="${args%% *}"
local pubkey="${args#* }"
# Handle case where pubkey might have spaces (rare but possible with some formats)
if [ "$pubkey" = "$args" ]; then
pubkey=""
fi
if [ -z "$project" ] || [ -z "$pubkey" ]; then
echo '{"error":"register requires <project> <pubkey>"}'
exit 1
fi
do_register "$project" "$pubkey"
;;
deregister)
# deregister <project>
local project="$args"
if [ -z "$project" ]; then
echo '{"error":"deregister requires <project>"}'
exit 1
fi
do_deregister "$project"
;;
list)
do_list
;;
*)
echo '{"error":"unknown command: '"$cmd"'" }'
usage
;;
esac
}
main "$@"