Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 52f31bbcc0 Wire AIS end to end: AIS-catcher support in secondary-sdr container
CI / lint (push) Successful in 5s
CI / lint (pull_request) Successful in 5s
CI / test (push) Successful in 38s
CI / test (pull_request) Successful in 38s
node-26#9. decoder_control.py's AIS mode now actually launches AIS-catcher
instead of 400ing: a background thread reads its stdout JSON stream
(one message per line) and keeps a live in-memory snapshot keyed by mmsi,
since AIS-catcher streams rather than writing a periodic file the way
dump1090's --write-json does. Messages are merged onto the existing entry
per mmsi rather than replacing it, since AIS-catcher emits static data
(name) and position reports (lat/lon) as separate message types — a naive
overwrite would blank the name back to null on every position-only update
(caught by a standalone unit check against a fake stdout stream before
this fix, not by pytest — this container has no test suite yet).

edge-node's telemetry_uplink_loop now posts non-empty vessel snapshots to
the new /telemetry/ais the same way it already does for aircraft.

UNVERIFIED against real hardware/binary in this session, same caveat as
the ADS-B commit — AIS-catcher's JSON field names are believed correct
from its docs, not confirmed against a real capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:30 -04:00
Logan CusanoandClaude Sonnet 5 b2e3804dc3 Wire ADS-B end to end: secondary-sdr container running dump1090
node-26#9. New secondary-sdr-container claims the node's SECOND physical
SDR (RTL-SDR index 1 — op25 always claims index 0; no serial-based binding
yet, same gap op25 itself has). Its control API (start/stop/status/data,
mirroring op25_controller.py) launches dump1090 in adsb mode and exposes
the decoded aircraft.json snapshot; AIS mode 400s until it's wired next.

edge-node: on_config_push starts/stops it when secondary_sdr_mode changes,
lifespan resumes it after a restart if already configured, and a new
telemetry_uplink_loop polls its /secondary/data every 10s and POSTs
non-empty snapshots to C2's new /telemetry/adsb (same bearer-key pattern
call_recorder.py already uses for audio upload).

