The system can transmit RF on your licence, so security has two jobs: keep strangers out, and make sure a bug, a flaky network or a second user can never leave the transmitter keyed or in the wrong hands.
| Role | Watch state, listen | Take control, change radio, PTT, mic audio | Users, config, diagnostics, power off |
|---|---|---|---|
| viewer | yes | no | no |
| operator | yes | yes (when holding control) | no |
| admin | yes | yes | yes |
safety.control_request_timeout_s
(default 10 s, 3-120): control passes automatically, so an unattended session never blocks anyone.| Concern | Measure | Where |
|---|---|---|
| Passwords | scrypt (N=2^14), min 10 chars, constant-time verify, dummy hash for unknown users | auth.py |
| Brute force | per address+user back-off (4 failures, doubling up to 5 min); address taken from X-Forwarded-For only when the peer is the local proxy |
auth.py, common.py |
| Sessions | random 256-bit id, only a hash is stored, HttpOnly; SameSite=Strict, Secure behind HTTPS, 12 h idle / 7 d absolute, revoked on password change, role change, deletion |
auth.py, admin.py |
| CSRF | SameSite=Strict plus a per-session token header on every state-changing request |
app.py |
| WebSocket | session cookie, Origin must match Host (or allowed_origins), session re-checked on every message, 4 KB message cap, max 24 clients |
app.py |
| Command flood | token bucket 40/s (burst 80) per connection; heartbeats exempt; persistent abuse closes the socket | ratelimit.py |
| Input | every command is a typed intent validated against the radio's capabilities; no raw CAT, no shell, no paths from the browser | app.py, radio/controls.py |
| PTT | allow flag (off by default; an administrator turns it on with a password re-check, audited); single owner; heartbeat; hard time limit; un-key on disconnect / lease change / shutdown; retried until acknowledged | safety.py, lease.py, admin.py |
| Web-editable config | an allow-list. server.* and storage.* can only be changed by editing the file on the Pi; safety.allow_ptt only through its own endpoint (administrator, password re-entered, throttled, audited) |
admin.py |
| Headers | CSP (no inline script/style), X-Frame-Options: DENY, nosniff, Permissions-Policy (mic for self only), HSTS over HTTPS, no-store on API |
app.py |
| Logs | passwords, cookies, CSRF tokens and rr_session values are scrubbed before any handler; the audit table records logins, failures, control changes, PTT, user/config changes, power-off, restarts |
logs.py, auth.py |
| Last-resort recovery | python -m radio_remote.cli reset-password USER on the Pi (needs filesystem access, prompts for the password) |
cli.py |
Not done, deliberately or for now:
SameSite=Strict plus the Origin check cover cross-site hijacking.Internet X (never port-forward this)
LAN / Tailscale ---> Caddy :443 (TLS) ---> app 127.0.0.1:8080 ---> radio (USB)
server.host = "127.0.0.1". The app trusts X-Forwarded-* headers only from a loopback peer; binding it to
0.0.0.0 behind a proxy would let anyone on the LAN spoof their address and "Secure" status.sudo apt install caddy, copy config/Caddyfile.example to /etc/caddy/Caddyfile (Option A), sudo systemctl reload caddy./var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt on the Pi (the path can differ between Caddy installs; the
installer pack and the image also publish it for download). iOS additionally needs it enabled under Settings > General > About >
Certificate Trust Settings.https://raspberrypi.local.sudo tailscale up. Enable MagicDNS and HTTPS certificates in the admin console.sudo /opt/radio-remote/current/scripts/tailscale_setup.sh: it adds the Tailscale name to Caddy (verified on a real Pi). Step by step in
tailscale. tailscale serve is not used: Caddy forwards X-Forwarded-Proto: https, which the session cookie's Secure flag needs.sudo ufw default deny incoming
sudo ufw allow in on tailscale0
sudo ufw allow from 192.168.0.0/16 to any port 443 proto tcp # your LAN range
sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp
sudo ufw allow from 192.168.0.0/16 to any port 4532 proto tcp # only if you use the logbook link (docs/logbook.md)
sudo ufw enable
WebRTC audio selects ports dynamically; with deny incoming allow UDP from your LAN/tailnet interfaces as well
(not verified against a real firewall yet).
safety.allow_ptt = true in
the config file and restart. It is off by default and every change is in the audit log.python -m radio_remote.cli reset-password <user> on the Pi.data/radio-remote.db (users, sessions, audit).Automated (222 tests, mock radio and simulated CAT): lease transfer / deny / timeout / force / refusal while keyed, heartbeat and disconnect un-keying, role enforcement, rate limiting, user management and last-admin protection, session revocation, config allow-list and validation, audit contents, secret scrubbing, HSTS / cookie flags, recovery CLI. In a real browser: admin sheet (users, clients, config, diagnostics, audit, account), request dialog and hand-over between two users, watching-mode lock-out of dial and PTT.
Not tested: TLS in front of the app (Caddy / Tailscale), real phones/tablets, behaviour behind a real firewall, several simultaneous audio listeners on a Pi.