2f215793e9
Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs, and logs API endpoints. Document parameters, return values, and error cases for the documentation system.
380 lines
10 KiB
Python
380 lines
10 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.
|
|
|
|
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: str, path: str):
|
|
"""Decorator that registers a handler for the given method and path.
|
|
|
|
Args:
|
|
method: HTTP method (e.g. "GET", "POST").
|
|
path: URL path to register the handler under.
|
|
|
|
Returns:
|
|
Decorator function wrapping the handler.
|
|
"""
|
|
|
|
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:
|
|
"""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))
|
|
|
|
|
|
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)
|
|
|
|
|
|
class NotFoundError(Exception):
|
|
"""Raised when a requested resource is not found."""
|
|
|
|
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)
|
|
|
|
|
|
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 = 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.
|
|
|
|
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 = 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:
|
|
"""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("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 the health check response.
|
|
|
|
Returns:
|
|
JSON response with process ID and socket path.
|
|
"""
|
|
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.
|
|
|
|
Returns:
|
|
JSON response containing state data for all subsystems.
|
|
"""
|
|
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.
|
|
|
|
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
|
|
dnsmasq, # noqa: F401
|
|
firewall, # noqa: F401
|
|
logs, # noqa: F401
|
|
nginx, # 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()
|
|
|
|
_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()
|