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
+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()