Confirmed prod bug: §5 keyed idempotency on configs/credentials.json only.
Re-running the installer on a pending-then-approved node (the exact flow the
script's own output tells you to do) found no credentials.json and fell
through to a fresh POST /nodes/enroll — which enrollment.py's CRITICAL GUARD
answers with 403 for an already-approved node_id, rendered as "use Reissue
key". The working path (GET /nodes/{id}/credentials with the saved
pickup_secret — no already-approved guard on that endpoint) was only ever
tried within a single run.
§5 rewritten as a decision tree that runs before any POST /nodes/enroll:
- credentials.json has api_key -> skip (unchanged)
- configs/pickup_secret exists -> GET /credentials with it:
200 + api_key -> write credentials.json, done
200, no key -> say "approve it, re-run"; clean exit, start stack
401 (rotated) -> re-enroll iff --token, else specific die
404 (deleted) -> re-enroll iff --token, else specific die
000 -> connectivity die
- no creds, no pickup_secret -> fresh enroll (do_fresh_enroll)
Also: fresh-enroll 403/401/429 handlers are now specific and point at
pickup-secret recovery, not just "Reissue key"; --wait-approval polling now
applies on the re-run path; §7's "re-run the installer" banner is
conditional on pickup_secret existing.
Response shapes verified against enrollment.py @ v1. approve_node mints
node_keys/{id}.api_key synchronously — no second bug; assigning a system is
independent and not required for a key. bash -n clean.
Recovery for a node stuck by the old behaviour (approved, no credentials.json,
pickup_secret on disk): re-run the patched install.sh (no --token needed), or
curl -fsS $C2_URL/nodes/$NODE_ID/credentials \
-H "X-Pickup-Secret: $(cat configs/pickup_secret)" | jq '{api_key}' > configs/credentials.json
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
install.sh is the `curl -fsSL <url> | sudo bash -s -- --token ...` bootstrap:
preflight (root/arch/apt/SDR) → install docker + compose + git + jq → clone
node-26 at a pinned ref (default `v1`, `--track-main` opt-in) → write .env
non-interactively from flags/env with a /dev/tty interactive fallback →
enroll with C2 (POST /nodes/enroll, poll GET /nodes/{id}/credentials, matches
drb-c2-core/app/routers/enrollment.py exactly) and write configs/credentials.json
→ docker compose pull && up -d (prebuilt; --build opts into the ~1h op25 build)
→ print the admin-approval step. Idempotent: re-run picks up the api_key after
approval; existing .env is preserved.
- setup.sh deleted — two scripts writing .env drift. install.sh owns it now.
- Makefile `setup:` no longer calls the removed script (cp .env.example fallback).
- README Setup section rewritten around the one-liner; `make setup`/`make up`
kept as the local-dev path.
Notes carried in the script header: git.vpn.cusano.net is public (D1, settled);
`v1` must be re-cut at this change's merge commit so the tag actually contains
install.sh. Standing hazard D3: docker-compose.yml bind-mounts the app source
over the image, so a pinned ref and the pulled image tags must not diverge.
Client-side enrollment still belongs in the edge-node app (mqtt_manager.py:73-85);
install.sh doing it is the interim. Tracked for follow-up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>