Authenticate the node dashboard, and the broker connection per node
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:
co-authored by
Claude Opus 5
parent
a61a7b2c31
commit
87633ab50d
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user