Files
node-26/drb-edge-node/app/config.py
T
Logan Cusano e6aab7589a
Build edge-node / build (push) Failing after 6s
CI / lint (push) Successful in 6s
CI / test (push) Successful in 40s
Build op25 / build (push) Successful in 1m48s
Build icecast / build (push) Successful in 3m16s
Stop shipping "hackme" as the Icecast password
The source password had a `hackme` fallback in five places -- entrypoint.sh,
docker-compose.yml, setup.sh's prompt default, .env.example, edge-node's
config.py -- plus op25-container's os.getenv default and two README rows. Any
node whose operator pressed Enter through setup.sh is running a credential
that is written down in this repo.

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

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

Client suite: 185 passed.

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

Closes logan/node-26#3
2026-08-20 03:12:44 -04:00

167 lines
8.3 KiB
Python

from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# Node identity
node_id: str
node_name: str = "Unnamed Node"
node_lat: float = 0.0
node_lon: float = 0.0
# 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_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_pass: Optional[str] = None
# C2 server (audio upload destination); None disables upload
c2_url: Optional[str] = None
# Local Icecast — live listening only (frontend / mobile).
# NOT used for call recording or Discord voice: it lags 1s and drifts to 100s+.
icecast_host: str = "localhost"
icecast_port: int = 8000
icecast_mount: str = "/radio"
# 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.
# Liquidsoap (op25 container) writes into the `drb_sink` null sink; we capture
# its monitor. Addressed explicitly rather than via "default" because the op25
# entrypoint starts pulseaudio with -n and never applies system.pa's
# `set-default-source` line.
pulse_source: str = "drb_sink.monitor"
# Bounded wait for the shared PulseAudio socket before launching FFmpeg.
pulse_wait_timeout: float = 30.0
# ------------------------------------------------------------------
# Call segmentation
#
# Boundaries come from the AUDIO, not the control channel. A recording
# starts at voice onset and ends after call_silence_timeout seconds of
# silence actually heard in the stream. See internal/metadata_watcher.py
# for why the control channel is no longer trusted for either edge.
# ------------------------------------------------------------------
# Seconds of continuous silence IN THE AUDIO before the current recording is
# closed. This is the primary segmentation control. Consecutive
# transmissions on the SAME talkgroup separated by less than this stay in
# one recording, so back-and-forth traffic is one file.
#
# Defaults to 3.0 to match the behaviour of the control-channel idle timer
# it replaces, but it is NOT the same clock: this one measures real silence
# in the audio, with no grant->speech delay mixed in. metadata_watcher logs
# the measured trailing silence on every close — tune from that number.
call_silence_timeout: float = 3.0
# dBFS (RMS, measured over one ~46ms capture chunk) below which audio counts
# as silence for the purpose of ending a recording.
#
# This does NOT need field calibration against radio noise. Between
# transmissions the capture is the monitor of a PulseAudio *null sink*,
# which emits digital silence, not an analog noise floor: measured on a live
# node the gap sits at about -91 dBFS, i.e. one least-significant bit of a
# 16-bit sample. Speech on the same node averages about -18 dBFS. Anything
# between roughly -70 and -40 therefore behaves identically; -50 is chosen
# to sit far below even quiet speech while staying far above the floor.
call_silence_threshold_db: float = -50.0
# DEPRECATED as a primary control — used ONLY in console fallback mode, i.e.
# when PulseAudio capture is not producing audio and there is nothing to
# segment on. Then, and only then, the old control-channel state machine
# runs and closes a segment this many seconds after the last observed
# transmission. Those segments carry no audio; they exist so the node keeps
# reporting real radio activity to C2 while its audio path is broken.
#
# Do NOT tune this against measured *audio* silence — use
# call_silence_timeout for that.
call_idle_timeout: float = 3.0
# Audio kept past a CONSOLE-DERIVED segment boundary, covering the fact that
# buffered audio lags control-channel timestamps by ~1.5s (grant->speech
# offset measured 0.84-1.62s across 7 field calls).
#
# Still needed, with a narrower job than before. It no longer pads the
# normal end of a call — that boundary now comes from the audio itself and
# needs no pad at all. It applies to the three boundaries that are still
# control-channel timestamps:
#
# tgid_change close the outgoing call at the new grant + pad
# tgid_change_unlogged close at the observing poll + pad
# idle_timeout console fallback mode only
#
# Safe to be generous: trim_silence strips trailing silence back to
# trim_silence_guard_seconds before upload, so a larger pad costs long calls
# nothing. Over-capture is free; under-capture loses words permanently. If
# the outgoing and incoming recordings overlap in the underlying audio
# because of this pad, that is correct — the audio contains both.
call_tail_pad_seconds: float = 3.0
# Strip leading/trailing dead air before upload. A recording deliberately
# over-captures at both ends (pre-roll at the head, the whole measured
# silence run at the tail), which inflates Whisper cost and is a
# well-documented trigger for hallucinated transcript text. Trimming is a
# sample-offset slice of the buffered PCM — no re-encode — and only ever
# touches the head and tail. See internal/audio_trim.py.
trim_silence: bool = True
# dBFS (RMS) below which audio counts as silence when trimming the ends.
# Kept above call_silence_threshold_db on purpose: the closer must not miss
# speech (permissive), the trimmer must not leave dead air (stricter), and
# trim_silence_guard_seconds protects the syllable either way.
trim_silence_threshold_db: float = -40.0
# Guard margin kept around detected speech so no syllable is clipped.
trim_silence_guard_seconds: float = 0.25
# OP25 container
op25_api_url: str = "http://localhost:8001"
op25_terminal_url: str = "http://localhost:8081"
# Paths (volume mounts)
config_path: str = "/configs"
recordings_path: str = "/recordings"
# Offline call buffer — how many call_end events to keep while disconnected
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:
env_file = ".env"
settings = Settings()