13 Commits
Author SHA1 Message Date
logan 31e1176c45 install.sh: re-run collects the key via pickup_secret, never re-enrolls (#7)
CI / test (push) Successful in 39s
CI / lint (push) Successful in 5s
2026-09-06 22:32:19 -04:00
Logan CusanoandClaude Sonnet 5 4f942bd770 install.sh: re-run collects the key via pickup_secret, never re-enrolls
CI / lint (push) Successful in 12s
CI / test (push) Successful in 51s
CI / test (pull_request) Failing after 11m12s
CI / lint (pull_request) Failing after 11m21s
Confirmed prod bug: §5 keyed idempotency on configs/credentials.json only.
Re-running the installer on a pending-then-approved node (the exact flow the
script's own output tells you to do) found no credentials.json and fell
through to a fresh POST /nodes/enroll — which enrollment.py's CRITICAL GUARD
answers with 403 for an already-approved node_id, rendered as "use Reissue
key". The working path (GET /nodes/{id}/credentials with the saved
pickup_secret — no already-approved guard on that endpoint) was only ever
tried within a single run.

§5 rewritten as a decision tree that runs before any POST /nodes/enroll:
  - credentials.json has api_key            -> skip (unchanged)
  - configs/pickup_secret exists            -> GET /credentials with it:
      200 + api_key   -> write credentials.json, done
      200, no key     -> say "approve it, re-run"; clean exit, start stack
      401 (rotated)   -> re-enroll iff --token, else specific die
      404 (deleted)   -> re-enroll iff --token, else specific die
      000             -> connectivity die
  - no creds, no pickup_secret              -> fresh enroll (do_fresh_enroll)

Also: fresh-enroll 403/401/429 handlers are now specific and point at
pickup-secret recovery, not just "Reissue key"; --wait-approval polling now
applies on the re-run path; §7's "re-run the installer" banner is
conditional on pickup_secret existing.

Response shapes verified against enrollment.py @ v1. approve_node mints
node_keys/{id}.api_key synchronously — no second bug; assigning a system is
independent and not required for a key. bash -n clean.

Recovery for a node stuck by the old behaviour (approved, no credentials.json,
pickup_secret on disk): re-run the patched install.sh (no --token needed), or
  curl -fsS $C2_URL/nodes/$NODE_ID/credentials \
    -H "X-Pickup-Secret: $(cat configs/pickup_secret)" | jq '{api_key}' > configs/credentials.json

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:37:14 -04:00
logan 94c3e2a952 Merge pull request 'One-shot install.sh for a fresh Pi; retire setup.sh (node-26#4)' (#5) from feat/one-shot-install into main
CI / lint (push) Successful in 7s
CI / test (push) Successful in 42s
Reviewed-on: #5
2026-09-06 19:29:30 -04:00
Logan CusanoandClaude Sonnet 5 5b0048e8db node: one-shot install.sh for a fresh Pi; retire setup.sh (node-26#4)
CI / lint (pull_request) Successful in 10s
CI / lint (push) Successful in 11s
CI / test (pull_request) Successful in 46s
CI / test (push) Successful in 46s
install.sh is the `curl -fsSL <url> | sudo bash -s -- --token ...` bootstrap:
preflight (root/arch/apt/SDR) → install docker + compose + git + jq → clone
node-26 at a pinned ref (default `v1`, `--track-main` opt-in) → write .env
non-interactively from flags/env with a /dev/tty interactive fallback →
enroll with C2 (POST /nodes/enroll, poll GET /nodes/{id}/credentials, matches
drb-c2-core/app/routers/enrollment.py exactly) and write configs/credentials.json
→ docker compose pull && up -d (prebuilt; --build opts into the ~1h op25 build)
→ print the admin-approval step. Idempotent: re-run picks up the api_key after
approval; existing .env is preserved.

- setup.sh deleted — two scripts writing .env drift. install.sh owns it now.
- Makefile `setup:` no longer calls the removed script (cp .env.example fallback).
- README Setup section rewritten around the one-liner; `make setup`/`make up`
  kept as the local-dev path.

Notes carried in the script header: git.vpn.cusano.net is public (D1, settled);
`v1` must be re-cut at this change's merge commit so the tag actually contains
install.sh. Standing hazard D3: docker-compose.yml bind-mounts the app source
over the image, so a pinned ref and the pulled image tags must not diverge.

Client-side enrollment still belongs in the edge-node app (mqtt_manager.py:73-85);
install.sh doing it is the interim. Tracked for follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:14:40 -04:00
Logan CusanoandClaude Opus 5 0c08275482 Stop throwing away the audio Whisper has to read
Build edge-node / build (push) Successful in 35s
CI / lint (push) Successful in 7s
CI / test (push) Successful in 42s
The single encode at save time was %mp3(bitrate=16), and the comment said why:
it matched what Liquidsoap pushes to Icecast. That was the wrong thing to
match. Icecast is the LISTENING path and 16 kbps is a bandwidth budget for a
live stream; this file is the ACCURACY path -- it is what Whisper transcribes,
and CLAUDE.md is explicit that everything downstream is hostage to it. P25 has
already been through a vocoder, so 16 kbps MP3 stacked a second lossy stage on
the one copy that had to stay faithful.

FLAC instead. Lossless, so the bytes Whisper receives are the bytes PulseAudio
captured. ~1.3 MB/min against 120 KB/min, which keeps a 600 s call (the time
cap) around 13 MB -- inside Whisper's 25 MB request cap and well inside
upload_max_bytes. Icecast's own 16 kbps stream is untouched; nothing about
live listening changes.

Capture, buffering, silence detection and the byte-offset trim are all
unchanged: they operate on raw PCM and never saw the encode. The sample rate
stays pinned to pcm.SAMPLE_RATE so the encode remains a straight pass -- the
trim arithmetic depends on that, and Whisper resamples to 16 kHz itself.

encode_mp3 is now encode_recording, the upload sends audio/flac, and the test
that pinned the old contract now pins losslessness instead, including an
assertion that no bitrate constant comes back.

This is the before/after boundary for STT quality. Last night's window is the
16 kbps baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:48:32 -04:00
Logan Cusano fb13bb8ae3 Pin *.sh to LF so Windows checkouts cannot ship a CRLF shebang
CI / lint (push) Successful in 10s
CI / test (push) Successful in 57s
2026-08-20 03:13:10 -04:00
Logan Cusano e6aab7589a Stop shipping "hackme" as the Icecast password
Build edge-node / build (push) Failing after 6s
CI / lint (push) Successful in 6s
CI / test (push) Successful in 40s
Build op25 / build (push) Successful in 1m48s
Build icecast / build (push) Successful in 3m16s
The source password had a `hackme` fallback in five places -- entrypoint.sh,
docker-compose.yml, setup.sh's prompt default, .env.example, edge-node's
config.py -- plus op25-container's os.getenv default and two README rows. Any
node whose operator pressed Enter through setup.sh is running a credential
that is written down in this repo.

That matters more than the usual default-password case because Icecast binds
all interfaces and the SOURCE password is write access: it does not just let a
LAN neighbour listen, it lets them PUSH audio into the stream the frontend and
mobile clients play as live radio. Injecting fake traffic into a public-safety
feed is the failure worth preventing.

Approach: remove every fallback rather than change them to a better default.
- icecast/entrypoint.sh refuses to start if either password is empty, and says
  how to generate one. This is the single hard gate; everything else is
  defence in depth behind it.
- docker-compose.yml uses ${VAR:?message} so a missing value stops the stack
  at compose time with a readable error instead of becoming an empty string.
- setup.sh GENERATES a random password when the operator presses Enter, via
  openssl rand -base64 24 with a /dev/urandom fallback. Pressing Enter now
  gives you a random password rather than a known one, which is the actual
  behaviour change -- a prompt default nobody types over is not a default, it
  is the value.
- .env.example ships the keys empty with the generation command in a comment,
  and README.md now marks both as required with no default.

Client suite: 185 passed.

Note this does NOT rotate anything already deployed. node-002's .env still has
whatever it was set up with; that is an operational step, tracked in the issue.

Closes logan/node-26#3
2026-08-20 03:12:44 -04:00
Logan Cusano 28266b4441 Pin the op25 container to a Python major version
CI / lint (push) Successful in 6s
Build op25 / build (push) Successful in 21s
CI / test (push) Successful in 38s
`python:slim-trixie` carried no version at all, so a rebuild could move the
interpreter across a major release without anything in the repo changing. That
is not hypothetical here: app/models.py referenced IcecastConfig about 85 lines
before its definition and ran only because trixie currently ships Python 3.14,
where PEP 649 defers annotation evaluation. On 3.13 it was a hard NameError.
The ordering was fixed on 2026-08-16; the unpinned base outlived it.

Pinned to 3.14-slim rather than 3.14-slim-trixie so it matches drb-edge-node,
which was already on 3.14-slim. Patch releases still float, which is what we
want for security updates -- only the major version is nailed down.

Every other Dockerfile in both repos already pinned a major version
(python:3.12-slim, python:3.14-slim, node:20-slim, debian:bookworm-slim), so
this was the only genuinely unpinned base image, despite server-26#11 claiming
none of them were pinned.

Closes logan/server-26#11 (filed against the wrong repo -- the file lives in
the client repo).
2026-08-20 03:01:38 -04:00
Logan CusanoandClaude Opus 5 7f1d09c753 Satisfy flake8 so the lint job stops failing
CI / lint (push) Successful in 6s
CI / test (push) Successful in 39s
Build edge-node / build (push) Successful in 7m44s
Pure formatting, no behaviour change: strip trailing whitespace from blank
lines, give top-level defs in system_cacher.py their two blank lines, wrap
the long discord_radio.join signature, and split the duplicated
active_config ternary in main.py and routers/api.py across lines.

Verified clean with flake8 --max-line-length=120, matching CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:56:22 -04:00
Logan CusanoandClaude Opus 5 9d86304b8a Revert the CI full-fetch workaround and drop unreachable workflows
CI / lint (push) Failing after 17s
CI / test (push) Successful in 49s
Build op25 / build (push) Successful in 1h29m25s
Shallow clones were never a Gitea packing bug. An intruder had set
uploadpack.packObjectsHook in Gitea's HOME gitconfig, pointing at a
non-executable dropper, so every upload-pack died mid-pack. That hook is
gone and --depth=1 clones are verified working, so fetch-depth: 0 buys
nothing but slower CI. See INCIDENT-2026-08-11.md.

op25-container/.gitea/workflows/* never ran: Gitea only executes
workflows under .gitea/workflows at the repo root, and op25-container is
a subdirectory of this repo, not a repo of its own. The live OP25 image
build is .gitea/workflows/build-op25.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:00:02 -04:00
Logan CusanoandClaude Opus 5 5ff1089551 Use a full fetch in CI: Gitea fails to pack a shallow clone
CI / test (push) Failing after 30s
CI / lint (push) Failing after 39s
actions/checkout defaults to depth=1, and Gitea aborted generating that pack
with a bad pack header protocol error on every retry, failing the run before
any image was built. Same fix already applied on the server repo; applied here
to all four workflows so the edge-node, op25 and icecast images can build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:09:48 -04:00
Logan CusanoandClaude Opus 5 87633ab50d Authenticate the node dashboard, and the broker connection per node
CI / lint (push) Failing after 24s
CI / test (push) Failing after 28s
Build edge-node / build (push) Failing after 43s
Build op25 / build (push) Failing after 47s
Two unauthenticated surfaces closed on the edge node.

Dashboard and API: the local dashboard and every /api/* route were open to
anything on the node's LAN. Adds a login page plus session-cookie auth for
the browser, and cookie-or-Basic for the API so scripted callers stay
possible. Passwords are hashed with stdlib scrypt (no new dependency, this
runs on a Pi) and compared in constant time; the salt and session-signing
secret persist in credentials.json. Startup warns while the default password
is still in place. No non-browser callers of the node API exist today
(C2 talks to nodes over MQTT and nodes call C2 outbound), so nothing breaks.

Adds python-multipart, which FastAPI's Form() needs for the login POST and
which was missing from requirements entirely.

MQTT: nodes authenticated with a shared drb-node password, and the broker
ACL keyed off %c — the client-supplied client id — so any holder of that one
password could claim another node's topic namespace. Nodes now connect as
username=<node_id>, password=<their C2-issued api_key>, which mosquitto's
dynamic-security plugin checks, with the ACL keyed off the authenticated %u.
TLS is gated on MQTT_TLS and uses default CA verification.

The old key_request MQTT path stays in place behind TODO(mqtt-cutover)
markers as the fallback until the cutover is proven; a node with no api_key
on disk logs a clear repeated refusal rather than spinning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:34:16 -04:00
Logan CusanoandClaude Opus 5 a61a7b2c31 Bind OP25 control API and terminal to loopback by default
Both :8001 (FastAPI control API) and :8081 (OP25's HTTP terminal) listened on
0.0.0.0 with no authentication, on a container that is privileged with /dev
mounted and network_mode: host. Nodes get deployed to third-party sites, so
that exposed start/stop/retune to anyone on the host's LAN.

All three containers share the host network namespace, so edge-node still
reaches both over 127.0.0.1 unchanged. OP25_DEBUG_EXPOSE=true restores the
old 0.0.0.0 binding and logs a loud warning; it is off by default.

Confirmed against boatbod/op25 gr310 that the terminal's http:<host>:<port>
string is honoured as a real bind address (http_server.py splits it and hands
the host to create_server), so no flag was invented.

Also reorder models.py so IcecastConfig precedes ConfigGenerator, which
annotates a field with it. That only worked because python:slim-trixie is
currently Python 3.14, where PEP 649 defers annotation evaluation; on 3.13 or
earlier the same file is a hard NameError at import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:33:25 -04:00
33 changed files with 1886 additions and 395 deletions
+44 -5
View File
@@ -5,11 +5,32 @@ NODE_LAT=0.0
NODE_LON=0.0 NODE_LON=0.0
# MQTT — point to your C2 server # MQTT — point to your C2 server
#
# Post-cutover (MQTT-PUBLIC-AUTH-PLAN.md dynsec revision): there is no shared
# node login any more. The node authenticates as username=NODE_ID,
# password=<this node's C2-issued api_key> automatically — nothing to set
# here for that; the api_key is provisioned via MQTT after an admin approves
# the node (see credentials.json) and does not go in this file.
#
# Local/dev, pointed at a plaintext broker on :1883: leave MQTT_TLS unset.
MQTT_BROKER=localhost MQTT_BROKER=localhost
MQTT_PORT=1883 MQTT_PORT=1883
# Must match MQTT_NODE_USER/MQTT_NODE_PASS in the server's top-level .env MQTT_TLS=false
MQTT_USER=drb-node
MQTT_PASS=change-me-node # Production, pointed at the public broker (real Let's Encrypt cert, default
# CA verification — do not disable it):
# MQTT_BROKER=mqtt.<domain>
# MQTT_PORT=8883
# MQTT_TLS=true
# DEPRECATED / REMOVED post-cutover — the shared "drb-node" login these
# backed no longer exists on the server (dynsec creates only per-node
# clients, keyed by api_key; see Server/drb-c2-core/app/internal/dynsec.py).
# Leave unset for any node pointed at a cut-over broker. Only meaningful as a
# legacy fallback if MQTT_BROKER still points at a pre-cutover broker running
# mosquitto's old password_file auth.
# MQTT_USER=drb-node
# MQTT_PASS=change-me-node
# C2 server for audio upload (leave blank to disable upload) # C2 server for audio upload (leave blank to disable upload)
C2_URL=http://localhost:8888 C2_URL=http://localhost:8888
@@ -17,8 +38,10 @@ C2_URL=http://localhost:8888
# Icecast (local container — usually no need to change) # Icecast (local container — usually no need to change)
# Live listening only. Call recording and Discord voice use PulseAudio instead. # Live listening only. Call recording and Discord voice use PulseAudio instead.
ICECAST_SOURCE_PASSWORD=hackme # REQUIRED, no default — the container refuses to start without them.
ICECAST_ADMIN_PASSWORD=admin # Generate with: openssl rand -base64 24
ICECAST_SOURCE_PASSWORD=
ICECAST_ADMIN_PASSWORD=
ICECAST_HOST=localhost ICECAST_HOST=localhost
ICECAST_PORT=8000 ICECAST_PORT=8000
ICECAST_MOUNT=/radio ICECAST_MOUNT=/radio
@@ -79,6 +102,22 @@ TRIM_SILENCE_GUARD_SECONDS=0.25
# OP25 container (usually no need to change) # OP25 container (usually no need to change)
OP25_API_URL=http://localhost:8001 OP25_API_URL=http://localhost:8001
OP25_TERMINAL_URL=http://localhost:8081 OP25_TERMINAL_URL=http://localhost:8081
# DEBUGGING AID, NOT A DEPLOYMENT OPTION. Both OP25's control API (:8001) and
# its HTTP terminal (:8081) have NO authentication, so they are bound to
# 127.0.0.1 by default — reachable only from other containers on this same
# host (they share its network namespace), not from the site's LAN. Setting
# this to true rebinds both to 0.0.0.0, exposing unauthenticated OP25
# start/stop/config-rewrite and the raw terminal to anyone on that LAN. Only
# for local development off a real node; leave false everywhere else.
OP25_DEBUG_EXPOSE=false
# --- Local dashboard / API login ---------------------------------------------
# Protects the node's local dashboard (port 80) and JSON API. The node is
# reachable by anyone on whatever site's LAN it's deployed to, so this MUST be
# changed before the node leaves the bench — the default below is flagged at
# every startup in the logs until it's changed.
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=CHANGE-ME-drb-default
# Container registry — set these to pull pre-built images instead of building locally. # Container registry — set these to pull pre-built images instead of building locally.
# Must match the DOCKER_ORG variable and repo name configured in Gitea. # Must match the DOCKER_ORG variable and repo name configured in Gitea.
+4
View File
@@ -0,0 +1,4 @@
# Shell scripts run inside Linux containers. A CRLF shebang there fails as
# "bad interpreter: /bin/sh^M", which surfaces only as a container that will
# not start. Windows checkouts have core.autocrlf=true, so pin these to LF.
*.sh text eol=lf
+6 -1
View File
@@ -1,7 +1,12 @@
.PHONY: setup test up up-prebuilt pull down logs .PHONY: setup test up up-prebuilt pull down logs
# Local-dev only: seed .env from the example so `make up` has something to read.
# A real edge node is provisioned with install.sh (one-shot bootstrap for a
# clean Pi — clones, enrolls with C2, pulls prebuilt images):
# curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh | sudo bash -s -- --help
setup: setup:
@bash setup.sh @test -f .env || cp .env.example .env
@echo ".env ready — edit it, then 'make up' (local build) or 'make up-prebuilt'"
# Run pytest inside the running edge-node container. # Run pytest inside the running edge-node container.
# Requires: docker compose up (or at least the edge-node image built). # Requires: docker compose up (or at least the edge-node image built).
+25 -14
View File
@@ -135,21 +135,32 @@ Client/
## Setup ## Setup
### Provision a real node — `install.sh`
One-shot bootstrap for a clean Raspberry Pi OS (arm64). Installs Docker, clones
this repo at the `v1` tag, enrols with C2, pulls the prebuilt images and starts:
```bash ```bash
# 1. Copy env template curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
cp .env.example .env | sudo bash -s -- --token DRB-xxxx --node-id node-003 \
--c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
# 2. Fill in at minimum: NODE_ID and MQTT_BROKER
nano .env
# 3. Build all images (op25 takes ~10-15 minutes first time)
docker compose build
# 4. Start
docker compose up -d
``` ```
The node will appear as **pending** in the server admin dashboard. An admin must approve it before it becomes operational. After approval, assign a radio system in the dashboard and the node will start decoding automatically. Mint the `--token` at **Settings → Nodes** in the web app (the panel prints the
whole command). 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
```bash
make setup # seeds .env from .env.example
nano .env # at minimum: NODE_ID, MQTT_BROKER, C2_URL
make up # build locally (op25 ~10-15 min first time)
# or: make up-prebuilt # pull images, no local build
```
The node appears as **pending** in the admin dashboard. An admin approves it,
then assigns a radio system, and the node starts decoding automatically.
## Environment Variables (`.env`) ## Environment Variables (`.env`)
@@ -167,8 +178,8 @@ The node will appear as **pending** in the server admin dashboard. An admin must
| `ICECAST_HOST` | No | `localhost` | Icecast hostname (leave as localhost — host network mode) | | `ICECAST_HOST` | No | `localhost` | Icecast hostname (leave as localhost — host network mode) |
| `ICECAST_PORT` | No | `8000` | Icecast HTTP port | | `ICECAST_PORT` | No | `8000` | Icecast HTTP port |
| `ICECAST_MOUNT` | No | `/radio` | Icecast mount point | | `ICECAST_MOUNT` | No | `/radio` | Icecast mount point |
| `ICECAST_SOURCE_PASSWORD` | No | `hackme` | Icecast source password — change this | | `ICECAST_SOURCE_PASSWORD` | **Yes** | none | Icecast source password. No default — the container refuses to start without it. `install.sh` generates one; otherwise `openssl rand -base64 24` |
| `ICECAST_ADMIN_PASSWORD` | No | `hackme` | Icecast admin password — change this | | `ICECAST_ADMIN_PASSWORD` | **Yes** | none | Icecast admin password. Same rules |
| `OP25_API_URL` | No | `http://localhost:8001` | OP25 container HTTP API | | `OP25_API_URL` | No | `http://localhost:8001` | OP25 container HTTP API |
| `OP25_TERMINAL_URL` | No | `http://localhost:8081` | OP25 HTTP terminal (live talkgroup metadata) | | `OP25_TERMINAL_URL` | No | `http://localhost:8081` | OP25 HTTP terminal (live talkgroup metadata) |
+9 -2
View File
@@ -5,9 +5,16 @@ services:
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
environment: environment:
ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:-hackme} # :? not :- — a missing password must stop the stack, not silently
ICECAST_ADMIN_PASSWORD: ${ICECAST_ADMIN_PASSWORD:-admin} # become a credential that is published in this file.
ICECAST_SOURCE_PASSWORD: ${ICECAST_SOURCE_PASSWORD:?set ICECAST_SOURCE_PASSWORD in .env (run setup.sh, or openssl rand -base64 24)}
ICECAST_ADMIN_PASSWORD: ${ICECAST_ADMIN_PASSWORD:?set ICECAST_ADMIN_PASSWORD in .env (run setup.sh, or openssl rand -base64 24)}
# No `ports:` here — network_mode: host makes it a no-op either way. The
# control API (:8001) and OP25's HTTP terminal (:8081) are unauthenticated,
# so they bind 127.0.0.1 by default (see OP25_DEBUG_EXPOSE in .env.example)
# rather than being exposed. edge-node still reaches both over localhost
# 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:stable
build: ./op25-container build: ./op25-container
+37 -1
View File
@@ -10,8 +10,27 @@ class Settings(BaseSettings):
node_lon: float = 0.0 node_lon: float = 0.0
# MQTT # MQTT
#
# Broker cutover (MQTT-PUBLIC-AUTH-PLAN.md, dynsec revision): the server no
# longer has a shared node login. Each node authenticates as
# username=NODE_ID, password=<its C2-issued api_key> (the same credential
# /upload already trusts via node_keys) — see mqtt_manager._build_client().
# For local dev against the old-style broker (localhost:1883, no TLS) set
# MQTT_BROKER=localhost and leave MQTT_TLS unset/false.
mqtt_broker: str mqtt_broker: str
mqtt_port: int = 1883 mqtt_port: int = 1883
# Set true for the public broker (mqtt.<domain>:8883, real Let's Encrypt
# cert) so client.tls_set() runs with default system-CA verification.
# False by default so local/dev against a plaintext :1883 broker still
# works unchanged. Do NOT pair with a self-signed/insecure cert setup —
# verification is never disabled (no tls_insecure_set(True) anywhere).
mqtt_tls: bool = False
# DEPRECATED / effectively dead post-cutover: the shared node login these
# backed no longer exists on the server (dynsec has no such client — see
# dynsec.py). Left in only as a legacy fallback for a pre-cutover broker
# that still uses mosquitto's old password_file auth; mqtt_manager only
# falls back to these when no api_key is on disk yet. Do not provision new
# nodes with these — see MQTT_USER/MQTT_PASS removal note in .env.example.
mqtt_user: Optional[str] = None mqtt_user: Optional[str] = None
mqtt_pass: Optional[str] = None mqtt_pass: Optional[str] = None
@@ -23,7 +42,8 @@ class Settings(BaseSettings):
icecast_host: str = "localhost" icecast_host: str = "localhost"
icecast_port: int = 8000 icecast_port: int = 8000
icecast_mount: str = "/radio" icecast_mount: str = "/radio"
icecast_source_password: str = "hackme" # No default: see icecast/entrypoint.sh, which refuses to start without one.
icecast_source_password: str = ""
# PulseAudio — the low-latency path used for call recording and Discord voice. # PulseAudio — the low-latency path used for call recording and Discord voice.
# Liquidsoap (op25 container) writes into the `drb_sink` null sink; we capture # Liquidsoap (op25 container) writes into the `drb_sink` null sink; we capture
@@ -123,6 +143,22 @@ class Settings(BaseSettings):
# Offline call buffer — how many call_end events to keep while disconnected # Offline call buffer — how many call_end events to keep while disconnected
offline_call_buffer_size: int = 35 offline_call_buffer_size: int = 35
# ------------------------------------------------------------------
# Local dashboard / API authentication
#
# These nodes are deployed at arbitrary third-party locations, reachable by
# anyone on that site's LAN — there is no auth on this HTTP surface without
# these. The password below is a FIRST-BOOT DEFAULT ONLY: change it via
# DASHBOARD_PASSWORD in .env before a node leaves the bench. main.py logs a
# startup warning every boot the default is still active.
#
# See app/internal/auth.py — the password is never compared or stored in
# plaintext (scrypt-hashed, constant-time compare); this setting just holds
# the operator-facing plaintext the same way MQTT_PASS/ICECAST_* already do.
# ------------------------------------------------------------------
dashboard_username: str = "admin"
dashboard_password: str = "CHANGE-ME-drb-default"
class Config: class Config:
env_file = ".env" env_file = ".env"
+178
View File
@@ -0,0 +1,178 @@
"""
Local dashboard / API authentication.
These edge nodes are deployed at arbitrary third-party locations and serve
both an HTML dashboard and a JSON API on the same FastAPI app (port 80,
network_mode: host) — anyone on that site's LAN can otherwise reach every
control endpoint. This module adds username/password auth in front of it.
Design:
- Username + password come from app/config.py (DASHBOARD_USERNAME /
DASHBOARD_PASSWORD env vars), with a first-boot default that MUST be
changed — see is_using_default_password() and its call site in main.py.
- The password is never compared or stored in plaintext. It's hashed with
stdlib hashlib.scrypt (no new dependency — this image runs on a Raspberry
Pi) using a salt generated once on first boot and persisted via
app/internal/credentials.py, then compared with hmac.compare_digest.
- Two auth paths, both accepted on every protected route:
* Browser dashboard: a signed session cookie set by POST /login
(HMAC-SHA256 over "username:expiry", no server-side session store —
the signing key is the persisted session secret from credentials.py).
* Machine callers: HTTP Basic with the same username/password. As of
this writing no non-browser caller of this node's own API was found
anywhere in Client/ or Server/ (nodes are only ever reached over MQTT
+ node-initiated outbound HTTP to C2, never the other way around) —
Basic is kept anyway as a stateless fallback for curl/scripts in the
field, since it needs no login flow and costs little to support.
Caveat worth knowing: this node's dashboard is plain HTTP (no TLS
termination on :80), so both the session cookie and Basic credentials travel
unencrypted on the local network either way. Auth here stops a passerby from
opening the dashboard and pressing buttons; it does not stop a LAN-level
sniffer. That would need TLS in front of the node, which is out of scope here.
"""
import base64
import hashlib
import hmac
import time
from typing import Optional
from fastapi import Cookie, Header, HTTPException, status
from app.config import settings
from app.internal import credentials
from app.internal.logger import logger
SESSION_COOKIE_NAME = "drb_node_session"
# 12h: long enough that the dashboard doesn't demand a daily re-login on a
# device left open on someone's desk, short enough that a stolen cookie isn't
# valid forever.
SESSION_TTL_SECONDS = 12 * 60 * 60
# scrypt cost parameters. n=2**14 (16384) keeps the derivation well under the
# ~1s ballpark on a Raspberry Pi's memory/CPU budget — this only runs on
# login attempts (rare), never on the hot path.
_SCRYPT_N = 2 ** 14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_DKLEN = 32
# Kept in sync with app/config.py's Settings.dashboard_password default.
DEFAULT_PASSWORD = "CHANGE-ME-drb-default"
def _hash_password(password: str, salt: bytes) -> bytes:
return hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=_SCRYPT_N,
r=_SCRYPT_R,
p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN,
)
def is_using_default_password() -> bool:
return settings.dashboard_password == DEFAULT_PASSWORD
def verify_credentials(username: str, password: str) -> bool:
"""Constant-time check of a submitted username/password against config."""
salt = credentials.get_auth_salt()
expected_hash = _hash_password(settings.dashboard_password, salt)
submitted_hash = _hash_password(password, salt)
user_ok = hmac.compare_digest(
username.encode("utf-8"), settings.dashboard_username.encode("utf-8")
)
pass_ok = hmac.compare_digest(submitted_hash, expected_hash)
return user_ok and pass_ok
def _sign(payload: str) -> str:
secret = credentials.get_session_secret()
return hmac.new(secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
def create_session_token(username: str) -> str:
"""Build a signed, expiring, opaque session token (no server-side state)."""
expiry = int(time.time()) + SESSION_TTL_SECONDS
payload = f"{username}:{expiry}"
sig = _sign(payload)
raw = f"{payload}:{sig}"
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("utf-8")
def _verify_session_token(token: str) -> Optional[str]:
try:
raw = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
username, expiry_s, sig = raw.rsplit(":", 2)
expiry = int(expiry_s)
except Exception:
return None
expected_sig = _sign(f"{username}:{expiry_s}")
if not hmac.compare_digest(sig, expected_sig):
return None
if time.time() > expiry:
return None
if not hmac.compare_digest(
username.encode("utf-8"), settings.dashboard_username.encode("utf-8")
):
return None
return username
def _verify_basic_auth(header_value: str) -> bool:
try:
scheme, _, encoded = header_value.partition(" ")
if scheme.lower() != "basic":
return False
decoded = base64.b64decode(encoded).decode("utf-8")
username, _, password = decoded.partition(":")
except Exception:
return False
return verify_credentials(username, password)
def is_authenticated(
session_cookie: Optional[str], authorization: Optional[str]
) -> bool:
if session_cookie and _verify_session_token(session_cookie):
return True
if authorization and _verify_basic_auth(authorization):
return True
return False
async def require_session(
drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> bool:
"""Dependency for dashboard HTML pages. Returns False rather than raising
so the route can redirect to /login instead of showing a bare 401."""
return bool(drb_node_session and _verify_session_token(drb_node_session))
async def require_auth(
drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
authorization: Optional[str] = Header(default=None),
) -> None:
"""Dependency for /api/* routes — session cookie (dashboard's own fetch
calls) or HTTP Basic (machine callers) both satisfy it."""
if is_authenticated(drb_node_session, authorization):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Basic"},
)
def warn_if_default_password() -> None:
if is_using_default_password():
logger.warning(
"DASHBOARD_PASSWORD is still the first-boot default — "
"set DASHBOARD_USERNAME/DASHBOARD_PASSWORD in .env before this "
"node leaves the bench. Anyone on the node's LAN can currently "
"log in with the default credentials."
)
+46 -23
View File
@@ -7,14 +7,16 @@ A persistent capture process runs for the lifetime of the node. Spawning FFmpeg
per call used to lose the first 1-2 s to process startup, which meant short per call used to lose the first 1-2 s to process startup, which meant short
transmissions produced empty files, so capture never stops. transmissions produced empty files, so capture never stops.
RAW PCM, NOT MP3 — this is the change everything else hangs off. FFmpeg is RAW PCM, NOT A COMPRESSED STREAM — this is the change everything else hangs
asked for s16le/22050/mono on stdout instead of an MP3 stream, so: off. FFmpeg is asked for s16le/22050/mono on stdout instead of an encoded
stream, so:
* silence detection is integer arithmetic over each chunk as it arrives, with * silence detection is integer arithmetic over each chunk as it arrives, with
no decode, which is what makes AUDIO-DRIVEN call boundaries possible; no decode, which is what makes AUDIO-DRIVEN call boundaries possible;
* trimming is a byte-offset slice, not a second FFmpeg pass; * trimming is a byte-offset slice, not a second FFmpeg pass;
* MP3 encoding happens exactly ONCE, at save time, so uploads are no longer * encoding happens exactly ONCE, at save time, so uploads are no longer
double-encoded. double-encoded. That encode is now FLAC (lossless) rather than 16 kbps MP3
— see the AUDIO_* constants below for why.
TWO BUFFERS, TWO JOBS — this split is load-bearing: TWO BUFFERS, TWO JOBS — this split is load-bearing:
@@ -94,14 +96,32 @@ RING_BUFFER_SECONDS = 30
# under PRE_ROLL_SECONDS and well under the shortest utterance we care about. # under PRE_ROLL_SECONDS and well under the shortest utterance we care about.
READ_CHUNK_BYTES = 2048 READ_CHUNK_BYTES = 2048
# Encoder settings for the single encode at save time, matched on purpose to # Encoder settings for the single encode at save time.
# what Liquidsoap already pushes to Icecast — %mp3(bitrate=16, samplerate=22050, #
# stereo=false) — so the C2 /upload endpoint keeps receiving exactly the kind of # This used to be %mp3(bitrate=16) chosen to match what Liquidsoap pushes to
# MP3 it has always received (multipart "audio/mpeg", stored to GCS as .mp3, # Icecast. That was the wrong thing to match: Icecast is the LISTENING path and
# then fed to Whisper). MP3_SAMPLE_RATE MUST equal pcm.SAMPLE_RATE: the encode # 16 kbps is a bandwidth budget for a live stream, while this file is the
# is a straight pass with no resampling. # ACCURACY path — it is what Whisper transcribes, and the transcript is what
MP3_BITRATE = "16k" # every downstream stage is hostage to. P25 audio has already been through a
MP3_SAMPLE_RATE = str(pcm.SAMPLE_RATE) # vocoder; 16 kbps MP3 stacked a second lossy stage on top of that, on the one
# copy that had to stay faithful.
#
# FLAC instead: lossless, so the bytes Whisper receives are the bytes PulseAudio
# captured. Roughly 1.3 MB/min against 120 KB/min for 16k MP3 — larger, but a
# 600 s call is still ~13 MB, inside both Whisper's 25 MB request cap and the
# C2 upload_max_bytes (100 MB). Icecast's own 16 kbps stream is untouched;
# nothing about the listening path changes.
#
# AUDIO_SAMPLE_RATE MUST equal pcm.SAMPLE_RATE: the encode is a straight pass
# with no resampling. Whisper resamples to 16 kHz itself, so handing it 22050
# unresampled keeps the one resample in the pipeline inside the model.
AUDIO_SAMPLE_RATE = str(pcm.SAMPLE_RATE)
AUDIO_FORMAT = "flac"
AUDIO_SUFFIX = ".flac"
AUDIO_MIME = "audio/flac"
# -compression_level 5 is ffmpeg's default: near-best ratio, and the encode is
# off the hot path anyway (once per call, at save).
FLAC_COMPRESSION_LEVEL = "5"
# Bounded so a wedged encoder can never stall the upload path. # Bounded so a wedged encoder can never stall the upload path.
ENCODE_TIMEOUT_SECONDS = 60.0 ENCODE_TIMEOUT_SECONDS = 60.0
@@ -218,9 +238,12 @@ class Recording:
all_silence: bool = False all_silence: bool = False
async def encode_mp3(audio: bytes, path: Path) -> bool: async def encode_recording(audio: bytes, path: Path) -> bool:
""" """
The one and only encode in the pipeline: raw PCM in, MP3 file out. The one and only encode in the pipeline: raw PCM in, FLAC file out.
Lossless on purpose — see the AUDIO_* constants above. This file is what
Whisper transcribes, so the encode must not throw anything away.
Module-level rather than a method so tests can substitute it without Module-level rather than a method so tests can substitute it without
needing FFmpeg, and so the "exactly one encode per call" property is needing FFmpeg, and so the "exactly one encode per call" property is
@@ -233,13 +256,13 @@ async def encode_mp3(audio: bytes, path: Path) -> bool:
"-hide_banner", "-nostdin", "-nostats", "-hide_banner", "-nostdin", "-nostats",
"-loglevel", "warning", "-y", "-loglevel", "warning", "-y",
"-f", "s16le", "-f", "s16le",
"-ar", MP3_SAMPLE_RATE, "-ar", AUDIO_SAMPLE_RATE,
"-ac", str(pcm.CHANNELS), "-ac", str(pcm.CHANNELS),
"-i", "pipe:0", "-i", "pipe:0",
"-ar", MP3_SAMPLE_RATE, "-ar", AUDIO_SAMPLE_RATE,
"-ac", str(pcm.CHANNELS), "-ac", str(pcm.CHANNELS),
"-b:a", MP3_BITRATE, "-compression_level", FLAC_COMPRESSION_LEVEL,
"-f", "mp3", str(path), "-f", AUDIO_FORMAT, str(path),
] ]
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
@@ -327,7 +350,7 @@ class CallRecorder:
"-loglevel", "warning", "-loglevel", "warning",
"-f", "pulse", "-i", settings.pulse_source, "-f", "pulse", "-i", settings.pulse_source,
"-ac", str(pcm.CHANNELS), "-ac", str(pcm.CHANNELS),
"-ar", MP3_SAMPLE_RATE, "-ar", AUDIO_SAMPLE_RATE,
# Raw PCM on stdout. No muxer, so no -flush_packets games: s16le is # Raw PCM on stdout. No muxer, so no -flush_packets games: s16le is
# a bare byte stream and every byte FFmpeg produces is immediately # a bare byte stream and every byte FFmpeg produces is immediately
# readable, which is what keeps arrival timestamps honest. # readable, which is what keeps arrival timestamps honest.
@@ -360,7 +383,7 @@ class CallRecorder:
async def _run_capture(self) -> None: async def _run_capture(self) -> None:
cmd = self._ffmpeg_command() cmd = self._ffmpeg_command()
logger.info(f"Starting capture: ffmpeg -f pulse -i {settings.pulse_source} (s16le/{MP3_SAMPLE_RATE}/mono)") logger.info(f"Starting capture: ffmpeg -f pulse -i {settings.pulse_source} (s16le/{AUDIO_SAMPLE_RATE}/mono)")
self._last_stderr_lines.clear() self._last_stderr_lines.clear()
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
@@ -674,9 +697,9 @@ class CallRecorder:
self._recordings_dir.mkdir(parents=True, exist_ok=True) self._recordings_dir.mkdir(parents=True, exist_ok=True)
ts_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") ts_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_path = self._recordings_dir / f"{ts_str}_{recording.call_id}.mp3" output_path = self._recordings_dir / f"{ts_str}_{recording.call_id}{AUDIO_SUFFIX}"
if not await encode_mp3(audio, output_path): if not await encode_recording(audio, output_path):
output_path.unlink(missing_ok=True) output_path.unlink(missing_ok=True)
return None return None
@@ -766,7 +789,7 @@ class CallRecorder:
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
r = await client.post( r = await client.post(
upload_url, upload_url,
files={"file": (file_path.name, f, "audio/mpeg")}, files={"file": (file_path.name, f, AUDIO_MIME)},
data=form, data=form,
headers=headers, headers=headers,
) )
+57 -5
View File
@@ -1,30 +1,73 @@
""" """
Manages the persisted node API key. Manages the persisted node API key, plus the local-auth signing material used
by app/internal/auth.py.
The key is provisioned by the C2 server after an admin approves the node. The API key is provisioned by the C2 server after an admin approves the node.
It arrives via MQTT and is saved to /configs/credentials.json so it survives It arrives via MQTT and is saved to /configs/credentials.json so it survives
container restarts. container restarts.
The scrypt salt and session-signing secret are generated locally on first boot
(never provisioned externally) and persisted the same way, so dashboard
sessions survive a container restart instead of forcing every operator to
re-login whenever the node restarts.
""" """
import json import json
import secrets
from pathlib import Path from pathlib import Path
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
_CREDS_FILE = Path(settings.config_path) / "credentials.json" _CREDS_FILE = Path(settings.config_path) / "credentials.json"
_api_key: str | None = None _api_key: str | None = None
_auth_salt: bytes | None = None
_session_secret: bytes | None = None
def load() -> None: def load() -> None:
"""Load persisted credentials from disk on startup.""" """Load persisted credentials from disk on startup."""
global _api_key global _api_key, _auth_salt, _session_secret
if _CREDS_FILE.exists(): if _CREDS_FILE.exists():
try: try:
data = json.loads(_CREDS_FILE.read_text()) data = json.loads(_CREDS_FILE.read_text())
_api_key = data.get("api_key") _api_key = data.get("api_key")
if data.get("auth_salt"):
_auth_salt = bytes.fromhex(data["auth_salt"])
if data.get("session_secret"):
_session_secret = bytes.fromhex(data["session_secret"])
if _api_key: if _api_key:
logger.info("Node credentials loaded from disk.") logger.info("Node credentials loaded from disk.")
except Exception as e: except Exception as e:
logger.warning(f"Could not read credentials file: {e}") logger.warning(f"Could not read credentials file: {e}")
_ensure_auth_material()
def _ensure_auth_material() -> None:
"""Generate (once) and persist the local-auth salt + session secret."""
global _auth_salt, _session_secret
changed = False
if _auth_salt is None:
_auth_salt = secrets.token_bytes(16)
changed = True
if _session_secret is None:
_session_secret = secrets.token_bytes(32)
changed = True
if changed:
_write()
logger.info("Generated local-auth signing material (first boot).")
def get_auth_salt() -> bytes:
"""Scrypt salt for dashboard password hashing — generated once, persisted."""
if _auth_salt is None:
_ensure_auth_material()
return _auth_salt # type: ignore[return-value]
def get_session_secret() -> bytes:
"""HMAC key used to sign dashboard session cookies — generated once, persisted."""
if _session_secret is None:
_ensure_auth_material()
return _session_secret # type: ignore[return-value]
def get_api_key() -> str | None: def get_api_key() -> str | None:
@@ -34,6 +77,15 @@ def get_api_key() -> str | None:
def save_api_key(key: str) -> None: def save_api_key(key: str) -> None:
global _api_key global _api_key
_api_key = key _api_key = key
_CREDS_FILE.parent.mkdir(parents=True, exist_ok=True) _write()
_CREDS_FILE.write_text(json.dumps({"api_key": key}))
logger.info("Node API key saved to disk.") logger.info("Node API key saved to disk.")
def _write() -> None:
_CREDS_FILE.parent.mkdir(parents=True, exist_ok=True)
data: dict = {"api_key": _api_key}
if _auth_salt is not None:
data["auth_salt"] = _auth_salt.hex()
if _session_secret is not None:
data["session_secret"] = _session_secret.hex()
_CREDS_FILE.write_text(json.dumps(data))
+2 -1
View File
@@ -27,7 +27,8 @@ class RadioBot:
self._channel_id: Optional[int] = None self._channel_id: Optional[int] = None
self._was_streaming: bool = False self._was_streaming: bool = False
async def join(self, guild_id: int, channel_id: int, token: str, call_active: bool = False, system_name: str = None) -> bool: async def join(self, guild_id: int, channel_id: int, token: str,
call_active: bool = False, system_name: str = None) -> bool:
# (Re)start the bot if the token changed or the bot isn't running # (Re)start the bot if the token changed or the bot isn't running
if self._current_token != token or not self._is_bot_running(): if self._current_token != token or not self._is_bot_running():
if not await self._start_bot(token): if not await self._start_bot(token):
+52 -2
View File
@@ -32,6 +32,16 @@ class MQTTManager:
self._t_metadata = f"nodes/{nid}/metadata" self._t_metadata = f"nodes/{nid}/metadata"
self._t_commands = f"nodes/{nid}/commands" self._t_commands = f"nodes/{nid}/commands"
self._t_config = f"nodes/{nid}/config" self._t_config = f"nodes/{nid}/config"
# TODO(mqtt-cutover): dead once enrollment lands client-side. This
# was the pre-dynsec key-delivery path (server retain-publishes the
# api_key here after admin approval; node asks for redelivery via
# _t_key_request if none shows up). Under dynsec a node with no
# api_key can't authenticate to the broker at all — see
# _build_client() — so this subscribe is only ever reachable while
# still using the legacy mqtt_user/mqtt_pass fallback against a
# pre-cutover broker. Left in as the rollback path per
# MQTT-PUBLIC-AUTH-PLAN.md; remove together with the server's
# matching TODO(mqtt-cutover) markers once enrollment replaces it.
self._t_api_key = f"nodes/{nid}/api_key" self._t_api_key = f"nodes/{nid}/api_key"
self._t_key_request = f"nodes/{nid}/key_request" self._t_key_request = f"nodes/{nid}/key_request"
self._t_discovery = "nodes/discovery/request" self._t_discovery = "nodes/discovery/request"
@@ -41,8 +51,47 @@ class MQTTManager:
callback_api_version=mqtt.CallbackAPIVersion.VERSION2, callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=settings.node_id, client_id=settings.node_id,
) )
if settings.mqtt_user:
api_key = credentials.get_api_key()
if api_key:
# Post-cutover auth: broker's dynsec plugin authenticates this
# exact (username, password) pair as this node's own client — see
# Server/drb-c2-core/app/internal/dynsec.py upsert_node_client()
# and MQTT-PUBLIC-AUTH-PLAN.md. node_id doubles as the dynsec
# username AND the %u substitution in the "node" role's
# nodes/%u/# ACL pattern, so it must match exactly what C2 has on
# file for this node (it always does — node_id is not operator
# editable post-provisioning).
client.username_pw_set(settings.node_id, api_key)
elif settings.mqtt_user:
# Legacy fallback — only valid against a pre-cutover broker still
# using mosquitto's old password_file auth. See config.py's
# mqtt_user/mqtt_pass docstring. Not accepted by a dynsec broker.
client.username_pw_set(settings.mqtt_user, settings.mqtt_pass) client.username_pw_set(settings.mqtt_user, settings.mqtt_pass)
else:
# No api_key on disk and no legacy shared login configured. A
# dynsec broker (allow_anonymous false) refuses this outright —
# expected, not a bug to route around here: this node hasn't been
# enrolled/approved yet, and the enrollment flow that would fix
# that client-side is a later, separate pass (out of scope here;
# see MQTT-PUBLIC-AUTH-PLAN.md). paho's reconnect_delay_set()
# below bounds the retry rate (2..60s exponential backoff), so
# this degrades to a slow, clearly-logged refusal loop via
# _on_connect's "MQTT connect refused" line — not a hot spin.
logger.warning(
"No API key on disk and no legacy MQTT_USER configured — "
"connecting without credentials; the broker is expected to "
"refuse this until the node is enrolled/approved."
)
if settings.mqtt_tls:
# No arguments = system CA store + ssl.CERT_REQUIRED (verified
# against paho's tls_set() source/docstring — unverified by
# running anything, per instruction). The broker presents a real
# Let's Encrypt cert for mqtt.<domain>:8883, so default
# verification is exactly correct: do not pass ca_certs, do not
# call tls_insecure_set(True).
client.tls_set()
lwt = json.dumps({ lwt = json.dumps({
"node_id": settings.node_id, "node_id": settings.node_id,
@@ -62,10 +111,11 @@ class MQTTManager:
self._connected = True self._connected = True
client.subscribe(self._t_commands, qos=1) client.subscribe(self._t_commands, qos=1)
client.subscribe(self._t_config, qos=1) client.subscribe(self._t_config, qos=1)
client.subscribe(self._t_api_key, qos=2) client.subscribe(self._t_api_key, qos=2) # TODO(mqtt-cutover): see _t_api_key comment above
client.subscribe(self._t_discovery, qos=0) client.subscribe(self._t_discovery, qos=0)
logger.info("MQTT connected.") logger.info("MQTT connected.")
asyncio.run_coroutine_threadsafe(self._publish_checkin(), self._loop) asyncio.run_coroutine_threadsafe(self._publish_checkin(), self._loop)
# TODO(mqtt-cutover): see _t_api_key comment above
asyncio.run_coroutine_threadsafe(self._maybe_request_key(), self._loop) asyncio.run_coroutine_threadsafe(self._maybe_request_key(), self._loop)
asyncio.run_coroutine_threadsafe(self._flush_offline_buffer(), self._loop) asyncio.run_coroutine_threadsafe(self._flush_offline_buffer(), self._loop)
else: else:
@@ -8,6 +8,7 @@ from app.internal import credentials
_CACHE_FILE = Path(settings.config_path) / "systems_cache.json" _CACHE_FILE = Path(settings.config_path) / "systems_cache.json"
async def fetch_and_cache_systems() -> bool: async def fetch_and_cache_systems() -> bool:
"""Fetch all systems from the C2 server and cache them locally.""" """Fetch all systems from the C2 server and cache them locally."""
if not settings.c2_url: if not settings.c2_url:
@@ -32,6 +33,7 @@ async def fetch_and_cache_systems() -> bool:
logger.warning(f"Failed to fetch systems from C2: {e}. Offline cache will be used.") logger.warning(f"Failed to fetch systems from C2: {e}. Offline cache will be used.")
return False return False
def load_cached_systems() -> List[Dict[str, Any]]: def load_cached_systems() -> List[Dict[str, Any]]:
"""Load cached systems from disk.""" """Load cached systems from disk."""
if _CACHE_FILE.exists(): if _CACHE_FILE.exists():
@@ -41,6 +43,7 @@ def load_cached_systems() -> List[Dict[str, Any]]:
logger.error(f"Failed to read systems cache: {e}") logger.error(f"Failed to read systems cache: {e}")
return [] return []
def get_cached_system(system_id: str) -> Optional[Dict[str, Any]]: def get_cached_system(system_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve a single system config from the cache.""" """Retrieve a single system config from the cache."""
systems = load_cached_systems() systems = load_cached_systems()
+9 -2
View File
@@ -266,8 +266,11 @@ async def on_config_push(payload: dict):
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info(f"Edge node starting — ID: {settings.node_id}") logger.info(f"Edge node starting — ID: {settings.node_id}")
# Load persisted credentials (API key provisioned by C2 after approval) # Load persisted credentials (API key provisioned by C2 after approval;
# also generates/loads the local dashboard's auth salt + session secret)
credentials.load() credentials.load()
from app.internal import auth
auth.warn_if_default_password()
# Wire callbacks # Wire callbacks
metadata_watcher.on_call_start = on_call_start metadata_watcher.on_call_start = on_call_start
@@ -294,7 +297,11 @@ async def lifespan(app: FastAPI):
initial_status = "online" if node_cfg.configured else "unconfigured" initial_status = "online" if node_cfg.configured else "unconfigured"
await mqtt_manager.publish_status(initial_status) await mqtt_manager.publish_status(initial_status)
active_config = node_cfg.override_config if (node_cfg.override_system_id and node_cfg.override_config) else node_cfg.system_config active_config = (
node_cfg.override_config
if (node_cfg.override_system_id and node_cfg.override_config)
else node_cfg.system_config
)
if node_cfg.configured and active_config: if node_cfg.configured and active_config:
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
logger.info("Node is configured — waiting for OP25 API then generating config.") logger.info("Node is configured — waiting for OP25 API then generating config.")
+13 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Body from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Optional from typing import Optional
import asyncio import asyncio
import httpx import httpx
@@ -11,8 +11,14 @@ from app.internal.discord_radio import radio_bot
from app.internal.metadata_watcher import metadata_watcher from app.internal.metadata_watcher import metadata_watcher
from app.internal import credentials from app.internal import credentials
from app.internal.mqtt_manager import mqtt_manager from app.internal.mqtt_manager import mqtt_manager
from app.internal import auth
router = APIRouter(prefix="/api", tags=["api"]) # Every route in this router requires auth — a valid dashboard session cookie
# or HTTP Basic (see app/internal/auth.py). No exemption exists for any route
# here: there is no health/liveness endpoint in this file or anywhere else in
# the edge node (confirmed against source — no docker healthcheck references
# one either), so nothing needs to stay open for a container healthcheck.
router = APIRouter(prefix="/api", tags=["api"], dependencies=[Depends(auth.require_auth)])
@router.get("/status") @router.get("/status")
@@ -24,7 +30,11 @@ async def get_status():
active_tgid_name = metadata_watcher.current_tgid_name active_tgid_name = metadata_watcher.current_tgid_name
system_name = None system_name = None
active_config = node_cfg.override_config if (node_cfg.override_system_id and node_cfg.override_config) else node_cfg.system_config active_config = (
node_cfg.override_config
if (node_cfg.override_system_id and node_cfg.override_config)
else node_cfg.system_config
)
if active_config: if active_config:
system_name = active_config.name system_name = active_config.name
if active_tgid: if active_tgid:
+50 -4
View File
@@ -1,18 +1,64 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from typing import Optional
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from app.internal import auth
router = APIRouter(tags=["ui"]) router = APIRouter(tags=["ui"])
_TEMPLATE = Path(__file__).parent.parent / "templates" / "index.html" _TEMPLATE = Path(__file__).parent.parent / "templates" / "index.html"
_SCANNER_TEMPLATE = Path(__file__).parent.parent / "templates" / "scanner.html" _SCANNER_TEMPLATE = Path(__file__).parent.parent / "templates" / "scanner.html"
_LOGIN_TEMPLATE = Path(__file__).parent.parent / "templates" / "login.html"
@router.get("/login", response_class=HTMLResponse)
async def login_page(error: Optional[str] = None):
html = _LOGIN_TEMPLATE.read_text()
banner = (
'<div class="error">Invalid username or password.</div>' if error else ""
)
return html.replace("<!--ERROR_BANNER-->", banner)
@router.post("/login")
async def login_submit(username: str = Form(...), password: str = Form(...)):
if not auth.verify_credentials(username, password):
return RedirectResponse("/login?error=1", status_code=303)
token = auth.create_session_token(username)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(
auth.SESSION_COOKIE_NAME,
token,
max_age=auth.SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# No TLS termination on this port (LAN dashboard on :80) — `secure`
# would make the cookie never get sent at all.
secure=False,
)
return resp
@router.post("/logout")
@router.get("/logout")
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
return resp
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def index(): async def index(authed: bool = Depends(auth.require_session)):
if not authed:
return RedirectResponse("/login")
return _TEMPLATE.read_text() return _TEMPLATE.read_text()
@router.get("/scanner", response_class=HTMLResponse) @router.get("/scanner", response_class=HTMLResponse)
async def scanner(): async def scanner(authed: bool = Depends(auth.require_session)):
if not authed:
return RedirectResponse("/login")
return _SCANNER_TEMPLATE.read_text() return _SCANNER_TEMPLATE.read_text()
+4
View File
@@ -268,6 +268,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Scanner Mode Scanner Mode
</a> </a>
<a href="/logout" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
Logout
</a>
</div> </div>
</header> </header>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRB Edge Node — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0f19;
--glass-bg: rgba(20, 25, 40, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--text-main: #f8fafc;
--text-muted: #94a3b8;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
background-image:
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
h1 {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
font-family: inherit;
font-size: 0.95rem;
}
input[type="text"]:focus, input[type="password"]:focus {
outline: none;
border-color: var(--accent);
}
button {
width: 100%;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
border: none;
cursor: pointer;
background: var(--accent);
color: white;
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
transition: all 0.2s ease;
}
button:hover {
background: var(--accent-hover);
}
.error {
margin-top: 1rem;
padding: 0.6rem 0.8rem;
border-radius: 8px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--danger);
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="login-card">
<h1>DRB Edge Node</h1>
<p class="subtitle">Sign in to the local dashboard</p>
<form method="post" action="/login">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<!--ERROR_BANNER-->
</div>
</body>
</html>
+1
View File
@@ -5,5 +5,6 @@ paho-mqtt>=2.0.0
httpx httpx
discord.py[voice] discord.py[voice]
PyNaCl PyNaCl
python-multipart
pytest pytest
pytest-asyncio pytest-asyncio
+248
View File
@@ -0,0 +1,248 @@
"""
Unit tests for local dashboard/API auth (app.internal.auth), plus the
credentials.py additions that persist its signing material (auth_salt,
session_secret) alongside the existing node API key.
This file is pure logic: password hashing/constant-time comparison, session
token signing/expiry, and HTTP Basic header parsing. See test_auth_endpoints.py
for the HTTP-level login/redirect/protection round trip through the routers.
"""
import base64
import secrets
import time
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import auth, credentials
@pytest.fixture(autouse=True)
def isolated_credentials(tmp_path, monkeypatch):
"""Every test gets a fresh, on-disk-isolated credentials store so the
generated auth salt / session secret never leak between tests, and a
known username/password instead of the shipped default."""
creds_file = tmp_path / "credentials.json"
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
monkeypatch.setattr(credentials, "_api_key", None)
monkeypatch.setattr(credentials, "_auth_salt", None)
monkeypatch.setattr(credentials, "_session_secret", None)
monkeypatch.setattr(settings, "dashboard_username", "tester")
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
yield
def _basic_header(username: str, password: str) -> str:
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return f"Basic {encoded}"
# ---------------------------------------------------------------------------
# credentials.py: auth salt / session secret generation + persistence
# ---------------------------------------------------------------------------
def test_auth_material_is_generated_on_first_access():
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
assert isinstance(salt, bytes) and len(salt) == 16
assert isinstance(secret, bytes) and len(secret) == 32
def test_auth_material_is_stable_across_repeated_calls():
assert credentials.get_auth_salt() == credentials.get_auth_salt()
assert credentials.get_session_secret() == credentials.get_session_secret()
def test_auth_material_persists_to_disk_and_survives_reload():
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
# Simulate a container restart: drop in-memory state, reload from disk.
credentials._api_key = None
credentials._auth_salt = None
credentials._session_secret = None
credentials.load()
assert credentials.get_auth_salt() == salt
assert credentials.get_session_secret() == secret
def test_saving_api_key_does_not_clobber_auth_material():
"""save_api_key() used to json.dumps({"api_key": key}) directly, which
would have wiped auth_salt/session_secret out of credentials.json the
moment C2 provisioned an API key after this feature was added."""
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
credentials.save_api_key("some-node-api-key")
assert credentials.get_api_key() == "some-node-api-key"
assert credentials.get_auth_salt() == salt
assert credentials.get_session_secret() == secret
# ---------------------------------------------------------------------------
# verify_credentials() — password hashing + constant-time compare
# ---------------------------------------------------------------------------
def test_verify_credentials_accepts_correct_username_and_password():
assert auth.verify_credentials("tester", "s3cret-pass") is True
def test_verify_credentials_rejects_wrong_password():
assert auth.verify_credentials("tester", "wrong") is False
def test_verify_credentials_rejects_wrong_username():
assert auth.verify_credentials("someone-else", "s3cret-pass") is False
def test_verify_credentials_rejects_empty_password():
assert auth.verify_credentials("tester", "") is False
def test_password_is_hashed_not_compared_in_plaintext():
with patch.object(auth, "_hash_password", wraps=auth._hash_password) as spy:
auth.verify_credentials("tester", "s3cret-pass")
# Once for the configured password, once for the submitted one — neither
# side is ever compared as a raw string.
assert spy.call_count == 2
def test_is_using_default_password_detects_the_shipped_default(monkeypatch):
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
assert auth.is_using_default_password() is True
def test_is_using_default_password_false_once_changed():
assert auth.is_using_default_password() is False # fixture already changed it
# ---------------------------------------------------------------------------
# session tokens
# ---------------------------------------------------------------------------
def test_session_token_round_trips():
token = auth.create_session_token("tester")
assert auth._verify_session_token(token) == "tester"
def test_session_token_rejects_tampered_payload():
token = auth.create_session_token("tester")
tampered = ("X" if token[0] != "X" else "Y") + token[1:]
assert auth._verify_session_token(tampered) is None
def test_session_token_rejects_expired_token(monkeypatch):
token = auth.create_session_token("tester")
future = time.time() + auth.SESSION_TTL_SECONDS + 1
monkeypatch.setattr(time, "time", lambda: future)
assert auth._verify_session_token(token) is None
def test_session_token_rejects_username_mismatch(monkeypatch):
token = auth.create_session_token("tester")
monkeypatch.setattr(settings, "dashboard_username", "someone-else")
assert auth._verify_session_token(token) is None
def test_session_token_garbage_input_does_not_raise():
assert auth._verify_session_token("not-a-real-token") is None
assert auth._verify_session_token("") is None
def test_session_token_signed_with_a_different_secret_is_rejected():
token = auth.create_session_token("tester")
# As if the node restarted without a persisted credentials.json.
credentials._session_secret = secrets.token_bytes(32)
assert auth._verify_session_token(token) is None
# ---------------------------------------------------------------------------
# HTTP Basic parsing
# ---------------------------------------------------------------------------
def test_basic_auth_accepts_valid_header():
assert auth._verify_basic_auth(_basic_header("tester", "s3cret-pass")) is True
def test_basic_auth_rejects_wrong_credentials():
assert auth._verify_basic_auth(_basic_header("tester", "wrong")) is False
def test_basic_auth_rejects_non_basic_scheme():
assert auth._verify_basic_auth("Bearer sometoken") is False
def test_basic_auth_tolerates_garbage_without_raising():
assert auth._verify_basic_auth("Basic not-valid-base64!!") is False
assert auth._verify_basic_auth("") is False
# ---------------------------------------------------------------------------
# is_authenticated() — the combined check require_auth is built on
# ---------------------------------------------------------------------------
def test_is_authenticated_true_with_valid_session_cookie():
token = auth.create_session_token("tester")
assert auth.is_authenticated(token, None) is True
def test_is_authenticated_true_with_valid_basic_header():
assert auth.is_authenticated(None, _basic_header("tester", "s3cret-pass")) is True
def test_is_authenticated_false_with_neither():
assert auth.is_authenticated(None, None) is False
def test_is_authenticated_false_with_invalid_session_and_no_header():
assert auth.is_authenticated("garbage", None) is False
# ---------------------------------------------------------------------------
# FastAPI dependencies: require_session / require_auth
# ---------------------------------------------------------------------------
async def test_require_session_false_with_no_cookie():
assert await auth.require_session(None) is False
async def test_require_session_true_with_valid_cookie():
token = auth.create_session_token("tester")
assert await auth.require_session(token) is True
async def test_require_auth_raises_401_with_no_credentials():
with pytest.raises(HTTPException) as exc_info:
await auth.require_auth(None, None)
assert exc_info.value.status_code == 401
assert exc_info.value.headers["WWW-Authenticate"] == "Basic"
async def test_require_auth_passes_with_valid_session_cookie():
token = auth.create_session_token("tester")
await auth.require_auth(token, None) # must not raise
async def test_require_auth_passes_with_valid_basic_header():
await auth.require_auth(None, _basic_header("tester", "s3cret-pass")) # must not raise
# ---------------------------------------------------------------------------
# startup warning
# ---------------------------------------------------------------------------
def test_warn_if_default_password_logs_when_default(monkeypatch):
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
with patch("app.internal.auth.logger") as mock_logger:
auth.warn_if_default_password()
mock_logger.warning.assert_called_once()
def test_warn_if_default_password_silent_once_changed():
with patch("app.internal.auth.logger") as mock_logger:
auth.warn_if_default_password()
mock_logger.warning.assert_not_called()
+157
View File
@@ -0,0 +1,157 @@
"""
HTTP-level tests for the auth-protected dashboard/API surface: login/logout
flow, session-cookie protection of the HTML pages, and Basic-auth protection
of the JSON API.
Built as a standalone FastAPI app assembling the real api/ui routers — NOT
app.main:app, which wires a lifespan that connects to MQTT, starts the
PulseAudio capture loop, and pings OP25/C2. None of that belongs in a unit
test, and none of it is needed to exercise the auth layer: the auth
dependency runs (and short-circuits with a redirect/401) before any route
body that would touch those services.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.internal import auth, credentials
from app.routers import api, ui
@pytest.fixture(autouse=True)
def isolated_credentials(tmp_path, monkeypatch):
creds_file = tmp_path / "credentials.json"
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
monkeypatch.setattr(credentials, "_api_key", None)
monkeypatch.setattr(credentials, "_auth_salt", None)
monkeypatch.setattr(credentials, "_session_secret", None)
monkeypatch.setattr(settings, "dashboard_username", "tester")
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
yield
@pytest.fixture
def client():
test_app = FastAPI()
test_app.include_router(api.router)
test_app.include_router(ui.router)
with TestClient(test_app) as c:
yield c
def _basic_header(username: str, password: str) -> dict:
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {encoded}"}
# ---------------------------------------------------------------------------
# /api/* — machine-facing JSON API
# ---------------------------------------------------------------------------
def test_api_route_rejects_unauthenticated_requests(client):
r = client.get("/api/status", follow_redirects=False)
assert r.status_code == 401
assert r.headers["www-authenticate"] == "Basic"
def test_api_route_accepts_valid_basic_auth(client):
r = client.get("/api/config", headers=_basic_header("tester", "s3cret-pass"))
assert r.status_code == 200
def test_api_route_rejects_wrong_basic_auth_password(client):
r = client.get("/api/config", headers=_basic_header("tester", "wrong"))
assert r.status_code == 401
def test_api_route_accepts_dashboard_session_cookie(client):
login = client.post(
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
)
assert login.status_code == 303
assert auth.SESSION_COOKIE_NAME in login.cookies
r = client.get("/api/config") # cookie jar carries the session cookie
assert r.status_code == 200
def test_every_api_route_is_registered_behind_the_auth_dependency():
"""Structural guard: catches a future route added to api.py that forgets
the router is meant to protect everything in it."""
assert any(
getattr(dep, "dependency", None) is auth.require_auth
for dep in api.router.dependencies
)
# ---------------------------------------------------------------------------
# / and /scanner — the HTML dashboard
# ---------------------------------------------------------------------------
def test_index_redirects_to_login_when_unauthenticated(client):
r = client.get("/", follow_redirects=False)
assert r.status_code in (302, 307)
assert r.headers["location"] == "/login"
def test_scanner_redirects_to_login_when_unauthenticated(client):
r = client.get("/scanner", follow_redirects=False)
assert r.status_code in (302, 307)
assert r.headers["location"] == "/login"
def test_index_served_with_a_valid_session_cookie(client):
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# ---------------------------------------------------------------------------
# /login, /logout
# ---------------------------------------------------------------------------
def test_login_page_loads_without_auth(client):
r = client.get("/login")
assert r.status_code == 200
def test_login_with_correct_credentials_sets_cookie_and_redirects_home(client):
r = client.post(
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"] == "/"
cookie = r.cookies.get(auth.SESSION_COOKIE_NAME)
assert cookie
assert auth._verify_session_token(cookie) == "tester"
def test_login_with_wrong_password_redirects_back_with_error_and_no_cookie(client):
r = client.post(
"/login", data={"username": "tester", "password": "wrong"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"] == "/login?error=1"
assert auth.SESSION_COOKIE_NAME not in r.cookies
def test_login_error_banner_renders_on_the_login_page(client):
r = client.get("/login?error=1")
assert r.status_code == 200
assert "Invalid username or password" in r.text
def test_logout_clears_the_session_cookie_and_redirects_to_login(client):
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
assert client.get("/").status_code == 200 # confirm we were logged in
r = client.get("/logout", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/login"
r2 = client.get("/", follow_redirects=False)
assert r2.status_code in (302, 307) # session cookie was cleared
+18 -8
View File
@@ -59,7 +59,7 @@ def encodes(monkeypatch):
path.write_bytes(audio) path.write_bytes(audio)
return True return True
monkeypatch.setattr(recorder_mod, "encode_mp3", _encode) monkeypatch.setattr(recorder_mod, "encode_recording", _encode)
return calls return calls
@@ -412,21 +412,31 @@ async def test_a_failed_encode_leaves_no_file_and_no_recording(recorder, monkeyp
async def _fail(audio, path): async def _fail(audio, path):
return False return False
monkeypatch.setattr(recorder_mod, "encode_mp3", _fail) monkeypatch.setattr(recorder_mod, "encode_recording", _fail)
ingest(recorder, T0, T0 + 5.0) ingest(recorder, T0, T0 + 5.0)
await recorder.start_recording("call-1", start_epoch=T0 + 1.0) await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
assert await recorder.stop_recording(end_epoch=T0 + 3.0) is None assert await recorder.stop_recording(end_epoch=T0 + 3.0) is None
assert list(recorder._recordings_dir.glob("*.mp3")) == [] assert list(recorder._recordings_dir.glob("*.flac")) == []
def test_encoder_command_contract_matches_what_c2_expects(): def test_encoder_command_contract_matches_what_c2_expects():
""" """
/upload has always received mono MP3 at 22050 Hz / 16 kbps, and Whisper The saved file is what Whisper transcribes, so the encode must stay
consumes it downstream. The single encode must not quietly change that. LOSSLESS and must not resample. It was 16 kbps MP3 — a bitrate copied from
Icecast's live stream, i.e. the listening path's budget applied to the
accuracy path — which put a second lossy stage on top of the P25 vocoder.
The sample rate must equal pcm.SAMPLE_RATE or the encode stops being a
straight pass and the byte-offset trim arithmetic no longer lines up.
""" """
assert recorder_mod.MP3_SAMPLE_RATE == str(pcm.SAMPLE_RATE) == "22050" assert recorder_mod.AUDIO_SAMPLE_RATE == str(pcm.SAMPLE_RATE) == "22050"
assert recorder_mod.MP3_BITRATE == "16k" assert recorder_mod.AUDIO_FORMAT == "flac"
assert recorder_mod.AUDIO_SUFFIX == ".flac"
assert recorder_mod.AUDIO_MIME == "audio/flac"
# No bitrate constant should exist: a bitrate on a lossless codec would mean
# someone reintroduced lossy encoding.
assert not hasattr(recorder_mod, "MP3_BITRATE")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -523,7 +533,7 @@ async def test_discard_drops_the_audio_without_writing_anything(recorder, encode
assert not recorder.is_recording assert not recorder.is_recording
assert encodes == [] assert encodes == []
assert list(recorder._recordings_dir.glob("*.mp3")) == [] assert list(recorder._recordings_dir.glob("*.flac")) == []
# ...and the recorder is immediately reusable. # ...and the recorder is immediately reusable.
assert await recorder.start_recording("call-next", start_epoch=T0 + 2.0) is True assert await recorder.start_recording("call-next", start_epoch=T0 + 2.0) is True
+142
View File
@@ -0,0 +1,142 @@
"""
Unit tests for mqtt_manager's per-node auth + TLS wiring
(MQTT-PUBLIC-AUTH-PLAN.md dynsec cutover).
Pure client-construction tests — _build_client() only builds a paho Client
object, it never calls .connect(), so no real broker is involved. What's
verified here is the credential/TLS *selection logic*, matching what the
server's dynsec plugin now expects (username=node_id, password=api_key,
default-verified TLS on the public listener) — see
Server/drb-c2-core/app/internal/dynsec.py and mosquitto.conf (read-only
reference, not touched by this change).
"""
import ssl
from unittest.mock import patch
import pytest
from app.config import settings
from app.internal import credentials
from app.internal.mqtt_manager import mqtt_manager
@pytest.fixture(autouse=True)
def isolated_mqtt_settings(monkeypatch):
"""Every test gets known, isolated mqtt_* settings and a clean
credentials._api_key so tests can't see real .env values or leak state
between tests (mirrors the isolated_credentials fixture in test_auth.py)."""
monkeypatch.setattr(settings, "mqtt_user", None)
monkeypatch.setattr(settings, "mqtt_pass", None)
monkeypatch.setattr(settings, "mqtt_tls", False)
monkeypatch.setattr(credentials, "_api_key", None)
yield
# ---------------------------------------------------------------------------
# Credential selection: api_key > legacy mqtt_user > anonymous
# ---------------------------------------------------------------------------
def test_build_client_uses_node_id_and_api_key_when_present(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
client = mqtt_manager._build_client()
assert client._username == settings.node_id.encode()
assert client._password == b"the-api-key"
def test_build_client_falls_back_to_legacy_mqtt_user_without_api_key(monkeypatch):
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
client = mqtt_manager._build_client()
assert client._username == b"drb-node"
assert client._password == b"legacy-pass"
def test_build_client_api_key_takes_priority_over_legacy_mqtt_user(monkeypatch):
"""Once a node has a real api_key, it must never fall back to the shared
legacy login even if MQTT_USER/MQTT_PASS are still set in .env."""
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
client = mqtt_manager._build_client()
assert client._username == settings.node_id.encode()
assert client._password == b"the-api-key"
def test_build_client_with_no_credentials_connects_anonymously(monkeypatch):
"""No api_key on disk, no legacy login configured: _build_client() must
still return a usable client (paho, not this code, decides what happens
on the wire — the dynsec broker refuses it, see the warning test below).
This must never raise."""
client = mqtt_manager._build_client()
assert client._username is None
assert client._password is None
def test_build_client_warns_when_no_credentials_available(caplog):
with caplog.at_level("WARNING", logger="drb-edge-node"):
mqtt_manager._build_client()
messages = [r.message for r in caplog.records]
assert any("No API key" in m for m in messages), \
"an unenrolled node must log a clear, greppable warning, not fail silently"
def test_build_client_does_not_warn_when_api_key_present(monkeypatch, caplog):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
with caplog.at_level("WARNING", logger="drb-edge-node"):
mqtt_manager._build_client()
assert not any("No API key" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# TLS
# ---------------------------------------------------------------------------
def test_build_client_no_tls_by_default(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_tls", False)
client = mqtt_manager._build_client()
assert client._ssl_context is None
def test_build_client_enables_tls_with_default_verification(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_tls", True)
client = mqtt_manager._build_client()
assert isinstance(client._ssl_context, ssl.SSLContext)
# The whole point: default CA verification against the broker's real
# Let's Encrypt cert must stay ON. tls_insecure_set(True) must never be
# called — that would defeat verification entirely.
assert client._ssl_context.verify_mode == ssl.CERT_REQUIRED
assert client._tls_insecure is False
# ---------------------------------------------------------------------------
# Offline call buffer must be untouched by the auth/TLS change
# ---------------------------------------------------------------------------
def test_build_client_does_not_touch_offline_buffer(monkeypatch):
"""_build_client() is called fresh on every connect(); it must never
reset or otherwise touch the offline call-buffer deque — that survives
reconnects/auth changes by design (the whole point of the buffer)."""
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
mqtt_manager._offline_buffer.append(("nodes/test/metadata", {"call_id": "sentinel"}))
with patch.object(mqtt_manager, "_offline_buffer", mqtt_manager._offline_buffer):
mqtt_manager._build_client()
assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})]
mqtt_manager._offline_buffer.clear()
+13 -2
View File
@@ -1,8 +1,19 @@
#!/bin/sh #!/bin/sh
set -e set -e
ICECAST_SOURCE_PASSWORD="${ICECAST_SOURCE_PASSWORD:-hackme}" # No defaults here on purpose. This container binds all interfaces, so a
ICECAST_ADMIN_PASSWORD="${ICECAST_ADMIN_PASSWORD:-admin}" # fallback password is a published credential on every node that ever accepted
# it -- and the source password is what lets a caller PUSH audio into the
# stream the frontend plays as live radio. Refuse to start instead.
for var in ICECAST_SOURCE_PASSWORD ICECAST_ADMIN_PASSWORD; do
eval "value=\${$var}"
if [ -z "$value" ]; then
echo "icecast: $var is not set." >&2
echo "icecast: set it in the node's .env -- 'bash setup.sh' generates a random one," >&2
echo "icecast: or run: openssl rand -base64 24" >&2
exit 1
fi
done
export ICECAST_SOURCE_PASSWORD ICECAST_ADMIN_PASSWORD export ICECAST_SOURCE_PASSWORD ICECAST_ADMIN_PASSWORD
+538
View File
@@ -0,0 +1,538 @@
#!/usr/bin/env bash
#
# DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64).
#
# 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 \
# --c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
#
# (fetch install.sh from the same tag it installs; use raw/branch/main with
# --track-main for the owner's own always-latest nodes)
#
# Hosting, the pinned ref, and setup.sh's retirement are settled (owner
# decisions, 2026-09-06). One standing hazard remains — D3 below.
#
# What it does, in order:
# 1. Preflight (root, arch, apt, SDR present)
# 2. Install Docker + compose plugin + git + curl + jq
# 3. Clone logan/node-26 at a PINNED ref into $INSTALL_DIR
# 4. Write .env (non-interactive from flags/env, interactive fallback)
# 5. Enroll with C2 (POST /nodes/enroll) and poll for the api_key
# 6. docker compose pull && up -d (prebuilt; --build opts into the ~1h build)
# 7. Print the approval step
#
# It does NOT set up WireGuard. WireGuard-per-node was evaluated and rejected
# (Server/MQTT-PUBLIC-AUTH-PLAN.md, Server/infra/main.tf:67-73). A field node
# reaches production over the public internet only:
# https://api.<domain> enrollment + /upload (Caddy, real TLS)
# mqtt.<domain>:8883 MQTT over TLS, username=NODE_ID password=api_key
# The two stale "nodes reach it via WireGuard" comments in
# Server/docker-compose.prod.yml:6 and Server/infra/main.tf:69 are leftovers.
#
# ---------------------------------------------------------------------------
# SETTLED (owner decisions, 2026-09-06 — node-26#4)
#
# D1 HOSTING. git.vpn.cusano.net is PUBLIC — it is a CNAME to
# cusano-net.duckdns.org (71.117.95.129) with a real Let's Encrypt cert,
# resolvable from any public resolver. install.sh, `git clone` and
# `docker compose pull` all work from a customer's Pi with no VPN.
# Caveat, not a blocker: that IP is a dynamic-DNS record on the owner's
# home uplink, so it is a single point of failure and a bandwidth limit
# — fine for the beachhead, revisit before scaling node count.
#
# D2 PIN. node-26 gets a `v1` tag (owner cuts it — see the git command in
# the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main`
# stays as an opt-in for the owner's own nodes.
#
# STANDING HAZARD
#
# 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
# the running Python is the CLONED REF's code against the pulled image's
# dependencies. `v1` == the commit CI built the current :latest/:stable
# from, so today they match — but the moment `v1` and the image tags
# diverge this silently mixes them. Fix: drop those two mounts from the
# prod compose path, or always retag images from the same ref as `v1`.
# ---------------------------------------------------------------------------
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
DEFAULT_REF="v1" # D2
DEFAULT_REPO_URL="https://git.vpn.cusano.net/logan/node-26.git" # D1 (public)
DEFAULT_INSTALL_DIR="/opt/drb/node-26"
REPO_URL="${DRB_REPO_URL:-$DEFAULT_REPO_URL}"
REF="${DRB_REF:-$DEFAULT_REF}"
INSTALL_DIR="${DRB_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
NODE_ID="${DRB_NODE_ID:-}"
NODE_NAME="${DRB_NODE_NAME:-}"
NODE_LAT="${DRB_NODE_LAT:-}"
NODE_LON="${DRB_NODE_LON:-}"
C2_URL="${DRB_C2_URL:-}"
MQTT_BROKER="${DRB_MQTT_BROKER:-}"
MQTT_PORT="${DRB_MQTT_PORT:-8883}"
MQTT_TLS="${DRB_MQTT_TLS:-true}"
ENROLLMENT_TOKEN="${DRB_ENROLLMENT_TOKEN:-}"
DASHBOARD_USER="${DRB_DASHBOARD_USERNAME:-admin}"
DASHBOARD_PASS="${DRB_DASHBOARD_PASSWORD:-}"
REGISTRY="${DRB_IMAGE_REGISTRY:-git.vpn.cusano.net}" # D1 (public host)
DOCKER_ORG="${DRB_DOCKER_ORG:-logan}"
DOCKER_REPO="${DRB_DOCKER_REPO:-node-26}"
REGISTRY_USER="${DRB_REGISTRY_USER:-}"
REGISTRY_PASS="${DRB_REGISTRY_PASS:-}"
DO_BUILD=0 # 0 = pull prebuilt images (default), 1 = build on the Pi (~1h for op25)
DO_START=1
ASSUME_YES=0
ENROLL_WAIT="${DRB_ENROLL_WAIT:-0}" # seconds to block waiting for admin approval; 0 = don't block
C='\033[0;36m'; G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; N='\033[0m'
say() { printf "${C}==>${N} %s\n" "$*"; }
ok() { printf "${G} ok${N} %s\n" "$*"; }
warn() { printf "${Y} !!${N} %s\n" "$*" >&2; }
die() { printf "${R}error:${N} %s\n" "$*" >&2; exit 1; }
usage() {
cat <<'USAGE'
Usage: install.sh [options]
--token TOKEN Enrollment token (Settings -> Nodes -> New token)
--node-id ID Unique node id, e.g. node-003
--name NAME Display name (default: node id)
--lat N --lon N Decimal degrees for the map
--c2-url URL e.g. https://api.drb.example.net
--mqtt-broker HOST e.g. mqtt.drb.example.net
--mqtt-port N default 8883
--no-tls plaintext MQTT (LAN/dev brokers only)
--dashboard-pass PW local dashboard password (generated if omitted)
--ref REF git ref to install (default: v1)
--track-main install main HEAD instead of the v1 tag
--dir PATH install location (default /opt/drb/node-26)
--build build images locally instead of pulling (~1h for op25)
--no-start configure and enroll, but do not start containers
--wait-approval SEC block up to SEC seconds polling for admin approval
-y, --yes never prompt; fail instead of asking
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, ...
Secrets are read from the environment or prompted on the TTY, never from a pipe.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
--node-id) NODE_ID="$2"; shift 2 ;;
--name) NODE_NAME="$2"; shift 2 ;;
--lat) NODE_LAT="$2"; shift 2 ;;
--lon) NODE_LON="$2"; shift 2 ;;
--c2-url) C2_URL="$2"; shift 2 ;;
--mqtt-broker) MQTT_BROKER="$2"; shift 2 ;;
--mqtt-port) MQTT_PORT="$2"; shift 2 ;;
--no-tls) MQTT_TLS=false; [ "$MQTT_PORT" = 8883 ] && MQTT_PORT=1883; shift ;;
--dashboard-pass) DASHBOARD_PASS="$2"; shift 2 ;;
--ref) REF="$2"; shift 2 ;;
--track-main) REF="main"; shift ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--build) DO_BUILD=1; shift ;;
--no-start) DO_START=0; shift ;;
--wait-approval) ENROLL_WAIT="$2"; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
# Prompts must come from the terminal, not from the `curl |` pipe on stdin.
ask() { # ask VAR "prompt" "default"
local __v="$1" __p="$2" __d="${3:-}" __r=""
if [ "$ASSUME_YES" = 1 ] || [ ! -r /dev/tty ]; then
[ -n "$__d" ] || die "$__p is required (non-interactive: pass the flag or env var)"
printf -v "$__v" '%s' "$__d"; return
fi
read -rp "$__p${__d:+ [$__d]}: " __r </dev/tty
printf -v "$__v" '%s' "${__r:-$__d}"
}
ask_secret() {
local __v="$1" __p="$2" __r=""
if [ "$ASSUME_YES" = 1 ] || [ ! -r /dev/tty ]; then printf -v "$__v" '%s' ''; return; fi
read -rsp "$__p: " __r </dev/tty; echo >/dev/tty
printf -v "$__v" '%s' "$__r"
}
genpw() { head -c 24 /dev/urandom | base64 | tr -d '\n=' ; }
# ── 1. Preflight ────────────────────────────────────────────────────────────
say "Preflight"
[ "$(id -u)" -eq 0 ] || die "run as root: curl -fsSL <url> | sudo bash -s -- ..."
command -v apt-get >/dev/null || die "apt-get not found — this script targets Raspberry Pi OS / Debian"
ARCH="$(dpkg --print-architecture)"
case "$ARCH" in
arm64|aarch64) ok "arch $ARCH" ;;
*) warn "arch is $ARCH — CI only builds linux/arm64 images. Prebuilt pull will fail; use --build." ;;
esac
ok "running as root"
# Not fatal: the dongle can be plugged in after install.
if command -v lsusb >/dev/null 2>&1 && lsusb | grep -qiE 'rtl2838|realtek.*283[28]|sdr'; then
ok "SDR dongle detected on USB"
else
warn "no RTL-SDR dongle detected on USB — plug one in before expecting audio"
fi
# ── 2. Dependencies ─────────────────────────────────────────────────────────
say "Installing dependencies"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git curl ca-certificates jq usbutils openssl >/dev/null
ok "git curl jq openssl"
if ! command -v docker >/dev/null 2>&1; then
say "Installing Docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
fi
docker --version >/dev/null || die "docker install failed"
ok "$(docker --version)"
if ! docker compose version >/dev/null 2>&1; then
apt-get install -y -qq docker-compose-plugin >/dev/null
fi
docker compose version >/dev/null 2>&1 || die "docker compose plugin missing"
ok "$(docker compose version --short 2>/dev/null || echo 'compose plugin')"
systemctl enable --now docker >/dev/null 2>&1 || true
# The docker group only matters for the human who logs in LATER — this script
# is already root, so nothing below needs a re-login. setup.sh's bug (add the
# group, then immediately run compose in the same unprivileged shell) does not
# apply here.
TARGET_USER="${SUDO_USER:-}"
if [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != root ]; then
usermod -aG docker "$TARGET_USER" || true
ok "added '$TARGET_USER' to the docker group (takes effect at their next login)"
fi
# ── 3. Fetch the repo at a pinned ref ───────────────────────────────────────
say "Fetching node-26 @ ${REF}"
mkdir -p "$(dirname "$INSTALL_DIR")"
if [ -d "$INSTALL_DIR/.git" ]; then
ok "existing install at $INSTALL_DIR — updating in place (.env is preserved)"
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
git -C "$INSTALL_DIR" fetch --tags --prune origin
else
git clone --no-checkout "$REPO_URL" "$INSTALL_DIR"
fi
git -C "$INSTALL_DIR" -c advice.detachedHead=false checkout --force "$REF"
RESOLVED_SHA="$(git -C "$INSTALL_DIR" rev-parse HEAD)"
ok "checked out $REF ($RESOLVED_SHA)"
[ "$REF" = main ] && warn "tracking main — two nodes installed on different days run different software"
cd "$INSTALL_DIR"
mkdir -p configs recordings
chmod 700 configs
# ── 4. Configuration ────────────────────────────────────────────────────────
say "Configuring"
if [ -f .env ]; then
ok ".env already present — keeping it (delete it to reconfigure)"
# Re-read the three values section 5 needs. Not `source .env` — that would
# execute whatever is in the file.
envget() { grep -E "^$1=" .env | head -1 | cut -d= -f2- | tr -d '"'; }
NODE_ID="$(envget NODE_ID)"
C2_URL="${C2_URL:-$(envget C2_URL)}"; C2_URL="${C2_URL%/}"
NODE_NAME="${NODE_NAME:-$(envget NODE_NAME)}"
NODE_LAT="${NODE_LAT:-$(envget NODE_LAT)}"
NODE_LON="${NODE_LON:-$(envget NODE_LON)}"
DASHBOARD_USER="$(envget DASHBOARD_USERNAME)"
[ -n "$NODE_ID" ] || die ".env exists but has no NODE_ID — fix or delete it"
else
[ -n "$NODE_ID" ] || ask NODE_ID "Node ID (e.g. node-003)"
[[ "$NODE_ID" =~ ^[A-Za-z0-9_-]+$ ]] || die "NODE_ID must be letters/numbers/dash/underscore only"
[ -n "$NODE_NAME" ] || ask NODE_NAME "Display name" "$NODE_ID"
[ -n "$NODE_LAT" ] || ask NODE_LAT "Latitude" "0.0"
[ -n "$NODE_LON" ] || ask NODE_LON "Longitude" "0.0"
[ -n "$C2_URL" ] || ask C2_URL "C2 API base URL (https://api.<domain>)"
C2_URL="${C2_URL%/}"
[ -n "$MQTT_BROKER" ] || ask MQTT_BROKER "MQTT broker host (mqtt.<domain>)"
if [ -z "$DASHBOARD_PASS" ]; then
ask_secret DASHBOARD_PASS "Local dashboard password (Enter to generate)"
[ -n "$DASHBOARD_PASS" ] || { DASHBOARD_PASS="$(genpw)"; GENERATED_DASH=1; }
fi
ICE_SRC="$(genpw)"; ICE_ADM="$(genpw)"
umask 077
cat > .env <<EOF
# Written by install.sh on $(date -Is) from ref ${RESOLVED_SHA}
NODE_ID=${NODE_ID}
NODE_NAME="${NODE_NAME}"
NODE_LAT=${NODE_LAT}
NODE_LON=${NODE_LON}
# MQTT — post-cutover auth. There is NO shared node login: the node
# authenticates as username=NODE_ID, password=<its C2-issued api_key>, which
# section 5 below fetches into configs/credentials.json. Deliberately no
# MQTT_USER/MQTT_PASS here; a dynsec broker rejects them.
MQTT_BROKER=${MQTT_BROKER}
MQTT_PORT=${MQTT_PORT}
MQTT_TLS=${MQTT_TLS}
C2_URL=${C2_URL}
ICECAST_SOURCE_PASSWORD=${ICE_SRC}
ICECAST_ADMIN_PASSWORD=${ICE_ADM}
ICECAST_HOST=localhost
ICECAST_PORT=8000
ICECAST_MOUNT=/radio
DASHBOARD_USERNAME=${DASHBOARD_USER}
DASHBOARD_PASSWORD=${DASHBOARD_PASS}
PULSE_SOURCE=drb_sink.monitor
OP25_API_URL=http://localhost:8001
OP25_TERMINAL_URL=http://localhost:8081
OP25_DEBUG_EXPOSE=false
IMAGE_REGISTRY=${REGISTRY}
DOCKER_ORG=${DOCKER_ORG}
DOCKER_REPO=${DOCKER_REPO}
EOF
umask 022
chmod 600 .env
[ -n "$TARGET_USER" ] && chown "$TARGET_USER" .env 2>/dev/null || true
ok ".env written for '$NODE_ID'"
fi
# ── 5. Enrollment ───────────────────────────────────────────────────────────
# 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),
# so without this section a fresh node can never obtain an api_key against a
# dynsec broker: MQTT needs the key, and the legacy key-over-MQTT delivery
# needs MQTT. Doing it here breaks that loop.
#
# Two server endpoints, and the exact response shapes verified against
# enrollment.py @ v1:
#
# POST /nodes/enroll (X-Enrollment-Token)
# 200 -> {node_id, pickup_secret, approval_status}
# 403 -> node_id is ALREADY APPROVED. The CRITICAL GUARD in enrollment.py
# refuses to mint a fresh pickup_secret off the shared fleet token.
# So we must only ever POST this for a node we have not enrolled
# from this machine before — i.e. when configs/pickup_secret is
# absent. Re-running the installer must NOT re-POST here.
# 401 bad/revoked token · 400 missing node_id · 429 rate limited
#
# GET /nodes/{id}/credentials (X-Pickup-Secret)
# Always HTTP 200 with {approval_status, api_key} unless the secret or
# node is bad. api_key is null until an admin approves the node in the UI
# (nodes.py approve_node() mints node_keys/{id}.api_key synchronously in
# the same call — approve is enough; assigning a system is independent and
# NOT required for a key). This endpoint has NO already-approved guard, so
# it is the correct — and only working — re-run path after approval.
# 401 -> missing/invalid/rotated pickup secret
# 404 -> node unknown to C2 (deleted server-side, or never enrolled)
CREDS="$INSTALL_DIR/configs/credentials.json"
PICKUP_FILE="$INSTALL_DIR/configs/pickup_secret"
# GET /nodes/{id}/credentials. Sets CRED_HTTP + CRED_BODY (no -f: we need the
# body and status on a 4xx). One implementation so first-run and re-run agree.
creds_pickup() { # creds_pickup PICKUP_SECRET
local _tmp; _tmp="$(mktemp)"
CRED_HTTP="$(curl -sS -o "$_tmp" -w '%{http_code}' \
"$C2_URL/nodes/$NODE_ID/credentials" -H "X-Pickup-Secret: $1" 2>/dev/null || echo 000)"
CRED_BODY="$(cat "$_tmp" 2>/dev/null || true)"
rm -f "$_tmp"
}
cred_field() { printf '%s' "${CRED_BODY:-}" | jq -r "$1 // empty" 2>/dev/null || true; }
write_creds() { # write_creds API_KEY
umask 077; jq -n --arg k "$1" '{api_key:$k}' > "$CREDS"; umask 022
ok "api_key received and written to configs/credentials.json"
}
say_pending() { # say_pending APPROVAL_STATUS — not an error: node is enrolled, key not minted yet
warn "not approved yet — C2 reports approval_status=${1:-pending}, no api_key minted."
warn "an admin must, at <app-url>/settings/nodes : Approve '$NODE_ID' (assigning a system is separate)."
warn "then re-run this installer — it reuses configs/pickup_secret — or fetch it directly:"
warn " curl -fsS $C2_URL/nodes/$NODE_ID/credentials -H \"X-Pickup-Secret: \$(cat $PICKUP_FILE)\" | jq -r .api_key"
}
# Poll creds_pickup for up to ENROLL_WAIT seconds while still pending. Result
# left in CRED_HTTP/CRED_BODY. --wait-approval is what would have avoided the
# original prod bug; the default (0) does not block, so the re-run path below
# must stand on its own.
wait_for_key() { # wait_for_key PICKUP_SECRET
[ "${ENROLL_WAIT:-0}" -gt 0 ] || return 0
local _end; _end=$(( $(date +%s) + ENROLL_WAIT ))
say "Waiting up to ${ENROLL_WAIT}s for an admin to approve '$NODE_ID'"
while [ "$(date +%s)" -lt "$_end" ]; do
sleep 10
creds_pickup "$1"
[ "$CRED_HTTP" = 200 ] || return 0
[ -z "$(cred_field '.api_key')" ] || return 0
done
}
do_fresh_enroll() {
say "Enrolling '$NODE_ID' with $C2_URL"
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token"
[ -n "$ENROLLMENT_TOKEN" ] || die "no enrollment token — mint one at Settings -> Nodes, then re-run with --token"
local BODY RESP PICKUP STATUS KEY
BODY="$(jq -nc --arg id "$NODE_ID" --arg n "${NODE_NAME:-$NODE_ID}" \
--argjson lat "${NODE_LAT:-0}" --argjson lon "${NODE_LON:-0}" \
'{node_id:$id,name:$n,lat:$lat,lon:$lon}')"
# Token goes in a header from a shell variable — never on a process command
# line, never echoed.
if ! RESP="$(curl -fsS -X POST "$C2_URL/nodes/enroll" \
-H "Content-Type: application/json" \
-H "X-Enrollment-Token: $ENROLLMENT_TOKEN" \
--data "$BODY" 2>&1)"; then
case "$RESP" in
*403*) die "enroll refused (403): node '$NODE_ID' is already approved on C2, and
this machine has no configs/pickup_secret to collect its key with. A token
alone cannot re-issue an approved node's key (enrollment.py CRITICAL GUARD).
Recover by restoring this node's original configs/pickup_secret and re-running,
or have an admin Reissue key (Settings -> Nodes) and write the key into
$CREDS by hand (see node-26#6)." ;;
*401*) die "enroll refused (401): enrollment token missing/invalid/revoked —
mint a fresh one at Settings -> Nodes and re-run with --token." ;;
*429*) die "enroll refused (429): rate limited. Wait ~1 minute, then re-run." ;;
*) die "enroll failed: $RESP" ;;
esac
fi
PICKUP="$(printf '%s' "$RESP" | jq -r '.pickup_secret')"
STATUS="$(printf '%s' "$RESP" | jq -r '.approval_status')"
[ -n "$PICKUP" ] && [ "$PICKUP" != null ] || die "enroll returned no pickup_secret: $RESP"
umask 077; printf '%s' "$PICKUP" > "$PICKUP_FILE"; umask 022
ok "enrolled — approval_status=$STATUS (pickup secret saved to configs/pickup_secret)"
creds_pickup "$PICKUP"
[ "$CRED_HTTP" = 200 ] || die "post-enroll credential fetch failed (HTTP $CRED_HTTP): ${CRED_BODY:-<no body>}"
KEY="$(cred_field '.api_key')"
if [ -z "$KEY" ]; then
wait_for_key "$PICKUP" || true
KEY="$(cred_field '.api_key')"
fi
if [ -n "$KEY" ]; then
write_creds "$KEY"
else
say_pending "$(cred_field '.approval_status')"
fi
}
if [ -s "$CREDS" ] && jq -e '.api_key // empty' "$CREDS" >/dev/null 2>&1; then
say "Enrollment"
ok "api_key already on disk — skipping enrollment"
elif [ -z "${C2_URL:-}" ]; then
say "Enrollment"
warn "no C2_URL — skipping enrollment"
elif [ -s "$PICKUP_FILE" ]; then
# RE-RUN. This node already enrolled from this machine. Do NOT POST
# /nodes/enroll again — an approved node_id gets 403 there, and the v1
# installer's own "re-run to pick up the key" advice then dead-ends on
# "use Reissue key". The pickup endpoint has no such guard: use it.
say "Enrollment — collecting credentials for '$NODE_ID' (pickup secret from a previous run)"
PICKUP="$(cat "$PICKUP_FILE")"
creds_pickup "$PICKUP"
case "$CRED_HTTP" in
200)
API_KEY="$(cred_field '.api_key')"
if [ -z "$API_KEY" ]; then
wait_for_key "$PICKUP" || true
API_KEY="$(cred_field '.api_key')"
fi
if [ -n "$API_KEY" ]; then
write_creds "$API_KEY"
else
# Still pending. Enrolled and idempotent — next run collects the key.
# Clean exit, fall through to start the stack. NOT a failure.
say_pending "$(cred_field '.approval_status')"
fi
;;
401)
if [ -n "$ENROLLMENT_TOKEN" ]; then
warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere. Re-enrolling with --token."
rm -f "$PICKUP_FILE"
do_fresh_enroll
else
die "saved pickup secret is stale (401) and no --token was given. Re-run with
--token DRB-… to re-enroll (only works while the node is still pending), or
have an admin Reissue key for an approved node and write $CREDS by hand."
fi
;;
404)
if [ -n "$ENROLLMENT_TOKEN" ]; then
warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled. Re-enrolling with --token."
rm -f "$PICKUP_FILE"
do_fresh_enroll
else
die "C2 does not know node '$NODE_ID' (404) and no --token was given.
Re-run with --token DRB-… to enroll it again."
fi
;;
000)
die "could not reach $C2_URL/nodes/$NODE_ID/credentials — check --c2-url and connectivity." ;;
*)
die "credential pickup failed (HTTP $CRED_HTTP): ${CRED_BODY:-<no body>}" ;;
esac
else
say "Enrollment"
do_fresh_enroll
fi
# ── 6. Images + start ───────────────────────────────────────────────────────
if [ "$DO_START" = 1 ]; then
if [ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_PASS" ]; then
printf '%s' "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin >/dev/null
ok "logged in to $REGISTRY"
fi
if [ "$DO_BUILD" = 1 ]; then
say "Building images locally — op25 takes roughly an hour on a Pi"
docker compose build
docker compose up -d
else
say "Pulling prebuilt images from $REGISTRY/$DOCKER_ORG/$DOCKER_REPO"
if ! docker compose pull; then
die "pull failed. $REGISTRY is public, so this is most likely a login
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)."
fi
docker compose up --no-build -d
fi
ok "stack started"
else
say "Skipping start (--no-start). Run: cd $INSTALL_DIR && make up-prebuilt"
fi
# ── 7. What the operator does next ──────────────────────────────────────────
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<EOF
$(printf "${G}Node '%s' installed at %s${N}" "$NODE_ID" "$INSTALL_DIR")
ref ${RESOLVED_SHA}
images $([ "$DO_BUILD" = 1 ] && echo "built locally" || echo "pulled from $REGISTRY")
dashboard http://${IP:-<node-ip>}/ (user: ${DASHBOARD_USER})
logs cd $INSTALL_DIR && docker compose logs -f edge-node
NEXT — an admin must approve this node before it can do anything:
1. Open <app-url>/settings/nodes
2. Approve "$NODE_ID"
3. Assign it a radio system
EOF
if [ "${GENERATED_DASH:-0}" = 1 ]; then
printf "${Y}Generated dashboard password (shown once): %s${N}\n\n" "$DASHBOARD_PASS"
fi
if [ ! -s "$CREDS" ]; then
if [ -s "$PICKUP_FILE" ]; then
printf "${Y}This node has no api_key yet. After an admin approves it, re-run the same\ninstall command — it reuses configs/pickup_secret and will collect the key\n(no --token needed for the re-run).${N}\n\n"
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"
fi
fi
@@ -1,57 +0,0 @@
name: release-tag
on:
push:
branches:
- dev
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with: # replace it with your local IP
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: | # replace it with your local IP and tags
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}
@@ -1,60 +0,0 @@
name: release-tag
on:
push:
branches:
- master
jobs:
release-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
DOCKER_LATEST: stable
CONTAINER_NAME: op25-client
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ gitea.actor }} # Uses the user or bot that triggered the workflow
password: ${{ secrets.GITHUB_COM_TOKEN }} # The built-in, temporary token
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
push:
branches:
- master
pull_request:
branches:
- "*"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8
- name: Run Lint
run: |
flake8 --max-line-length=88 --ignore=E203,E302,E501 .
+11 -3
View File
@@ -1,5 +1,10 @@
# OP25 Core Container # OP25 Core Container
FROM python:slim-trixie # Pinned to a Python major version deliberately. The bare `slim-trixie` tag
# carries no version at all, so a rebuild could move the interpreter across a
# major release -- which this repo has already been bitten by once, when
# app/models.py only ran because trixie happened to ship 3.14 and PEP 649
# defers annotation evaluation. Matches drb-edge-node, which is already 3.14.
FROM python:3.14-slim
# Set environment variables # Set environment variables
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
@@ -50,5 +55,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
# 2. Update ENTRYPOINT to use the wrapper script # 2. Update ENTRYPOINT to use the wrapper script
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# 3. Use CMD to pass the uvicorn command as arguments to the ENTRYPOINT script # 3. Use CMD to pass the launch command as arguments to the ENTRYPOINT script.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] # main.py starts uvicorn itself (see `if __name__ == "__main__"`) so the bind
# address can be driven by OP25_DEBUG_EXPOSE at runtime instead of being baked
# into this image at build time.
CMD ["python", "main.py"]
+33
View File
@@ -0,0 +1,33 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# ------------------------------------------------------------------
# OP25_DEBUG_EXPOSE — debugging aid, NOT a deployment mode.
#
# False (default): the op25 FastAPI control API (:8001, start/stop/
# generate-config) and OP25's own HTTP terminal (:8081, live talkgroup
# metadata) both bind 127.0.0.1. All three Client containers share the
# host network namespace (network_mode: host), so edge-node still reaches
# both over localhost with no functional change — nothing off-box can.
# Neither surface has authentication, so this is the only thing closing
# that hole.
#
# True: both bind 0.0.0.0 — reachable by anything on the node's LAN with
# NO authentication (start/stop OP25, rewrite its config, raw terminal
# access). Only ever set this for local development off a real deployed
# node. A loud warning naming both ports is logged at startup whenever
# this is true.
# ------------------------------------------------------------------
op25_debug_expose: bool = False
class Config:
env_file = ".env"
settings = Settings()
def bind_host() -> str:
"""Resolve the single bind address for both :8001 and :8081 from the flag."""
return "0.0.0.0" if settings.op25_debug_expose else "127.0.0.1"
+18 -1
View File
@@ -5,18 +5,26 @@ import routers.op25_controller as op25_controller
from internal.logger import create_logger from internal.logger import create_logger
from internal.liquidsoap_config_utils import generate_liquid_script from internal.liquidsoap_config_utils import generate_liquid_script
from models import IcecastConfig from models import IcecastConfig
from config import settings, bind_host
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
if settings.op25_debug_expose:
LOGGER.warning(
"OP25_DEBUG_EXPOSE=true — the op25 control API (:8001) and OP25's "
"HTTP terminal (:8081) are bound to 0.0.0.0 and reachable by "
"ANYTHING on this node's LAN with NO authentication. This is a "
"debugging aid only; do not leave it set on a deployed node."
)
try: try:
config = IcecastConfig( config = IcecastConfig(
icecast_host=os.getenv("ICECAST_HOST", "localhost"), icecast_host=os.getenv("ICECAST_HOST", "localhost"),
icecast_port=int(os.getenv("ICECAST_PORT", "8000")), icecast_port=int(os.getenv("ICECAST_PORT", "8000")),
icecast_mountpoint=os.getenv("ICECAST_MOUNT", "/radio"), icecast_mountpoint=os.getenv("ICECAST_MOUNT", "/radio"),
icecast_password=os.getenv("ICECAST_SOURCE_PASSWORD", "hackme"), icecast_password=os.getenv("ICECAST_SOURCE_PASSWORD", ""),
) )
generate_liquid_script(config) generate_liquid_script(config)
LOGGER.info("op25.liq generated from environment variables.") LOGGER.info("op25.liq generated from environment variables.")
@@ -28,3 +36,12 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.include_router(op25_controller.create_op25_router(), prefix="/op25") app.include_router(op25_controller.create_op25_router(), prefix="/op25")
if __name__ == "__main__":
# Launched directly (see Dockerfile CMD) instead of via `uvicorn main:app
# --host ...` so the bind address is driven by OP25_DEBUG_EXPOSE (config.py)
# rather than a value baked into the image at build time.
import uvicorn
uvicorn.run("main:app", host=bind_host(), port=8001, reload=True)
+19 -8
View File
@@ -1,6 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional, Union from typing import List, Optional, Union
from enum import Enum from enum import Enum
from config import bind_host
# Preset device settings for common RTL-SDR hardware. # Preset device settings for common RTL-SDR hardware.
# gains: OP25 gain string passed to the device block. # gains: OP25 gain string passed to the device block.
@@ -34,6 +35,20 @@ class TalkgroupTag(BaseModel):
talkgroup: str talkgroup: str
tagDec: int tagDec: int
# Defined before ConfigGenerator, which annotates a field with it. Under
# Python 3.14 (PEP 649) annotations are evaluated lazily, so the original
# order happened to work in the container; on 3.13 or earlier it is a hard
# NameError at import. Keep the definition above its first use so this file
# does not depend on the base image's Python version.
class IcecastConfig(BaseModel):
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
class ConfigGenerator(BaseModel): class ConfigGenerator(BaseModel):
type: DecodeMode type: DecodeMode
systemName: str systemName: str
@@ -115,7 +130,9 @@ class MetadataConfig(BaseModel):
class TerminalConfig(BaseModel): class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py" module: Optional[str] = "terminal.py"
terminal_type: Optional[str] = "http:0.0.0.0:8081" # Bind address comes from OP25_DEBUG_EXPOSE (config.py) — 127.0.0.1 unless
# that flag is set. See config.py for why.
terminal_type: Optional[str] = f"http:{bind_host()}:8081"
terminal_timeout: Optional[float] = 5.0 terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2 curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0 http_plot_interval: Optional[float] = 1.0
@@ -127,10 +144,4 @@ class TerminalConfig(BaseModel):
### ====================================================== ### ======================================================
# Icecast models # Icecast models
class IcecastConfig(BaseModel): # (IcecastConfig itself is defined above ConfigGenerator, which references it.)
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
+1
View File
@@ -1,2 +1,3 @@
uvicorn uvicorn
fastapi fastapi
pydantic-settings
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env bash
# Interactive first-time setup for a DRB edge node.
# Installs system dependencies (Docker, make, curl) then writes .env
# and optionally builds + starts the stack.
set -e
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
cd "$(dirname "$0")"
echo -e "${CYAN}DRB Edge Node Setup${NC}"
echo "-------------------"
# ── Dependency installation ──────────────────────────────────────────────────
install_deps() {
if ! command -v apt-get &>/dev/null; then
echo -e "${YELLOW}⚠ apt-get not found — skipping auto-install. Ensure docker, make, and curl are installed.${NC}"
return
fi
echo ""
echo -e "${CYAN}Installing system dependencies…${NC}"
sudo apt-get update -qq
local pkgs=()
command -v make &>/dev/null || pkgs+=(make)
command -v curl &>/dev/null || pkgs+=(curl)
command -v git &>/dev/null || pkgs+=(git)
if [ ${#pkgs[@]} -gt 0 ]; then
echo " Installing: ${pkgs[*]}"
sudo apt-get install -y -qq "${pkgs[@]}"
fi
# Docker — use get.docker.com if not present
if ! command -v docker &>/dev/null; then
echo " Installing Docker via get.docker.com…"
curl -fsSL https://get.docker.com | sudo sh
# Allow current user to run docker without sudo
sudo usermod -aG docker "$USER"
echo -e "${YELLOW} ⚠ Docker group added. You may need to log out and back in for it to take effect.${NC}"
echo -e "${YELLOW} If 'docker compose' fails below, run: newgrp docker${NC}"
else
echo -e "${GREEN} ✓ docker$(docker --version | grep -oP ' \d+\.\d+\.\d+' | head -1)${NC}"
fi
# Docker Compose plugin check (comes with Docker Engine ≥ 20.10)
if ! docker compose version &>/dev/null 2>&1; then
echo -e "${RED} docker compose plugin not found. Installing…${NC}"
sudo apt-get install -y -qq docker-compose-plugin
else
echo -e "${GREEN} ✓ docker compose $(docker compose version --short 2>/dev/null || true)${NC}"
fi
echo -e "${GREEN}✓ Dependencies ready${NC}"
}
install_deps
if [ -f .env ]; then
echo -e "${YELLOW}Warning: .env already exists.${NC}"
read -rp "Overwrite? [y/N] " yn
[[ "$yn" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
# --- Node identity ---
echo ""
echo "Unique node ID — no spaces (e.g. node-ossining, node-002)"
read -rp "NODE_ID: " NODE_ID
while [[ ! "$NODE_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; do
echo " Use letters, numbers, dashes, underscores only."
read -rp "NODE_ID: " NODE_ID
done
echo ""
read -rp "Node display name [$NODE_ID]: " NODE_NAME
NODE_NAME="${NODE_NAME:-$NODE_ID}"
# --- GPS ---
echo ""
echo "GPS coordinates (decimal degrees — used for the map)"
read -rp "Latitude [0.0]: " NODE_LAT; NODE_LAT="${NODE_LAT:-0.0}"
read -rp "Longitude [0.0]: " NODE_LON; NODE_LON="${NODE_LON:-0.0}"
# --- C2 server ---
echo ""
echo "C2 server — hostname or IP of the machine running the server stack"
read -rp "C2 server host: " C2_HOST; C2_HOST="${C2_HOST:-localhost}"
read -rp "C2 API port [8888]: " C2_PORT; C2_PORT="${C2_PORT:-8888}"
# --- MQTT ---
echo ""
echo "MQTT credentials (must match MQTT_NODE_USER/PASS in the server .env)"
read -rp "MQTT port [1883]: " MQTT_PORT; MQTT_PORT="${MQTT_PORT:-1883}"
read -rp "MQTT username [drb-node]: " MQTT_USER; MQTT_USER="${MQTT_USER:-drb-node}"
read -rsp "MQTT password: " MQTT_PASS; echo ""; MQTT_PASS="${MQTT_PASS:-change-me-node}"
# --- Icecast ---
echo ""
echo "Icecast passwords (local container)"
read -rsp "Source password [hackme]: " ICECAST_SOURCE; echo ""; ICECAST_SOURCE="${ICECAST_SOURCE:-hackme}"
read -rsp "Admin password [admin]: " ICECAST_ADMIN; echo ""; ICECAST_ADMIN="${ICECAST_ADMIN:-admin}"
# --- Write .env ---
cat > .env <<EOF
# Node Identity
NODE_ID=${NODE_ID}
NODE_NAME="${NODE_NAME}"
NODE_LAT=${NODE_LAT}
NODE_LON=${NODE_LON}
# MQTT — point to your C2 server
MQTT_BROKER=${C2_HOST}
MQTT_PORT=${MQTT_PORT}
MQTT_USER=${MQTT_USER}
MQTT_PASS=${MQTT_PASS}
# C2 server for audio upload
C2_URL=http://${C2_HOST}:${C2_PORT}
# API key is provisioned automatically via MQTT after admin approves the node
# Icecast (local container — usually no need to change)
ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE}
ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN}
ICECAST_HOST=localhost
ICECAST_PORT=8000
ICECAST_MOUNT=/radio
# OP25 container (usually no need to change)
OP25_API_URL=http://localhost:8001
OP25_TERMINAL_URL=http://localhost:8081
EOF
echo ""
echo -e "${GREEN}✓ .env written for node '${NODE_ID}'${NC}"
echo ""
read -rp "Build and start now? [Y/n] " start
if [[ ! "$start" =~ ^[Nn]$ ]]; then
echo ""
echo "Building images (op25 takes ~10 min on first run)…"
docker compose build
docker compose up -d
echo ""
echo -e "${GREEN}✓ Node '${NODE_ID}' started.${NC}"
echo " → Check the dashboard — it will appear as pending approval."
else
echo "Run 'make up' when ready."
fi