Files
vacuum-wall/daemon/server.py
T
mteehan dc96e15643 feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
2026-05-30 05:46:09 +00:00

274 lines
8.1 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 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()