fix: edge-control: admin-approved allowlist for project names (#1092)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3116293d8e
commit
d055bc3a3a
3 changed files with 107 additions and 2 deletions
|
|
@ -21,6 +21,7 @@ This control plane runs on the public edge host (Debian DO box) and provides:
|
||||||
│ │ disinto-register│ │ /var/lib/disinto/ │ │
|
│ │ disinto-register│ │ /var/lib/disinto/ │ │
|
||||||
│ │ (authorized_keys│ │ ├── registry.json (source of truth) │ │
|
│ │ (authorized_keys│ │ ├── registry.json (source of truth) │ │
|
||||||
│ │ forced cmd) │ │ ├── registry.lock (flock) │ │
|
│ │ forced cmd) │ │ ├── registry.lock (flock) │ │
|
||||||
|
│ │ │ │ └── allowlist.json (admin-approved names) │ │
|
||||||
│ │ │ │ └── authorized_keys (rebuildable) │ │
|
│ │ │ │ └── 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
|
- `disinto-tunnel` — no password, no shell, only receives reverse tunnels
|
||||||
|
|
||||||
2. **Creates data directory**:
|
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`
|
- Permissions: `root:disinto-register 0750`
|
||||||
|
|
||||||
3. **Installs Caddy**:
|
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
|
## Recovery
|
||||||
|
|
||||||
### After State Loss
|
### 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/ports.sh` — Port allocator over `20000-29999`, jq-based, flockd
|
||||||
- `lib/authorized_keys.sh` — Deterministic rebuild of `disinto-tunnel` authorized_keys
|
- `lib/authorized_keys.sh` — Deterministic rebuild of `disinto-tunnel` authorized_keys
|
||||||
- `lib/caddy.sh` — POST to Caddy admin API for route mapping
|
- `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
|
## Dependencies
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Creates users: disinto-register, disinto-tunnel
|
# 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
|
# 3. Installs Caddy with Gandi DNS plugin
|
||||||
# 4. Sets up SSH authorized_keys for both users
|
# 4. Sets up SSH authorized_keys for both users
|
||||||
# 5. Installs control plane scripts to /opt/disinto-edge/
|
# 5. Installs control plane scripts to /opt/disinto-edge/
|
||||||
|
|
@ -152,6 +152,15 @@ LOCK_FILE="${REGISTRY_DIR}/registry.lock"
|
||||||
touch "$LOCK_FILE"
|
touch "$LOCK_FILE"
|
||||||
chmod 0644 "$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
|
# 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 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)
|
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
|
# Print usage
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -42,6 +48,51 @@ EOF
|
||||||
exit 1
|
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
|
# Register a new tunnel
|
||||||
# Usage: do_register <project> <pubkey>
|
# Usage: do_register <project> <pubkey>
|
||||||
# When EDGE_ROUTING_MODE=subdomain, also registers forge.<project>, ci.<project>,
|
# When EDGE_ROUTING_MODE=subdomain, also registers forge.<project>, ci.<project>,
|
||||||
|
|
@ -85,6 +136,12 @@ do_register() {
|
||||||
# Full pubkey for registry
|
# Full pubkey for registry
|
||||||
local full_pubkey="${key_type} ${key}"
|
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)
|
# Allocate port (idempotent - returns existing if already registered)
|
||||||
local port
|
local port
|
||||||
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")
|
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue