Node self-heals its API key; one-stop install.sh; real host-level OTA updater (#14, #15) #16

Merged
logan merged 3 commits from fix/14-15-provision-ota into main 2026-10-04 01:26:04 -04:00
Owner

Fixes node-26#14 and node-26#15. Pairs with server-26 PR fix/node-14-15-update-endpoint. The node half works without it, except the HTTPS update check-in, which skips cleanly until that PR deploys.

install.sh (#14)

  • After enrolling it now blocks and prints Please have an admin approve node '<id>' at https://drb.cusano.net/settings/nodes to continue, then polls GET /nodes/{id}/credentials with the pickup secret. No default timeout. Ctrl-C is safe: a re-run resumes from configs/pickup_secret. --wait-approval N caps the wait, and 0 skips it.
  • A re-run with a key already on disk re-checks that key against C2 and replaces a stale or reissued one.
  • credentials.json is merged atomically, so the dashboard's auth salt and session secret survive.
  • edge-node is always force-recreated at the end, so it loads the key.
  • git fetch --force accepts a re-cut v1 tag.
  • The enrollment token and the pickup secret no longer appear on curl's argv. The enrollment token was on argv before, even though the comment said otherwise.

edge-node (#14)

  • When there is no key at startup, or the broker refuses auth (CONNACK 4/5/134/135), mqtt_manager re-reads credentials.json. If that doesn't help, it fetches the key over HTTPS (key_pickup.py, using /configs/pickup_secret), persists it and rebuilds the paho client in place. No restart needed. Recovery is single-flight with a 30s to 15min backoff.

OTA (#15)

  • The node_update handler now writes configs/update-requested instead of os._exit(0).
  • install.sh installs drb-update.{service,timer,path} via scripts/install-updater.sh.
    • The path unit fires on the sentinel file.
    • The timer runs every ~15 min and POSTs /nodes/{id}/update-check over HTTPS, so offline or locked-out nodes still update.
  • scripts/drb-update.sh installs only the repo URL, ref and registry in root-owned /etc/drb/updater.conf.
    • C2's reply is a timestamp only. The sentinel's content is never read.
    • It aborts if the git origin or .env IMAGE_REGISTRY doesn't match the config.
    • It requires https for both repo and C2.
    • Credentials go to curl in a 0600 header file.
    • The script body is wrapped in main(), so a checkout that replaces the file mid-run is safe.

Tests: edge-node 227 pass (194 before this branch). New files: test_credential_recovery.py, and test_host_updater.py, which runs the real updater with stub git/docker/curl. flake8 is clean.
Not verified: the systemd units and install.sh have not run on a real Pi.

Release step after merge: re-cut v1 at the merge commit (git tag -f v1 <sha> && git push -f origin v1). install.sh is served from raw/tag/v1, and both installs and the updater track v1. No image rebuild is needed: the app code is bind-mounted and httpx is already in the image.

Fixes node-26#14 and node-26#15. Pairs with server-26 PR `fix/node-14-15-update-endpoint`. The node half works without it, except the HTTPS update check-in, which skips cleanly until that PR deploys. **install.sh (#14)** - After enrolling it now **blocks** and prints `Please have an admin approve node '<id>' at https://drb.cusano.net/settings/nodes to continue`, then polls `GET /nodes/{id}/credentials` with the pickup secret. No default timeout. Ctrl-C is safe: a re-run resumes from `configs/pickup_secret`. `--wait-approval N` caps the wait, and `0` skips it. - A re-run with a key already on disk re-checks that key against C2 and **replaces a stale or reissued one**. - `credentials.json` is merged atomically, so the dashboard's auth salt and session secret survive. - `edge-node` is **always force-recreated** at the end, so it loads the key. - `git fetch --force` accepts a re-cut `v1` tag. - The enrollment token and the pickup secret no longer appear on curl's argv. The enrollment token was on argv before, even though the comment said otherwise. **edge-node (#14)** - When there is no key at startup, or the broker refuses auth (CONNACK 4/5/134/135), `mqtt_manager` re-reads `credentials.json`. If that doesn't help, it fetches the key over HTTPS (`key_pickup.py`, using `/configs/pickup_secret`), persists it and rebuilds the paho client in place. No restart needed. Recovery is single-flight with a 30s to 15min backoff. **OTA (#15)** - The `node_update` handler now writes `configs/update-requested` instead of `os._exit(0)`. - `install.sh` installs `drb-update.{service,timer,path}` via `scripts/install-updater.sh`. - The path unit fires on the sentinel file. - The timer runs every ~15 min and POSTs `/nodes/{id}/update-check` over HTTPS, so offline or locked-out nodes still update. - `scripts/drb-update.sh` installs **only** the repo URL, ref and registry in root-owned `/etc/drb/updater.conf`. - C2's reply is a timestamp only. The sentinel's content is never read. - It aborts if the git origin or `.env` `IMAGE_REGISTRY` doesn't match the config. - It requires https for both repo and C2. - Credentials go to curl in a 0600 header file. - The script body is wrapped in `main()`, so a checkout that replaces the file mid-run is safe. **Tests:** edge-node 227 pass (194 before this branch). New files: `test_credential_recovery.py`, and `test_host_updater.py`, which runs the real updater with stub git/docker/curl. flake8 is clean. **Not verified:** the systemd units and install.sh have not run on a real Pi. **Release step after merge:** re-cut `v1` at the merge commit (`git tag -f v1 <sha> && git push -f origin v1`). `install.sh` is served from `raw/tag/v1`, and both installs and the updater track `v1`. No image rebuild is needed: the app code is bind-mounted and httpx is already in the image.
logan added 3 commits 2026-10-04 01:11:23 -04:00
install.sh (#14): after enrolling it now BLOCKS until an admin approves,
printing "Please have an admin approve node '<id>' at <app>/settings/nodes
to continue", polling GET /nodes/{id}/credentials with the pickup secret
(no default timeout; Ctrl-C safe; re-run resumes from configs/pickup_secret).
A re-run with a key on disk re-checks it against C2 and replaces a stale
(reissued) key. credentials.json is merged atomically so the dashboard auth
salt/secret survive. edge-node is ALWAYS force-recreated at the end, so a key
written after the container started is loaded. --wait-approval N caps the
wait; 0 = don't wait. git fetch uses --force so a re-cut v1 tag is accepted.

edge-node (#14): with no key at startup, or on a broker auth refusal
(CONNACK 4/5/134/135), mqtt_manager re-reads credentials.json, else fetches
the key over HTTPS (key_pickup.py, pickup secret from /configs), persists it
and rebuilds the paho client in place. Single-flight, 30s..15min backoff.
A key pushed over MQTT while connected is applied for the next reconnect.

OTA (#15): node_update no longer os._exit()s. It drops
configs/update-requested; install.sh installs scripts/install-updater.sh's
drb-update.{service,timer,path}. The path unit fires on the sentinel, the
timer (~15 min) does an HTTPS check-in (POST /nodes/{id}/update-check,
server-26 PR) so offline/locked-out nodes still update. drb-update.sh only
installs the repo/ref/registry in root-owned /etc/drb/updater.conf; C2's
answer is just a timestamp; origin and registry mismatches abort; creds go
to curl via a 0600 header file.

Tests: test_credential_recovery.py, test_host_updater.py (runs the real
updater script with stub git/docker/curl).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
install.sh: enrollment token via header file too — it was on curl argv despite the comment
CI / lint (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / test (push) Successful in 42s
CI / test (pull_request) Successful in 40s
32692030fb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Security review (parent session, by hand — /security-review can't run from a non-repo root). Verdict: no high-severity findings beyond node-26#17 (unsigned refs/images).

Checked and OK: C2 can only say "update now" (timestamp regex-validated, never used as ref/URL/command); repo/ref/registry come only from root-owned /etc/drb/updater.conf; git fetch https-only, curl --proto =https; creds via 0600 header file, regex-validated before use; flock against concurrent runs; whole script in main() so self-checkout can't alter the running code. Container→host escalation via the root updater: edge-node mounts only ./configs, ./recordings, ./drb-edge-node/app — none of scripts/, .git/, .env, so a compromised edge-node can at most drop the sentinel (harmless). op25 is privileged + /dev (already host-root-equivalent; pre-existing, not this PR). Server: update-check returns 401 for unknown node (no id oracle), constant-time compares, payload bounded; admin = platform admin by design, so no cross-tenant gap.

Low, non-blocking:

  1. drb-update.sh deletes the MQTT sentinel before updating; if the update fails, an MQTT-only trigger is not retried (the "will retry on the next timer run" log is only true for C2 requests). Remove the sentinel after success, or write a retry marker in STATE_DIR.
  2. key_pickup.fetch_api_key() sends the pickup secret to whatever scheme C2_URL has; install.sh/updater enforce https but the app doesn't. Refuse non-https unless an explicit dev flag is set.
Security review (parent session, by hand — /security-review can't run from a non-repo root). Verdict: **no high-severity findings beyond node-26#17 (unsigned refs/images).** Checked and OK: C2 can only say "update now" (timestamp regex-validated, never used as ref/URL/command); repo/ref/registry come only from root-owned /etc/drb/updater.conf; git fetch https-only, curl `--proto =https`; creds via 0600 header file, regex-validated before use; flock against concurrent runs; whole script in main() so self-checkout can't alter the running code. Container→host escalation via the root updater: edge-node mounts only ./configs, ./recordings, ./drb-edge-node/app — none of scripts/, .git/, .env, so a compromised edge-node can at most drop the sentinel (harmless). op25 is privileged + /dev (already host-root-equivalent; pre-existing, not this PR). Server: update-check returns 401 for unknown node (no id oracle), constant-time compares, payload bounded; admin = platform admin by design, so no cross-tenant gap. Low, non-blocking: 1. `drb-update.sh` deletes the MQTT sentinel before updating; if the update fails, an MQTT-only trigger is not retried (the "will retry on the next timer run" log is only true for C2 requests). Remove the sentinel after success, or write a retry marker in STATE_DIR. 2. `key_pickup.fetch_api_key()` sends the pickup secret to whatever scheme C2_URL has; install.sh/updater enforce https but the app doesn't. Refuse non-https unless an explicit dev flag is set.
logan merged commit f46c7983ef into main 2026-10-04 01:26:04 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/node-26#16