fix: feat: disinto edge command + SSH-forced-command control plane in tools/edge-control/ (#621)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/smoke-init Pipeline failed

This commit is contained in:
Claude 2026-04-10 18:42:41 +00:00
parent f8bb3eea7d
commit a4fe845b9d
7 changed files with 1498 additions and 0 deletions

View file

@ -0,0 +1,98 @@
#!/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="$(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
project=$(echo "$line" | jq -r '.name') # shellcheck disable=SC2034
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))"
}
# 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
}

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

@ -0,0 +1,151 @@
#!/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 Caddy site block configuration
local config
config=$(cat <<EOF
{
"apps": {
"http": {
"servers": {
"edge": {
"listen": [":80", ":443"],
"routes": [
{
"match": [
{
"host": ["${fqdn}"]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "127.0.0.1:${port}"
}
]
}
]
}
]
}
]
}
]
}
}
}
}
}
EOF
)
# Send POST to Caddy admin API to load config
# Note: This appends to existing config rather than replacing
local response
response=$(curl -s -X POST \
"${CADDY_ADMIN_URL}/load" \
-H "Content-Type: application/json" \
-d "$config" 2>&1) || {
echo "Error: failed to add route for ${fqdn}" >&2
echo "Response: ${response}" >&2
return 1
}
# Check response
local loaded
loaded=$(echo "$response" | jq -r '.loaded // empty' 2>/dev/null) || loaded=""
if [ "$loaded" = "true" ]; then
echo "Added route: ${fqdn} → 127.0.0.1:${port}"
else
echo "Warning: Caddy admin response: ${response}" >&2
# Don't fail hard - config might have been merged successfully
fi
}
# Remove a route for a project
# Usage: remove_route <project>
remove_route() {
local project="$1"
local fqdn="${project}.${DOMAIN_SUFFIX}"
# Use Caddy admin API to delete the config for this host
# We need to delete the specific host match from the config
local response
response=$(curl -s -X DELETE \
"${CADDY_ADMIN_URL}/config/apps/http/servers/edge/routes/0" \
-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}"
}
# 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"
}
# 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 ""
}