c21639b7f1
Add docstrings to all handler functions in daemon/handlers/firewall.py, covering params, return values, and raised exceptions. Add inline comments to _config_apply() reconciliation steps and the request body merge order. Add docstrings across lib/ modules for emit helpers (_emit_str, _emit_int, etc.), volatile stripping logic, two-layer diff strategy, sync event dispatch, and all cross-subsystem sync subscribers (DnsToFirewall, WgToFirewall, FirewallToDhcp, NetworkToAllSync). Document WireGuard/networkd config parsers and key-value mappers in system_import.py. Add docstrings to _ep(), Registry.decorator, setup_logging, and _replace helper across daemon/ and lib/.
244 lines
7.6 KiB
Python
244 lines
7.6 KiB
Python
"""Sync daemon client for the web UI.
|
|
|
|
Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import urllib.parse
|
|
from typing import Any
|
|
|
|
import requests
|
|
import requests_unixsocket
|
|
|
|
from daemon.iface import PathLike
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_param_re = re.compile(r"<(\w+)>")
|
|
|
|
|
|
class NotFound(Exception):
|
|
"""Raised when the daemon returns HTTP 404."""
|
|
|
|
pass
|
|
|
|
|
|
class BadRequest(Exception):
|
|
"""Exception raised when the daemon returns HTTP 400.
|
|
|
|
Used to signal client-side input or formatting errors from the
|
|
daemon for distinction from general failures.
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
class Conflict(Exception):
|
|
"""Raised when the daemon returns HTTP 409."""
|
|
|
|
pass
|
|
|
|
|
|
_DEFAULT_SOCKET = None
|
|
|
|
|
|
def _get_socket_path() -> str:
|
|
"""Return the daemon Unix socket path.
|
|
|
|
Lazily resolves the path from the VACUUM_WALLD_SOCKET environment
|
|
variable or falls back to data/daemon.sock under the project
|
|
directory. The result is cached in _DEFAULT_SOCKET.
|
|
|
|
Returns:
|
|
Absolute path string for the daemon socket.
|
|
"""
|
|
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:
|
|
"""Override the default daemon socket path.
|
|
|
|
Useful for tests that need an alternate socket.
|
|
|
|
Args:
|
|
path: Absolute path to the Unix socket file.
|
|
"""
|
|
global _DEFAULT_SOCKET
|
|
_DEFAULT_SOCKET = path
|
|
|
|
|
|
def _format_path(path: str, params: dict[str, Any] | None) -> str:
|
|
"""Replace ``<param>`` path segments with URL-encoded values from *params*.
|
|
|
|
Args:
|
|
path: URL path that may contain ``<key>`` placeholders.
|
|
params: Dict of parameter values to substitute.
|
|
|
|
Returns:
|
|
Path with all ``<key>`` segments replaced by their URL-encoded
|
|
values. Unmatched placeholders are left unchanged.
|
|
"""
|
|
if params is None:
|
|
return path
|
|
|
|
def _replace(m: re.Match[str]) -> str:
|
|
"""Regex callback that replaces ``<key>`` segments with URL-encoded values.
|
|
|
|
Args:
|
|
m: Match object containing the parameter name.
|
|
|
|
Returns:
|
|
URL-encoded value from params dict, or original text if key
|
|
not found.
|
|
"""
|
|
key = m.group(1)
|
|
if key in params:
|
|
return urllib.parse.quote(str(params[key]), safe="")
|
|
return m.group(0)
|
|
|
|
return _param_re.sub(_replace, path)
|
|
|
|
|
|
def _resolve_path(method_or_ep: PathLike, path: str | None = None) -> tuple[str, str]:
|
|
"""Resolve method/path from an Endpoint tuple or two separate arguments."""
|
|
if isinstance(method_or_ep, tuple):
|
|
return (method_or_ep[0], method_or_ep[1])
|
|
if path is None:
|
|
raise ValueError("path is required when method is a string")
|
|
return (method_or_ep, path)
|
|
|
|
|
|
def request(
|
|
method: PathLike,
|
|
path: str | None = None,
|
|
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.
|
|
|
|
*method* can be an :class:`Endpoint` tuple from :mod:`daemon.iface`,
|
|
in which case *path* should be omitted.
|
|
|
|
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.
|
|
"""
|
|
resolved_method, resolved_path = _resolve_path(method, path)
|
|
|
|
# Substitute <param> segments from body/query params so the daemon
|
|
# receives a concrete path instead of a template.
|
|
# Merge body and query params for <param> substitution. query_params
|
|
# takes precedence on key conflicts, so callers should avoid passing
|
|
# the same key in both dicts.
|
|
combined = {**(json_body or {}), **(query_params or {})}
|
|
formatted_path = _format_path(resolved_path, combined)
|
|
|
|
sp = socket_path or _get_socket_path()
|
|
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{formatted_path}"
|
|
sess = requests_unixsocket.Session()
|
|
try:
|
|
kwargs: dict[str, Any] = {
|
|
"timeout": timeout,
|
|
}
|
|
if resolved_method == "GET":
|
|
if query_params:
|
|
kwargs["params"] = query_params
|
|
else:
|
|
if json_body is not None:
|
|
kwargs["json"] = json_body
|
|
resp = sess.request(
|
|
resolved_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: {resolved_method} {resolved_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
|
|
if resp.status_code == 409:
|
|
raise Conflict(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: PathLike, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
"""Send a GET request to the daemon.
|
|
|
|
*path* can be a :class:`Endpoint` tuple from :mod:`daemon.iface`
|
|
(e.g., ``GET_FIREWALL_ZONES``), or a plain string path.
|
|
"""
|
|
return request(path, query_params=params, **kwargs)
|
|
|
|
|
|
def post(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
"""Send a POST request to the daemon.
|
|
|
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
|
"""
|
|
return request(path, json_body=body, **kwargs)
|
|
|
|
|
|
def patch(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
"""Send a PATCH request to the daemon.
|
|
|
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
|
"""
|
|
return request(path, json_body=body, **kwargs)
|
|
|
|
|
|
def delete(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
"""Send a DELETE request to the daemon.
|
|
|
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
|
"""
|
|
return request(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 request("POST", "/batch", json_body={"ops": ops}, **kwargs)
|