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
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Sync daemon client for the web UI.
|
||||
|
||||
Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotFound(Exception):
|
||||
"""Raised when the daemon returns HTTP 404."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BadRequest(Exception):
|
||||
"""Raised when the daemon returns HTTP 400."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_DEFAULT_SOCKET = None
|
||||
|
||||
|
||||
def _get_socket_path() -> str:
|
||||
global _DEFAULT_SOCKET
|
||||
if _DEFAULT_SOCKET is None:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
project_dir = Path(__file__).resolve().parent.parent
|
||||
socket_env = os.environ.get(
|
||||
"VACUUM_WALLD_SOCKET", str(project_dir / "data" / "daemon.sock")
|
||||
)
|
||||
_DEFAULT_SOCKET = socket_env
|
||||
return _DEFAULT_SOCKET
|
||||
|
||||
|
||||
def set_socket_path(path: str) -> None:
|
||||
global _DEFAULT_SOCKET
|
||||
_DEFAULT_SOCKET = path
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
query_params: dict[str, Any] | None = None,
|
||||
socket_path: str | None = None,
|
||||
timeout: float = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Make a request to the daemon and return the parsed response body.
|
||||
|
||||
For GET requests, query_params are sent as URL query parameters instead
|
||||
of a JSON body. For other methods, json_body is sent as JSON.
|
||||
|
||||
Raises RuntimeError on non-2xx responses or connection errors.
|
||||
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
|
||||
"""
|
||||
sp = socket_path or _get_socket_path()
|
||||
url = f"http://localhost{path}"
|
||||
sess = requests_unixsocket.Session()
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"timeout": timeout,
|
||||
"unix_socket": sp,
|
||||
}
|
||||
if method == "GET":
|
||||
if query_params:
|
||||
kwargs["params"] = query_params
|
||||
else:
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
resp = sess.request(
|
||||
method,
|
||||
url,
|
||||
**kwargs,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"Daemon returned non-JSON response ({resp.status_code}): {exc}"
|
||||
) from exc
|
||||
except requests.ConnectionError as exc:
|
||||
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
|
||||
except requests.Timeout as exc:
|
||||
raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc
|
||||
except requests.HTTPError as exc:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "error": resp.text}
|
||||
if not data.get("ok"):
|
||||
if resp.status_code == 404:
|
||||
raise NotFound(data.get("error", str(exc))) from exc
|
||||
if resp.status_code == 400:
|
||||
raise BadRequest(data.get("error", str(exc))) from exc
|
||||
raise RuntimeError(data.get("error", str(exc))) from exc
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(data.get("error", "Unknown error"))
|
||||
return data.get("data")
|
||||
|
||||
|
||||
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""GET request to daemon. Params are sent as URL query parameters."""
|
||||
return request("GET", path, query_params=params, **kwargs)
|
||||
|
||||
|
||||
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""POST request to daemon."""
|
||||
return request("POST", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""PATCH request to daemon."""
|
||||
return request("PATCH", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""DELETE request to daemon."""
|
||||
return request("DELETE", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
||||
"""Batch request to daemon.
|
||||
|
||||
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
|
||||
Returns a dict mapping each id to its result.
|
||||
"""
|
||||
return post("/batch", {"ops": ops}, **kwargs)
|
||||
Reference in New Issue
Block a user