UNVERIFIED: this container has not been built or run against real hardware
in this session (sandboxed authoring machine, no docker) — dump1090's
--write-json field names are believed correct from its docs but not
confirmed against a real capture. Build + hardware smoke test before this
reaches a real node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:24 -04:00
Logan CusanoandClaude Sonnet 5 8f5fca8757 Add second-SDR plumbing: secondary_sdr_mode config + hardware reporting
Adds a per-node secondary_sdr_mode config field (none|adsb|ais|op25_2),
applied the same way as hardware_preset/ppm_override so it survives system
reassignment. op25-container gets a GET /devices endpoint that counts
connected SDRs via lsusb; the edge-node checkin now reports sdr_count and
secondary_sdr_mode up to the server, the first node-initiated hardware
report (everything else was C2 pushing config down). Tracked as node-26#9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:17 -04:00
logan a53000a092 ci: publish node images to a public registry on a version tag, DISABLED (#8)
CI / lint (push) Successful in 6s
CI / test (push) Successful in 37s
2026-09-06 23:49:02 -04:00
Logan CusanoandClaude Sonnet 5 bee9e173e6 ci: workflow to publish node images to a public registry on a version tag (DISABLED)
CI / lint (pull_request) Successful in 6s
CI / lint (push) Successful in 7s
CI / test (pull_request) Successful in 37s
CI / test (push) Successful in 39s
git.vpn.cusano.net is now behind REQUIRE_SIGNIN_VIEW (INCIDENT-2026-09-06),
so a fresh Pi can no longer anonymously `docker pull` the node images or
fetch install.sh. Plan (owner, option 3): on a `v*` tag, build the three
node images (edge-node, icecast, op25-client, arm64) and push them to a
PUBLIC registry; source and the private Gitea registry stay walled.

This workflow is wired but INERT — the job is gated on
`vars.NODE_PUBLIC_PUBLISH == 'true'`, which is unset. It triggers on tags
and skips. Enabling is three repo variables + two secrets, documented in the
file header; no code change. Reuses the existing Gitea buildcache refs so
op25 doesn't recompile from scratch.

Not turning it on now — still building the core.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:14:40 -04:00
24 changed files with 1242 additions and 179 deletions
+7
View File
@@ -111,6 +111,13 @@ OP25_TERMINAL_URL=http://localhost:8081
# for local development off a real node; leave false everywhere else.
OP25_DEBUG_EXPOSE=false
# Secondary SDR container (node-26#9) — only matters if a second physical SDR
# is present and secondary_sdr_mode is set to adsb|ais via the edge dashboard
# or C2. Usually no need to change.
SECONDARY_SDR_API_URL=http://localhost:8002
# Same caveat as OP25_DEBUG_EXPOSE — debugging aid only, leave false.
SECONDARY_SDR_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
+103
View File
@@ -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
+6 -1
View File
@@ -1,7 +1,12 @@
.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:
@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.
# Requires: docker compose up (or at least the edge-node image built).
+24 -13
View File
@@ -135,21 +135,32 @@ Client/
## 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
# 1. Copy env template
cp .env.example .env
# 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
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>
```
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`)
@@ -167,7 +178,7 @@ 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_PORT` | No | `8000` | Icecast HTTP port |
| `ICECAST_MOUNT` | No | `/radio` | Icecast mount point |
| `ICECAST_SOURCE_PASSWORD` | **Yes** | none | Icecast source password. No default — the container refuses to start without it. `setup.sh` generates one; otherwise `openssl rand -base64 24` |
| `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` | **Yes** | none | Icecast admin password. Same rules |
| `OP25_API_URL` | No | `http://localhost:8001` | OP25 container HTTP API |
| `OP25_TERMINAL_URL` | No | `http://localhost:8081` | OP25 HTTP terminal (live talkgroup metadata) |
+15
View File
@@ -32,6 +32,21 @@ services:
depends_on:
- icecast
# Claims the node's SECOND physical SDR (op25 always claims the first).
# Only useful if secondary_sdr_mode is set to adsb|ais via the edge-node
# config; otherwise it just sits idle answering /secondary/status. See
# node-26#9. Same network/device access as op25 for the same reason: it
# needs the raw USB device, not a virtualized one.
secondary-sdr:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest
build: ./secondary-sdr-container
restart: unless-stopped
privileged: true
network_mode: host
env_file: .env
volumes:
- /dev:/dev
edge-node:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest
build: ./drb-edge-node
+3
View File
@@ -136,6 +136,9 @@ class Settings(BaseSettings):
op25_api_url: str = "http://localhost:8001"
op25_terminal_url: str = "http://localhost:8081"
# Secondary SDR container (node-26#9) — ADS-B / AIS on a second SDR
secondary_sdr_api_url: str = "http://localhost:8002"
# Paths (volume mounts)
config_path: str = "/configs"
recordings_path: str = "/recordings"
@@ -213,7 +213,9 @@ class MQTTManager:
async def _publish_checkin(self):
from app.internal.discord_radio import radio_bot
from app.internal.config_manager import load_node_config
from app.internal.op25_client import op25_client
config = load_node_config()
devices = await op25_client.devices()
payload = {
"node_id": settings.node_id,
"name": settings.node_name,
@@ -225,7 +227,12 @@ class MQTTManager:
"is_overridden": config.override_system_id is not None and config.node_type != "portable",
"override_system_id": config.override_system_id,
"enforce_override_timeout": config.enforce_override_timeout,
"secondary_sdr_mode": config.secondary_sdr_mode,
}
# Best-effort — the first node-initiated hardware-report field. Omit
# rather than guess if op25's control API is unreachable.
if devices is not None:
payload["sdr_count"] = devices.get("count")
self._publish(self._t_checkin, payload, qos=1)
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
+10
View File
@@ -65,6 +65,16 @@ class OP25Client:
logger.error(f"OP25 status failed: {e}")
return None
async def devices(self) -> Optional[Dict[str, Any]]:
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{self.api_url}/op25/devices")
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"OP25 device enumeration failed: {e}")
return None
async def generate_config(self, config: Dict[str, Any]) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
@@ -0,0 +1,59 @@
import httpx
from typing import Any, Dict, Optional
from app.config import settings
from app.internal.logger import logger
class SecondarySdrClient:
"""Talks to the secondary-sdr-container (node-26#9) over its control API.
Mirrors op25_client.py's shape on purpose — same failure handling (log
and return None/False rather than raise), since this container is
optional and its absence must never break the primary op25 radio path.
"""
def __init__(self):
self.api_url = settings.secondary_sdr_api_url
async def start(self, mode: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{self.api_url}/secondary/start", json={"mode": mode})
r.raise_for_status()
return True
except Exception as e:
logger.error(f"Secondary SDR start (mode={mode!r}) failed: {e}")
return False
async def stop(self) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{self.api_url}/secondary/stop")
r.raise_for_status()
return True
except Exception as e:
logger.error(f"Secondary SDR stop failed: {e}")
return False
async def status(self) -> Optional[Dict[str, Any]]:
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{self.api_url}/secondary/status")
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"Secondary SDR status failed: {e}")
return None
async def data(self) -> Optional[Dict[str, Any]]:
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{self.api_url}/secondary/data")
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"Secondary SDR data fetch failed: {e}")
return None
secondary_sdr_client = SecondarySdrClient()
@@ -0,0 +1,44 @@
import asyncio
import httpx
from app.config import settings
from app.internal import credentials
from app.internal.config_manager import load_node_config
from app.internal.logger import logger
from app.internal.secondary_sdr_client import secondary_sdr_client
# How often the second-SDR decoder's current snapshot is forwarded to C2
# (node-26#9). This is a live-map overlay, not a flight/vessel history, so
# there is no backlog/retry on a missed tick — the next one supersedes it.
UPLINK_INTERVAL_SECONDS = 10
async def _post_snapshot(path: str, body: dict) -> None:
if not settings.c2_url:
return
api_key = credentials.get_api_key()
if not api_key:
return
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{settings.c2_url}{path}", json=body, headers=headers)
r.raise_for_status()
except Exception as e:
logger.debug(f"Telemetry uplink to {path} failed: {e}")
async def telemetry_uplink_loop():
while True:
await asyncio.sleep(UPLINK_INTERVAL_SECONDS)
config = load_node_config()
if config.secondary_sdr_mode not in ("adsb", "ais"):
continue
snapshot = await secondary_sdr_client.data()
if not snapshot:
continue
if config.secondary_sdr_mode == "adsb" and snapshot.get("aircraft"):
await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]})
elif config.secondary_sdr_mode == "ais" and snapshot.get("vessels"):
await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]})
+18
View File
@@ -224,6 +224,7 @@ async def on_config_push(payload: dict):
hardware_preset = payload.pop("hardware_preset", None)
ppm_override = payload.pop("ppm_override", None)
node_type = payload.pop("node_type", None)
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None)
enforce_override_timeout = payload.pop("enforce_override_timeout", None)
try:
config = SystemConfig(**payload)
@@ -243,6 +244,8 @@ async def on_config_push(payload: dict):
node_cfg.ppm_override = float(ppm_override)
if node_type is not None:
node_cfg.node_type = node_type
if secondary_sdr_mode is not None:
node_cfg.secondary_sdr_mode = secondary_sdr_mode
if enforce_override_timeout is not None:
node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
save_node_config(node_cfg)
@@ -257,6 +260,13 @@ async def on_config_push(payload: dict):
await op25_client.start()
logger.info(f"Config push applied: {config.name}")
if secondary_sdr_mode is not None:
from app.internal.secondary_sdr_client import secondary_sdr_client
if secondary_sdr_mode in ("adsb", "ais"):
await secondary_sdr_client.start(secondary_sdr_mode)
else:
await secondary_sdr_client.stop()
# ---------------------------------------------------------------------------
# App lifecycle
@@ -312,12 +322,20 @@ async def lifespan(app: FastAPI):
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
await asyncio.sleep(3)
if node_cfg.secondary_sdr_mode in ("adsb", "ais"):
from app.internal.secondary_sdr_client import secondary_sdr_client
logger.info(f"Resuming secondary SDR (mode={node_cfg.secondary_sdr_mode!r}) after restart.")
await secondary_sdr_client.start(node_cfg.secondary_sdr_mode)
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
from app.internal.telemetry_uplink import telemetry_uplink_loop
telemetry_task = asyncio.create_task(telemetry_uplink_loop())
yield # --- app running ---
logger.info("Edge node shutting down.")
heartbeat_task.cancel()
telemetry_task.cancel()
await metadata_watcher.stop()
await call_recorder.stop()
await radio_bot.stop()
+1
View File
@@ -34,6 +34,7 @@ class NodeConfig(BaseModel):
hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None
node_type: str = "fixed" # fixed or portable
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
enforce_override_timeout: bool = True
override_system_id: Optional[str] = None
override_config: Optional[SystemConfig] = None
+538
View File
@@ -0,0 +1,538 @@
#!/usr/bin/env bash
#
# DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64).
#
# curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
# | sudo bash -s -- --token DRB-xxxx --node-id node-003 \
# --c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
#
# (fetch install.sh from the same tag it installs; use raw/branch/main with
# --track-main for the owner's own always-latest nodes)
#
# Hosting, the pinned ref, and setup.sh's retirement are settled (owner
# decisions, 2026-09-06). One standing hazard remains — D3 below.
#
# What it does, in order:
# 1. Preflight (root, arch, apt, SDR present)
# 2. Install Docker + compose plugin + git + curl + jq
# 3. Clone logan/node-26 at a PINNED ref into $INSTALL_DIR
# 4. Write .env (non-interactive from flags/env, interactive fallback)
# 5. Enroll with C2 (POST /nodes/enroll) and poll for the api_key
# 6. docker compose pull && up -d (prebuilt; --build opts into the ~1h build)
# 7. Print the approval step
#
# It does NOT set up WireGuard. WireGuard-per-node was evaluated and rejected
# (Server/MQTT-PUBLIC-AUTH-PLAN.md, Server/infra/main.tf:67-73). A field node
# reaches production over the public internet only:
# https://api.<domain> enrollment + /upload (Caddy, real TLS)
# mqtt.<domain>:8883 MQTT over TLS, username=NODE_ID password=api_key
# The two stale "nodes reach it via WireGuard" comments in
# Server/docker-compose.prod.yml:6 and Server/infra/main.tf:69 are leftovers.
#
# ---------------------------------------------------------------------------
# SETTLED (owner decisions, 2026-09-06 — node-26#4)
#
# D1 HOSTING. git.vpn.cusano.net is PUBLIC — it is a CNAME to
# cusano-net.duckdns.org (71.117.95.129) with a real Let's Encrypt cert,
# resolvable from any public resolver. install.sh, `git clone` and
# `docker compose pull` all work from a customer's Pi with no VPN.
# Caveat, not a blocker: that IP is a dynamic-DNS record on the owner's
# home uplink, so it is a single point of failure and a bandwidth limit
# — fine for the beachhead, revisit before scaling node count.
#
# D2 PIN. node-26 gets a `v1` tag (owner cuts it — see the git command in
# the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main`
# stays as an opt-in for the owner's own nodes.
#
# STANDING HAZARD
#
# D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and
# ./op25-container/app OVER the image's /app. So even in prebuilt mode
# the running Python is the CLONED REF's code against the pulled image's
# dependencies. `v1` == the commit CI built the current :latest/:stable
# from, so today they match — but the moment `v1` and the image tags
# diverge this silently mixes them. Fix: drop those two mounts from the
# prod compose path, or always retag images from the same ref as `v1`.
# ---------------------------------------------------------------------------
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
DEFAULT_REF="v1" # D2
DEFAULT_REPO_URL="https://git.vpn.cusano.net/logan/node-26.git" # D1 (public)
DEFAULT_INSTALL_DIR="/opt/drb/node-26"
REPO_URL="${DRB_REPO_URL:-$DEFAULT_REPO_URL}"
REF="${DRB_REF:-$DEFAULT_REF}"
INSTALL_DIR="${DRB_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
NODE_ID="${DRB_NODE_ID:-}"
NODE_NAME="${DRB_NODE_NAME:-}"
NODE_LAT="${DRB_NODE_LAT:-}"
NODE_LON="${DRB_NODE_LON:-}"
C2_URL="${DRB_C2_URL:-}"
MQTT_BROKER="${DRB_MQTT_BROKER:-}"
MQTT_PORT="${DRB_MQTT_PORT:-8883}"
MQTT_TLS="${DRB_MQTT_TLS:-true}"
ENROLLMENT_TOKEN="${DRB_ENROLLMENT_TOKEN:-}"
DASHBOARD_USER="${DRB_DASHBOARD_USERNAME:-admin}"
DASHBOARD_PASS="${DRB_DASHBOARD_PASSWORD:-}"
REGISTRY="${DRB_IMAGE_REGISTRY:-git.vpn.cusano.net}" # D1 (public host)
DOCKER_ORG="${DRB_DOCKER_ORG:-logan}"
DOCKER_REPO="${DRB_DOCKER_REPO:-node-26}"
REGISTRY_USER="${DRB_REGISTRY_USER:-}"
REGISTRY_PASS="${DRB_REGISTRY_PASS:-}"
DO_BUILD=0 # 0 = pull prebuilt images (default), 1 = build on the Pi (~1h for op25)
DO_START=1
ASSUME_YES=0
ENROLL_WAIT="${DRB_ENROLL_WAIT:-0}" # seconds to block waiting for admin approval; 0 = don't block
C='\033[0;36m'; G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; N='\033[0m'
say() { printf "${C}==>${N} %s\n" "$*"; }
ok() { printf "${G} ok${N} %s\n" "$*"; }
warn() { printf "${Y} !!${N} %s\n" "$*" >&2; }
die() { printf "${R}error:${N} %s\n" "$*" >&2; exit 1; }
usage() {
cat <<'USAGE'
Usage: install.sh [options]
--token TOKEN Enrollment token (Settings -> Nodes -> New token)
--node-id ID Unique node id, e.g. node-003
--name NAME Display name (default: node id)
--lat N --lon N Decimal degrees for the map
--c2-url URL e.g. https://api.drb.example.net
--mqtt-broker HOST e.g. mqtt.drb.example.net
--mqtt-port N default 8883
--no-tls plaintext MQTT (LAN/dev brokers only)
--dashboard-pass PW local dashboard password (generated if omitted)
--ref REF git ref to install (default: v1)
--track-main install main HEAD instead of the v1 tag
--dir PATH install location (default /opt/drb/node-26)
--build build images locally instead of pulling (~1h for op25)
--no-start configure and enroll, but do not start containers
--wait-approval SEC block up to SEC seconds polling for admin approval
-y, --yes never prompt; fail instead of asking
Every option also has an env var: DRB_NODE_ID, DRB_C2_URL, DRB_ENROLLMENT_TOKEN,
DRB_MQTT_BROKER, DRB_REF, DRB_INSTALL_DIR, DRB_REGISTRY_USER/PASS, ...
Secrets are read from the environment or prompted on the TTY, never from a pipe.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
--node-id) NODE_ID="$2"; shift 2 ;;
--name) NODE_NAME="$2"; shift 2 ;;
--lat) NODE_LAT="$2"; shift 2 ;;
--lon) NODE_LON="$2"; shift 2 ;;
--c2-url) C2_URL="$2"; shift 2 ;;
--mqtt-broker) MQTT_BROKER="$2"; shift 2 ;;
--mqtt-port) MQTT_PORT="$2"; shift 2 ;;
--no-tls) MQTT_TLS=false; [ "$MQTT_PORT" = 8883 ] && MQTT_PORT=1883; shift ;;
--dashboard-pass) DASHBOARD_PASS="$2"; shift 2 ;;
--ref) REF="$2"; shift 2 ;;
--track-main) REF="main"; shift ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--build) DO_BUILD=1; shift ;;
--no-start) DO_START=0; shift ;;
--wait-approval) ENROLL_WAIT="$2"; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
# Prompts must come from the terminal, not from the `curl |` pipe on stdin.
ask() { # ask VAR "prompt" "default"
local __v="$1" __p="$2" __d="${3:-}" __r=""
if [ "$ASSUME_YES" = 1 ] || [ ! -r /dev/tty ]; then
[ -n "$__d" ] || die "$__p is required (non-interactive: pass the flag or env var)"
printf -v "$__v" '%s' "$__d"; return
fi
read -rp "$__p${__d:+ [$__d]}: " __r </dev/tty
printf -v "$__v" '%s' "${__r:-$__d}"
}
ask_secret() {
local __v="$1" __p="$2" __r=""
if [ "$ASSUME_YES" = 1 ] || [ ! -r /dev/tty ]; then printf -v "$__v" '%s' ''; return; fi
read -rsp "$__p: " __r </dev/tty; echo >/dev/tty
printf -v "$__v" '%s' "$__r"
}
genpw() { head -c 24 /dev/urandom | base64 | tr -d '\n=' ; }
# ── 1. Preflight ────────────────────────────────────────────────────────────
say "Preflight"
[ "$(id -u)" -eq 0 ] || die "run as root: curl -fsSL <url> | sudo bash -s -- ..."
command -v apt-get >/dev/null || die "apt-get not found — this script targets Raspberry Pi OS / Debian"
ARCH="$(dpkg --print-architecture)"
case "$ARCH" in
arm64|aarch64) ok "arch $ARCH" ;;
*) warn "arch is $ARCH — CI only builds linux/arm64 images. Prebuilt pull will fail; use --build." ;;
esac
ok "running as root"
# Not fatal: the dongle can be plugged in after install.
if command -v lsusb >/dev/null 2>&1 && lsusb | grep -qiE 'rtl2838|realtek.*283[28]|sdr'; then
ok "SDR dongle detected on USB"
else
warn "no RTL-SDR dongle detected on USB — plug one in before expecting audio"
fi
# ── 2. Dependencies ─────────────────────────────────────────────────────────
say "Installing dependencies"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git curl ca-certificates jq usbutils openssl >/dev/null
ok "git curl jq openssl"
if ! command -v docker >/dev/null 2>&1; then
say "Installing Docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
fi
docker --version >/dev/null || die "docker install failed"
ok "$(docker --version)"
if ! docker compose version >/dev/null 2>&1; then
apt-get install -y -qq docker-compose-plugin >/dev/null
fi
docker compose version >/dev/null 2>&1 || die "docker compose plugin missing"
ok "$(docker compose version --short 2>/dev/null || echo 'compose plugin')"
systemctl enable --now docker >/dev/null 2>&1 || true
# The docker group only matters for the human who logs in LATER — this script
# is already root, so nothing below needs a re-login. setup.sh's bug (add the
# group, then immediately run compose in the same unprivileged shell) does not
# apply here.
TARGET_USER="${SUDO_USER:-}"
if [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != root ]; then
usermod -aG docker "$TARGET_USER" || true
ok "added '$TARGET_USER' to the docker group (takes effect at their next login)"
fi
# ── 3. Fetch the repo at a pinned ref ───────────────────────────────────────
say "Fetching node-26 @ ${REF}"
mkdir -p "$(dirname "$INSTALL_DIR")"
if [ -d "$INSTALL_DIR/.git" ]; then
ok "existing install at $INSTALL_DIR — updating in place (.env is preserved)"
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
git -C "$INSTALL_DIR" fetch --tags --prune origin
else
git clone --no-checkout "$REPO_URL" "$INSTALL_DIR"
fi
git -C "$INSTALL_DIR" -c advice.detachedHead=false checkout --force "$REF"
RESOLVED_SHA="$(git -C "$INSTALL_DIR" rev-parse HEAD)"
ok "checked out $REF ($RESOLVED_SHA)"
[ "$REF" = main ] && warn "tracking main — two nodes installed on different days run different software"
cd "$INSTALL_DIR"
mkdir -p configs recordings
chmod 700 configs
# ── 4. Configuration ────────────────────────────────────────────────────────
say "Configuring"
if [ -f .env ]; then
ok ".env already present — keeping it (delete it to reconfigure)"
# Re-read the three values section 5 needs. Not `source .env` — that would
# execute whatever is in the file.
envget() { grep -E "^$1=" .env | head -1 | cut -d= -f2- | tr -d '"'; }
NODE_ID="$(envget NODE_ID)"
C2_URL="${C2_URL:-$(envget C2_URL)}"; C2_URL="${C2_URL%/}"
NODE_NAME="${NODE_NAME:-$(envget NODE_NAME)}"
NODE_LAT="${NODE_LAT:-$(envget NODE_LAT)}"
NODE_LON="${NODE_LON:-$(envget NODE_LON)}"
DASHBOARD_USER="$(envget DASHBOARD_USERNAME)"
[ -n "$NODE_ID" ] || die ".env exists but has no NODE_ID — fix or delete it"
else
[ -n "$NODE_ID" ] || ask NODE_ID "Node ID (e.g. node-003)"
[[ "$NODE_ID" =~ ^[A-Za-z0-9_-]+$ ]] || die "NODE_ID must be letters/numbers/dash/underscore only"
[ -n "$NODE_NAME" ] || ask NODE_NAME "Display name" "$NODE_ID"
[ -n "$NODE_LAT" ] || ask NODE_LAT "Latitude" "0.0"
[ -n "$NODE_LON" ] || ask NODE_LON "Longitude" "0.0"
[ -n "$C2_URL" ] || ask C2_URL "C2 API base URL (https://api.<domain>)"
C2_URL="${C2_URL%/}"
[ -n "$MQTT_BROKER" ] || ask MQTT_BROKER "MQTT broker host (mqtt.<domain>)"
if [ -z "$DASHBOARD_PASS" ]; then
ask_secret DASHBOARD_PASS "Local dashboard password (Enter to generate)"
[ -n "$DASHBOARD_PASS" ] || { DASHBOARD_PASS="$(genpw)"; GENERATED_DASH=1; }
fi
ICE_SRC="$(genpw)"; ICE_ADM="$(genpw)"
umask 077
cat > .env <<EOF
# Written by install.sh on $(date -Is) from ref ${RESOLVED_SHA}
NODE_ID=${NODE_ID}
NODE_NAME="${NODE_NAME}"
NODE_LAT=${NODE_LAT}
NODE_LON=${NODE_LON}
# MQTT — post-cutover auth. There is NO shared node login: the node
# authenticates as username=NODE_ID, password=<its C2-issued api_key>, which
# section 5 below fetches into configs/credentials.json. Deliberately no
# MQTT_USER/MQTT_PASS here; a dynsec broker rejects them.
MQTT_BROKER=${MQTT_BROKER}
MQTT_PORT=${MQTT_PORT}
MQTT_TLS=${MQTT_TLS}
C2_URL=${C2_URL}
ICECAST_SOURCE_PASSWORD=${ICE_SRC}
ICECAST_ADMIN_PASSWORD=${ICE_ADM}
ICECAST_HOST=localhost
ICECAST_PORT=8000
ICECAST_MOUNT=/radio
DASHBOARD_USERNAME=${DASHBOARD_USER}
DASHBOARD_PASSWORD=${DASHBOARD_PASS}
PULSE_SOURCE=drb_sink.monitor
OP25_API_URL=http://localhost:8001
OP25_TERMINAL_URL=http://localhost:8081
OP25_DEBUG_EXPOSE=false
IMAGE_REGISTRY=${REGISTRY}
DOCKER_ORG=${DOCKER_ORG}
DOCKER_REPO=${DOCKER_REPO}
EOF
umask 022
chmod 600 .env
[ -n "$TARGET_USER" ] && chown "$TARGET_USER" .env 2>/dev/null || true
ok ".env written for '$NODE_ID'"
fi
# ── 5. Enrollment ───────────────────────────────────────────────────────────
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
# exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly),
# so without this section a fresh node can never obtain an api_key against a
# dynsec broker: MQTT needs the key, and the legacy key-over-MQTT delivery
# needs MQTT. Doing it here breaks that loop.
#
# Two server endpoints, and the exact response shapes verified against
# enrollment.py @ v1:
#
# POST /nodes/enroll (X-Enrollment-Token)
# 200 -> {node_id, pickup_secret, approval_status}
# 403 -> node_id is ALREADY APPROVED. The CRITICAL GUARD in enrollment.py
# refuses to mint a fresh pickup_secret off the shared fleet token.
# So we must only ever POST this for a node we have not enrolled
# from this machine before — i.e. when configs/pickup_secret is
# absent. Re-running the installer must NOT re-POST here.
# 401 bad/revoked token · 400 missing node_id · 429 rate limited
#
# GET /nodes/{id}/credentials (X-Pickup-Secret)
# Always HTTP 200 with {approval_status, api_key} unless the secret or
# node is bad. api_key is null until an admin approves the node in the UI
# (nodes.py approve_node() mints node_keys/{id}.api_key synchronously in
# the same call — approve is enough; assigning a system is independent and
# NOT required for a key). This endpoint has NO already-approved guard, so
# it is the correct — and only working — re-run path after approval.
# 401 -> missing/invalid/rotated pickup secret
# 404 -> node unknown to C2 (deleted server-side, or never enrolled)
CREDS="$INSTALL_DIR/configs/credentials.json"
PICKUP_FILE="$INSTALL_DIR/configs/pickup_secret"
# GET /nodes/{id}/credentials. Sets CRED_HTTP + CRED_BODY (no -f: we need the
# body and status on a 4xx). One implementation so first-run and re-run agree.
creds_pickup() { # creds_pickup PICKUP_SECRET
local _tmp; _tmp="$(mktemp)"
CRED_HTTP="$(curl -sS -o "$_tmp" -w '%{http_code}' \
"$C2_URL/nodes/$NODE_ID/credentials" -H "X-Pickup-Secret: $1" 2>/dev/null || echo 000)"
CRED_BODY="$(cat "$_tmp" 2>/dev/null || true)"
rm -f "$_tmp"
}
cred_field() { printf '%s' "${CRED_BODY:-}" | jq -r "$1 // empty" 2>/dev/null || true; }
write_creds() { # write_creds API_KEY
umask 077; jq -n --arg k "$1" '{api_key:$k}' > "$CREDS"; umask 022
ok "api_key received and written to configs/credentials.json"
}
say_pending() { # say_pending APPROVAL_STATUS — not an error: node is enrolled, key not minted yet
warn "not approved yet — C2 reports approval_status=${1:-pending}, no api_key minted."
warn "an admin must, at <app-url>/settings/nodes : Approve '$NODE_ID' (assigning a system is separate)."
warn "then re-run this installer — it reuses configs/pickup_secret — or fetch it directly:"
warn " curl -fsS $C2_URL/nodes/$NODE_ID/credentials -H \"X-Pickup-Secret: \$(cat $PICKUP_FILE)\" | jq -r .api_key"
}
# Poll creds_pickup for up to ENROLL_WAIT seconds while still pending. Result
# left in CRED_HTTP/CRED_BODY. --wait-approval is what would have avoided the
# original prod bug; the default (0) does not block, so the re-run path below
# must stand on its own.
wait_for_key() { # wait_for_key PICKUP_SECRET
[ "${ENROLL_WAIT:-0}" -gt 0 ] || return 0
local _end; _end=$(( $(date +%s) + ENROLL_WAIT ))
say "Waiting up to ${ENROLL_WAIT}s for an admin to approve '$NODE_ID'"
while [ "$(date +%s)" -lt "$_end" ]; do
sleep 10
creds_pickup "$1"
[ "$CRED_HTTP" = 200 ] || return 0
[ -z "$(cred_field '.api_key')" ] || return 0
done
}
do_fresh_enroll() {
say "Enrolling '$NODE_ID' with $C2_URL"
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token"
[ -n "$ENROLLMENT_TOKEN" ] || die "no enrollment token — mint one at Settings -> Nodes, then re-run with --token"
local BODY RESP PICKUP STATUS KEY
BODY="$(jq -nc --arg id "$NODE_ID" --arg n "${NODE_NAME:-$NODE_ID}" \
--argjson lat "${NODE_LAT:-0}" --argjson lon "${NODE_LON:-0}" \
'{node_id:$id,name:$n,lat:$lat,lon:$lon}')"
# Token goes in a header from a shell variable — never on a process command
# line, never echoed.
if ! RESP="$(curl -fsS -X POST "$C2_URL/nodes/enroll" \
-H "Content-Type: application/json" \
-H "X-Enrollment-Token: $ENROLLMENT_TOKEN" \
--data "$BODY" 2>&1)"; then
case "$RESP" in
*403*) die "enroll refused (403): node '$NODE_ID' is already approved on C2, and
this machine has no configs/pickup_secret to collect its key with. A token
alone cannot re-issue an approved node's key (enrollment.py CRITICAL GUARD).
Recover by restoring this node's original configs/pickup_secret and re-running,
or have an admin Reissue key (Settings -> Nodes) and write the key into
$CREDS by hand (see node-26#6)." ;;
*401*) die "enroll refused (401): enrollment token missing/invalid/revoked —
mint a fresh one at Settings -> Nodes and re-run with --token." ;;
*429*) die "enroll refused (429): rate limited. Wait ~1 minute, then re-run." ;;
*) die "enroll failed: $RESP" ;;
esac
fi
PICKUP="$(printf '%s' "$RESP" | jq -r '.pickup_secret')"
STATUS="$(printf '%s' "$RESP" | jq -r '.approval_status')"
[ -n "$PICKUP" ] && [ "$PICKUP" != null ] || die "enroll returned no pickup_secret: $RESP"
umask 077; printf '%s' "$PICKUP" > "$PICKUP_FILE"; umask 022
ok "enrolled — approval_status=$STATUS (pickup secret saved to configs/pickup_secret)"
creds_pickup "$PICKUP"
[ "$CRED_HTTP" = 200 ] || die "post-enroll credential fetch failed (HTTP $CRED_HTTP): ${CRED_BODY:-<no body>}"
KEY="$(cred_field '.api_key')"
if [ -z "$KEY" ]; then
wait_for_key "$PICKUP" || true
KEY="$(cred_field '.api_key')"
fi
if [ -n "$KEY" ]; then
write_creds "$KEY"
else
say_pending "$(cred_field '.approval_status')"
fi
}
if [ -s "$CREDS" ] && jq -e '.api_key // empty' "$CREDS" >/dev/null 2>&1; then
say "Enrollment"
ok "api_key already on disk — skipping enrollment"
elif [ -z "${C2_URL:-}" ]; then
say "Enrollment"
warn "no C2_URL — skipping enrollment"
elif [ -s "$PICKUP_FILE" ]; then
# RE-RUN. This node already enrolled from this machine. Do NOT POST
# /nodes/enroll again — an approved node_id gets 403 there, and the v1
# installer's own "re-run to pick up the key" advice then dead-ends on
# "use Reissue key". The pickup endpoint has no such guard: use it.
say "Enrollment — collecting credentials for '$NODE_ID' (pickup secret from a previous run)"
PICKUP="$(cat "$PICKUP_FILE")"
creds_pickup "$PICKUP"
case "$CRED_HTTP" in
200)
API_KEY="$(cred_field '.api_key')"
if [ -z "$API_KEY" ]; then
wait_for_key "$PICKUP" || true
API_KEY="$(cred_field '.api_key')"
fi
if [ -n "$API_KEY" ]; then
write_creds "$API_KEY"
else
# Still pending. Enrolled and idempotent — next run collects the key.
# Clean exit, fall through to start the stack. NOT a failure.
say_pending "$(cred_field '.approval_status')"
fi
;;
401)
if [ -n "$ENROLLMENT_TOKEN" ]; then
warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere. Re-enrolling with --token."
rm -f "$PICKUP_FILE"
do_fresh_enroll
else
die "saved pickup secret is stale (401) and no --token was given. Re-run with
--token DRB-… to re-enroll (only works while the node is still pending), or
have an admin Reissue key for an approved node and write $CREDS by hand."
fi
;;
404)
if [ -n "$ENROLLMENT_TOKEN" ]; then
warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled. Re-enrolling with --token."
rm -f "$PICKUP_FILE"
do_fresh_enroll
else
die "C2 does not know node '$NODE_ID' (404) and no --token was given.
Re-run with --token DRB-… to enroll it again."
fi
;;
000)
die "could not reach $C2_URL/nodes/$NODE_ID/credentials — check --c2-url and connectivity." ;;
*)
die "credential pickup failed (HTTP $CRED_HTTP): ${CRED_BODY:-<no body>}" ;;
esac
else
say "Enrollment"
do_fresh_enroll
fi
# ── 6. Images + start ───────────────────────────────────────────────────────
if [ "$DO_START" = 1 ]; then
if [ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_PASS" ]; then
printf '%s' "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin >/dev/null
ok "logged in to $REGISTRY"
fi
if [ "$DO_BUILD" = 1 ]; then
say "Building images locally — op25 takes roughly an hour on a Pi"
docker compose build
docker compose up -d
else
say "Pulling prebuilt images from $REGISTRY/$DOCKER_ORG/$DOCKER_REPO"
if ! docker compose pull; then
die "pull failed. $REGISTRY is public, so this is most likely a login
requirement or a transient network error: re-run with DRB_REGISTRY_USER /
DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)."
fi
docker compose up --no-build -d
fi
ok "stack started"
else
say "Skipping start (--no-start). Run: cd $INSTALL_DIR && make up-prebuilt"
fi
# ── 7. What the operator does next ──────────────────────────────────────────
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<EOF
$(printf "${G}Node '%s' installed at %s${N}" "$NODE_ID" "$INSTALL_DIR")
ref ${RESOLVED_SHA}
images $([ "$DO_BUILD" = 1 ] && echo "built locally" || echo "pulled from $REGISTRY")
dashboard http://${IP:-<node-ip>}/ (user: ${DASHBOARD_USER})
logs cd $INSTALL_DIR && docker compose logs -f edge-node
NEXT — an admin must approve this node before it can do anything:
1. Open <app-url>/settings/nodes
2. Approve "$NODE_ID"
3. Assign it a radio system
EOF
if [ "${GENERATED_DASH:-0}" = 1 ]; then
printf "${Y}Generated dashboard password (shown once): %s${N}\n\n" "$DASHBOARD_PASS"
fi
if [ ! -s "$CREDS" ]; then
if [ -s "$PICKUP_FILE" ]; then
printf "${Y}This node has no api_key yet. After an admin approves it, re-run the same\ninstall command — it reuses configs/pickup_secret and will collect the key\n(no --token needed for the re-run).${N}\n\n"
else
printf "${Y}This node has no api_key and no saved pickup secret, so a plain re-run cannot\nfix it. Re-run with --token DRB-… to enroll; or, if the node is already\napproved, have an admin Reissue key and write it into\n%s by hand.${N}\n\n" "$CREDS"
fi
fi
+1 -1
View File
@@ -12,7 +12,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install git pulseaudio pulseaudio-utils liquidsoap -y
apt-get install git pulseaudio pulseaudio-utils liquidsoap usbutils -y
# Install custom PulseAudio system config (enables anonymous access for edge-node)
COPY system.pa /etc/pulse/system.pa
@@ -1,6 +1,7 @@
from fastapi import HTTPException, APIRouter
import subprocess
import os
import re
import signal
import json
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
@@ -69,6 +70,24 @@ def create_op25_router():
async def get_status():
return {"status": "running" if _is_running() else "stopped"}
@router.get("/devices")
async def list_sdr_devices():
"""Enumerate connected SDR-looking USB devices via lsusb.
Same match heuristic as install.sh's one-shot host check — good enough
to answer "is a second SDR plugged in", not a serial-level device
binding (op25's DeviceConfig.args has no serial concept yet either).
"""
devices = []
try:
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
for line in out.splitlines():
if re.search(r"rtl2838|realtek.*283[28]|sdr", line, re.IGNORECASE):
devices.append(line.strip())
except Exception as e:
LOGGER.warning(f"SDR device enumeration failed: {e}")
return {"count": len(devices), "devices": devices}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
+52
View File
@@ -0,0 +1,52 @@
# Secondary-SDR Container — node-26#9
#
# Claims the node's SECOND physical SDR (the first is always op25's). Mode is
# chosen at runtime via the control API, not baked in: dump1090 for ADS-B,
# AIS-catcher for AIS (op25_2 mode is not handled here yet — see node-26#9).
#
# Device claiming is by RTL-SDR index, not serial (op25's DeviceConfig.args
# has no serial concept either — see op25-container/app/models.py). Index 0
# is reserved for op25; this container always addresses index 1. That's a
# real limitation once serial-stable device binding matters (hot-unplug /
# replug can swap indices) — tracked in node-26#9, not fixed here.
#
# UNVERIFIED: this image has not been built or run against real hardware in
# this session (sandboxed authoring machine, no docker). dump1090 and
# AIS-catcher's exact CLI flags below are believed correct from their
# published docs but not confirmed against a real capture — the CTO/QA
# review before this ships to a real node should build and smoke-test it.
FROM python:3.14-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
git build-essential cmake pkg-config \
librtlsdr-dev libusb-1.0-0-dev libssl-dev zlib1g-dev usbutils
# dump1090 (antirez/classic) — ADS-B decoder. --write-json support is a
# long-standing, well-documented feature of this fork.
RUN git clone https://github.com/antirez/dump1090 /opt/dump1090 && \
cd /opt/dump1090 && make
# AIS-catcher — AIS decoder.
RUN git clone https://github.com/jvde-github/AIS-catcher /opt/AIS-catcher && \
cd /opt/AIS-catcher && mkdir build && cd build && cmake .. && make
EXPOSE 8002
VOLUME ["/configs"]
WORKDIR /app
COPY ./app /app
COPY docker-entrypoint.sh /usr/local/bin/
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-entrypoint.sh
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "main.py"]
+20
View File
@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Same rationale as op25-container's OP25_DEBUG_EXPOSE: both containers
# share the host network namespace (network_mode: host), so edge-node
# reaches this control API over localhost regardless of this flag. False
# (default) binds 127.0.0.1; true exposes unauthenticated start/stop to
# the node's LAN and should only ever be set for local development.
secondary_sdr_debug_expose: bool = False
class Config:
env_file = ".env"
settings = Settings()
def bind_host() -> str:
return "0.0.0.0" if settings.secondary_sdr_debug_expose else "127.0.0.1"
@@ -0,0 +1,225 @@
import json
import os
import signal
import subprocess
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# AIS-catcher streams one JSON object per received message on stdout rather
# than writing a periodic snapshot file (dump1090's approach above) — so the
# current-vessel snapshot lives in memory, keyed by mmsi, kept warm by a
# background reader thread for as long as the subprocess we started is
# alive. Unlike the pgid-file state, this does NOT survive this API
# process restarting independently of its subprocess — acceptable since
# nothing here does that today.
_ais_vessels: Dict[str, Dict[str, Any]] = {}
_ais_lock = threading.Lock()
# The node's SECOND SDR, addressed by RTL-SDR index — not serial. op25 always
# claims index 0 (its DeviceConfig.args has no serial concept either, see
# op25-container/app/models.py). No hot-plug re-detection: if the two
# dongles' USB enumeration order changes, this claims the wrong one. Tracked
# as a real gap in node-26#9, not fixed here.
SECONDARY_SDR_DEVICE_INDEX = 1
_PGID_FILE = "/tmp/secondary_sdr.pgid"
_MODE_FILE = "/tmp/secondary_sdr.mode"
ADSB_JSON_DIR = Path("/tmp/adsb")
def _save_state(pgid: int, mode: str) -> None:
Path(_PGID_FILE).write_text(str(pgid))
Path(_MODE_FILE).write_text(mode)
def _read_pgid() -> Optional[int]:
try:
return int(Path(_PGID_FILE).read_text().strip())
except Exception:
return None
def _read_mode() -> Optional[str]:
try:
return Path(_MODE_FILE).read_text().strip()
except Exception:
return None
def is_running() -> bool:
pgid = _read_pgid()
if pgid is None:
return False
try:
os.killpg(pgid, 0)
return True
except OSError:
return False
def _adsb_command() -> List[str]:
ADSB_JSON_DIR.mkdir(parents=True, exist_ok=True)
return [
"/opt/dump1090/dump1090",
"--net",
"--device-index", str(SECONDARY_SDR_DEVICE_INDEX),
"--write-json", str(ADSB_JSON_DIR),
"--write-json-every", "1",
]
def _ais_command() -> List[str]:
return [
"/opt/AIS-catcher/build/AIS-catcher",
"-d", str(SECONDARY_SDR_DEVICE_INDEX),
"-o", "JSON",
]
def _ais_reader(proc: subprocess.Popen) -> None:
"""
Consume AIS-catcher's stdout, one JSON message per line, and keep the
latest report per mmsi. Field names (mmsi/lat/lon/speed/course or
heading/shipname or name) are believed correct from AIS-catcher's
published JSON output docs but UNVERIFIED against a real capture in
this session — same caveat as dump1090's aircraft.json mapping.
Malformed/partial lines (e.g. static-data-only messages with no
position) are skipped rather than raising, since dropping one line must
never kill the reader thread.
"""
if not proc.stdout:
return
for line in proc.stdout:
try:
msg = json.loads(line)
except Exception:
continue
mmsi = msg.get("mmsi")
if not mmsi:
continue
# AIS-catcher emits separate message TYPES per mmsi — static data
# (name, no position) and position reports (lat/lon, no name) arrive
# as distinct lines. Merge onto the existing entry, only overwriting
# a field the new message actually carries, so a position-only
# report doesn't blank out a name learned from an earlier message.
name = (msg.get("shipname") or msg.get("name") or "").strip() or None
heading = msg.get("heading") if msg.get("heading") is not None else msg.get("course")
updates = {
"mmsi": str(mmsi),
"name": name,
"lat": msg.get("lat"),
"lon": msg.get("lon"),
"speed_kt": msg.get("speed"),
"heading_deg": heading,
}
with _ais_lock:
existing = _ais_vessels.get(str(mmsi), {})
for key, value in updates.items():
if value is not None:
existing[key] = value
_ais_vessels[str(mmsi)] = existing
def start(mode: str) -> bool:
if is_running():
stop()
if mode == "adsb":
cmd = _adsb_command()
elif mode == "ais":
cmd = _ais_command()
with _ais_lock:
_ais_vessels.clear()
else:
raise ValueError(f"Unknown secondary SDR mode: {mode!r}")
try:
needs_stdout = mode == "ais"
proc = subprocess.Popen(
cmd,
preexec_fn=os.setsid,
stdout=subprocess.PIPE if needs_stdout else None,
text=True if needs_stdout else None,
bufsize=1 if needs_stdout else -1,
)
if needs_stdout:
threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start()
_save_state(proc.pid, mode)
LOGGER.info(f"Started secondary SDR decoder mode={mode!r} pid={proc.pid}")
return True
except Exception as e:
LOGGER.error(f"Failed to start secondary SDR decoder mode={mode!r}: {e}")
return False
def stop() -> bool:
pgid = _read_pgid()
if pgid is None:
return True
try:
os.killpg(pgid, signal.SIGTERM)
except OSError:
pass
try:
os.remove(_PGID_FILE)
except OSError:
pass
try:
os.remove(_MODE_FILE)
except OSError:
pass
return True
def status() -> Dict[str, Any]:
running = is_running()
return {
"status": "running" if running else "stopped",
"mode": _read_mode() if running else None,
}
def _read_adsb_snapshot() -> List[Dict[str, Any]]:
"""
Map dump1090's aircraft.json (--write-json output) to the server's
telemetry schema. Field names (hex/flight/lat/lon/altitude/speed/track)
match dump1090's long-documented JSON format — UNVERIFIED against a real
capture in this session, see the Dockerfile's caveat.
"""
path = ADSB_JSON_DIR / "aircraft.json"
try:
raw = json.loads(path.read_text())
except Exception:
return []
out = []
for a in raw.get("aircraft", []):
icao = a.get("hex")
if not icao:
continue
out.append({
"icao": icao.upper(),
"callsign": (a.get("flight") or "").strip() or None,
"lat": a.get("lat"),
"lon": a.get("lon"),
"altitude_ft": a.get("altitude"),
"ground_speed_kt": a.get("speed"),
"track_deg": a.get("track"),
})
return out
def data() -> Dict[str, Any]:
mode = _read_mode()
if mode == "adsb":
return {"mode": mode, "aircraft": _read_adsb_snapshot()}
if mode == "ais":
with _ais_lock:
vessels = list(_ais_vessels.values())
return {"mode": mode, "vessels": vessels}
return {"mode": mode, "aircraft": [], "vessels": []}
@@ -0,0 +1,31 @@
import logging
from logging.handlers import RotatingFileHandler
def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2):
debug_log_file = "./secondary-sdr.debug.log"
info_log_file = "./secondary-sdr.log"
logger = logging.getLogger(name)
logger.setLevel(level)
if not logger.hasHandlers():
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count)
debug_file_handler.setLevel(logging.DEBUG)
info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count)
info_file_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
debug_file_handler.setFormatter(formatter)
info_file_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.addHandler(debug_file_handler)
logger.addHandler(info_file_handler)
return logger
+13
View File
@@ -0,0 +1,13 @@
from fastapi import FastAPI
import routers.secondary_controller as secondary_controller
from config import bind_host
app = FastAPI()
app.include_router(secondary_controller.create_secondary_router(), prefix="/secondary")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=bind_host(), port=8002, reload=True)
@@ -0,0 +1,40 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from internal import decoder_control
from internal.logger import create_logger
LOGGER = create_logger(__name__)
class StartBody(BaseModel):
mode: str # adsb | ais
def create_secondary_router():
router = APIRouter()
@router.post("/start")
async def start(body: StartBody):
try:
ok = decoder_control.start(body.mode)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not ok:
raise HTTPException(status_code=500, detail="Failed to start secondary SDR decoder")
return {"status": f"secondary SDR started ({body.mode})"}
@router.post("/stop")
async def stop():
decoder_control.stop()
return {"status": "secondary SDR stopped"}
@router.get("/status")
async def get_status():
return decoder_control.status()
@router.get("/data")
async def get_data():
return decoder_control.data()
return router
@@ -0,0 +1,3 @@
#!/bin/bash
mkdir -p /tmp/adsb
exec "$@"
+3
View File
@@ -0,0 +1,3 @@
uvicorn
fastapi
pydantic-settings
-164
View File
@@ -1,164 +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 ---
# Generated, not defaulted. Icecast binds all interfaces, and the SOURCE
# password is what lets a caller push audio into the stream users listen to as
# live radio -- so a shared default is worse here than a forgotten password.
# Pressing Enter gives you a random one rather than a known one.
gen_password() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 24 | tr -d '
'
else
head -c 24 /dev/urandom | base64 | tr -d '
'
fi
}
echo ""
echo "Icecast passwords (local container). Press Enter to generate a random one."
read -rsp "Source password [generate]: " ICECAST_SOURCE; echo ""
if [ -z "$ICECAST_SOURCE" ]; then ICECAST_SOURCE="$(gen_password)"; echo " generated a random source password"; fi
read -rsp "Admin password [generate]: " ICECAST_ADMIN; echo ""
if [ -z "$ICECAST_ADMIN" ]; then ICECAST_ADMIN="$(gen_password)"; echo " generated a random admin password"; fi
# --- 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