Merge pull request 'fix: edge-control: admin-approved allowlist for project names (#1092)' (#1097) from fix/issue-1092 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
2fd4da6b64
4 changed files with 109 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 <keyfile>`.
|
||||
- **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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 <<EOF
|
||||
|
|
@ -42,6 +48,51 @@ EOF
|
|||
exit 1
|
||||
}
|
||||
|
||||
# Check whether the project/pubkey pair is allowed by the allowlist.
|
||||
# Usage: check_allowlist <project> <pubkey>
|
||||
# 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 <project> <pubkey>
|
||||
# When EDGE_ROUTING_MODE=subdomain, also registers forge.<project>, ci.<project>,
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue