"""aiohttp server for vacuum-walld. Listens on a Unix socket, serves the daemon API to the web UI. Handles routing, caching, 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 logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock" class Cache: """Tag-based cache. Entries persist until invalidated by write operations. External changes to system state (e.g., manual firewall-cmd, config edits on disk) bypass cache invalidation and will result in stale data until the cache is cleared or affected tags are invalidated. """ def __init__(self) -> None: self._store: dict[str, Any] = {} self._tags: dict[str, set[str]] = {} def get(self, key: str) -> Any | None: return self._store.get(key) def set(self, key: str, value: Any, tags: set[str]) -> None: self._store[key] = value self._tags[key] = tags def invalidate(self, *tags: str) -> None: for tag in tags: keys = [k for k, ts in self._tags.items() if tag in ts] for k in keys: self._store.pop(k, None) self._tags.pop(k, None) def clear(self) -> None: self._store.clear() self._tags.clear() cache = Cache() class Handler: """Wrapper for a daemon handler function.""" def __init__( self, method: str, path: str, cache_tags: set[str] | None = None, invalidate: set[str] | None = None, ) -> None: self.method = method.upper() self.path = path self.cache_tags = cache_tags or set() self.invalidate = invalidate or set() 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, cache_tags: set[str] | None = None, invalidate: set[str] | None = None, ): def decorator(fn: Callable) -> Callable: self._routes[(method.upper(), path)] = fn fn._handler = Handler(method, path, cache_tags, invalidate) # 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() 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) h = getattr(handler_fn, "_handler", None) # 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 cache_key = json.dumps( { "method": request.method, "path": request.path, "query": query_dict, "body": body, }, sort_keys=True, ) # Cache hit for read operations if h and h.cache_tags: cached = cache.get(cache_key) if cached is not None: return ok(cached) 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) # Cache write for read operations if h and h.cache_tags and isinstance(result, dict) and result.get("ok"): cache.set(cache_key, result.get("data"), h.cache_tags) # Invalidate on write operations if h and h.invalidate: cache.invalidate(*h.invalidate) # 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") h = getattr(handler_fn, "_handler", None) 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} # Invalidate on write if h and h.invalidate: cache.invalidate(*h.invalidate) return ok(results) def create_app() -> web.Application: app = web.Application() app.router.add_route("GET", "/health", _health) 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 _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) 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()