Authenticate the node dashboard, and the broker connection per node
CI / lint (push) Failing after 24s
CI / test (push) Failing after 28s
Build edge-node / build (push) Failing after 43s
Build op25 / build (push) Failing after 47s

Two unauthenticated surfaces closed on the edge node.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-16 09:34:16 -04:00
co-authored by Claude Opus 5
parent a61a7b2c31
commit 87633ab50d
14 changed files with 1109 additions and 17 deletions
+35
View File
@@ -10,8 +10,27 @@ class Settings(BaseSettings):
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
@@ -123,6 +142,22 @@ class Settings(BaseSettings):
# 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"
+178
View File
@@ -0,0 +1,178 @@
"""
Local dashboard / API authentication.
These edge nodes are deployed at arbitrary third-party locations and serve
both an HTML dashboard and a JSON API on the same FastAPI app (port 80,
network_mode: host) — anyone on that site's LAN can otherwise reach every
control endpoint. This module adds username/password auth in front of it.
Design:
- Username + password come from app/config.py (DASHBOARD_USERNAME /
DASHBOARD_PASSWORD env vars), with a first-boot default that MUST be
changed — see is_using_default_password() and its call site in main.py.
- The password is never compared or stored in plaintext. It's hashed with
stdlib hashlib.scrypt (no new dependency — this image runs on a Raspberry
Pi) using a salt generated once on first boot and persisted via
app/internal/credentials.py, then compared with hmac.compare_digest.
- Two auth paths, both accepted on every protected route:
* Browser dashboard: a signed session cookie set by POST /login
(HMAC-SHA256 over "username:expiry", no server-side session store —
the signing key is the persisted session secret from credentials.py).
* Machine callers: HTTP Basic with the same username/password. As of
this writing no non-browser caller of this node's own API was found
anywhere in Client/ or Server/ (nodes are only ever reached over MQTT
+ node-initiated outbound HTTP to C2, never the other way around) —
Basic is kept anyway as a stateless fallback for curl/scripts in the
field, since it needs no login flow and costs little to support.
Caveat worth knowing: this node's dashboard is plain HTTP (no TLS
termination on :80), so both the session cookie and Basic credentials travel
unencrypted on the local network either way. Auth here stops a passerby from
opening the dashboard and pressing buttons; it does not stop a LAN-level
sniffer. That would need TLS in front of the node, which is out of scope here.
"""
import base64
import hashlib
import hmac
import time
from typing import Optional
from fastapi import Cookie, Header, HTTPException, status
from app.config import settings
from app.internal import credentials
from app.internal.logger import logger
SESSION_COOKIE_NAME = "drb_node_session"
# 12h: long enough that the dashboard doesn't demand a daily re-login on a
# device left open on someone's desk, short enough that a stolen cookie isn't
# valid forever.
SESSION_TTL_SECONDS = 12 * 60 * 60
# scrypt cost parameters. n=2**14 (16384) keeps the derivation well under the
# ~1s ballpark on a Raspberry Pi's memory/CPU budget — this only runs on
# login attempts (rare), never on the hot path.
_SCRYPT_N = 2 ** 14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_DKLEN = 32
# Kept in sync with app/config.py's Settings.dashboard_password default.
DEFAULT_PASSWORD = "CHANGE-ME-drb-default"
def _hash_password(password: str, salt: bytes) -> bytes:
return hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=_SCRYPT_N,
r=_SCRYPT_R,
p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN,
)
def is_using_default_password() -> bool:
return settings.dashboard_password == DEFAULT_PASSWORD
def verify_credentials(username: str, password: str) -> bool:
"""Constant-time check of a submitted username/password against config."""
salt = credentials.get_auth_salt()
expected_hash = _hash_password(settings.dashboard_password, salt)
submitted_hash = _hash_password(password, salt)
user_ok = hmac.compare_digest(
username.encode("utf-8"), settings.dashboard_username.encode("utf-8")
)
pass_ok = hmac.compare_digest(submitted_hash, expected_hash)
return user_ok and pass_ok
def _sign(payload: str) -> str:
secret = credentials.get_session_secret()
return hmac.new(secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
def create_session_token(username: str) -> str:
"""Build a signed, expiring, opaque session token (no server-side state)."""
expiry = int(time.time()) + SESSION_TTL_SECONDS
payload = f"{username}:{expiry}"
sig = _sign(payload)
raw = f"{payload}:{sig}"
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("utf-8")
def _verify_session_token(token: str) -> Optional[str]:
try:
raw = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
username, expiry_s, sig = raw.rsplit(":", 2)
expiry = int(expiry_s)
except Exception:
return None
expected_sig = _sign(f"{username}:{expiry_s}")
if not hmac.compare_digest(sig, expected_sig):
return None
if time.time() > expiry:
return None
if not hmac.compare_digest(
username.encode("utf-8"), settings.dashboard_username.encode("utf-8")
):
return None
return username
def _verify_basic_auth(header_value: str) -> bool:
try:
scheme, _, encoded = header_value.partition(" ")
if scheme.lower() != "basic":
return False
decoded = base64.b64decode(encoded).decode("utf-8")
username, _, password = decoded.partition(":")
except Exception:
return False
return verify_credentials(username, password)
def is_authenticated(
session_cookie: Optional[str], authorization: Optional[str]
) -> bool:
if session_cookie and _verify_session_token(session_cookie):
return True
if authorization and _verify_basic_auth(authorization):
return True
return False
async def require_session(
drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> bool:
"""Dependency for dashboard HTML pages. Returns False rather than raising
so the route can redirect to /login instead of showing a bare 401."""
return bool(drb_node_session and _verify_session_token(drb_node_session))
async def require_auth(
drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
authorization: Optional[str] = Header(default=None),
) -> None:
"""Dependency for /api/* routes — session cookie (dashboard's own fetch
calls) or HTTP Basic (machine callers) both satisfy it."""
if is_authenticated(drb_node_session, authorization):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Basic"},
)
def warn_if_default_password() -> None:
if is_using_default_password():
logger.warning(
"DASHBOARD_PASSWORD is still the first-boot default — "
"set DASHBOARD_USERNAME/DASHBOARD_PASSWORD in .env before this "
"node leaves the bench. Anyone on the node's LAN can currently "
"log in with the default credentials."
)
+57 -5
View File
@@ -1,30 +1,73 @@
"""
Manages the persisted node API key.
Manages the persisted node API key, plus the local-auth signing material used
by app/internal/auth.py.
The key is provisioned by the C2 server after an admin approves the node.
The API key is provisioned by the C2 server after an admin approves the node.
It arrives via MQTT and is saved to /configs/credentials.json so it survives
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 secrets
from pathlib import Path
from app.config import settings
from app.internal.logger import logger
_CREDS_FILE = Path(settings.config_path) / "credentials.json"
_api_key: str | None = None
_auth_salt: bytes | None = None
_session_secret: bytes | None = None
def load() -> None:
"""Load persisted credentials from disk on startup."""
global _api_key
global _api_key, _auth_salt, _session_secret
if _CREDS_FILE.exists():
try:
data = json.loads(_CREDS_FILE.read_text())
_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:
logger.info("Node credentials loaded from disk.")
except Exception as 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:
@@ -34,6 +77,15 @@ def get_api_key() -> str | None:
def save_api_key(key: str) -> None:
global _api_key
_api_key = key
_CREDS_FILE.parent.mkdir(parents=True, exist_ok=True)
_CREDS_FILE.write_text(json.dumps({"api_key": key}))
_write()
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))
+52 -2
View File
@@ -32,6 +32,16 @@ class MQTTManager:
self._t_metadata = f"nodes/{nid}/metadata"
self._t_commands = f"nodes/{nid}/commands"
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_key_request = f"nodes/{nid}/key_request"
self._t_discovery = "nodes/discovery/request"
@@ -41,8 +51,47 @@ class MQTTManager:
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
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)
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({
"node_id": settings.node_id,
@@ -62,10 +111,11 @@ class MQTTManager:
self._connected = True
client.subscribe(self._t_commands, 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)
logger.info("MQTT connected.")
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._flush_offline_buffer(), self._loop)
else:
+4 -1
View File
@@ -266,8 +266,11 @@ async def on_config_push(payload: dict):
async def lifespan(app: FastAPI):
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()
from app.internal import auth
auth.warn_if_default_password()
# Wire callbacks
metadata_watcher.on_call_start = on_call_start
+8 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Body
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Optional
import asyncio
import httpx
@@ -11,8 +11,14 @@ from app.internal.discord_radio import radio_bot
from app.internal.metadata_watcher import metadata_watcher
from app.internal import credentials
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")
+50 -4
View File
@@ -1,18 +1,64 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from typing import Optional
from fastapi import APIRouter, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from app.internal import auth
router = APIRouter(tags=["ui"])
_TEMPLATE = Path(__file__).parent.parent / "templates" / "index.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)
async def index():
async def index(authed: bool = Depends(auth.require_session)):
if not authed:
return RedirectResponse("/login")
return _TEMPLATE.read_text()
@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()
+4
View File
@@ -268,6 +268,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Scanner Mode
</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>
</header>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRB Edge Node — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0f19;
--glass-bg: rgba(20, 25, 40, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--text-main: #f8fafc;
--text-muted: #94a3b8;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
background-image:
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
h1 {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
font-family: inherit;
font-size: 0.95rem;
}
input[type="text"]:focus, input[type="password"]:focus {
outline: none;
border-color: var(--accent);
}
button {
width: 100%;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
border: none;
cursor: pointer;
background: var(--accent);
color: white;
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
transition: all 0.2s ease;
}
button:hover {
background: var(--accent-hover);
}
.error {
margin-top: 1rem;
padding: 0.6rem 0.8rem;
border-radius: 8px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--danger);
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="login-card">
<h1>DRB Edge Node</h1>
<p class="subtitle">Sign in to the local dashboard</p>
<form method="post" action="/login">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<!--ERROR_BANNER-->
</div>
</body>
</html>
+1
View File
@@ -5,5 +5,6 @@ paho-mqtt>=2.0.0
httpx
discord.py[voice]
PyNaCl
python-multipart
pytest
pytest-asyncio
+248
View File
@@ -0,0 +1,248 @@
"""
Unit tests for local dashboard/API auth (app.internal.auth), plus the
credentials.py additions that persist its signing material (auth_salt,
session_secret) alongside the existing node API key.
This file is pure logic: password hashing/constant-time comparison, session
token signing/expiry, and HTTP Basic header parsing. See test_auth_endpoints.py
for the HTTP-level login/redirect/protection round trip through the routers.
"""
import base64
import secrets
import time
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import auth, credentials
@pytest.fixture(autouse=True)
def isolated_credentials(tmp_path, monkeypatch):
"""Every test gets a fresh, on-disk-isolated credentials store so the
generated auth salt / session secret never leak between tests, and a
known username/password instead of the shipped default."""
creds_file = tmp_path / "credentials.json"
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
monkeypatch.setattr(credentials, "_api_key", None)
monkeypatch.setattr(credentials, "_auth_salt", None)
monkeypatch.setattr(credentials, "_session_secret", None)
monkeypatch.setattr(settings, "dashboard_username", "tester")
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
yield
def _basic_header(username: str, password: str) -> str:
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return f"Basic {encoded}"
# ---------------------------------------------------------------------------
# credentials.py: auth salt / session secret generation + persistence
# ---------------------------------------------------------------------------
def test_auth_material_is_generated_on_first_access():
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
assert isinstance(salt, bytes) and len(salt) == 16
assert isinstance(secret, bytes) and len(secret) == 32
def test_auth_material_is_stable_across_repeated_calls():
assert credentials.get_auth_salt() == credentials.get_auth_salt()
assert credentials.get_session_secret() == credentials.get_session_secret()
def test_auth_material_persists_to_disk_and_survives_reload():
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
# Simulate a container restart: drop in-memory state, reload from disk.
credentials._api_key = None
credentials._auth_salt = None
credentials._session_secret = None
credentials.load()
assert credentials.get_auth_salt() == salt
assert credentials.get_session_secret() == secret
def test_saving_api_key_does_not_clobber_auth_material():
"""save_api_key() used to json.dumps({"api_key": key}) directly, which
would have wiped auth_salt/session_secret out of credentials.json the
moment C2 provisioned an API key after this feature was added."""
salt = credentials.get_auth_salt()
secret = credentials.get_session_secret()
credentials.save_api_key("some-node-api-key")
assert credentials.get_api_key() == "some-node-api-key"
assert credentials.get_auth_salt() == salt
assert credentials.get_session_secret() == secret
# ---------------------------------------------------------------------------
# verify_credentials() — password hashing + constant-time compare
# ---------------------------------------------------------------------------
def test_verify_credentials_accepts_correct_username_and_password():
assert auth.verify_credentials("tester", "s3cret-pass") is True
def test_verify_credentials_rejects_wrong_password():
assert auth.verify_credentials("tester", "wrong") is False
def test_verify_credentials_rejects_wrong_username():
assert auth.verify_credentials("someone-else", "s3cret-pass") is False
def test_verify_credentials_rejects_empty_password():
assert auth.verify_credentials("tester", "") is False
def test_password_is_hashed_not_compared_in_plaintext():
with patch.object(auth, "_hash_password", wraps=auth._hash_password) as spy:
auth.verify_credentials("tester", "s3cret-pass")
# Once for the configured password, once for the submitted one — neither
# side is ever compared as a raw string.
assert spy.call_count == 2
def test_is_using_default_password_detects_the_shipped_default(monkeypatch):
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
assert auth.is_using_default_password() is True
def test_is_using_default_password_false_once_changed():
assert auth.is_using_default_password() is False # fixture already changed it
# ---------------------------------------------------------------------------
# session tokens
# ---------------------------------------------------------------------------
def test_session_token_round_trips():
token = auth.create_session_token("tester")
assert auth._verify_session_token(token) == "tester"
def test_session_token_rejects_tampered_payload():
token = auth.create_session_token("tester")
tampered = ("X" if token[0] != "X" else "Y") + token[1:]
assert auth._verify_session_token(tampered) is None
def test_session_token_rejects_expired_token(monkeypatch):
token = auth.create_session_token("tester")
future = time.time() + auth.SESSION_TTL_SECONDS + 1
monkeypatch.setattr(time, "time", lambda: future)
assert auth._verify_session_token(token) is None
def test_session_token_rejects_username_mismatch(monkeypatch):
token = auth.create_session_token("tester")
monkeypatch.setattr(settings, "dashboard_username", "someone-else")
assert auth._verify_session_token(token) is None
def test_session_token_garbage_input_does_not_raise():
assert auth._verify_session_token("not-a-real-token") is None
assert auth._verify_session_token("") is None
def test_session_token_signed_with_a_different_secret_is_rejected():
token = auth.create_session_token("tester")
# As if the node restarted without a persisted credentials.json.
credentials._session_secret = secrets.token_bytes(32)
assert auth._verify_session_token(token) is None
# ---------------------------------------------------------------------------
# HTTP Basic parsing
# ---------------------------------------------------------------------------
def test_basic_auth_accepts_valid_header():
assert auth._verify_basic_auth(_basic_header("tester", "s3cret-pass")) is True
def test_basic_auth_rejects_wrong_credentials():
assert auth._verify_basic_auth(_basic_header("tester", "wrong")) is False
def test_basic_auth_rejects_non_basic_scheme():
assert auth._verify_basic_auth("Bearer sometoken") is False
def test_basic_auth_tolerates_garbage_without_raising():
assert auth._verify_basic_auth("Basic not-valid-base64!!") is False
assert auth._verify_basic_auth("") is False
# ---------------------------------------------------------------------------
# is_authenticated() — the combined check require_auth is built on
# ---------------------------------------------------------------------------
def test_is_authenticated_true_with_valid_session_cookie():
token = auth.create_session_token("tester")
assert auth.is_authenticated(token, None) is True
def test_is_authenticated_true_with_valid_basic_header():
assert auth.is_authenticated(None, _basic_header("tester", "s3cret-pass")) is True
def test_is_authenticated_false_with_neither():
assert auth.is_authenticated(None, None) is False
def test_is_authenticated_false_with_invalid_session_and_no_header():
assert auth.is_authenticated("garbage", None) is False
# ---------------------------------------------------------------------------
# FastAPI dependencies: require_session / require_auth
# ---------------------------------------------------------------------------
async def test_require_session_false_with_no_cookie():
assert await auth.require_session(None) is False
async def test_require_session_true_with_valid_cookie():
token = auth.create_session_token("tester")
assert await auth.require_session(token) is True
async def test_require_auth_raises_401_with_no_credentials():
with pytest.raises(HTTPException) as exc_info:
await auth.require_auth(None, None)
assert exc_info.value.status_code == 401
assert exc_info.value.headers["WWW-Authenticate"] == "Basic"
async def test_require_auth_passes_with_valid_session_cookie():
token = auth.create_session_token("tester")
await auth.require_auth(token, None) # must not raise
async def test_require_auth_passes_with_valid_basic_header():
await auth.require_auth(None, _basic_header("tester", "s3cret-pass")) # must not raise
# ---------------------------------------------------------------------------
# startup warning
# ---------------------------------------------------------------------------
def test_warn_if_default_password_logs_when_default(monkeypatch):
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
with patch("app.internal.auth.logger") as mock_logger:
auth.warn_if_default_password()
mock_logger.warning.assert_called_once()
def test_warn_if_default_password_silent_once_changed():
with patch("app.internal.auth.logger") as mock_logger:
auth.warn_if_default_password()
mock_logger.warning.assert_not_called()
+157
View File
@@ -0,0 +1,157 @@
"""
HTTP-level tests for the auth-protected dashboard/API surface: login/logout
flow, session-cookie protection of the HTML pages, and Basic-auth protection
of the JSON API.
Built as a standalone FastAPI app assembling the real api/ui routers — NOT
app.main:app, which wires a lifespan that connects to MQTT, starts the
PulseAudio capture loop, and pings OP25/C2. None of that belongs in a unit
test, and none of it is needed to exercise the auth layer: the auth
dependency runs (and short-circuits with a redirect/401) before any route
body that would touch those services.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.internal import auth, credentials
from app.routers import api, ui
@pytest.fixture(autouse=True)
def isolated_credentials(tmp_path, monkeypatch):
creds_file = tmp_path / "credentials.json"
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
monkeypatch.setattr(credentials, "_api_key", None)
monkeypatch.setattr(credentials, "_auth_salt", None)
monkeypatch.setattr(credentials, "_session_secret", None)
monkeypatch.setattr(settings, "dashboard_username", "tester")
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
yield
@pytest.fixture
def client():
test_app = FastAPI()
test_app.include_router(api.router)
test_app.include_router(ui.router)
with TestClient(test_app) as c:
yield c
def _basic_header(username: str, password: str) -> dict:
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {encoded}"}
# ---------------------------------------------------------------------------
# /api/* — machine-facing JSON API
# ---------------------------------------------------------------------------
def test_api_route_rejects_unauthenticated_requests(client):
r = client.get("/api/status", follow_redirects=False)
assert r.status_code == 401
assert r.headers["www-authenticate"] == "Basic"
def test_api_route_accepts_valid_basic_auth(client):
r = client.get("/api/config", headers=_basic_header("tester", "s3cret-pass"))
assert r.status_code == 200
def test_api_route_rejects_wrong_basic_auth_password(client):
r = client.get("/api/config", headers=_basic_header("tester", "wrong"))
assert r.status_code == 401
def test_api_route_accepts_dashboard_session_cookie(client):
login = client.post(
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
)
assert login.status_code == 303
assert auth.SESSION_COOKIE_NAME in login.cookies
r = client.get("/api/config") # cookie jar carries the session cookie
assert r.status_code == 200
def test_every_api_route_is_registered_behind_the_auth_dependency():
"""Structural guard: catches a future route added to api.py that forgets
the router is meant to protect everything in it."""
assert any(
getattr(dep, "dependency", None) is auth.require_auth
for dep in api.router.dependencies
)
# ---------------------------------------------------------------------------
# / and /scanner — the HTML dashboard
# ---------------------------------------------------------------------------
def test_index_redirects_to_login_when_unauthenticated(client):
r = client.get("/", follow_redirects=False)
assert r.status_code in (302, 307)
assert r.headers["location"] == "/login"
def test_scanner_redirects_to_login_when_unauthenticated(client):
r = client.get("/scanner", follow_redirects=False)
assert r.status_code in (302, 307)
assert r.headers["location"] == "/login"
def test_index_served_with_a_valid_session_cookie(client):
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# ---------------------------------------------------------------------------
# /login, /logout
# ---------------------------------------------------------------------------
def test_login_page_loads_without_auth(client):
r = client.get("/login")
assert r.status_code == 200
def test_login_with_correct_credentials_sets_cookie_and_redirects_home(client):
r = client.post(
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"] == "/"
cookie = r.cookies.get(auth.SESSION_COOKIE_NAME)
assert cookie
assert auth._verify_session_token(cookie) == "tester"
def test_login_with_wrong_password_redirects_back_with_error_and_no_cookie(client):
r = client.post(
"/login", data={"username": "tester", "password": "wrong"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"] == "/login?error=1"
assert auth.SESSION_COOKIE_NAME not in r.cookies
def test_login_error_banner_renders_on_the_login_page(client):
r = client.get("/login?error=1")
assert r.status_code == 200
assert "Invalid username or password" in r.text
def test_logout_clears_the_session_cookie_and_redirects_to_login(client):
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
assert client.get("/").status_code == 200 # confirm we were logged in
r = client.get("/logout", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/login"
r2 = client.get("/", follow_redirects=False)
assert r2.status_code in (302, 307) # session cookie was cleared
+142
View File
@@ -0,0 +1,142 @@
"""
Unit tests for mqtt_manager's per-node auth + TLS wiring
(MQTT-PUBLIC-AUTH-PLAN.md dynsec cutover).
Pure client-construction tests — _build_client() only builds a paho Client
object, it never calls .connect(), so no real broker is involved. What's
verified here is the credential/TLS *selection logic*, matching what the
server's dynsec plugin now expects (username=node_id, password=api_key,
default-verified TLS on the public listener) — see
Server/drb-c2-core/app/internal/dynsec.py and mosquitto.conf (read-only
reference, not touched by this change).
"""
import ssl
from unittest.mock import patch
import pytest
from app.config import settings
from app.internal import credentials
from app.internal.mqtt_manager import mqtt_manager
@pytest.fixture(autouse=True)
def isolated_mqtt_settings(monkeypatch):
"""Every test gets known, isolated mqtt_* settings and a clean
credentials._api_key so tests can't see real .env values or leak state
between tests (mirrors the isolated_credentials fixture in test_auth.py)."""
monkeypatch.setattr(settings, "mqtt_user", None)
monkeypatch.setattr(settings, "mqtt_pass", None)
monkeypatch.setattr(settings, "mqtt_tls", False)
monkeypatch.setattr(credentials, "_api_key", None)
yield
# ---------------------------------------------------------------------------
# Credential selection: api_key > legacy mqtt_user > anonymous
# ---------------------------------------------------------------------------
def test_build_client_uses_node_id_and_api_key_when_present(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
client = mqtt_manager._build_client()
assert client._username == settings.node_id.encode()
assert client._password == b"the-api-key"
def test_build_client_falls_back_to_legacy_mqtt_user_without_api_key(monkeypatch):
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
client = mqtt_manager._build_client()
assert client._username == b"drb-node"
assert client._password == b"legacy-pass"
def test_build_client_api_key_takes_priority_over_legacy_mqtt_user(monkeypatch):
"""Once a node has a real api_key, it must never fall back to the shared
legacy login even if MQTT_USER/MQTT_PASS are still set in .env."""
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
client = mqtt_manager._build_client()
assert client._username == settings.node_id.encode()
assert client._password == b"the-api-key"
def test_build_client_with_no_credentials_connects_anonymously(monkeypatch):
"""No api_key on disk, no legacy login configured: _build_client() must
still return a usable client (paho, not this code, decides what happens
on the wire — the dynsec broker refuses it, see the warning test below).
This must never raise."""
client = mqtt_manager._build_client()
assert client._username is None
assert client._password is None
def test_build_client_warns_when_no_credentials_available(caplog):
with caplog.at_level("WARNING", logger="drb-edge-node"):
mqtt_manager._build_client()
messages = [r.message for r in caplog.records]
assert any("No API key" in m for m in messages), \
"an unenrolled node must log a clear, greppable warning, not fail silently"
def test_build_client_does_not_warn_when_api_key_present(monkeypatch, caplog):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
with caplog.at_level("WARNING", logger="drb-edge-node"):
mqtt_manager._build_client()
assert not any("No API key" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# TLS
# ---------------------------------------------------------------------------
def test_build_client_no_tls_by_default(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_tls", False)
client = mqtt_manager._build_client()
assert client._ssl_context is None
def test_build_client_enables_tls_with_default_verification(monkeypatch):
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
monkeypatch.setattr(settings, "mqtt_tls", True)
client = mqtt_manager._build_client()
assert isinstance(client._ssl_context, ssl.SSLContext)
# The whole point: default CA verification against the broker's real
# Let's Encrypt cert must stay ON. tls_insecure_set(True) must never be
# called — that would defeat verification entirely.
assert client._ssl_context.verify_mode == ssl.CERT_REQUIRED
assert client._tls_insecure is False
# ---------------------------------------------------------------------------
# Offline call buffer must be untouched by the auth/TLS change
# ---------------------------------------------------------------------------
def test_build_client_does_not_touch_offline_buffer(monkeypatch):
"""_build_client() is called fresh on every connect(); it must never
reset or otherwise touch the offline call-buffer deque — that survives
reconnects/auth changes by design (the whole point of the buffer)."""
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
mqtt_manager._offline_buffer.append(("nodes/test/metadata", {"call_id": "sentinel"}))
with patch.object(mqtt_manager, "_offline_buffer", mqtt_manager._offline_buffer):
mqtt_manager._build_client()
assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})]
mqtt_manager._offline_buffer.clear()