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
@@ -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"
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user