ba0c7bfa9b
The ?token= fallback leaked JWTs in server logs and was never used by the client, which always sends the token via WebSocket subprotocol header.
633 lines
20 KiB
Python
633 lines
20 KiB
Python
"""aiohttp server for vacuum-walld.
|
|
|
|
Listens on a Unix socket, serves the daemon API to the web UI.
|
|
Handles routing, batching, and request/response lifecycle.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from aiohttp import web
|
|
|
|
from daemon.iface import PathLike
|
|
from lib.state import _DEFAULT_POLL_INTERVALS
|
|
from lib.state import state as state_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
|
|
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
|
|
|
|
# Polling intervals per subsystem (seconds). VACUUM_WALL_POLL_INTERVALS overrides.
|
|
_RAW_POLL = os.environ.get("VACUUM_WALL_POLL_INTERVALS", "")
|
|
if _RAW_POLL:
|
|
_POLL_OVERRIDE: dict[str, int] = {}
|
|
for pair in _RAW_POLL.split(","):
|
|
if ":" in pair:
|
|
name, _, val = pair.partition(":")
|
|
try:
|
|
parsed = int(val.strip())
|
|
if parsed <= 0:
|
|
logger.warning(
|
|
"Invalid poll interval value %r for %r (must be > 0), skipping",
|
|
val,
|
|
name,
|
|
)
|
|
continue
|
|
_POLL_OVERRIDE[name.strip()] = parsed
|
|
except ValueError:
|
|
logger.warning(
|
|
"Invalid poll interval value %r for %r, skipping", val, name
|
|
)
|
|
_POLL_INTERVALS = {**_DEFAULT_POLL_INTERVALS, **_POLL_OVERRIDE}
|
|
else:
|
|
_POLL_INTERVALS = dict(_DEFAULT_POLL_INTERVALS)
|
|
|
|
|
|
class Handler:
|
|
"""Wrapper for a daemon handler function.
|
|
|
|
Attributes:
|
|
method: HTTP method (e.g. "GET", "POST").
|
|
path: URL path pattern.
|
|
"""
|
|
|
|
def __init__(self, method: str, path: str) -> None:
|
|
"""Initialize the handler metadata.
|
|
|
|
Args:
|
|
method: HTTP method.
|
|
path: URL path pattern.
|
|
"""
|
|
self.method = method.upper()
|
|
self.path = path
|
|
|
|
|
|
class Registry:
|
|
"""Route registry for daemon handlers.
|
|
|
|
Attributes:
|
|
_routes: Mapping of (method, path) tuples to handler callables.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty route registry."""
|
|
self._routes: dict[tuple[str, str], Callable] = {}
|
|
|
|
def register(self, method: PathLike, path: str | None = None):
|
|
"""Decorator that registers a handler for the given method and path.
|
|
|
|
Accepts either two separate arguments (``method``, ``path``) or a
|
|
single :class:`daemon.iface.Endpoint` tuple.
|
|
|
|
Args:
|
|
method: HTTP method string, or an :class:`Endpoint` tuple.
|
|
path: URL path (omit when passing an :class:`Endpoint`).
|
|
|
|
Returns:
|
|
Decorator function wrapping the handler.
|
|
"""
|
|
|
|
if isinstance(method, tuple):
|
|
ep_method, ep_path = method
|
|
path = ep_path
|
|
method = ep_method
|
|
|
|
def decorator(fn: Callable) -> Callable:
|
|
"""Inner decorator that stores *fn* in the registry and attaches Handler metadata."""
|
|
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
|
|
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
def get(self, method: str, path: str) -> Callable | None:
|
|
"""Look up a registered handler by method and path.
|
|
|
|
Args:
|
|
method: HTTP method to match.
|
|
path: URL path to match.
|
|
|
|
Returns:
|
|
The handler callable, or None if not registered.
|
|
"""
|
|
return self._routes.get((method.upper(), path))
|
|
|
|
def match(self, method: str, path: str):
|
|
"""Match *path* against registered patterns, returning handler + params.
|
|
|
|
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
|
|
Matching segments are captured into a dict and merged into *body*.
|
|
|
|
Returns:
|
|
Tuple of (handler_fn, params_dict) or (None, None) if no match.
|
|
"""
|
|
for (reg_method, reg_path), fn in self._routes.items():
|
|
if reg_method != method.upper():
|
|
continue
|
|
pat_params = _match_path(reg_path, path)
|
|
if pat_params is not None:
|
|
return fn, pat_params
|
|
return None, None
|
|
|
|
|
|
registry = Registry()
|
|
|
|
|
|
def refresh_state(subsystems: list[str] | None = None) -> None:
|
|
"""Refresh the pre-computed state for the given subsystems (or all).
|
|
|
|
Args:
|
|
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
|
"""
|
|
state_store.populate(subsystems)
|
|
targets = subsystems or state_store.SUBSYSTEMS
|
|
for name in targets:
|
|
state_store.bump(name)
|
|
try:
|
|
asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
task = asyncio.create_task(broadcast_versions())
|
|
task.add_done_callback(_ws_tasks.discard)
|
|
_ws_tasks.add(task)
|
|
|
|
|
|
class NotFoundError(Exception):
|
|
"""Raised when a requested resource is not found."""
|
|
|
|
pass
|
|
|
|
|
|
class ConflictError(Exception):
|
|
"""Raised when a request conflicts with an existing resource."""
|
|
|
|
pass
|
|
|
|
|
|
def ok(data: Any = None) -> web.Response:
|
|
"""Create a success JSON response.
|
|
|
|
Args:
|
|
data: Response payload. Defaults to None.
|
|
|
|
Returns:
|
|
A JSON Response with `ok` set to True.
|
|
"""
|
|
return web.json_response({"ok": True, "data": data})
|
|
|
|
|
|
def error(msg: str, code: int = 400) -> web.Response:
|
|
"""Create an error JSON response.
|
|
|
|
Args:
|
|
msg: Error message.
|
|
code: HTTP status code. Defaults to 400.
|
|
|
|
Returns:
|
|
A JSON Response with `ok` set to False.
|
|
"""
|
|
return web.json_response({"ok": False, "error": msg}, status=code)
|
|
|
|
|
|
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
|
|
"""Match *path* against a URL pattern containing ``<param>`` segments.
|
|
|
|
Args:
|
|
pattern: URL pattern like ``/network/interfaces/<name>``.
|
|
path: Actual request path like ``/network/interfaces/eth1``.
|
|
|
|
Returns:
|
|
Dict mapping param names to their matched values, or ``None`` if no match.
|
|
"""
|
|
p_parts = pattern.strip("/").split("/")
|
|
r_parts = path.strip("/").split("/")
|
|
if len(p_parts) != len(r_parts):
|
|
return None
|
|
params: dict[str, str] = {}
|
|
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
|
|
if p_seg.startswith("<") and p_seg.endswith(">"):
|
|
params[p_seg[1:-1]] = r_seg
|
|
elif p_seg != r_seg:
|
|
return None
|
|
return params
|
|
|
|
|
|
async def _handle_request(request: web.Request) -> web.Response:
|
|
"""Dispatch a request to the appropriate handler.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
|
|
Returns:
|
|
The handler's response.
|
|
"""
|
|
handler_fn, pat_params = registry.match(request.method, request.path)
|
|
if handler_fn is None:
|
|
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
|
|
|
# Build body — merge order (highest wins): path params > JSON body > query params.
|
|
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
|
# be overridable by body or query parameters. This prevents callers from
|
|
# spoofing path-scoped parameters via request body.
|
|
body: dict[str, Any] | None = pat_params if pat_params else None
|
|
if request.content_type == "application/json":
|
|
try:
|
|
json_body = await request.json()
|
|
body = {**json_body, **body} if body is not None else json_body
|
|
except json.JSONDecodeError:
|
|
return error("Invalid JSON body", 400)
|
|
|
|
query_dict = dict(request.query)
|
|
if query_dict:
|
|
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
|
body = {**query_body, **body} if body is not None else query_body
|
|
|
|
try:
|
|
if body is not None:
|
|
result = handler_fn(request, body)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
else:
|
|
result = handler_fn(request, None)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
except NotFoundError as exc:
|
|
return error(str(exc), 404)
|
|
except ConflictError as exc:
|
|
return error(str(exc), 409)
|
|
except ValueError as exc:
|
|
return error(str(exc), 400)
|
|
except RuntimeError as exc:
|
|
logger.error("Handler error: %s", exc)
|
|
return error(str(exc), 500)
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"Unexpected handler error in %s %s", request.method, request.path
|
|
)
|
|
return error(f"Internal error: {exc}", 500)
|
|
|
|
# Convert result to response if not already
|
|
if isinstance(result, web.Response):
|
|
return result
|
|
if isinstance(result, dict) and result.get("ok") is False:
|
|
return error(result["error"], result.get("code", 400))
|
|
return ok(result)
|
|
|
|
|
|
async def _handle_batch(request: web.Request) -> web.Response:
|
|
"""Handle batch requests: execute operations in order, return keyed results.
|
|
|
|
Args:
|
|
request: The incoming HTTP request containing operations.
|
|
|
|
Returns:
|
|
A JSON response keyed by operation IDs.
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return error("Invalid JSON body", 400)
|
|
|
|
ops = body.get("ops", [])
|
|
if not isinstance(ops, list):
|
|
return error("'ops' must be a list", 400)
|
|
|
|
results: dict[str, Any] = {}
|
|
for op in ops:
|
|
op_id = op.get("id")
|
|
method = op.get("method", "GET").upper()
|
|
path = op.get("path", "")
|
|
|
|
if not op_id or not path:
|
|
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
|
continue
|
|
|
|
handler_fn, pat_params = registry.match(method, path)
|
|
if handler_fn is None:
|
|
results[op_id] = {
|
|
"ok": False,
|
|
"error": f"Endpoint not found: {method} {path}",
|
|
}
|
|
continue
|
|
|
|
op_body = op.get("body")
|
|
if pat_params:
|
|
op_body = {**(op_body or {}), **pat_params}
|
|
|
|
try:
|
|
result = handler_fn(None, op_body)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
except Exception as exc:
|
|
logger.error("Batch handler error for %s: %s", op_id, exc)
|
|
results[op_id] = {"ok": False, "error": str(exc)}
|
|
continue
|
|
|
|
# Strip ok/data wrapper for batch results
|
|
if isinstance(result, dict) and result.get("ok") is not None:
|
|
results[op_id] = result
|
|
else:
|
|
results[op_id] = {"ok": True, "data": result}
|
|
|
|
return ok(results)
|
|
|
|
|
|
def create_app() -> web.Application:
|
|
"""Create and configure the aiohttp application.
|
|
|
|
Returns:
|
|
A configured web.Application instance.
|
|
"""
|
|
app = web.Application()
|
|
app.router.add_route("GET", "/health", _health)
|
|
app.router.add_route("POST", "/status/refresh", refresh_status)
|
|
app.router.add_route("POST", "/batch", _handle_batch)
|
|
app.router.add_route("GET", "/ws", _handle_ws)
|
|
app.router.add_route("*", "/{tail:.*}", _catch_all)
|
|
return app
|
|
|
|
|
|
# WebSocket subscribers
|
|
_ws_subscribers: set[web.WebSocketResponse] = set()
|
|
_ws_tasks: set[asyncio.Task[None]] = set()
|
|
_poll_tasks: set[asyncio.Task[None]] = set()
|
|
|
|
|
|
async def _handle_ws(request: web.Request) -> web.Response:
|
|
"""WebSocket endpoint for real-time state change notifications.
|
|
|
|
On connect: sends current versions. On state change: broadcasts
|
|
updated subsystem versions. Clients disconnect to unsubscribe.
|
|
|
|
Authentication: JWT access token passed via:
|
|
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
|
2. X-Auth-Token header (nginx-injected)
|
|
"""
|
|
from lib.auth import validate_token
|
|
|
|
token_param = None
|
|
|
|
# Prefer subprotocol header (client JS sends "Bearer <token>")
|
|
subprotocols = request.get_subprotocols()
|
|
for proto in subprotocols or []:
|
|
if proto and proto.startswith("Bearer "):
|
|
token_param = proto[7:]
|
|
break
|
|
|
|
if token_param is None:
|
|
token_param = request.headers.get("X-Auth-Token")
|
|
|
|
if token_param is None:
|
|
return web.json_response(
|
|
{"ok": False, "error": "authentication required"}, status=401
|
|
)
|
|
|
|
session_header = request.headers.get("X-Session-Id")
|
|
if not session_header:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
payload = validate_token(
|
|
token_param, token_type="access", session_id=session_header
|
|
)
|
|
if payload is None:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
|
|
# Negotiate the subprotocol the client sent
|
|
ws = web.WebSocketResponse(protocols=request.get_subprotocols())
|
|
await ws.prepare(request)
|
|
_ws_subscribers.add(ws)
|
|
|
|
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
|
|
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == web.WSMsgType.ERROR:
|
|
break
|
|
if msg.type == web.WSMsgType.CLOSE:
|
|
break
|
|
finally:
|
|
_ws_subscribers.discard(ws)
|
|
|
|
return ws
|
|
|
|
|
|
async def broadcast_versions() -> None:
|
|
"""Broadcast updated subsystem versions to all WebSocket clients."""
|
|
updated = state_store.get_updated_versions()
|
|
if not updated or not _ws_subscribers:
|
|
return
|
|
data = json.dumps({"type": "versions", "updated": updated})
|
|
dead: set[web.WebSocketResponse] = set()
|
|
for ws in _ws_subscribers:
|
|
try:
|
|
await ws.send_str(data)
|
|
except Exception:
|
|
dead.add(ws)
|
|
_ws_subscribers.difference_update(dead)
|
|
if dead:
|
|
logger.warning("Removed %d dead WS subscribers", len(dead))
|
|
|
|
|
|
async def broadcast_tick(subsystems: list[str]) -> None:
|
|
"""Broadcast a lightweight tick to WS clients without version payload."""
|
|
data = json.dumps({"type": "tick", "subsystems": subsystems})
|
|
dead: set[web.WebSocketResponse] = set()
|
|
for ws in _ws_subscribers:
|
|
try:
|
|
await ws.send_str(data)
|
|
except Exception:
|
|
dead.add(ws)
|
|
_ws_subscribers.difference_update(dead)
|
|
if dead:
|
|
logger.warning("Removed %d dead WS subscribers", len(dead))
|
|
|
|
|
|
async def _poll_loop(subsystem: str, interval: int) -> None:
|
|
"""Periodically poll a subsystem for state changes and broadcast as needed."""
|
|
offset = int(hashlib.md5(subsystem.encode()).hexdigest(), 16) % interval
|
|
await asyncio.sleep(offset)
|
|
while True:
|
|
try:
|
|
structural, volatile = state_store.poll(subsystem)
|
|
if structural:
|
|
state_store.bump(subsystem)
|
|
await broadcast_versions()
|
|
elif volatile:
|
|
await broadcast_tick([subsystem])
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.error("Poll loop error for %s", subsystem, exc_info=True)
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
def start_polling(loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Start one poll loop task per subsystem."""
|
|
for subsystem, interval in _POLL_INTERVALS.items():
|
|
task = loop.create_task(_poll_loop(subsystem, interval))
|
|
task.add_done_callback(_poll_tasks.discard)
|
|
_poll_tasks.add(task)
|
|
|
|
|
|
def _stop_polling() -> None:
|
|
"""Cancel all polling tasks."""
|
|
for task in _poll_tasks:
|
|
task.cancel()
|
|
_poll_tasks.clear()
|
|
|
|
|
|
async def _health(_request: web.Request) -> web.Response:
|
|
"""Return the health check response.
|
|
|
|
Returns:
|
|
JSON response with process ID and socket path.
|
|
"""
|
|
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
|
|
|
|
|
|
async def refresh_status(_request: web.Request) -> web.Response:
|
|
"""Re-collect all state from system.
|
|
|
|
Args:
|
|
_request: The incoming request. JSON body may contain a "subsystems" list.
|
|
|
|
Returns:
|
|
JSON response with the updated state snapshot.
|
|
"""
|
|
try:
|
|
body = await _request.json()
|
|
except (json.JSONDecodeError, ValueError):
|
|
body = None
|
|
subsystems = None
|
|
if body and "subsystems" in body:
|
|
subsystems = body["subsystems"]
|
|
state_store.populate(subsystems)
|
|
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
|
|
|
|
|
|
async def _catch_all(request: web.Request) -> web.Response:
|
|
"""Catch-all for registered routes.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
|
|
Returns:
|
|
The dispatched handler's response.
|
|
"""
|
|
return await _handle_request(request)
|
|
|
|
|
|
def _register_routes() -> None:
|
|
"""Import all handler modules to register routes.
|
|
|
|
Side effect: each imported module's `@registry.register` calls populate the
|
|
route registry with their handler endpoints.
|
|
"""
|
|
from daemon.handlers import (
|
|
acme, # noqa: F401
|
|
auth, # noqa: F401
|
|
dnsmasq, # noqa: F401
|
|
firewall, # noqa: F401
|
|
logs, # noqa: F401
|
|
network, # noqa: F401
|
|
nginx, # noqa: F401
|
|
status, # noqa: F401
|
|
system, # noqa: F401
|
|
wireguard, # noqa: F401
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point for vacuum-walld.
|
|
|
|
Sets up logging, registers routes, creates the aiohttp application,
|
|
and starts listening on the Unix socket.
|
|
"""
|
|
from lib.logging import setup_logging
|
|
|
|
setup_logging()
|
|
|
|
# Startup checks
|
|
try:
|
|
from lib import webauthn as lib_webauthn
|
|
|
|
lib_webauthn.check_webauthn_config()
|
|
except Exception:
|
|
pass # ignore if webauthn module import failed
|
|
|
|
_register_routes()
|
|
app = create_app()
|
|
|
|
socket_path = os.environ.get("VACUUM_WALLD_SOCKET", str(SOCKET_PATH))
|
|
socket_dir = Path(socket_path).parent
|
|
socket_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
if Path(socket_path).exists():
|
|
os.unlink(socket_path)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def _shutdown() -> None:
|
|
"""Graceful shutdown: cancel poller, close runner, teardown."""
|
|
logger.info("Shutting down daemon...")
|
|
_stop_polling()
|
|
try:
|
|
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
|
except TimeoutError:
|
|
logger.warning("Runner cleanup timed out, abandoning")
|
|
if Path(socket_path).exists():
|
|
os.unlink(socket_path)
|
|
logger.info("vacuum-walld stopped")
|
|
loop.call_soon(loop.stop)
|
|
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
loop.add_signal_handler(sig, lambda: loop.create_task(_shutdown()))
|
|
|
|
runner = web.AppRunner(app)
|
|
loop.run_until_complete(runner.setup())
|
|
site = web.UnixSite(runner, socket_path)
|
|
loop.run_until_complete(site.start())
|
|
tcp_site = web.TCPSite(runner, "127.0.0.1", _WS_PORT)
|
|
loop.run_until_complete(tcp_site.start())
|
|
|
|
os.chmod(socket_path, 0o660)
|
|
|
|
# Import system configs → JSON (blocking — OK at startup)
|
|
from lib.system_import import import_all
|
|
|
|
reconciled = import_all()
|
|
if reconciled:
|
|
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
|
|
|
|
# Populate state from system (blocking — OK at startup)
|
|
logger.info("Populating system state...")
|
|
state_store.populate()
|
|
for subsystem in state_store.SUBSYSTEMS:
|
|
if state_store.get(subsystem) is not None:
|
|
state_store.bump(subsystem)
|
|
start_polling(loop)
|
|
logger.info("vacuum-walld listening on %s", socket_path)
|
|
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
|
|
|
|
try:
|
|
loop.run_forever()
|
|
finally:
|
|
# _shutdown() handles cleanup when invoked via signal handler;
|
|
# this block is only reached if shutdown didn't happen cleanly
|
|
# (e.g., unexpected exit), in which case we unlink the socket.
|
|
if Path(socket_path).exists():
|
|
os.unlink(socket_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|