#!/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, allowlist.json # 3. On upgrade: auto-populates allowlist.json from existing registry entries # 4. On fresh install: seeds empty allowlist with warning (registration gated) # 5. Installs Caddy with Gandi DNS plugin # 6. Sets up SSH authorized_keys for both users # 7. 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" EXTRA_CADDYFILE="/etc/caddy/extra.d/*.caddy" ADMIN_TAG="admin" usage() { cat < Gandi API token for wildcard cert (required) --install-dir Install directory (default: /opt/disinto-edge) --registry-dir Registry directory (default: /var/lib/disinto) --caddy-version Caddy version to install (default: ${CADDY_VERSION}) --domain-suffix Domain suffix for tunnels (default: disinto.ai) --extra-caddyfile Import path for operator-owned Caddy config (default: /etc/caddy/extra.d/*.caddy) --admin-tag Caller tag for the initial admin key (default: admin) -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 ;; --extra-caddyfile) EXTRA_CADDYFILE="$2" shift 2 ;; --admin-tag) ADMIN_TAG="$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" # Initialize allowlist.json ALLOWLIST_FILE="${REGISTRY_DIR}/allowlist.json" _ALLOWLIST_MODE="" if [ ! -f "$ALLOWLIST_FILE" ]; then # Check whether the registry already has projects that need allowlisting _EXISTING_PROJECTS="" if [ -f "$REGISTRY_FILE" ]; then _EXISTING_PROJECTS=$(jq -r '.projects // {} | keys[]' "$REGISTRY_FILE" 2>/dev/null) || _EXISTING_PROJECTS="" fi if [ -n "$_EXISTING_PROJECTS" ]; then # Upgrade path: auto-populate allowlist with existing projects (unbound). # This preserves current behavior — existing tunnels keep working. # Operator can tighten pubkey bindings later. _ALLOWED='{}' _PROJECT_COUNT=0 while IFS= read -r _proj; do _ALLOWED=$(echo "$_ALLOWED" | jq --arg p "$_proj" '. + {($p): {"pubkey_fingerprint": ""}}') _PROJECT_COUNT=$((_PROJECT_COUNT + 1)) done <<< "$_EXISTING_PROJECTS" echo "{\"version\":1,\"allowed\":${_ALLOWED}}" | jq '.' > "$ALLOWLIST_FILE" chmod 0644 "$ALLOWLIST_FILE" chown root:root "$ALLOWLIST_FILE" _ALLOWLIST_MODE="upgraded:${_PROJECT_COUNT}" log_info "Initialized allowlist with ${_PROJECT_COUNT} existing project(s): ${ALLOWLIST_FILE}" else # Fresh install: seed empty allowlist and warn the operator. echo '{"version":1,"allowed":{}}' > "$ALLOWLIST_FILE" chmod 0644 "$ALLOWLIST_FILE" chown root:root "$ALLOWLIST_FILE" _ALLOWLIST_MODE="fresh-empty" log_warn "Allowlist seeded empty — no project can register until you add entries to ${ALLOWLIST_FILE}." fi log_info "Initialized allowlist: ${ALLOWLIST_FILE}" fi # ============================================================================= # Step 3: Create audit log directory and logrotate config # ============================================================================= log_info "Setting up audit log..." LOG_DIR="/var/log/disinto" LOG_FILE="${LOG_DIR}/edge-register.log" mkdir -p "$LOG_DIR" chown root:disinto-register "$LOG_DIR" chmod 0750 "$LOG_DIR" # Touch the log file so it exists from day one touch "$LOG_FILE" chmod 0640 "$LOG_FILE" chown root:disinto-register "$LOG_FILE" # Install logrotate config (daily rotation, 30 days retention) LOGROTATE_CONF="/etc/logrotate.d/disinto-edge" cat > "$LOGROTATE_CONF" </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" </dev/null || true # Create Gandi environment file GANDI_ENV="/etc/caddy/gandi.env" cat > "$GANDI_ENV" </dev/null 2>&1; then chown root:caddy "$EXTRA_DIR" else log_warn "Group 'caddy' does not exist; extra.d owned by root:root" fi log_info "Created ${EXTRA_DIR} for operator-owned Caddy config" cat > "$CADDYFILE" </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 5: 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 6: 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 7: 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 with caller attribution tag FORCED_CMD="restrict,command=\"${INSTALL_DIR}/register.sh --as ${ADMIN_TAG}\" ${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 8: 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 " Allowlist: ${ALLOWLIST_FILE}" echo " Caddy admin API: http://127.0.0.1:2019" echo " Operator site blocks: ${EXTRA_DIR}/ (import ${EXTRA_CADDYFILE})" echo "" echo "Users:" echo " disinto-register - SSH forced command (runs ${INSTALL_DIR}/register.sh)" echo " disinto-tunnel - Reverse tunnel receiver (no shell)" echo "" echo "Allowlist:" case "${_ALLOWLIST_MODE:-}" in upgraded:*) echo " Allowlist was auto-populated from existing registry entries." echo " Existing projects can register without further action." ;; fresh-empty) echo " Allowlist is empty — registration is GATED until you add entries." echo " Edit ${ALLOWLIST_FILE} as root:" echo ' {"version":1,"allowed":{"myproject":{"pubkey_fingerprint":""}}}' echo " See ${INSTALL_DIR}/../README.md for the full workflow." ;; *) echo " Allowlist already existed (no changes made)." ;; esac 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 " echo "" echo "To test:" echo " ssh disinto-register@$(hostname) 'list'" echo ""