Compare commits

..
Author SHA1 Message Date
logan ab106264e0 Merge pull request 'checkin: report host disk free/total (#41)' (#43) from feat/41-disk-reporting into main
Build edge-node / build (push) Successful in 59s
Publish public images / publish (edge-node, ./drb-edge-node, ./drb-edge-node/Dockerfile, edge-node) (push) Skipped
Publish public images / publish (icecast, ./icecast, ./icecast/Dockerfile, icecast) (push) Skipped
Publish public images / publish (op25-client, ./op25-container, ./op25-container/Dockerfile, op25-client) (push) Skipped
CI / lint (push) Successful in 8s
CI / test-op25 (push) Successful in 15s
Release images / ensure (drb-edge-node, edge-node) (push) Successful in 32s
Release images / ensure (icecast, icecast) (push) Successful in 42s
Release images / ensure (op25-container, op25-client) (push) Successful in 41s
Release images / ensure (secondary-sdr-container, secondary-sdr) (push) Successful in 38s
CI / test (push) Successful in 52s
2026-10-26 02:28:11 -04:00
Logan CusanoandClaude Opus 5.5 7f52e0e891 checkin: report host disk free/total bytes (#41)
CI / lint (push) Successful in 7s
CI / lint (pull_request) Successful in 9s
CI / test-op25 (push) Successful in 15s
CI / test-op25 (pull_request) Successful in 16s
CI / test (push) Successful in 45s
CI / test (pull_request) Successful in 46s
/recordings is a bind mount of the host checkout, so shutil.disk_usage on it
is the disk the recorder writes to and Docker fills. Explicit nulls on error
so C2 never keeps a stale reading. Server half: server-26 feat/41-node-disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:24:07 -04:00
logan 04ba5457fa Merge pull request 'drb-update: prune build cache and stale images after every update (#41)' (#42) from fix/41-updater-prune into main
Build edge-node / build (push) Successful in 8m1s
Publish public images / publish (edge-node, ./drb-edge-node, ./drb-edge-node/Dockerfile, edge-node) (push) Skipped
Publish public images / publish (op25-client, ./op25-container, ./op25-container/Dockerfile, op25-client) (push) Skipped
Publish public images / publish (icecast, ./icecast, ./icecast/Dockerfile, icecast) (push) Skipped
CI / lint (push) Successful in 7s
CI / test-op25 (push) Successful in 15s
Release images / ensure (drb-edge-node, edge-node) (push) Successful in 31s
Release images / ensure (icecast, icecast) (push) Successful in 33s
Release images / ensure (op25-container, op25-client) (push) Successful in 35s
Release images / ensure (secondary-sdr-container, secondary-sdr) (push) Successful in 30s
CI / test (push) Successful in 50s
2026-10-26 01:28:11 -04:00
Logan CusanoandClaude Opus 5.5 a26a77dd0a drb-update: prune BuildKit cache and stale images after every update (#41)
CI / lint (pull_request) Successful in 8s
CI / lint (push) Successful in 9s
CI / test-op25 (pull_request) Successful in 15s
CI / test-op25 (push) Successful in 17s
CI / test (pull_request) Successful in 45s
CI / test (push) Successful in 47s
node-002's root disk hit 100% (39.5 GB build cache + 16 GB unused images)
and the node silently stopped recording for 17 days: BUILD=1 runs
`docker compose build` on every update and nothing ever pruned the cache;
the existing prunes only drop dangling images and old src- pins.

After `up -d`: `builder prune -af --filter until=72h` and
`image prune -af --filter until=168h`. Neither touches anything a running
container uses; the age filters keep the next build's cache warm.

Disk-free reporting to the portal (the other half of #41) is not in this
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:14:45 -04:00
logan 9377a94cba Merge pull request 'drb-update: trust INSTALL_DIR so an updater on a user-owned checkout works' (#36) from fix/updater-safe-directory into main
CI / lint (push) Successful in 5s
CI / test-op25 (push) Successful in 13s
CI / test (push) Successful in 44s
2026-10-25 21:09:38 -04:00
logan 5770ecae49 Merge pull request 'discord_radio: presence follows voice; name why a voice join fails' (#38) from fix/discord-voice-join into main
Build edge-node / build (push) Successful in 1m56s
Publish public images / publish (edge-node, ./drb-edge-node, ./drb-edge-node/Dockerfile, edge-node) (push) Skipped
Publish public images / publish (icecast, ./icecast, ./icecast/Dockerfile, icecast) (push) Skipped
Publish public images / publish (op25-client, ./op25-container, ./op25-container/Dockerfile, op25-client) (push) Skipped
CI / lint (push) Successful in 9s
CI / test-op25 (push) Successful in 19s
Release images / ensure (drb-edge-node, edge-node) (push) Successful in 34s
Release images / ensure (icecast, icecast) (push) Successful in 38s
Release images / ensure (op25-container, op25-client) (push) Successful in 33s
Release images / ensure (secondary-sdr-container, secondary-sdr) (push) Successful in 28s
CI / test (push) Successful in 55s
Reviewed-on: #38
2026-10-04 22:52:30 -04:00
Logan CusanoandClaude Opus 5.5 2065446393 discord_radio: don't count the bot's own voice state toward a full channel; timeout text points at the handshake
CI / lint (push) Successful in 8s
CI / lint (pull_request) Successful in 10s
CI / test-op25 (push) Successful in 14s
CI / test-op25 (pull_request) Successful in 16s
CI / test (push) Successful in 48s
CI / test (pull_request) Successful in 47s
Permissions are pre-checked now, so a connect timeout is the voice handshake
(pre-2.7 discord.py: DAVE close 4017 retried to the 30s timeout) or UDP egress,
not permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:49:52 -04:00
Logan CusanoandClaude Opus 5.5 786e06a7a3 discord_radio: presence follows voice; name the reason a voice join fails
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 14s
CI / lint (pull_request) Successful in 7s
CI / test-op25 (pull_request) Successful in 14s
CI / test (push) Successful in 47s
CI / test (pull_request) Successful in 43s
Live symptom: the radio bot shows "Listening to <system>" but is not in the
voice channel.

- Presence was set once on the first successful join and never cleared, so a
  bot whose voice dropped (or whose rejoin kept failing) still looked healthy.
  Presence is now synced at the end of every reconcile: set only while a voice
  connection for that bot is up, cleared otherwise.
- Pre-check the bot's permissions (View Channel, Connect, Speak) and the
  channel's user limit before joining. Discord silently ignores a join the bot
  may not make; discord.py then times out after 30s and the admin page read
  "Could not connect to the voice channel (TimeoutError: )". Now: code
  missing_permissions / channel_full with the missing permission named.
- A connect timeout gets its own code (connect_timeout) and readable text.
- requirements: discord.py[voice]>=2.7 — Discord has required DAVE E2EE voice
  since 2026-03-01 and 2.7 is the first discord.py with it (the extra pulls
  davey). Today's resolution was already 2.7.1 + davey 0.1.6; this stops an
  older resolution from ever being accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:42:39 -04:00
Logan CusanoandClaude Opus 5.5 172f31d7c5 drb-update: trust INSTALL_DIR for git so a user-owned checkout can update
CI / lint (pull_request) Successful in 9s
CI / lint (push) Successful in 10s
CI / test-op25 (pull_request) Successful in 18s
CI / test-op25 (push) Successful in 19s
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 50s
radio-box (INSTALL_DIR=/home/pi/node-26, owned by pi) failed every update:
root's git refused the repo as 'dubious ownership', so origin read as ''
and the origin check refused. Scope safe.directory to INSTALL_DIR via
GIT_CONFIG_* env (command-line scope) for the updater process only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:55:30 -04:00
logan 44e41b3dd8 Merge pull request 'CI: build node images for amd64 as well as arm64 (#28)' (#35) from fix/28-amd64-images into main
Publish public images / publish (edge-node, ./drb-edge-node, ./drb-edge-node/Dockerfile, edge-node) (push) Skipped
Publish public images / publish (op25-client, ./op25-container, ./op25-container/Dockerfile, op25-client) (push) Skipped
Publish public images / publish (icecast, ./icecast, ./icecast/Dockerfile, icecast) (push) Skipped
CI / lint (push) Successful in 9s
CI / test-op25 (push) Successful in 18s
CI / test (push) Successful in 1m5s
Release images / ensure (drb-edge-node, edge-node) (push) Successful in 3m23s
Release images / ensure (icecast, icecast) (push) Successful in 5m7s
Release images / ensure (op25-container, op25-client) (push) Successful in 14m22s
Release images / ensure (secondary-sdr-container, secondary-sdr) (push) Successful in 47m53s
Reviewed-on: #35
2026-10-04 18:55:04 -04:00
Logan CusanoandClaude Opus 5.5 f9d23ad267 CI: build node images for amd64 as well as arm64 (node-26#28)
CI / lint (push) Successful in 7s
CI / lint (pull_request) Successful in 9s
CI / test-op25 (push) Successful in 17s
CI / test-op25 (pull_request) Successful in 17s
CI / test (push) Successful in 49s
CI / test (pull_request) Successful in 50s
amd64 nodes (radio-box) got 'no matching manifest for linux/amd64' on
pull and had to build locally. Every image workflow now builds
linux/amd64,linux/arm64. Release images treats an existing src- tag as
present only if it carries both platforms, so the arm64-only src- tags
pushed before this get rebuilt on the next v1 push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:53:37 -04:00
logan 582bc407be Merge pull request 'install.sh: enrollment token never on the command line (#18)' (#30) from fix/18-install-token-argv into main
Build edge-node / build (push) Successful in 43s
Publish public images / publish (edge-node, ./drb-edge-node, ./drb-edge-node/Dockerfile, edge-node) (push) Skipped
Publish public images / publish (icecast, ./icecast, ./icecast/Dockerfile, icecast) (push) Skipped
Publish public images / publish (op25-client, ./op25-container, ./op25-container/Dockerfile, op25-client) (push) Skipped
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 18s
Release images / ensure (drb-edge-node, edge-node) (push) Successful in 28s
Release images / ensure (op25-container, op25-client) (push) Successful in 40s
CI / test (push) Successful in 53s
Release images / ensure (icecast, icecast) (push) Successful in 3m0s
Release images / ensure (secondary-sdr-container, secondary-sdr) (push) Canceled after 20m47s
Reviewed-on: #30
2026-10-04 18:28:19 -04:00
logan dbb41f2fd3 Merge branch 'main' into fix/18-install-token-argv
CI / lint (push) Successful in 10s
CI / test-op25 (push) Successful in 24s
CI / lint (pull_request) Successful in 7s
CI / test-op25 (pull_request) Successful in 20s
CI / test (push) Successful in 54s
CI / test (pull_request) Successful in 54s
2026-10-04 18:26:10 -04:00
logan 737e7c9bb1 Merge pull request 'Pin node images to the checked-out source tree — fix v1 code / :latest image skew (#19)' (#31) from fix/19-image-tag-skew into main
CI / lint (push) Successful in 7s
CI / test-op25 (push) Successful in 18s
Build edge-node / build (push) Successful in 50s
CI / test (push) Successful in 51s
Reviewed-on: #31
2026-10-04 18:26:01 -04:00
logan 085139e171 Merge branch 'main' into fix/19-image-tag-skew
CI / lint (push) Successful in 9s
CI / test-op25 (push) Successful in 20s
CI / lint (pull_request) Successful in 11s
CI / test-op25 (pull_request) Successful in 23s
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 54s
2026-10-04 18:22:43 -04:00
logan 8c0bf752f0 Merge pull request 'discord_radio: desired-state streams, logout on release, no mention controls (#25)' (#32) from feat/discord-system into main
CI / lint (push) Successful in 7s
CI / test-op25 (push) Successful in 16s
Build edge-node / build (push) Successful in 52s
CI / test (push) Successful in 56s
Reviewed-on: #32
2026-10-04 18:22:29 -04:00
Logan CusanoandClaude Opus 5.5 c4b4cb92c9 Drop last --token hint; note sudo drops DRB_ENROLLMENT_TOKEN (node-26#18)
CI / lint (push) Successful in 8s
CI / test-op25 (push) Successful in 19s
CI / lint (pull_request) Successful in 8s
CI / test-op25 (pull_request) Successful in 18s
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 51s
- key_pickup.py still told operators to re-run install.sh with --token.
- `curl | sudo bash` resets the env, so the documented exported-token
  path needs `sudo --preserve-env=DRB_ENROLLMENT_TOKEN`; say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:17:30 -04:00
Logan CusanoandClaude Opus 5.5 e7b4aef142 Fix updater tests for image pinning; harden helper load (node-26#19)
CI / lint (push) Successful in 9s
CI / lint (pull_request) Successful in 9s
CI / test-op25 (push) Successful in 17s
CI / test-op25 (pull_request) Successful in 18s
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 57s
- test_host_updater: stub git/docker now model cat-file/show/rev-parse
  --verify and compose config/image inspect. The two update-path tests
  were failing on Linux CI (skipped locally on macOS, no flock). Adds
  tests for pull-before-checkout + .env pins, a missing pinned image
  leaving the node unchanged, and a pre-pinning target still updating.
- drb-update.sh: load image-tags.sh via a variable + eval and confirm the
  functions exist; `source <(git show ...)` "succeeded" with nothing
  defined when git show failed, wedging every later update.
- install.sh: skip pinning (warn) when the checked-out ref predates
  scripts/image-tags.sh instead of dying on the missing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:17:22 -04:00
Logan CusanoandClaude Opus 5.5 6c8289f3f3 Pin node images to the checked-out source tree (node-26#19)
CI / lint (push) Successful in 10s
CI / test-op25 (push) Successful in 16s
CI / test (push) Failing after 49s
CI / lint (pull_request) Successful in 8s
CI / test-op25 (pull_request) Successful in 18s
CI / test (pull_request) Failing after 46s
Nodes checked out v1 code but pulled :latest/:stable images built from main.
Each image is now tagged src-<git tree hash of its build directory>: CI adds
that tag in every build-*.yml, and scripts/image-tags.sh computes the same
value on the node and writes it into .env as DRB_TAG_*, which
docker-compose.yml uses (falls back to :latest/:stable for local dev).
Tree hashes stay exact even though each image only rebuilds when its own
directory changes.

- install.sh pins after checkout and fails loudly if a pinned image is
  missing (compose pull only warns for services with a build: section).
- drb-update.sh pulls the target commit's pinned images BEFORE checkout,
  using the target's own compose file, so a missing image changes nothing
  (uvicorn --reload would otherwise run new code on old images). It then
  checks out, writes the pins, recreates, and removes superseded src- tags.
- New "Release images" workflow (v* tags + manual dispatch) builds any
  src- image a release ref is missing; it skips ones already published.

The live v1 tag is not moved.

Refs #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:09:29 -04:00
Logan CusanoandClaude Opus 5.5 4dff9cbab6 install.sh: enrollment token never on the command line (node-26#18)
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 14s
CI / lint (pull_request) Successful in 8s
CI / test (push) Successful in 48s
CI / test-op25 (pull_request) Successful in 15s
CI / test (pull_request) Successful in 49s
The token is now prompted for on the TTY (hidden input), or read from
--token-file PATH / an exported DRB_ENROLLMENT_TOKEN for unattended installs.
--token still works for old copied commands but warns that it has landed in
shell history and ps. Every "re-run with --token DRB-..." hint now points at
the prompt; stale-pickup (401/404) re-runs prompt for a token instead of
dying. DRB_ENROLLMENT_TOKEN is unset after reading so children don't inherit
it. README install example drops --token.

Server half (mint panel still prints --token) is filed separately.

Refs #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:09:19 -04:00
19 changed files with 623 additions and 67 deletions
+6 -1
View File
@@ -38,15 +38,20 @@ jobs:
run: | run: |
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
# Source pin (node-26#19): the git tree hash of the build context.
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
# computes the identical value — so its code and image always match.
echo "SRC_TAG=src-$(git rev-parse HEAD:drb-edge-node)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: ./drb-edge-node context: ./drb-edge-node
file: ./drb-edge-node/Dockerfile file: ./drb-edge-node/Dockerfile
platforms: linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
+6 -1
View File
@@ -38,15 +38,20 @@ jobs:
run: | run: |
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
# Source pin (node-26#19): the git tree hash of the build context.
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
# computes the identical value — so its code and image always match.
echo "SRC_TAG=src-$(git rev-parse HEAD:icecast)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: ./icecast context: ./icecast
file: ./icecast/Dockerfile file: ./icecast/Dockerfile
platforms: linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
+6 -1
View File
@@ -38,15 +38,20 @@ jobs:
run: | run: |
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
# Source pin (node-26#19): the git tree hash of the build context.
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
# computes the identical value — so its code and image always match.
echo "SRC_TAG=src-$(git rev-parse HEAD:op25-container)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: ./op25-container context: ./op25-container
file: ./op25-container/Dockerfile file: ./op25-container/Dockerfile
platforms: linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:stable git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:stable
# Registry cache — avoids full recompile of GnuRadio/OP25 when only app code changes # Registry cache — avoids full recompile of GnuRadio/OP25 when only app code changes
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
+6 -1
View File
@@ -38,15 +38,20 @@ jobs:
run: | run: |
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
# Source pin (node-26#19): the git tree hash of the build context.
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
# computes the identical value — so its code and image always match.
echo "SRC_TAG=src-$(git rev-parse HEAD:secondary-sdr-container)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: ./secondary-sdr-container context: ./secondary-sdr-container
file: ./secondary-sdr-container/Dockerfile file: ./secondary-sdr-container/Dockerfile
platforms: linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
with: with:
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.file }} file: ${{ matrix.file }}
platforms: linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:${{ steps.meta.outputs.VERSION }} ${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:${{ steps.meta.outputs.VERSION }}
+91
View File
@@ -0,0 +1,91 @@
name: Release images
# Make sure every node image a release ref needs exists in the registry
# (node-26#19). A node on `v1` pulls <image>:src-<tree hash of that image's
# directory at v1>. The per-image build workflows only run when their own
# directory changes on main, and anything built before #19 has no src- tag at
# all, so a release tag can point at trees nobody ever built. This fills the
# gaps: for each image, if src-<tree> is already in the registry it does
# nothing; otherwise it builds and pushes it (registry cache keeps op25 off a
# full recompile when only its app code changed).
#
# Runs on every v* tag push, including a re-cut `v1`. Also runnable by hand
# (workflow_dispatch) on any ref — run it once on the ref `v1` points at
# before re-cutting `v1` onto a commit that contains #19, so nodes never see
# a moment where their pinned images don't exist yet.
#
# Pushes ONLY src- tags. :latest / :stable keep tracking main, as before.
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
ensure:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- name: edge-node
context: drb-edge-node
- name: icecast
context: icecast
- name: op25-client
context: op25-container
- name: secondary-sdr
context: secondary-sdr-container
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ gitea.actor }}
password: ${{ secrets.BUILD_TOKEN }}
- name: Resolve source tag
id: meta
run: |
REPO_NAME="$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')"
# Must match scripts/image-tags.sh and the build-*.yml workflows.
IMAGE="git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${REPO_NAME}/${{ matrix.name }}"
TAG="src-$(git rev-parse HEAD:${{ matrix.context }})"
echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT
echo "TAG=$TAG" >> $GITHUB_OUTPUT
# Present only counts if BOTH platforms are in it: src- tags pushed
# before node-26#28 are arm64-only, and an amd64 node can't pull them.
RAW="$(docker buildx imagetools inspect --raw "$IMAGE:$TAG" 2>/dev/null | tr -d ' \n' || true)"
if printf '%s' "$RAW" | grep -q '"architecture":"amd64"' \
&& printf '%s' "$RAW" | grep -q '"architecture":"arm64"'; then
echo "EXISTS=true" >> $GITHUB_OUTPUT
echo "$IMAGE:$TAG already published for amd64 + arm64 — nothing to do"
else
echo "EXISTS=false" >> $GITHUB_OUTPUT
echo "$IMAGE:$TAG missing or not multi-arch — building it"
fi
- if: steps.meta.outputs.EXISTS != 'true'
uses: docker/build-push-action@v6
with:
context: ./${{ matrix.context }}
file: ./${{ matrix.context }}/Dockerfile
# amd64 + arm64, same as the build-*.yml workflows (node-26#28).
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.IMAGE }}:${{ steps.meta.outputs.TAG }}
cache-from: type=registry,ref=${{ steps.meta.outputs.IMAGE }}:buildcache
+1
View File
@@ -13,6 +13,7 @@ venv/
# Runtime-generated configs and recordings # Runtime-generated configs and recordings
configs/ configs/
recordings/ recordings/
.drb-update-compose.yml
# Logs # Logs
*.log *.log
+8 -4
View File
@@ -142,13 +142,17 @@ this repo at the `v1` tag, enrols with C2, pulls the prebuilt images and starts:
```bash ```bash
curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \ curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
| sudo bash -s -- --token DRB-xxxx --node-id node-003 \ | sudo bash -s -- --node-id node-003 \
--c2-url https://api.<domain> --mqtt-broker mqtt.<domain> --c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
``` ```
Mint the `--token` at **Settings → Nodes** in the web app (the panel prints the It prompts (hidden input) for an enrollment token, minted at **Settings → Nodes**
whole command). Run `install.sh --help` for every flag; each also has a in the web app. The token never goes on the command line — argv lands in shell
`DRB_*` env var. `--build` compiles op25 on the Pi (~1h) instead of pulling. history and `ps` (node-26#18). Unattended installs use `--token-file PATH` (mode
0600) or an exported `DRB_ENROLLMENT_TOKEN` (pass it through with
`sudo --preserve-env=DRB_ENROLLMENT_TOKEN`; plain `sudo` drops it); the old `--token` flag still works
but warns. Run `install.sh --help` for every flag; each also has a `DRB_*` env
var. `--build` compiles op25 on the Pi (~1h) instead of pulling.
### Local dev / manual ### Local dev / manual
+8 -4
View File
@@ -1,6 +1,10 @@
# Image tags: a provisioned node pins every image to the source tree it has
# checked out (DRB_TAG_* = src-<git tree hash>, written into .env by install.sh
# and scripts/drb-update.sh via scripts/image-tags.sh — node-26#19). Unset, as
# in local dev, they fall back to :latest / :stable, which CI builds from main.
services: services:
icecast: icecast:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:latest image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:${DRB_TAG_ICECAST:-latest}
build: ./icecast build: ./icecast
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
@@ -16,7 +20,7 @@ services:
# rather than being exposed. edge-node still reaches both over localhost # rather than being exposed. edge-node still reaches both over localhost
# because it shares this host network namespace. # because it shares this host network namespace.
op25: op25:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:${DRB_TAG_OP25:-stable}
build: ./op25-container build: ./op25-container
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
@@ -38,7 +42,7 @@ services:
# node-26#9. Same network/device access as op25 for the same reason: it # node-26#9. Same network/device access as op25 for the same reason: it
# needs the raw USB device, not a virtualized one. # needs the raw USB device, not a virtualized one.
secondary-sdr: secondary-sdr:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:${DRB_TAG_SECONDARY_SDR:-latest}
build: ./secondary-sdr-container build: ./secondary-sdr-container
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
@@ -48,7 +52,7 @@ services:
- /dev:/dev - /dev:/dev
edge-node: edge-node:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:${DRB_TAG_EDGE_NODE:-latest}
build: ./drb-edge-node build: ./drb-edge-node
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
+77 -7
View File
@@ -74,6 +74,43 @@ class _Voice:
code: Optional[str] = None code: Optional[str] = None
def _join_refusal(guild, channel) -> Optional[tuple[str, str]]:
"""Why Discord would refuse this bot's voice join, or None.
Discord does not answer a join the bot lacks permission for — the gateway
just never sends the voice-server update, discord.py times out after 30s,
and the admin page used to read "(TimeoutError: )". Checking first turns
that into an instruction. Best effort: if the bot's own member or the
permission data is not cached, let the join go ahead.
"""
me = getattr(guild, "me", None)
permissions_for = getattr(channel, "permissions_for", None)
if me is None or permissions_for is None:
return None
try:
perms = permissions_for(me)
except Exception:
return None
missing = [label for attr, label in (("view_channel", "View Channel"), ("connect", "Connect"))
if not getattr(perms, attr, True)]
if getattr(channel, "type", None) == discord.ChannelType.voice and not getattr(perms, "speak", True):
missing.append("Speak")
if missing:
return ("missing_permissions",
f"The radio bot lacks {', '.join(missing)} on that voice channel — "
"allow it in the channel's permission settings in Discord.")
limit = getattr(channel, "user_limit", 0) or 0
# The bot's own lingering voice state (a dropped connection) is not a
# slot it needs; counting it would block the rejoin of a full channel.
me_id = getattr(me, "id", None)
members = [m for m in (getattr(channel, "members", None) or [])
if me_id is None or getattr(m, "id", None) != me_id]
if limit and len(members) >= limit and not getattr(perms, "move_members", False):
return ("channel_full", f"That voice channel is full ({len(members)}/{limit}) — "
"raise its user limit or free a slot.")
return None
def _default_client_factory(): def _default_client_factory():
intents = discord.Intents.none() intents = discord.Intents.none()
intents.guilds = True intents.guilds = True
@@ -220,6 +257,10 @@ class RadioManager:
v.error = self._session_errors.get(d["token_id"], "The radio bot could not log in to Discord.") v.error = self._session_errors.get(d["token_id"], "The radio bot could not log in to Discord.")
continue continue
await self._ensure_voice(sess, v, d) await self._ensure_voice(sess, v, d)
# 4) presence follows voice: "Listening to <system>" only while a
# voice connection for that bot is actually up.
for sess in list(self._sessions.values()):
await self._sync_presence(sess, target)
async def _ensure_session(self, token_id: str) -> Optional[_Session]: async def _ensure_session(self, token_id: str) -> Optional[_Session]:
sess = self._sessions.get(token_id) sess = self._sessions.get(token_id)
@@ -275,7 +316,12 @@ class RadioManager:
if not vc.is_playing(): if not vc.is_playing():
self._play(v) self._play(v)
v.state, v.error, v.code = "connected", None, None v.state, v.error, v.code = "connected", None, None
await self._set_presence(sess, d.get("system_name")) return
refusal = _join_refusal(guild, channel)
if refusal:
if v.error != refusal[1]: # once per change, not every watchdog pass
logger.error(f"Stream {v.stream_id}: {refusal[1]}")
v.state, v.code, v.error = "error", refusal[0], refusal[1]
return return
v.state = "connecting" v.state = "connecting"
try: try:
@@ -287,11 +333,23 @@ class RadioManager:
if existing is not None: if existing is not None:
await existing.disconnect(force=True) await existing.disconnect(force=True)
v.vc = await channel.connect() v.vc = await channel.connect()
except asyncio.TimeoutError:
# discord.py's TimeoutError has no message, so the old text read
# "(TimeoutError: )" — useless on the admin page.
v.vc = None
v.state, v.code = "error", "connect_timeout"
# Permissions were checked above, so this is usually the voice
# handshake itself (e.g. a pre-2.7 discord.py refused with 4017 for
# lacking DAVE E2EE, then retried until the 30s timeout) or UDP egress.
v.error = ("Discord never completed the voice join (timed out). Permissions look fine, so "
"check the node's discord.py voice log (DAVE close 4017, UDP blocked).")
logger.error(f"Stream {v.stream_id}: voice connect timed out")
return
except Exception as e: except Exception as e:
v.vc = None v.vc = None
v.state, v.code = "error", "connect_failed" v.state, v.code = "error", "connect_failed"
v.error = f"Could not connect to the voice channel ({type(e).__name__}: {str(e)[:120]})." v.error = f"Could not connect to the voice channel ({type(e).__name__}: {str(e)[:120]})."
logger.error(f"Stream {v.stream_id}: voice connect failed: {e}") logger.error(f"Stream {v.stream_id}: voice connect failed: {type(e).__name__}: {e}")
return return
# Bounded wait for the shared PulseAudio socket (op25 container creates it). # Bounded wait for the shared PulseAudio socket (op25 container creates it).
if not await pulse.wait_until_ready(): if not await pulse.wait_until_ready():
@@ -300,15 +358,27 @@ class RadioManager:
return return
self._play(v, pulse_checked=True) self._play(v, pulse_checked=True)
v.state, v.error, v.code = "connected", None, None v.state, v.error, v.code = "connected", None, None
await self._set_presence(sess, d.get("system_name"))
logger.info(f"Stream {v.stream_id} connected (bot {v.token_id})") logger.info(f"Stream {v.stream_id} connected (bot {v.token_id})")
async def _set_presence(self, sess: _Session, name: Optional[str]) -> None: async def _sync_presence(self, sess: _Session, target: dict[str, dict]) -> None:
if not name or sess.presence == name: """Show "Listening to <system>" only while this bot is really in voice.
It used to be set once on the first successful join and never cleared,
so a bot whose voice later dropped (or kept failing to rejoin) still
looked healthy in Discord's member list.
"""
name = None
for sid, v in sorted(self._voices.items()):
if (v.token_id == sess.token_id and v.state == "connected"
and v.vc is not None and v.vc.is_connected()):
name = (target.get(sid) or {}).get("system_name") or name
if name:
break
if sess.presence == name:
return return
try: try:
await sess.client.change_presence( activity = discord.Activity(type=discord.ActivityType.listening, name=name) if name else None
activity=discord.Activity(type=discord.ActivityType.listening, name=name)) await sess.client.change_presence(activity=activity)
sess.presence = name sess.presence = name
except Exception as e: except Exception as e:
logger.warning(f"Presence update failed: {e}") logger.warning(f"Presence update failed: {e}")
+1 -1
View File
@@ -50,7 +50,7 @@ async def fetch_api_key() -> Optional[str]:
if r.status_code == 401: if r.status_code == 401:
logger.error( logger.error(
"Key pickup: C2 rejected the saved pickup secret (401) — it was rotated by a re-enroll elsewhere. " "Key pickup: C2 rejected the saved pickup secret (401) — it was rotated by a re-enroll elsewhere. "
"Re-run install.sh with --token to recover." "Re-run install.sh and give it a fresh enrollment token when it asks (node-26#18)."
) )
return None return None
if r.status_code == 404: if r.status_code == 404:
@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from collections import deque from collections import deque
from typing import Optional, Callable, Awaitable, Dict, Any from typing import Optional, Callable, Awaitable, Dict, Any
@@ -8,6 +9,22 @@ from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import credentials, key_pickup from app.internal import credentials, key_pickup
def disk_report(path: str) -> Dict[str, Optional[int]]:
"""Free/total bytes of the filesystem holding `path` (node-26#41).
/recordings is a bind mount of the host checkout, so this is the host disk
the recorder writes to and Docker's images/build cache live on. A full disk
stopped node-002 recording for 17 days while it still showed Online. Nulls
on error, so C2 overwrites a stale reading instead of keeping it.
"""
try:
usage = shutil.disk_usage(path)
except OSError:
return {"disk_free_bytes": None, "disk_total_bytes": None}
return {"disk_free_bytes": usage.free, "disk_total_bytes": usage.total}
CommandCallback = Callable[[Dict[str, Any]], Awaitable[None]] CommandCallback = Callable[[Dict[str, Any]], Awaitable[None]]
ConfigCallback = Callable[[Dict[str, Any]], Awaitable[None]] ConfigCallback = Callable[[Dict[str, Any]], Awaitable[None]]
ApiKeyCallback = Callable[[Dict[str, Any]], Awaitable[None]] ApiKeyCallback = Callable[[Dict[str, Any]], Awaitable[None]]
@@ -372,6 +389,7 @@ class MQTTManager:
count = devices.get("count") or None count = devices.get("count") or None
if count is not None: if count is not None:
payload["sdr_count"] = count payload["sdr_count"] = count
payload.update(disk_report(settings.recordings_path))
self._publish(self._t_checkin, payload, qos=1) self._publish(self._t_checkin, payload, qos=1)
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False): def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
+3 -1
View File
@@ -3,7 +3,9 @@ uvicorn[standard]
pydantic-settings pydantic-settings
paho-mqtt>=2.0.0 paho-mqtt>=2.0.0
httpx httpx
discord.py[voice] # >=2.7: Discord requires DAVE (E2EE) voice since 2026-03-01; 2.7 is the first
# discord.py with it (the [voice] extra pulls `davey`). Older releases cannot join voice.
discord.py[voice]>=2.7
PyNaCl PyNaCl
python-multipart python-multipart
pytest pytest
+73
View File
@@ -47,8 +47,18 @@ class FakeVC:
class FakeChannel: class FakeChannel:
def __init__(self, guild, cid, kind=discord.ChannelType.voice): def __init__(self, guild, cid, kind=discord.ChannelType.voice):
self.guild, self.id, self.type = guild, cid, kind self.guild, self.id, self.type = guild, cid, kind
self.perms = discord.Permissions(view_channel=True, connect=True, speak=True)
self.user_limit = 0
self.members = []
self.connect_error = None
def permissions_for(self, member):
assert member is self.guild.me
return self.perms
async def connect(self): async def connect(self):
if self.connect_error:
raise self.connect_error
vc = FakeVC(self) vc = FakeVC(self)
self.guild.voice_client = vc self.guild.voice_client = vc
return vc return vc
@@ -57,6 +67,7 @@ class FakeChannel:
class FakeGuild: class FakeGuild:
def __init__(self, gid, channel_ids): def __init__(self, gid, channel_ids):
self.id = gid self.id = gid
self.me = object()
self.voice_client = None self.voice_client = None
self.channels = {c: FakeChannel(self, c) for c in channel_ids} self.channels = {c: FakeChannel(self, c) for c in channel_ids}
@@ -149,6 +160,7 @@ async def test_apply_connects_and_reports(env):
assert env.mgr.is_connected assert env.mgr.is_connected
vc = env.guilds[1].voice_client vc = env.guilds[1].voice_client
assert vc.plays == 1 and vc.channel.id == 11 assert vc.plays == 1 and vc.channel.id == 11
assert FakeClient.instances[0].presence.name == "County"
async def test_one_session_serves_two_guilds(env): async def test_one_session_serves_two_guilds(env):
@@ -258,3 +270,64 @@ async def test_no_message_content_intent_on_real_client():
assert client.intents.voice_states and client.intents.guilds assert client.intents.voice_states and client.intents.guilds
finally: finally:
await client.close() await client.close()
def _presence(client):
activity = getattr(client, "presence", None)
return activity.name if activity else None
async def test_missing_connect_permission_is_named_not_a_timeout(env):
"""Discord ignores a join the bot may not make; say which permission is missing."""
env.guilds[1].channels[11].perms = discord.Permissions(view_channel=True, speak=True)
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
st = env.published[-1]["streams"]["1:11"]
assert st["state"] == "error" and st["code"] == "missing_permissions"
assert "Connect" in st["error"] and "View Channel" not in st["error"]
assert env.guilds[1].voice_client is None
assert _presence(FakeClient.instances[0]) is None # not "Listening to …" while out of voice
async def test_full_channel_is_reported(env):
ch = env.guilds[1].channels[11]
ch.user_limit, ch.members = 2, [object(), object()]
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
st = env.published[-1]["streams"]["1:11"]
assert st["code"] == "channel_full" and "2/2" in st["error"]
async def test_bots_own_lingering_voice_state_does_not_fill_the_channel(env):
"""A dropped connection can leave the bot listed in the channel; that is
not a slot it needs to rejoin."""
guild = env.guilds[1]
guild.me = type("Me", (), {"id": 999})()
ch = guild.channels[11]
ch.user_limit, ch.members = 2, [object(), guild.me]
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
assert env.published[-1]["streams"]["1:11"]["state"] == "connected"
async def test_connect_timeout_has_a_readable_error(env):
env.guilds[1].channels[11].connect_error = asyncio.TimeoutError()
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
st = env.published[-1]["streams"]["1:11"]
assert st["state"] == "error" and st["code"] == "connect_timeout"
assert "timed out" in st["error"] and "TimeoutError: )" not in st["error"]
async def test_presence_is_cleared_when_voice_is_lost_and_rejoin_fails(env):
"""The owner saw "Listening to <system>" on a bot that was not in voice:
presence was set once on join and never cleared."""
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
client = FakeClient.instances[0]
assert _presence(client) == "County"
env.guilds[1].voice_client.connected = False
env.guilds[1].voice_client = None
env.guilds[1].channels[11].connect_error = RuntimeError("voice handshake failed")
await env.mgr.tick()
st = env.mgr.snapshot()["streams"]["1:11"]
assert st["state"] == "error" and st["code"] == "connect_failed"
assert _presence(client) is None
env.guilds[1].channels[11].connect_error = None
await env.mgr.tick()
assert _presence(client) == "County"
+78 -7
View File
@@ -24,21 +24,37 @@ pytestmark = pytest.mark.skipif(
) )
REPO = "https://git.example.net/logan/node-26.git" REPO = "https://git.example.net/logan/node-26.git"
OLD, NEW = "a" * 40, "b" * 40 OLD, NEW, TREE = "a" * 40, "b" * 40, "c" * 40
REPO_ROOT = SCRIPT.parents[1]
STUB_GIT = r'''#!/usr/bin/env bash STUB_GIT = r'''#!/usr/bin/env bash
echo "git $*" >> "$STUB/calls" echo "git $*" >> "$STUB/calls"
while [ "$1" = -c ]; do shift 2; done while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done
case "$1" in case "$1" in
rev-parse) if [ "$2" = HEAD ]; then cat "$STUB/head"; else echo "$STUB_NEW"; fi ;; rev-parse)
if [ "$2" = HEAD ]; then cat "$STUB/head"
elif [ "$2" = --verify ]; then echo "$STUB_TREE" # tree hash of <commit>:<dir>
else echo "$STUB_NEW"; fi ;;
remote) echo "$STUB_ORIGIN" ;; remote) echo "$STUB_ORIGIN" ;;
fetch) exit 0 ;; fetch) exit 0 ;;
show-ref) [ "$4" = "refs/tags/v1" ] ;; show-ref) [ "$4" = "refs/tags/v1" ] ;;
cat-file) [ -z "${STUB_NO_PIN:-}" ] ;; # target has scripts/image-tags.sh
show) case "$2" in
*:scripts/image-tags.sh) cat "$STUB_REPO/scripts/image-tags.sh" ;;
*:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;;
esac ;;
checkout) echo "${@: -1}" > "$STUB/head" ;; checkout) echo "${@: -1}" > "$STUB/head" ;;
*) exit 0 ;; *) exit 0 ;;
esac esac
''' '''
STUB_DOCKER = '#!/usr/bin/env bash\necho "docker $*" >> "$STUB/calls"\n' STUB_DOCKER = r'''#!/usr/bin/env bash
echo "docker $*" >> "$STUB/calls"
case "$*" in
*"config --images"*) echo "reg.example/edge-node:$STUB_PULLED_TAG" ;;
"image inspect"*) [ -z "${STUB_MISSING_IMAGE:-}" ] || exit 1 ;;
esac
exit 0
'''
STUB_CURL = r'''#!/usr/bin/env bash STUB_CURL = r'''#!/usr/bin/env bash
echo "curl $*" >> "$STUB/calls" echo "curl $*" >> "$STUB/calls"
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
@@ -79,6 +95,7 @@ def env(tmp_path):
e.update({ e.update({
"PATH": f"{bindir}:{os.environ['PATH']}", "PATH": f"{bindir}:{os.environ['PATH']}",
"STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO, "STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO,
"STUB_TREE": TREE, "STUB_REPO": str(REPO_ROOT), "STUB_PULLED_TAG": f"src-{TREE}",
"DRB_UPDATER_CONF": str(conf), "DRB_UPDATER_CONF": str(conf),
"DRB_UPDATER_STATE": str(tmp_path / "state"), "DRB_UPDATER_STATE": str(tmp_path / "state"),
"DRB_UPDATER_LOCK": str(tmp_path / "lock"), "DRB_UPDATER_LOCK": str(tmp_path / "lock"),
@@ -94,6 +111,10 @@ def _calls(env):
return (env["stub"] / "calls").read_text() return (env["stub"] / "calls").read_text()
def _pulled(calls: str) -> bool:
return any(line.startswith("docker compose ") and line.endswith(" pull") for line in calls.splitlines())
def test_no_request_means_checkin_only(env): def test_no_request_means_checkin_only(env):
r = _run(env) r = _run(env)
assert r.returncode == 0, r.stderr assert r.returncode == 0, r.stderr
@@ -108,7 +129,7 @@ def test_c2_request_updates_once_and_reports(env):
r = _run(env) r = _run(env)
assert r.returncode == 0, r.stderr + r.stdout assert r.returncode == 0, r.stderr + r.stdout
calls = _calls(env) calls = _calls(env)
assert "docker compose pull" in calls assert _pulled(calls)
assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls
assert (env["stub"] / "head").read_text().strip() == NEW assert (env["stub"] / "head").read_text().strip() == NEW
assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls
@@ -126,10 +147,21 @@ def test_mqtt_sentinel_triggers_update_and_is_consumed(env):
assert r.returncode == 0, r.stderr assert r.returncode == 0, r.stderr
assert not sentinel.exists() assert not sentinel.exists()
calls = _calls(env) calls = _calls(env)
assert "docker compose pull" in calls assert _pulled(calls)
assert "evil" not in calls and "rm -rf" not in calls assert "evil" not in calls and "rm -rf" not in calls
def test_update_prunes_build_cache_and_stale_images_after_up(env):
# node-26#41: the build cache was never pruned and filled node-002's disk.
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "2026-10-04T12:00:00+00:00"}))
assert _run(env).returncode == 0
lines = _calls(env).splitlines()
up = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose up -d"))
builder = next(i for i, ln in enumerate(lines) if ln.startswith("docker builder prune -af --filter until="))
images = next(i for i, ln in enumerate(lines) if ln.startswith("docker image prune -af --filter until="))
assert up < builder and up < images # only after the new containers are running
def test_c2_response_is_never_used_as_a_ref_or_command(env): def test_c2_response_is_never_used_as_a_ref_or_command(env):
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "$(touch /tmp/pwned); main"})) (env["stub"] / "response").write_text(json.dumps({"update_requested_at": "$(touch /tmp/pwned); main"}))
r = _run(env) r = _run(env)
@@ -153,7 +185,7 @@ def test_refuses_when_env_registry_differs(env):
(env["inst"] / "configs" / "update-requested").write_text("") (env["inst"] / "configs" / "update-requested").write_text("")
r = _run(env) r = _run(env)
assert r.returncode != 0 assert r.returncode != 0
assert "docker compose pull" not in _calls(env) assert not _pulled(_calls(env))
def test_non_https_repo_in_conf_is_rejected(env): def test_non_https_repo_in_conf_is_rejected(env):
@@ -177,3 +209,42 @@ def test_plain_http_c2_skips_checkin(env):
r = _run(env) r = _run(env)
assert r.returncode == 0 assert r.returncode == 0
assert "curl" not in _calls(env) assert "curl" not in _calls(env)
# ── node-26#19: images pinned to the target's source tree ─────────────────
def test_pins_images_and_pulls_before_checkout(env):
(env["inst"] / "configs" / "update-requested").write_text("")
r = _run(env)
assert r.returncode == 0, r.stderr + r.stdout
lines = _calls(env).splitlines()
pull = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose ") and ln.endswith(" pull"))
checkout = next(i for i, ln in enumerate(lines) if ln.startswith("git") and " checkout " in ln)
assert pull < checkout, "images must be pulled before the new code is checked out"
dotenv = (env["inst"] / ".env").read_text()
for var in ("DRB_TAG_EDGE_NODE", "DRB_TAG_OP25", "DRB_TAG_ICECAST", "DRB_TAG_SECONDARY_SDR"):
assert f"{var}=src-{TREE}\n" in dotenv
assert dotenv.startswith("NODE_ID=node-7\n") # existing lines kept
assert not (env["inst"] / ".drb-update-compose.yml").exists()
def test_missing_pinned_image_changes_nothing(env):
env["env"]["STUB_MISSING_IMAGE"] = "1"
(env["inst"] / "configs" / "update-requested").write_text("")
r = _run(env)
assert r.returncode != 0
assert (env["stub"] / "head").read_text().strip() == OLD
calls = _calls(env)
assert " checkout " not in calls
assert "up -d" not in calls
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
def test_target_without_pinning_still_updates(env):
env["env"]["STUB_NO_PIN"] = "1"
(env["inst"] / "configs" / "update-requested").write_text("")
r = _run(env)
assert r.returncode == 0, r.stderr + r.stdout
assert (env["stub"] / "head").read_text().strip() == NEW
assert _pulled(_calls(env))
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
+13
View File
@@ -140,3 +140,16 @@ def test_build_client_does_not_touch_offline_buffer(monkeypatch):
assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})] assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})]
mqtt_manager._offline_buffer.clear() mqtt_manager._offline_buffer.clear()
def test_disk_report_reads_free_and_total(tmp_path):
from app.internal.mqtt_manager import disk_report
rep = disk_report(str(tmp_path))
assert rep["disk_total_bytes"] > 0
assert 0 <= rep["disk_free_bytes"] <= rep["disk_total_bytes"]
def test_disk_report_nulls_when_path_unreadable(tmp_path):
# node-26#41: nulls (not omitted keys) so C2 never keeps a stale reading.
from app.internal.mqtt_manager import disk_report
assert disk_report(str(tmp_path / "missing")) == {"disk_free_bytes": None, "disk_total_bytes": None}
+88 -30
View File
@@ -1,16 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64). # DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64) or Debian (amd64).
# #
# curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \ # curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
# | sudo bash -s -- --token DRB-xxxx --node-id node-003 \ # | sudo bash -s -- --node-id node-003 \
# --c2-url https://api.<domain> --mqtt-broker mqtt.<domain> # --c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
# #
# It prompts (hidden input) for the enrollment token. The token is never
# taken from the command line by design (node-26#18, CISO condition from
# server-26#61): argv lands in shell history and in `ps`. Unattended installs
# use --token-file PATH (a 0600 file) or an exported DRB_ENROLLMENT_TOKEN.
#
# (fetch install.sh from the same tag it installs; use raw/branch/main with # (fetch install.sh from the same tag it installs; use raw/branch/main with
# --track-main for the owner's own always-latest nodes) # --track-main for the owner's own always-latest nodes)
# #
# Hosting, the pinned ref, and setup.sh's retirement are settled (owner # Hosting, the pinned ref, and setup.sh's retirement are settled (owner
# decisions, 2026-09-06). One standing hazard remains — D3 below. # decisions, 2026-09-06). D3 below is mitigated by image pinning (node-26#19).
# #
# What it does, in order: # What it does, in order:
# 1. Preflight (root, arch, apt, SDR present) # 1. Preflight (root, arch, apt, SDR present)
@@ -48,15 +53,18 @@
# the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main` # the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main`
# stays as an opt-in for the owner's own nodes. # stays as an opt-in for the owner's own nodes.
# #
# STANDING HAZARD # MITIGATED HAZARD
# #
# D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and # D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and
# ./op25-container/app OVER the image's /app. So even in prebuilt mode # ./op25-container/app OVER the image's /app, so the running Python is
# the running Python is the CLONED REF's code against the pulled image's # the CLONED REF's code against the pulled image's dependencies. Those
# dependencies. `v1` == the commit CI built the current :latest/:stable # used to be :latest/:stable, built from main — silently a different
# from, so today they match — but the moment `v1` and the image tags # commit from `v1`. Since node-26#19 every image is pinned to
# diverge this silently mixes them. Fix: drop those two mounts from the # src-<git tree hash of its own directory at the checked-out ref>
# prod compose path, or always retag images from the same ref as `v1`. # (scripts/image-tags.sh, written into .env as DRB_TAG_*), which CI tags
# identically. The mounts now overlay the same code the image was built
# from. A pinned image that was never built fails the pull loudly; the
# "Release images" workflow builds any a release tag is missing.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -euo pipefail set -euo pipefail
@@ -78,6 +86,7 @@ MQTT_BROKER="${DRB_MQTT_BROKER:-}"
MQTT_PORT="${DRB_MQTT_PORT:-8883}" MQTT_PORT="${DRB_MQTT_PORT:-8883}"
MQTT_TLS="${DRB_MQTT_TLS:-true}" MQTT_TLS="${DRB_MQTT_TLS:-true}"
ENROLLMENT_TOKEN="${DRB_ENROLLMENT_TOKEN:-}" ENROLLMENT_TOKEN="${DRB_ENROLLMENT_TOKEN:-}"
unset DRB_ENROLLMENT_TOKEN # read once; don't leak it into every child's env
DASHBOARD_USER="${DRB_DASHBOARD_USERNAME:-admin}" DASHBOARD_USER="${DRB_DASHBOARD_USERNAME:-admin}"
DASHBOARD_PASS="${DRB_DASHBOARD_PASSWORD:-}" DASHBOARD_PASS="${DRB_DASHBOARD_PASSWORD:-}"
REGISTRY="${DRB_IMAGE_REGISTRY:-git.vpn.cusano.net}" # D1 (public host) REGISTRY="${DRB_IMAGE_REGISTRY:-git.vpn.cusano.net}" # D1 (public host)
@@ -108,7 +117,10 @@ usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: install.sh [options] Usage: install.sh [options]
--token TOKEN Enrollment token (Settings -> Nodes -> New token) (enrollment token) Prompted for on the terminal, input hidden. Mint one at
Settings -> Nodes -> New token.
--token-file PATH read the enrollment token from PATH (unattended installs;
keep it mode 0600 and delete it afterwards)
--node-id ID Unique node id, e.g. node-003 --node-id ID Unique node id, e.g. node-003
--name NAME Display name (default: node id) --name NAME Display name (default: node id)
--lat N --lon N Decimal degrees for the map --lat N --lon N Decimal degrees for the map
@@ -132,13 +144,19 @@ Usage: install.sh [options]
Every option also has an env var: DRB_NODE_ID, DRB_C2_URL, DRB_ENROLLMENT_TOKEN, Every option also has an env var: DRB_NODE_ID, DRB_C2_URL, DRB_ENROLLMENT_TOKEN,
DRB_MQTT_BROKER, DRB_REF, DRB_INSTALL_DIR, DRB_REGISTRY_USER/PASS, ... DRB_MQTT_BROKER, DRB_REF, DRB_INSTALL_DIR, DRB_REGISTRY_USER/PASS, ...
Secrets are read from the environment or prompted on the TTY, never from a pipe. Secrets are read from a file, the environment or a TTY prompt, never from a
pipe. Never put the token on a command line: `--token X` and
`sudo DRB_ENROLLMENT_TOKEN=X bash` both expose it in shell history and `ps`.
An exported DRB_ENROLLMENT_TOKEN only reaches the installer through sudo with
`sudo --preserve-env=DRB_ENROLLMENT_TOKEN bash` (plain sudo drops it).
(`--token` still works for old copied commands but is deprecated, see node-26#18.)
USAGE USAGE
} }
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--token) ENROLLMENT_TOKEN="$2"; shift 2 ;; --token) ENROLLMENT_TOKEN="$2"; TOKEN_ON_ARGV=1; shift 2 ;;
--token-file) TOKEN_FILE="$2"; shift 2 ;;
--node-id) NODE_ID="$2"; shift 2 ;; --node-id) NODE_ID="$2"; shift 2 ;;
--name) NODE_NAME="$2"; shift 2 ;; --name) NODE_NAME="$2"; shift 2 ;;
--lat) NODE_LAT="$2"; shift 2 ;; --lat) NODE_LAT="$2"; shift 2 ;;
@@ -162,6 +180,18 @@ while [ $# -gt 0 ]; do
esac esac
done done
if [ "${TOKEN_ON_ARGV:-0}" = 1 ]; then
warn "--token is deprecated: the token is now in your shell history and visible in"
warn "\`ps\` for this whole install (node-26#18). Omit it to be prompted, or use --token-file."
fi
if [ -n "${TOKEN_FILE:-}" ]; then
[ -r "$TOKEN_FILE" ] || die "--token-file: cannot read $TOKEN_FILE"
_mode="$(stat -c %a "$TOKEN_FILE" 2>/dev/null || echo 600)"
(( 8#$_mode & 8#044 )) && warn "--token-file $TOKEN_FILE is readable by other users — chmod 600 it"
ENROLLMENT_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
[ -n "$ENROLLMENT_TOKEN" ] || die "--token-file: $TOKEN_FILE is empty"
fi
[ "$ENROLL_WAIT" = forever ] || [[ "$ENROLL_WAIT" =~ ^[0-9]+$ ]] \ [ "$ENROLL_WAIT" = forever ] || [[ "$ENROLL_WAIT" =~ ^[0-9]+$ ]] \
|| die "--wait-approval takes a number of seconds (or omit it to wait until approved)" || die "--wait-approval takes a number of seconds (or omit it to wait until approved)"
@@ -181,6 +211,14 @@ ask_secret() {
read -rsp "$__p: " __r </dev/tty; echo >/dev/tty read -rsp "$__p: " __r </dev/tty; echo >/dev/tty
printf -v "$__v" '%s' "$__r" printf -v "$__v" '%s' "$__r"
} }
# Make sure ENROLLMENT_TOKEN is set: already given, else prompt on the TTY.
# Returns 1 if there is still no token (non-interactive, or Enter at the prompt).
need_token() {
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token (input hidden)"
[ -n "$ENROLLMENT_TOKEN" ]
}
NO_TOKEN_HELP="mint one at Settings -> Nodes and re-run the install command; it prompts for
the token (unattended: --token-file PATH, or an exported DRB_ENROLLMENT_TOKEN)"
genpw() { head -c 24 /dev/urandom | base64 | tr -d '\n=' ; } genpw() { head -c 24 /dev/urandom | base64 | tr -d '\n=' ; }
# ── 1. Preflight ──────────────────────────────────────────────────────────── # ── 1. Preflight ────────────────────────────────────────────────────────────
@@ -190,8 +228,8 @@ command -v apt-get >/dev/null || die "apt-get not found — this script targets
ARCH="$(dpkg --print-architecture)" ARCH="$(dpkg --print-architecture)"
case "$ARCH" in case "$ARCH" in
arm64|aarch64) ok "arch $ARCH" ;; arm64|aarch64|amd64|x86_64) ok "arch $ARCH" ;;
*) warn "arch is $ARCH — CI only builds linux/arm64 images. Prebuilt pull will fail; use --build." ;; *) warn "arch is $ARCH — CI only builds linux/amd64 + linux/arm64 images. Prebuilt pull will fail; use --build." ;;
esac esac
ok "running as root" ok "running as root"
@@ -334,6 +372,20 @@ EOF
ok ".env written for '$NODE_ID'" ok ".env written for '$NODE_ID'"
fi fi
# Pin every image to the source just checked out (node-26#19, D3). Written
# into .env — fresh or preserved — so later manual `docker compose` runs on
# this node use the same pins.
# A --ref older than #19 has no helper; its compose file hardcodes its tags.
if [ -f "$INSTALL_DIR/scripts/image-tags.sh" ]; then
# shellcheck source=scripts/image-tags.sh
. "$INSTALL_DIR/scripts/image-tags.sh"
drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env"
ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)"
else
warn "$REF predates image pinning (node-26#19) — using its compose file's default image tags"
drb_images_present() { return 0; }
fi
# ── 5. Enrollment ─────────────────────────────────────────────────────────── # ── 5. Enrollment ───────────────────────────────────────────────────────────
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT # Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
# exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly), # exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly),
@@ -416,8 +468,8 @@ wait_for_key() { # wait_for_key PICKUP_SECRET
while :; do while :; do
case "$CRED_HTTP" in case "$CRED_HTTP" in
200) [ -n "$(cred_field '.api_key')" ] && { trap - INT TERM; return 0; } ;; 200) [ -n "$(cred_field '.api_key')" ] && { trap - INT TERM; return 0; } ;;
401) die "C2 rejected the saved pickup secret (401) while waiting — re-run with --token DRB-… to re-enroll." ;; 401) die "C2 rejected the saved pickup secret (401) while waiting — re-run the installer to re-enroll (it asks for a fresh enrollment token)." ;;
404) die "C2 no longer knows node '$NODE_ID' (404) — it was deleted. Re-run with --token DRB-… to enroll again." ;; 404) die "C2 no longer knows node '$NODE_ID' (404) — it was deleted. Re-run the installer to enroll again (it asks for an enrollment token)." ;;
*) : ;; # 000/5xx: transient, keep waiting *) : ;; # 000/5xx: transient, keep waiting
esac esac
if [ "$_shown" = 0 ]; then if [ "$_shown" = 0 ]; then
@@ -435,8 +487,7 @@ wait_for_key() { # wait_for_key PICKUP_SECRET
do_fresh_enroll() { do_fresh_enroll() {
say "Enrolling '$NODE_ID' with $C2_URL" say "Enrolling '$NODE_ID' with $C2_URL"
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token" need_token || die "no enrollment token — $NO_TOKEN_HELP"
[ -n "$ENROLLMENT_TOKEN" ] || die "no enrollment token — mint one at Settings -> Nodes, then re-run with --token"
local BODY RESP PICKUP STATUS KEY local BODY RESP PICKUP STATUS KEY
BODY="$(jq -nc --arg id "$NODE_ID" --arg n "${NODE_NAME:-$NODE_ID}" \ BODY="$(jq -nc --arg id "$NODE_ID" --arg n "${NODE_NAME:-$NODE_ID}" \
@@ -459,7 +510,7 @@ do_fresh_enroll() {
or have an admin Reissue key (Settings -> Nodes) and write the key into or have an admin Reissue key (Settings -> Nodes) and write the key into
$CREDS by hand (see node-26#6)." ;; $CREDS by hand (see node-26#6)." ;;
*401*) die "enroll refused (401): enrollment token missing/invalid/revoked — *401*) die "enroll refused (401): enrollment token missing/invalid/revoked —
mint a fresh one at Settings -> Nodes and re-run with --token." ;; $NO_TOKEN_HELP." ;;
*429*) die "enroll refused (429): rate limited. Wait ~1 minute, then re-run." ;; *429*) die "enroll refused (429): rate limited. Wait ~1 minute, then re-run." ;;
*) die "enroll failed: $RESP" ;; *) die "enroll failed: $RESP" ;;
esac esac
@@ -522,24 +573,26 @@ elif [ -s "$PICKUP_FILE" ]; then
fi fi
;; ;;
401) 401)
if [ -n "$ENROLLMENT_TOKEN" ]; then warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere."
warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere. Re-enrolling with --token." if need_token; then
warn "re-enrolling with the enrollment token."
rm -f "$PICKUP_FILE" rm -f "$PICKUP_FILE"
do_fresh_enroll do_fresh_enroll
else else
die "saved pickup secret is stale (401) and no --token was given. Re-run with die "saved pickup secret is stale (401) and no enrollment token was given.
--token DRB-… to re-enroll (only works while the node is still pending), or Re-enrolling only works while the node is still pending: $NO_TOKEN_HELP.
have an admin Reissue key for an approved node and write $CREDS by hand." For an approved node, have an admin Reissue key and write $CREDS by hand."
fi fi
;; ;;
404) 404)
if [ -n "$ENROLLMENT_TOKEN" ]; then warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled."
warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled. Re-enrolling with --token." if need_token; then
warn "re-enrolling with the enrollment token."
rm -f "$PICKUP_FILE" rm -f "$PICKUP_FILE"
do_fresh_enroll do_fresh_enroll
else else
die "C2 does not know node '$NODE_ID' (404) and no --token was given. die "C2 does not know node '$NODE_ID' (404) and no enrollment token was given:
Re-run with --token DRB-… to enroll it again." $NO_TOKEN_HELP."
fi fi
;; ;;
000) 000)
@@ -570,6 +623,11 @@ if [ "$DO_START" = 1 ]; then
requirement or a transient network error: re-run with DRB_REGISTRY_USER / requirement or a transient network error: re-run with DRB_REGISTRY_USER /
DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)." DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)."
fi fi
# compose pull only WARNS for a missing image that also has a build:
# section, so check the pinned images really arrived.
drb_images_present || die "the images pinned to $REF (${RESOLVED_SHA:0:12}) are not in
$REGISTRY yet. CI may still be building them — wait and re-run; the owner can
run the 'Release images' workflow on that ref; or re-run with --build."
docker compose up --no-build -d docker compose up --no-build -d
fi fi
# ALWAYS recreate edge-node (node-26#14): `up -d` leaves an already-running # ALWAYS recreate edge-node (node-26#14): `up -d` leaves an already-running
@@ -618,6 +676,6 @@ if [ -z "$(jq -r '.api_key // empty' "$CREDS" 2>/dev/null)" ]; then
if [ -s "$PICKUP_FILE" ]; then if [ -s "$PICKUP_FILE" ]; then
printf "${Y}This node has no api_key yet. Once an admin approves it at %s/settings/nodes\nthe node fetches the key and comes online by itself (no re-run needed).${N}\n\n" "$APP_URL" printf "${Y}This node has no api_key yet. Once an admin approves it at %s/settings/nodes\nthe node fetches the key and comes online by itself (no re-run needed).${N}\n\n" "$APP_URL"
else else
printf "${Y}This node has no api_key and no saved pickup secret, so a plain re-run cannot\nfix it. Re-run with --token DRB-… to enroll; or, if the node is already\napproved, have an admin Reissue key and write it into\n%s by hand.${N}\n\n" "$CREDS" printf "${Y}This node has no api_key and no saved pickup secret. Re-run the installer\nand give it an enrollment token when it asks; or, if the node is already\napproved, have an admin Reissue key and write it into\n%s by hand.${N}\n\n" "$CREDS"
fi fi
fi fi
+74 -8
View File
@@ -17,7 +17,9 @@
# * Node credentials go to curl via a 0600 header file, never argv. # * Node credentials go to curl via a 0600 header file, never argv.
# * C2 must be https. # * C2 must be https.
# What this does NOT defend against: a compromised git host or registry — # What this does NOT defend against: a compromised git host or registry —
# refs and images are not signed (see the issue filed with node-26#15). # refs and images are not signed (node-26#17).
# * Images are pinned to the checked-out source (node-26#19): each one is
# pulled as src-<git tree hash of its build directory>, never :latest.
# #
# The whole body is inside main(), called on the last line: bash parses the # The whole body is inside main(), called on the last line: bash parses the
# function completely before running it, so `git checkout` replacing this # function completely before running it, so `git checkout` replacing this
@@ -49,6 +51,12 @@ main() {
flock -n 9 || { log "another update is already running"; return 0; } flock -n 9 || { log "another update is already running"; return 0; }
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
# This runs as root, but a pre-install.sh checkout (e.g. /home/pi/node-26)
# is owned by the login user, and git >= 2.35.2 then refuses every command
# with "dubious ownership" — which surfaced as origin '' != REPO_URL. Trust
# exactly the configured INSTALL_DIR, for this process only (env config is
# command-line scope, which safe.directory honours; no global config change).
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$INSTALL_DIR"
envget() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true; } envget() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true; }
local NODE_ID C2_URL local NODE_ID C2_URL
NODE_ID="$(envget NODE_ID)"; C2_URL="$(envget C2_URL)"; C2_URL="${C2_URL%/}" NODE_ID="$(envget NODE_ID)"; C2_URL="$(envget C2_URL)"; C2_URL="${C2_URL%/}"
@@ -146,6 +154,47 @@ do_update() {
fi fi
local old new local old new
old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")" old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")"
local env_registry
env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)"
[ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \
|| { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; }
# Image pins for the TARGET commit (node-26#19): each image is tagged with
# the git tree hash of its own source directory, so the images always match
# the code being checked out. The helper is read from the target commit so
# its service list matches the target's compose file.
# Read into a variable, not `source <(...)`: a failed `git show` must leave
# pin=0 rather than "succeed" with no functions defined.
local pin=0 helper
if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then
helper="$(git show "$new:scripts/image-tags.sh")" && [ -n "$helper" ] && eval "$helper" \
&& declare -F drb_image_tags drb_images_present drb_pin_image_tags >/dev/null && pin=1
fi
[ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags"
if [ "$BUILD" != 1 ]; then
# Pull BEFORE checkout. The app code is bind-mounted from the checkout
# (install.sh D3) and edge-node runs uvicorn --reload, so checking out first
# would run the new code on the old images for as long as the pull takes —
# or for good, if the pinned images don't exist. Pulling against the
# target's own compose file means a failed pull changes nothing. It is
# written next to the real one so its relative paths (.env, env_file,
# build contexts) resolve exactly the same way.
local tfile="$INSTALL_DIR/.drb-update-compose.yml" pulled=0
git show "$new:docker-compose.yml" > "$tfile" || { rm -f "$tfile"; return 1; }
local -a tc=(-f "$tfile" --project-directory "$INSTALL_DIR")
(
if [ "$pin" = 1 ]; then
while IFS= read -r l; do export "$l"; done < <(drb_image_tags "$INSTALL_DIR" "$new")
fi
docker compose "${tc[@]}" pull || exit 1
if [ "$pin" = 1 ]; then drb_images_present "${tc[@]}" || exit 1; fi
) && pulled=1
rm -f "$tfile"
[ "$pulled" = 1 ] || { err "images for ${new:0:12} are not available — nothing changed (CI may still be building them; the 'Release images' workflow publishes any that are missing)"; return 1; }
fi
if [ "$old" != "$new" ]; then if [ "$old" != "$new" ]; then
git -c advice.detachedHead=false checkout --force --detach "$new" || return 1 git -c advice.detachedHead=false checkout --force --detach "$new" || return 1
log "checked out $REF: ${old:0:12} -> ${new:0:12}" log "checked out $REF: ${old:0:12} -> ${new:0:12}"
@@ -153,20 +202,37 @@ do_update() {
bash scripts/install-updater.sh --refresh || log "unit refresh failed (continuing with the old units)" bash scripts/install-updater.sh --refresh || log "unit refresh failed (continuing with the old units)"
fi fi
local env_registry if [ "$pin" = 1 ]; then
env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)" drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$new" || { err "could not write image pins to .env"; return 1; }
[ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \ fi
|| { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; }
if [ "$BUILD" = 1 ]; then if [ "$BUILD" = 1 ]; then
docker compose build || return 1 docker compose build || return 1 # tags the local builds with the same pins
else
docker compose pull || return 1
fi fi
# --force-recreate: app code is bind-mounted from the checkout (install.sh # --force-recreate: app code is bind-mounted from the checkout (install.sh
# D3), so a code-only change also needs every container restarted. # D3), so a code-only change also needs every container restarted.
docker compose up -d --no-build --force-recreate --remove-orphans || return 1 docker compose up -d --no-build --force-recreate --remove-orphans || return 1
# Drop pinned images this node no longer runs. `image prune` only removes
# dangling images, and every previous src- tag is still tagged. `image rm`
# without -f refuses anything a container still uses.
if [ "$pin" = 1 ]; then
local keep ref
keep="$(drb_image_tags "$INSTALL_DIR" "$new" | cut -d= -f2)"
docker images --format '{{.Repository}}:{{.Tag}}' | grep -E ':src-[0-9a-f]{40}$' \
| while IFS= read -r ref; do
grep -qxF "${ref##*:}" <<< "$keep" || docker image rm "$ref" >/dev/null 2>&1 || true
done || true
fi
docker image prune -f >/dev/null 2>&1 || true docker image prune -f >/dev/null 2>&1 || true
# node-26#41: neither of the above ever freed BuildKit cache or untagged-
# but-unused images, so a BUILD=1 node grew ~40 GB of build cache plus
# 16 GB of stale images until the root disk hit 100% and recording silently
# stopped (node-002, 17 days). Both prunes skip anything a container uses;
# the age filters keep the cache warm for the next build.
docker builder prune -af --filter until=72h >/dev/null 2>&1 || true
docker image prune -af --filter until=168h >/dev/null 2>&1 || true
} }
main "$@" main "$@"
+65
View File
@@ -0,0 +1,65 @@
# shellcheck shell=bash
#
# Pin each node image to the exact source it was built from (node-26#19).
# Sourced by install.sh and scripts/drb-update.sh; not run directly.
#
# The tag is the git TREE hash of the image's build context, `src-<tree>`.
# CI computes the same value (`git rev-parse HEAD:<dir>` in the build
# workflows), so a node asks for the image built from the very directory it
# has checked out. That is exact even though each image is only rebuilt when
# its own directory changes: an unchanged directory keeps the same tree hash,
# and so the same image, across commits. :latest / :stable still exist for
# local dev; a provisioned node never uses them.
#
# docker-compose.yml reads these as ${DRB_TAG_*:-latest}. The values live in
# .env so every manual `docker compose ...` on the node sees the same pins.
# VAR:build-context-dir, one per compose service with an image.
DRB_IMAGE_SOURCES="DRB_TAG_ICECAST:icecast DRB_TAG_OP25:op25-container \
DRB_TAG_SECONDARY_SDR:secondary-sdr-container DRB_TAG_EDGE_NODE:drb-edge-node"
# drb_image_tags REPO_DIR COMMIT -> "VAR=src-<tree>" lines. A directory that
# does not exist at COMMIT is skipped (its compose file won't reference it).
drb_image_tags() {
local _p _var _dir _tree
for _p in $DRB_IMAGE_SOURCES; do
_var="${_p%%:*}"; _dir="${_p#*:}"
_tree="$(git -C "$1" rev-parse --verify -q "$2:$_dir" 2>/dev/null)" || continue
printf '%s=src-%s\n' "$_var" "$_tree"
done
}
# drb_env_set ENV_FILE KEY VALUE — replace KEY's line in place, or append it.
# Rewrites through the same inode so .env keeps its 0600 mode and owner.
drb_env_set() {
local _tmp; _tmp="$(mktemp)"
awk -v k="$2" -v v="$3" '
index($0, k "=") == 1 { if (!done) print k "=" v; done = 1; next }
{ print }
END { if (!done) print k "=" v }' "$1" > "$_tmp" && cat "$_tmp" > "$1"
local _rc=$?; rm -f "$_tmp"; return $_rc
}
# drb_pin_image_tags ENV_FILE REPO_DIR COMMIT — write the pins into .env and
# export them, so compose sees them whether or not it re-reads .env.
drb_pin_image_tags() {
local _line
while IFS= read -r _line; do
drb_env_set "$1" "${_line%%=*}" "${_line#*=}" || return 1
export "$_line"
done < <(drb_image_tags "$2" "$3")
}
# drb_images_present COMPOSE_ARGS... — 0 if every image the compose project
# names is in the local image store. `compose pull` only WARNS when a service
# that also has a build: section fails to pull, so its exit code alone does
# not prove the pinned images exist.
drb_images_present() {
local _imgs _img _missing=0
_imgs="$(docker compose "$@" config --images)" || return 1
while IFS= read -r _img; do
[ -n "$_img" ] || continue
docker image inspect "$_img" >/dev/null 2>&1 || { printf 'missing image: %s\n' "$_img" >&2; _missing=1; }
done <<< "$_imgs"
return $_missing
}