diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 860ff27..473bb18 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -331,6 +331,8 @@ def main() -> int: "aefd9f655411a955395e6e5995ddbe6f": "vault-seed binary check pattern (forgejo + ops-repo)", "60f0c46deb5491599457efb4048918e5": "vault-seed VAULT_ADDR + hvault_token_lookup check (forgejo + ops-repo)", "f6838f581ef6b4d82b55268389032769": "vault-seed VAULT_ADDR + hvault_token_lookup die (forgejo + ops-repo)", + # Common shell control-flow: if → return 1 → fi → fi (env.sh + register.sh) + "a8bdb7f1a5d8cbd0a5921b17b6cf6f4d": "Common shell control-flow (return 1 / fi / fi / return 0 / }) (env.sh + register.sh)", } if not sh_files: diff --git a/tools/edge-control/README.md b/tools/edge-control/README.md index 019b385..0c95dda 100644 --- a/tools/edge-control/README.md +++ b/tools/edge-control/README.md @@ -21,6 +21,7 @@ This control plane runs on the public edge host (Debian DO box) and provides: │ │ disinto-register│ │ /var/lib/disinto/ │ │ │ │ (authorized_keys│ │ ├── registry.json (source of truth) │ │ │ │ forced cmd) │ │ ├── registry.lock (flock) │ │ +│ │ │ │ └── allowlist.json (admin-approved names) │ │ │ │ │ │ └── authorized_keys (rebuildable) │ │ │ └────────┬─────────┘ └───────────────────────────────────────────────┘ │ │ │ │ @@ -79,7 +80,7 @@ curl -sL https://raw.githubusercontent.com/disinto-admin/disinto/fix/issue-621/t - `disinto-tunnel` — no password, no shell, only receives reverse tunnels 2. **Creates data directory**: - - `/var/lib/disinto/` with `registry.json`, `registry.lock` + - `/var/lib/disinto/` with `registry.json`, `registry.lock`, `allowlist.json` - Permissions: `root:disinto-register 0750` 3. **Installs Caddy**: @@ -180,6 +181,43 @@ Shows all registered tunnels with their ports and FQDNs. } ``` +## Allowlist + +The allowlist prevents project name squatting by requiring admin approval before a name can be registered. It is **opt-in**: when `allowlist.json` is empty (no project entries), registration works as before. Once the admin adds entries, only approved names are accepted. + +### Setup + +Edit `/var/lib/disinto/allowlist.json` as root: + +```json +{ + "version": 1, + "allowed": { + "myproject": { + "pubkey_fingerprint": "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "open-project": { + "pubkey_fingerprint": "" + } + } +} +``` + +- **With `pubkey_fingerprint`**: Only the specified SSH key can register this project name. The fingerprint is the SHA256 output of `ssh-keygen -lf `. +- **With empty `pubkey_fingerprint`**: Any caller may register this project name (name reservation without key binding). +- **Not listed**: Registration is refused with `{"error":"name not approved"}`. + +### Workflow + +1. Admin edits `/var/lib/disinto/allowlist.json` (via ops repo PR, or direct `ssh root@edge`). +2. File is `root:root 0644` — `disinto-register` only reads it; `register.sh` never mutates it. +3. Callers run `register` as usual. The allowlist is checked transparently. + +### Security + +- The allowlist is a **first-come-first-serve defense**: once a name is approved for a key, no one else can claim it. +- It does **not** replace per-operation ownership checks (sibling issue #1094) — it only prevents the initial race. + ## Recovery ### After State Loss @@ -274,6 +312,7 @@ ssh disinto-register@edge.disinto.ai "register myproject $(cat ~/.ssh/id_ed25519 - `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 +- `/var/lib/disinto/allowlist.json` — Admin-approved project name allowlist (root-owned, read-only by register.sh) ## Dependencies diff --git a/tools/edge-control/install.sh b/tools/edge-control/install.sh index 9571311..c7af075 100755 --- a/tools/edge-control/install.sh +++ b/tools/edge-control/install.sh @@ -7,7 +7,7 @@ # # What it does: # 1. Creates users: disinto-register, disinto-tunnel -# 2. Creates /var/lib/disinto/ with registry.json, registry.lock +# 2. Creates /var/lib/disinto/ with registry.json, registry.lock, allowlist.json # 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/ @@ -152,6 +152,15 @@ LOCK_FILE="${REGISTRY_DIR}/registry.lock" touch "$LOCK_FILE" chmod 0644 "$LOCK_FILE" +# Initialize allowlist.json (empty = no restrictions until admin populates) +ALLOWLIST_FILE="${REGISTRY_DIR}/allowlist.json" +if [ ! -f "$ALLOWLIST_FILE" ]; then + echo '{"version":1,"allowed":{}}' > "$ALLOWLIST_FILE" + chmod 0644 "$ALLOWLIST_FILE" + chown root:root "$ALLOWLIST_FILE" + log_info "Initialized allowlist: ${ALLOWLIST_FILE}" +fi + # ============================================================================= # Step 3: Install Caddy with Gandi DNS plugin # ============================================================================= diff --git a/tools/edge-control/register.sh b/tools/edge-control/register.sh index 998656c..bef83e9 100755 --- a/tools/edge-control/register.sh +++ b/tools/edge-control/register.sh @@ -28,6 +28,12 @@ DOMAIN_SUFFIX="${DOMAIN_SUFFIX:-disinto.ai}" # Reserved project names — operator-adjacent, internal roles, and subdomain-mode prefixes RESERVED_NAMES=(www api admin root mail chat forge ci edge caddy disinto register tunnel) +# Allowlist path (root-owned, never mutated by this script) +ALLOWLIST_FILE="${ALLOWLIST_FILE:-/var/lib/disinto/allowlist.json}" + +# Captured error from check_allowlist (used for JSON response) +_ALLOWLIST_ERROR="" + # Print usage usage() { cat < +# Returns: 0 if allowed, 1 if denied (prints error JSON to stderr) +check_allowlist() { + local project="$1" + local pubkey="$2" + + # If allowlist file does not exist, allow all (opt-in policy) + if [ ! -f "$ALLOWLIST_FILE" ]; then + return 0 + fi + + # Look up the project in the allowlist + local entry + entry=$(jq -c --arg p "$project" '.allowed[$p] // empty' "$ALLOWLIST_FILE" 2>/dev/null) || entry="" + + if [ -z "$entry" ]; then + # Project not in allowlist at all + _ALLOWLIST_ERROR="name not approved" + return 1 + fi + + # Project found — check pubkey fingerprint binding + local bound_fingerprint + bound_fingerprint=$(echo "$entry" | jq -r '.pubkey_fingerprint // ""' 2>/dev/null) + + if [ -n "$bound_fingerprint" ]; then + # Fingerprint is bound — verify caller's pubkey matches + local caller_fingerprint + caller_fingerprint=$(ssh-keygen -lf /dev/stdin <<<"$pubkey" 2>/dev/null | awk '{print $2}') || caller_fingerprint="" + + if [ -z "$caller_fingerprint" ]; then + _ALLOWLIST_ERROR="invalid pubkey for fingerprint check" + return 1 + fi + + if [ "$caller_fingerprint" != "$bound_fingerprint" ]; then + _ALLOWLIST_ERROR="pubkey does not match allowed key for this project" + return 1 + fi + fi + + return 0 +} + # Register a new tunnel # Usage: do_register # When EDGE_ROUTING_MODE=subdomain, also registers forge., ci., @@ -85,6 +136,12 @@ do_register() { # Full pubkey for registry local full_pubkey="${key_type} ${key}" + # Check allowlist (opt-in: no file = allow all) + if ! check_allowlist "$project" "$full_pubkey"; then + echo "{\"error\":\"${_ALLOWLIST_ERROR}\"}" + exit 1 + fi + # Allocate port (idempotent - returns existing if already registered) local port port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")