"""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 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 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" class Handler: """Wrapper for a daemon handler function.""" def __init__(self, method: str, path: str) -> None: self.method = method.upper() self.path = path class Registry: """Route registry for daemon handlers.""" def __init__(self) -> None: self._routes: dict[tuple[str, str], Callable] = {} def register(self, method: str, path: str): def decorator(fn: Callable) -> Callable: self._routes[(method.upper(), path)] = fn fn._handler = Handler(method, path) # type: ignore[attr-defined] return fn return decorator def get(self, method: str, path: str) -> Callable | None: return self._routes.get((method.upper(), path)) registry = Registry() def refresh_state(subsystems: list[str] | None = None) -> None: """Refresh the pre-computed state for the given subsystems (or all).""" state_store.populate(subsystems) class NotFoundError(Exception): """Raised when a requested resource is not found.""" pass def ok(data: Any = None) -> web.Response: return web.json_response({"ok": True, "data": data}) def error(msg: str, code: int = 400) -> web.Response: return web.json_response({"ok": False, "error": msg}, status=code) async def _handle_request(request: web.Request) -> web.Response: """Dispatch a request to the appropriate handler.""" handler_fn = registry.get(request.method, request.path) if handler_fn is None: return error(f"Method {request.method} not allowed for {request.path}", 404) # Build body from JSON and merge query params. GET requests send params # as URL query string, so they need to be treated as body for handlers. body: dict[str, Any] | None = None if request.content_type == "application/json": try: body = await request.json() 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()} if body is not None: merged = {**query_body, **body} body = merged else: body = 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 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.""" 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 = registry.get(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") 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: app = web.Application() app.router.add_route("GET", "/health", _health) app.router.add_route("GET", "/status/all", get_status_all) app.router.add_route("POST", "/status/refresh", refresh_status) app.router.add_route("POST", "/batch", _handle_batch) app.router.add_route("*", "/{tail:.*}", _catch_all) return app async def _health(_request: web.Request) -> web.Response: return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)}) async def get_status_all(_request: web.Request) -> web.Response: """Return the entire state snapshot in one call.""" return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS}) async def refresh_status(_request: web.Request) -> web.Response: """Re-collect all state from system.""" 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.""" return await _handle_request(request) def _register_routes() -> None: """Import all handler modules to register routes.""" from daemon.handlers import ( acme, # noqa: F401 dnsmasq, # noqa: F401 firewall, # noqa: F401 logs, # noqa: F401 nginx, # noqa: F401 wireguard, # noqa: F401 ) def main() -> None: """Entry point for vacuum-walld.""" from lib.logging import setup_logging setup_logging() _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() def _on_shutdown(_sig: int) -> None: logger.info("Shutting down daemon...") loop.stop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, _on_shutdown, sig) runner = web.AppRunner(app) loop.run_until_complete(runner.setup()) site = web.UnixSite(runner, socket_path) loop.run_until_complete(site.start()) os.chmod(socket_path, 0o660) # Populate state from system (blocking — OK at startup) logger.info("Populating system state...") state_store.populate() logger.info("vacuum-walld listening on %s", socket_path) try: loop.run_forever() finally: loop.run_until_complete(runner.cleanup()) if Path(socket_path).exists(): os.unlink(socket_path) logger.info("vacuum-walld stopped") if __name__ == "__main__": main()