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
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
29cbbcb7de
7 changed files with 1503 additions and 0 deletions
233
bin/disinto
233
bin/disinto
|
|
@ -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
|
||||
|
|
|
|||
260
tools/edge-control/README.md
Normal file
260
tools/edge-control/README.md
Normal 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
375
tools/edge-control/install.sh
Executable 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 ""
|
||||
99
tools/edge-control/lib/authorized_keys.sh
Executable file
99
tools/edge-control/lib/authorized_keys.sh
Executable 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
143
tools/edge-control/lib/caddy.sh
Executable 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
202
tools/edge-control/lib/ports.sh
Executable 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
191
tools/edge-control/register.sh
Executable 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 "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue