Files
vacuum-wall/daemon/server.py
T
mteehan 200e078bc5 refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
2026-05-27 23:39:33 +00:00

322 lines
9.1 KiB
Python

"""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()