Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53000a092 | ||
|
|
bee9e173e6 | ||
|
|
31e1176c45 | ||
|
|
4f942bd770 | ||
|
|
94c3e2a952 | ||
|
|
5b0048e8db | ||
|
|
0c08275482 | ||
|
|
fb13bb8ae3 | ||
|
|
e6aab7589a | ||
|
|
28266b4441 | ||
|
|
7f1d09c753 | ||
|
|
9d86304b8a | ||
|
|
5ff1089551 | ||
|
|
87633ab50d | ||
|
|
a61a7b2c31 |
+44
-5
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
name: Publish public images
|
||||||
|
|
||||||
|
# Push the three node images to a PUBLIC registry on a version tag, so a fresh
|
||||||
|
# Pi can `docker pull` them without a Gitea login (git.vpn.cusano.net is now
|
||||||
|
# behind REQUIRE_SIGNIN_VIEW — see INCIDENT-2026-09-06). Source + the private
|
||||||
|
# registry stay walled; only the built node images go public.
|
||||||
|
#
|
||||||
|
# ── DISABLED ────────────────────────────────────────────────────────────────
|
||||||
|
# The job is gated on `vars.NODE_PUBLIC_PUBLISH == 'true'`. Until that repo
|
||||||
|
# variable is set the workflow triggers on tags but the job is skipped, so
|
||||||
|
# this file is wired and inert. We're still building the core; flip it on when
|
||||||
|
# self-serve node install is actually needed.
|
||||||
|
#
|
||||||
|
# To enable:
|
||||||
|
# 1. Repo → Settings → Actions → Variables:
|
||||||
|
# NODE_PUBLIC_PUBLISH = true
|
||||||
|
# PUBLIC_REGISTRY = ghcr.io (or docker.io)
|
||||||
|
# PUBLIC_NAMESPACE = <org-or-user> (images land at <ns>/drb-<name>)
|
||||||
|
# 2. Repo → Settings → Actions → Secrets:
|
||||||
|
# PUBLIC_REGISTRY_USER = <push user>
|
||||||
|
# PUBLIC_REGISTRY_TOKEN = <push token / PAT with write:packages>
|
||||||
|
# 3. Re-push a tag (or run this workflow via workflow_dispatch).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-public-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
# Inert until the repo variable is set. Do NOT convert this to `if: false`
|
||||||
|
# — the variable is the switch, no code change needed to go live.
|
||||||
|
if: ${{ vars.NODE_PUBLIC_PUBLISH == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: edge-node
|
||||||
|
context: ./drb-edge-node
|
||||||
|
file: ./drb-edge-node/Dockerfile
|
||||||
|
cache_name: edge-node
|
||||||
|
- name: icecast
|
||||||
|
context: ./icecast
|
||||||
|
file: ./icecast/Dockerfile
|
||||||
|
cache_name: icecast
|
||||||
|
- name: op25-client
|
||||||
|
context: ./op25-container
|
||||||
|
file: ./op25-container/Dockerfile
|
||||||
|
cache_name: op25-client
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # need tags for `git describe`
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."git.vpn.cusano.net"]
|
||||||
|
http = false
|
||||||
|
insecure = false
|
||||||
|
|
||||||
|
# Private Gitea registry — read only, to reuse the existing build cache
|
||||||
|
# (keeps the op25 image off a ~1h from-scratch compile).
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.vpn.cusano.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.BUILD_TOKEN }}
|
||||||
|
|
||||||
|
# Public registry — where the images are pushed.
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.PUBLIC_REGISTRY }}
|
||||||
|
username: ${{ secrets.PUBLIC_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.PUBLIC_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Version
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||||
|
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:${{ steps.meta.outputs.VERSION }}
|
||||||
|
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:latest
|
||||||
|
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ matrix.cache_name }}:buildcache
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -23,7 +24,7 @@ async def fetch_and_cache_systems() -> bool:
|
|||||||
r = await client.get(url, headers=headers)
|
r = await client.get(url, headers=headers)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
systems = r.json()
|
systems = r.json()
|
||||||
|
|
||||||
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_CACHE_FILE.write_text(json.dumps(systems, indent=2))
|
_CACHE_FILE.write_text(json.dumps(systems, indent=2))
|
||||||
logger.info(f"Cached {len(systems)} systems from C2.")
|
logger.info(f"Cached {len(systems)} systems from C2.")
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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,20 +11,30 @@ 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")
|
||||||
async def get_status():
|
async def get_status():
|
||||||
node_cfg = load_node_config()
|
node_cfg = load_node_config()
|
||||||
op25_status = await op25_client.status()
|
op25_status = await op25_client.status()
|
||||||
|
|
||||||
active_tgid = metadata_watcher.current_tgid
|
active_tgid = metadata_watcher.current_tgid
|
||||||
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:
|
||||||
@@ -120,7 +130,7 @@ async def set_override(
|
|||||||
):
|
):
|
||||||
node_cfg = load_node_config()
|
node_cfg = load_node_config()
|
||||||
config = None
|
config = None
|
||||||
|
|
||||||
if system_id:
|
if system_id:
|
||||||
from app.internal.system_cacher import get_cached_system
|
from app.internal.system_cacher import get_cached_system
|
||||||
cached = get_cached_system(system_id)
|
cached = get_cached_system(system_id)
|
||||||
@@ -133,19 +143,19 @@ async def set_override(
|
|||||||
config = SystemConfig(**system_config)
|
config = SystemConfig(**system_config)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(400, "Must specify system_id or system_config.")
|
raise HTTPException(400, "Must specify system_id or system_config.")
|
||||||
|
|
||||||
node_cfg.override_system_id = config.system_id
|
node_cfg.override_system_id = config.system_id
|
||||||
node_cfg.override_config = config
|
node_cfg.override_config = config
|
||||||
save_node_config(node_cfg)
|
save_node_config(node_cfg)
|
||||||
|
|
||||||
from app.main import _generate_op25_config
|
from app.main import _generate_op25_config
|
||||||
if not await _generate_op25_config(config):
|
if not await _generate_op25_config(config):
|
||||||
raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}")
|
raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}")
|
||||||
|
|
||||||
await op25_client.stop()
|
await op25_client.stop()
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
await op25_client.start()
|
await op25_client.start()
|
||||||
|
|
||||||
await mqtt_manager._publish_checkin()
|
await mqtt_manager._publish_checkin()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -155,20 +165,20 @@ async def revert_config():
|
|||||||
node_cfg = load_node_config()
|
node_cfg = load_node_config()
|
||||||
if not node_cfg.override_system_id:
|
if not node_cfg.override_system_id:
|
||||||
return {"ok": True, "message": "No override active."}
|
return {"ok": True, "message": "No override active."}
|
||||||
|
|
||||||
node_cfg.override_system_id = None
|
node_cfg.override_system_id = None
|
||||||
node_cfg.override_config = None
|
node_cfg.override_config = None
|
||||||
save_node_config(node_cfg)
|
save_node_config(node_cfg)
|
||||||
|
|
||||||
if node_cfg.system_config:
|
if node_cfg.system_config:
|
||||||
from app.main import _generate_op25_config
|
from app.main import _generate_op25_config
|
||||||
if not await _generate_op25_config(node_cfg.system_config):
|
if not await _generate_op25_config(node_cfg.system_config):
|
||||||
raise HTTPException(500, "Failed to regenerate original OP25 config.")
|
raise HTTPException(500, "Failed to regenerate original OP25 config.")
|
||||||
|
|
||||||
await op25_client.stop()
|
await op25_client.stop()
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
await op25_client.start()
|
await op25_client.start()
|
||||||
|
|
||||||
await mqtt_manager._publish_checkin()
|
await mqtt_manager._publish_checkin()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -177,10 +187,10 @@ async def revert_config():
|
|||||||
async def ack_override(timeout_minutes: int = Body(1440)):
|
async def ack_override(timeout_minutes: int = Body(1440)):
|
||||||
if not settings.c2_url:
|
if not settings.c2_url:
|
||||||
raise HTTPException(400, "C2_URL not configured.")
|
raise HTTPException(400, "C2_URL not configured.")
|
||||||
|
|
||||||
api_key = credentials.get_api_key()
|
api_key = credentials.get_api_key()
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 }}
|
|
||||||
@@ -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 .
|
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,2 +1,3 @@
|
|||||||
uvicorn
|
uvicorn
|
||||||
fastapi
|
fastapi
|
||||||
|
pydantic-settings
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user