Compare commits

..
Author SHA1 Message Date
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
17 changed files with 592 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:
+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()
+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
}