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>
143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
"""
|
|
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()
|