fix: feat: disinto edge command + SSH-forced-command control plane in tools/edge-control/ (#621)
This commit is contained in:
parent
f8bb3eea7d
commit
a4fe845b9d
7 changed files with 1498 additions and 0 deletions
98
tools/edge-control/lib/authorized_keys.sh
Executable file
98
tools/edge-control/lib/authorized_keys.sh
Executable 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
151
tools/edge-control/lib/caddy.sh
Executable 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
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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue