diff --git a/daemon/client.py b/daemon/client.py
index bd6264c..e0e2b04 100644
--- a/daemon/client.py
+++ b/daemon/client.py
@@ -5,14 +5,19 @@ 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."""
@@ -68,9 +73,41 @@ def set_socket_path(path: str) -> None:
_DEFAULT_SOCKET = path
+def _format_path(path: str, params: dict[str, Any] | None) -> str:
+ """Replace ```` path segments with URL-encoded values from *params*.
+
+ Args:
+ path: URL path that may contain ```` placeholders.
+ params: Dict of parameter values to substitute.
+
+ Returns:
+ Path with all ```` 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:
+ 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: str,
- path: str,
+ 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,
@@ -78,27 +115,40 @@ def request(
) -> 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 segments from body/query params so the daemon
+ # receives a concrete path instead of a template.
+ # Merge body and query params for 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='')}{path}"
+ url = f"http+unix://{urllib.parse.quote(sp, safe='')}{formatted_path}"
sess = requests_unixsocket.Session()
try:
kwargs: dict[str, Any] = {
"timeout": timeout,
}
- if method == "GET":
+ 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(
- method,
+ resolved_method,
url,
**kwargs,
)
@@ -112,7 +162,9 @@ def request(
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
+ raise RuntimeError(
+ f"Daemon request timed out: {resolved_method} {resolved_path}"
+ ) from exc
except requests.HTTPError as exc:
try:
data = resp.json()
@@ -129,72 +181,40 @@ def request(
return data.get("data")
-def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+def get(path: PathLike, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""Send a GET request to the daemon.
- Query parameters are passed as URL params rather than a JSON body.
- Additional keyword arguments are forwarded to request().
-
- Args:
- path: URL path to request on the daemon.
- params: Optional query parameters to append to the URL.
- **kwargs: Extra arguments forwarded to request().
-
- Returns:
- The parsed JSON response data from 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("GET", path, query_params=params, **kwargs)
+ return request(path, query_params=params, **kwargs)
-def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+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().
-
- Args:
- path: URL path to request on the daemon.
- body: Optional JSON-serializable payload.
- **kwargs: Extra arguments forwarded to request().
-
- Returns:
- The parsed JSON response data from the daemon.
+ are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
- return request("POST", path, json_body=body, **kwargs)
+ return request(path, json_body=body, **kwargs)
-def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+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().
-
- Args:
- path: URL path to request on the daemon.
- body: Optional JSON-serializable payload.
- **kwargs: Extra arguments forwarded to request().
-
- Returns:
- The parsed JSON response data from the daemon.
+ are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
- return request("PATCH", path, json_body=body, **kwargs)
+ return request(path, json_body=body, **kwargs)
-def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
+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().
-
- Args:
- path: URL path to request on the daemon.
- body: Optional JSON-serializable payload.
- **kwargs: Extra arguments forwarded to request().
-
- Returns:
- The parsed JSON response data from the daemon.
+ are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
"""
- return request("DELETE", path, json_body=body, **kwargs)
+ return request(path, json_body=body, **kwargs)
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
@@ -203,4 +223,4 @@ def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
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)
+ return request("POST", "/batch", json_body={"ops": ops}, **kwargs)
diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py
index 00ea021..ef2298e 100644
--- a/daemon/handlers/acme.py
+++ b/daemon/handlers/acme.py
@@ -12,7 +12,21 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
+from daemon.iface import (
+ DELETE_ACME_REMOVE,
+ GET_ACME_EMAIL,
+ GET_ACME_INFO,
+ GET_ACME_ISSUE_STATUS,
+ GET_ACME_LIST,
+ GET_ACME_PATHS,
+ POST_ACME_EMAIL,
+ POST_ACME_ISSUE,
+ POST_ACME_RENEW,
+ POST_ACME_SELF_SIGNED,
+ POST_ACME_VALIDATE,
+)
from daemon.server import NotFoundError, refresh_state, registry
+from lib.state import _run_acme
logger = logging.getLogger(__name__)
@@ -99,38 +113,6 @@ class IssueRequest:
# Internal helpers
-def _run_acme(args: list[str]) -> str:
- """Execute an acme.sh command and return combined output.
-
- Returns:
- Standard output (plus stderr).
-
- Raises:
- RuntimeError: On timeout or non-zero exit.
- """
- from lib.state import _find_acme
-
- acme_bin = _find_acme()
- acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
- cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
- try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=120,
- env={**os.environ, **_ACME_ENVIRON},
- )
- except subprocess.TimeoutExpired as exc:
- raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
- output = result.stdout
- if result.stderr:
- output = output + result.stderr if output else result.stderr
- if result.returncode != 0:
- raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
- return output
-
-
def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
@@ -326,14 +308,14 @@ def _validate(domain: str) -> dict[str, Any]:
# Routes — status reads from state, mutations call refresh_state
-@registry.register("GET", "/acme/list")
+@registry.register(GET_ACME_LIST)
def list_certs(_request: Any, _body: Any) -> list[dict]:
"""GET /acme/list — return managed certificates."""
ac = _get_acme_state()
return ac.get("certs", [])
-@registry.register("GET", "/acme/info")
+@registry.register(GET_ACME_INFO)
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate.
@@ -351,7 +333,7 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
raise NotFoundError(f"No certificate found for domain: {domain}")
-@registry.register("POST", "/acme/validate")
+@registry.register(POST_ACME_VALIDATE)
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/validate — run pre-flight checks for a domain.
@@ -366,7 +348,7 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return _validate(domain)
-@registry.register("POST", "/acme/issue")
+@registry.register(POST_ACME_ISSUE)
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/issue — create a new certificate issuance request.
@@ -429,7 +411,7 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"request_id": request_id, "domain": domain}
-@registry.register("GET", "/acme/issue/status")
+@registry.register(GET_ACME_ISSUE_STATUS)
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/issue/status — poll status of an issuance request.
@@ -498,7 +480,7 @@ async def _run_issue(req: IssueRequest) -> None:
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
-@registry.register("POST", "/acme/renew")
+@registry.register(POST_ACME_RENEW)
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
@@ -524,7 +506,7 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain, "output": output.strip()}
-@registry.register("DELETE", "/acme/remove")
+@registry.register(DELETE_ACME_REMOVE)
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/remove — remove a certificate from ACME management.
@@ -542,7 +524,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
-@registry.register("POST", "/acme/email")
+@registry.register(POST_ACME_EMAIL)
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/email — set the ACME contact email via account registration.
@@ -570,7 +552,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"email": email}
-@registry.register("GET", "/acme/email")
+@registry.register(GET_ACME_EMAIL)
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state()
@@ -582,7 +564,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
return {"email": email}
-@registry.register("GET", "/acme/paths")
+@registry.register(GET_ACME_PATHS)
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
@@ -600,3 +582,68 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
}
+
+
+@registry.register(POST_ACME_SELF_SIGNED)
+def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
+ """POST /acme/self-signed — generate a self-signed certificate for a domain.
+
+ Idempotent: skips generation if cert and key already exist.
+
+ Raises:
+ ValueError: When domain is missing.
+ """
+ if not body:
+ raise ValueError("Request body required")
+ domain = body.get("domain", "").strip()
+ if not domain:
+ raise ValueError("'domain' is required")
+ days = body.get("days", 365)
+
+ cert_dir = _ACME_HOME / domain
+ cert_dir.mkdir(parents=True, exist_ok=True)
+ cert_file = cert_dir / "fullchain.cer"
+ key_file = cert_dir / f"{domain}.key"
+
+ if cert_file.is_file() and key_file.is_file():
+ logger.info("Self-signed cert for %s already exists, skipping", domain)
+ return {
+ "domain": domain,
+ "cert": str(cert_file),
+ "key": str(key_file),
+ "generated": False,
+ }
+
+ subprocess.run(
+ [
+ "openssl",
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-keyout",
+ str(key_file),
+ "-out",
+ str(cert_file),
+ "-days",
+ str(days),
+ "-nodes",
+ "-subj",
+ f"/CN={domain}",
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+
+ cert_file.chmod(0o644)
+ key_file.chmod(0o600)
+
+ logger.info("Self-signed cert for %s generated (%d days)", domain, days)
+ return {
+ "domain": domain,
+ "cert": str(cert_file),
+ "key": str(key_file),
+ "generated": True,
+ }
diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py
index a6bec75..a4af2ea 100644
--- a/daemon/handlers/dnsmasq.py
+++ b/daemon/handlers/dnsmasq.py
@@ -8,6 +8,22 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
+from daemon.iface import (
+ DELETE_DNSMASQ_DNS_RECORD_REMOVE,
+ DELETE_DNSMASQ_RANGES_REMOVE,
+ DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
+ GET_DNSMASQ_CONFIG,
+ GET_DNSMASQ_LEASES,
+ GET_DNSMASQ_STATUS,
+ PATCH_DNSMASQ_CONFIG,
+ POST_DNSMASQ_APPLY,
+ POST_DNSMASQ_CONFIG,
+ POST_DNSMASQ_DNS_RECORD_ADD,
+ POST_DNSMASQ_DOMAIN,
+ POST_DNSMASQ_RANGES_ADD,
+ POST_DNSMASQ_STATIC_LEASE_ADD,
+ POST_DNSMASQ_UPSTREAMS,
+)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
@@ -64,10 +80,26 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
+
+ # Fallback: use network-managed interface addresses for listen-address
+ listen_addresses = []
+ try:
+ from lib.network import get_config as _get_net_config
+
+ net_cfg = _get_net_config()
+ for _iface, info in net_cfg.get("interfaces", {}).items():
+ for addr_str in info.get("addresses", []):
+ if "/" in addr_str:
+ addr_str = addr_str.split("/")[0]
+ listen_addresses.append(addr_str)
+ except Exception:
+ pass
+
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
- interfaces=interfaces,
+ interfaces=interfaces or None,
+ listen_addresses=listen_addresses if listen_addresses else None,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
@@ -86,7 +118,7 @@ def _get_dnsmasq_state() -> dict[str, Any]:
# Routes
-@registry.register("GET", "/dnsmasq/config")
+@registry.register(GET_DNSMASQ_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/config
@@ -99,7 +131,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
-@registry.register("POST", "/dnsmasq/config")
+@registry.register(POST_DNSMASQ_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/config
@@ -113,7 +145,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
-@registry.register("PATCH", "/dnsmasq/config")
+@registry.register(PATCH_DNSMASQ_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: PATCH /dnsmasq/config
@@ -129,7 +161,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
-@registry.register("POST", "/dnsmasq/apply")
+@registry.register(POST_DNSMASQ_APPLY)
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/apply
@@ -152,7 +184,7 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
-@registry.register("GET", "/dnsmasq/status")
+@registry.register(GET_DNSMASQ_STATUS)
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/status
@@ -165,7 +197,7 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
return {}
-@registry.register("POST", "/dnsmasq/ranges/add")
+@registry.register(POST_DNSMASQ_RANGES_ADD)
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/ranges/add
@@ -214,7 +246,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"interface": iface, "start": start, "end": end}
-@registry.register("DELETE", "/dnsmasq/ranges/remove")
+@registry.register(DELETE_DNSMASQ_RANGES_REMOVE)
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/ranges/remove
@@ -249,7 +281,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"interface": iface, "start": start, "end": end}
-@registry.register("GET", "/dnsmasq/leases")
+@registry.register(GET_DNSMASQ_LEASES)
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\
Endpoint: GET /dnsmasq/leases
@@ -262,7 +294,7 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
return []
-@registry.register("POST", "/dnsmasq/static-lease/add")
+@registry.register(POST_DNSMASQ_STATIC_LEASE_ADD)
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/static-lease/add
@@ -295,7 +327,7 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"mac": mac, "ip": ip, "hostname": hostname}
-@registry.register("DELETE", "/dnsmasq/static-lease/remove")
+@registry.register(DELETE_DNSMASQ_STATIC_LEASE_REMOVE)
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/static-lease/remove
@@ -320,7 +352,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"mac": mac}
-@registry.register("POST", "/dnsmasq/dns-record/add")
+@registry.register(POST_DNSMASQ_DNS_RECORD_ADD)
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/dns-record/add
@@ -353,7 +385,7 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"name": name, "address": address, "hostname": hostname}
-@registry.register("DELETE", "/dnsmasq/dns-record/remove")
+@registry.register(DELETE_DNSMASQ_DNS_RECORD_REMOVE)
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/dns-record/remove
@@ -376,7 +408,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"name": name}
-@registry.register("POST", "/dnsmasq/upstreams")
+@registry.register(POST_DNSMASQ_UPSTREAMS)
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/upstreams
@@ -392,7 +424,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"upstreams": cfg["dns"]["upstreams"]}
-@registry.register("POST", "/dnsmasq/domain")
+@registry.register(POST_DNSMASQ_DOMAIN)
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/domain
diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py
index a3f9645..eb913cb 100644
--- a/daemon/handlers/firewall.py
+++ b/daemon/handlers/firewall.py
@@ -10,6 +10,29 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
+from daemon.iface import (
+ DELETE_FIREWALL_FORWARD_PORT_REMOVE,
+ DELETE_FIREWALL_RICH_RULES_REMOVE,
+ DELETE_FIREWALL_ZONES_DELETE,
+ GET_FIREWALL_CONFIG,
+ GET_FIREWALL_CONFIG_PENDING,
+ GET_FIREWALL_INTERFACES,
+ GET_FIREWALL_RICH_RULES,
+ GET_FIREWALL_SERVICES,
+ GET_FIREWALL_STATE,
+ GET_FIREWALL_ZONES,
+ GET_FIREWALL_ZONES_ALL,
+ GET_FIREWALL_ZONES_INFO,
+ PATCH_FIREWALL_CONFIG,
+ POST_FIREWALL_CONFIG,
+ POST_FIREWALL_CONFIG_APPLY,
+ POST_FIREWALL_FORWARD_PORT_ADD,
+ POST_FIREWALL_MASQUERADE,
+ POST_FIREWALL_RICH_RULES_ADD,
+ POST_FIREWALL_ZONES_CREATE,
+ POST_FIREWALL_ZONES_INTERFACES,
+ POST_FIREWALL_ZONES_SERVICES,
+)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from lib.firewall import (
@@ -269,14 +292,14 @@ def _get_fw_state() -> dict[str, Any]:
return fw
-@registry.register("GET", "/firewall/interfaces")
+@registry.register(GET_FIREWALL_INTERFACES)
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /firewall/interfaces — return active interfaces from state."""
fw = _get_fw_state()
return fw.get("interfaces", [])
-@registry.register("GET", "/firewall/zones")
+@registry.register(GET_FIREWALL_ZONES)
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
active = fw.get("active_zones", {})
@@ -284,7 +307,7 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
return {"active": active, "available": list(zones.keys())}
-@registry.register("GET", "/firewall/zones/info")
+@registry.register(GET_FIREWALL_ZONES_INFO)
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -296,7 +319,7 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return zones[zone]
-@registry.register("GET", "/firewall/zones/all")
+@registry.register(GET_FIREWALL_ZONES_ALL)
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
fw = _get_fw_state()
active = fw.get("active_zones", {})
@@ -308,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result
-@registry.register("GET", "/firewall/services")
+@registry.register(GET_FIREWALL_SERVICES)
def get_services(_request: Any, _body: Any) -> list[str]:
fw = _get_fw_state()
return fw.get("available_services", [])
-@registry.register("GET", "/firewall/config")
+@registry.register(GET_FIREWALL_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
-@registry.register("POST", "/firewall/config")
+@registry.register(POST_FIREWALL_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
@@ -331,7 +354,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
-@registry.register("PATCH", "/firewall/config")
+@registry.register(PATCH_FIREWALL_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body must be a JSON object")
@@ -345,13 +368,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
-@registry.register("GET", "/firewall/config/pending")
+@registry.register(GET_FIREWALL_CONFIG_PENDING)
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
return fw.get("pending", {})
-@registry.register("POST", "/firewall/config/apply")
+@registry.register(POST_FIREWALL_CONFIG_APPLY)
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
@@ -359,7 +382,7 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
return result
-@registry.register("POST", "/firewall/zones/create")
+@registry.register(POST_FIREWALL_ZONES_CREATE)
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -385,7 +408,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone_name}
-@registry.register("DELETE", "/firewall/zones/delete")
+@registry.register(DELETE_FIREWALL_ZONES_DELETE)
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -400,7 +423,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone}
-@registry.register("POST", "/firewall/zones/interfaces")
+@registry.register(POST_FIREWALL_ZONES_INTERFACES)
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -470,7 +493,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"zone": zone, "interfaces": interfaces}
-@registry.register("POST", "/firewall/zones/services")
+@registry.register(POST_FIREWALL_ZONES_SERVICES)
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -509,7 +532,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
return {"zone": zone, "services": services}
-@registry.register("POST", "/firewall/rich-rules/add")
+@registry.register(POST_FIREWALL_RICH_RULES_ADD)
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -540,7 +563,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"zone": zone, "id": rule_id, "rule": rule}
-@registry.register("DELETE", "/firewall/rich-rules/remove")
+@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -578,7 +601,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"zone": zone, "id": rule_id}
-@registry.register("GET", "/firewall/rich-rules")
+@registry.register(GET_FIREWALL_RICH_RULES)
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -598,7 +621,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
return result
-@registry.register("POST", "/firewall/masquerade")
+@registry.register(POST_FIREWALL_MASQUERADE)
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -613,7 +636,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"zone": zone, "masquerade": bool(enable)}
-@registry.register("POST", "/firewall/forward-port/add")
+@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -656,7 +679,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
-@registry.register("DELETE", "/firewall/forward-port/remove")
+@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -703,7 +726,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"zone": zone, "port": int(port), "proto": proto}
-@registry.register("GET", "/firewall/state")
+@registry.register(GET_FIREWALL_STATE)
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_state()
if fw is None:
diff --git a/daemon/handlers/logs.py b/daemon/handlers/logs.py
index e1bd25c..cc15b38 100644
--- a/daemon/handlers/logs.py
+++ b/daemon/handlers/logs.py
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
import logging
from pathlib import Path
-from daemon.server import registry
+from daemon.iface import (
+ GET_LOGS_APP,
+ GET_LOGS_DNSMASQ,
+ GET_LOGS_JOURNAL,
+ GET_LOGS_NGINX_ACCESS,
+ GET_LOGS_NGINX_ERROR,
+)
+from daemon.server import NotFoundError, registry
from lib.common import run_proc
logger = logging.getLogger(__name__)
@@ -36,9 +43,9 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
lines = f.readlines()
return "".join(lines[-n:])
except FileNotFoundError:
- return "(log file not found)\n"
+ raise NotFoundError("log file not found") from None
except PermissionError:
- return "(permission denied)\n"
+ raise RuntimeError("permission denied") from None
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
@@ -61,34 +68,34 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
output = result.stdout.strip()
return output if output else f"(no journal entries for {unit})\n"
except Exception as exc:
- return f"(error reading journal: {exc})\n"
+ raise RuntimeError(f"error reading journal: {exc}") from exc
-@registry.register("GET", "/logs/journal")
+@registry.register(GET_LOGS_JOURNAL)
def journal(_request, _body) -> str:
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
return _sudo_journalctl("vacuum-wall")
-@registry.register("GET", "/logs/nginx/access")
+@registry.register(GET_LOGS_NGINX_ACCESS)
def nginx_access(_request, _body) -> str:
"""GET /logs/nginx/access — return recent nginx access log lines."""
return _tail_file("/var/log/nginx/access.log", sudo=True)
-@registry.register("GET", "/logs/nginx/error")
+@registry.register(GET_LOGS_NGINX_ERROR)
def nginx_error(_request, _body) -> str:
"""GET /logs/nginx/error — return recent nginx error log lines."""
return _tail_file("/var/log/nginx/error.log", sudo=True)
-@registry.register("GET", "/logs/dnsmasq")
+@registry.register(GET_LOGS_DNSMASQ)
def dnsmasq_log(_request, _body) -> str:
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
return _sudo_journalctl("dnsmasq")
-@registry.register("GET", "/logs/app")
+@registry.register(GET_LOGS_APP)
def app_log(_request, _body) -> str:
"""GET /logs/app — return recent application log lines."""
return _tail_file(str(_APP_LOG_FILE))
diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py
index 8766bb2..2f08a31 100644
--- a/daemon/handlers/network.py
+++ b/daemon/handlers/network.py
@@ -6,13 +6,25 @@ via config/network/config.json and generated .network files.
import contextlib
import logging
+import re
from pathlib import Path
from typing import Any
+from daemon.iface import (
+ GET_NETWORK_INFER_DHCP_RANGES,
+ GET_NETWORK_INFER_ZONES,
+ GET_NETWORK_INTERFACE_NAME,
+ GET_NETWORK_INTERFACES,
+ POST_NETWORK_APPLY,
+ POST_NETWORK_INTERFACE_NAME,
+ POST_NETWORK_INTERFACE_RELOAD,
+ POST_NETWORK_SYSCTL_SET,
+)
from daemon.server import NotFoundError, registry
-from lib.common import run
+from lib.common import run, validate_interface_name
from lib.dnsmasq import set_upstreams
from lib.network import (
+ KNOWN_INTERFACE_KEYS,
collect_upstream_dns,
generate_network_files,
get_config,
@@ -31,15 +43,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd"
def _copy_and_reload(iface_name: str) -> None:
- """Copy generated 50-.network file to /etc/systemd/network/ and reload."""
- src = DATA_DIR / f"50-{iface_name}.network"
+ """Copy generated 99-.network file to /etc/systemd/network/ and reload."""
+ validate_interface_name(iface_name)
+ src = DATA_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
run(["mkdir", "-p", str(dst_dir)], sudo=True)
- dst = dst_dir / f"50-{iface_name}.network"
+ dst = dst_dir / f"99-{iface_name}.network"
run(["cp", str(src), str(dst)], sudo=True)
+
+ # Remove lower-priority .network files that match this interface
+ # (they would override our config due to higher systemd priority)
+ if dst_dir.exists():
+ for f in dst_dir.iterdir():
+ if (
+ f.name.endswith(".network")
+ and f.name != dst.name
+ and _matches_interface(f.name, iface_name)
+ ):
+ with contextlib.suppress(Exception):
+ run(["rm", str(f)], sudo=True)
+ logger.info("Removed conflicting file: %s", f.name)
+
+ run(["networkctl", "reload"], sudo=True)
run(["networkctl", "reconfigure", iface_name], sudo=True)
+def _matches_interface(filename: str, iface_name: str) -> bool:
+ """Check if a .network filename would match the given interface."""
+ base = filename.replace(".network", "")
+ # Strip numeric priority prefix (e.g. "50-eth1" → "eth1")
+ if "-" in base and base.split("-", 1)[0].isdigit():
+ base = base.split("-", 1)[1]
+ return base == iface_name
+
+
+def _extract_iface_from_filename(filename: str) -> str | None:
+ """Extract interface name from a .network filename (e.g. '50-eth1.network' → 'eth1')."""
+ base = filename.replace(".network", "")
+ if "-" in base and base.split("-", 1)[0].isdigit():
+ return base.split("-", 1)[1]
+ return base if base else None
+
+
def _full_reload() -> None:
"""Reload networkd for all interfaces."""
run(["networkctl", "reload"], sudo=True)
@@ -50,7 +95,7 @@ def _full_reload() -> None:
# ---------------------------------------------------------------------------
-@registry.register("GET", "/network/interfaces")
+@registry.register(GET_NETWORK_INTERFACES)
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/interfaces — return all interface config + runtime state."""
cfg = get_config()
@@ -68,15 +113,17 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"runtime": runtime.get(name, {}),
}
- return {"interfaces": merged, "timestamp": ""}
+ from lib.state import _now_iso
+
+ return {"interfaces": merged, "timestamp": _now_iso()}
-@registry.register("GET", "/network/interfaces/")
+@registry.register(GET_NETWORK_INTERFACE_NAME)
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /network/interfaces/ — return config for one interface."""
if not body or "name" not in body:
raise ValueError("Interface name is required")
- name = body["name"]
+ name = validate_interface_name(body["name"])
cfg = get_config()
ifaces = cfg.get("interfaces", {})
if name not in ifaces:
@@ -94,17 +141,24 @@ def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
}
-@registry.register("POST", "/network/interfaces/")
+@registry.register(POST_NETWORK_INTERFACE_NAME)
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/ — save config, render, apply."""
if not body:
raise ValueError("Request body required")
- name = body.get("name", "").strip()
- if not name:
- raise ValueError("'name' is required")
+ name = validate_interface_name(body.get("name", ""))
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
+ unknown = set(iface_cfg.keys()) - KNOWN_INTERFACE_KEYS
+ if unknown:
+ logger.warning(
+ "Interface '%s': unexpected config keys %s — these will be "
+ "saved but not rendered to .network files",
+ name,
+ sorted(unknown),
+ )
+
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
@@ -122,7 +176,7 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
content = render_network_file(name, iface_cfg)
DATA_DIR.mkdir(parents=True, exist_ok=True)
- (DATA_DIR / f"50-{name}.network").write_text(content)
+ (DATA_DIR / f"99-{name}.network").write_text(content)
# Deploy to system. In containerized environments this may fail
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
@@ -142,12 +196,12 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"name": name, "applied": deployed}
-@registry.register("POST", "/network/interfaces//reload")
+@registry.register(POST_NETWORK_INTERFACE_RELOAD)
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces//reload — reload networkd for interface."""
if not body or "name" not in body:
raise ValueError("'name' is required in request body")
- name = body["name"]
+ name = validate_interface_name(body["name"])
with contextlib.suppress(Exception):
run(["networkctl", "reconfigure", name], sudo=True)
@@ -156,7 +210,7 @@ def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"name": name, "reloaded": True}
-@registry.register("POST", "/network/apply")
+@registry.register(POST_NETWORK_APPLY)
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /network/apply — apply ALL interfaces (full sync)."""
cfg = get_config()
@@ -164,14 +218,21 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
generated = result.get("generated", [])
cleaned = result.get("cleaned", [])
- # Remove stale files from system dir that aren't in config
+ # Remove stale/conflicting files from system dir
expected_names = {f.name for f in generated}
+ managed_ifaces = {
+ f.name.replace("99-", "").replace(".network", "") for f in generated
+ }
sys_dir = Path("/etc/systemd/network")
if sys_dir.exists():
for f in sys_dir.iterdir():
if f.name.endswith(".network") and f.name not in expected_names:
- with contextlib.suppress(Exception):
- run(["rm", str(f)], sudo=True)
+ iface_from_file = _extract_iface_from_filename(f.name)
+ if iface_from_file and iface_from_file in managed_ifaces:
+ # Remove conflicting external configs for managed interfaces
+ with contextlib.suppress(Exception):
+ run(["rm", str(f)], sudo=True)
+ cleaned.append(f)
for f in generated:
dst = sys_dir / f.name
@@ -201,7 +262,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
}
-@registry.register("GET", "/network/infer-dhcp-ranges")
+@registry.register(GET_NETWORK_INFER_DHCP_RANGES)
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
cfg = get_config()
@@ -209,9 +270,44 @@ def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
return {"ranges": ranges}
-@registry.register("GET", "/network/infer-zones")
+@registry.register(GET_NETWORK_INFER_ZONES)
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
cfg = get_config()
zones = infer_zones(cfg)
return {"zones": zones}
+
+
+@registry.register(POST_NETWORK_SYSCTL_SET)
+def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
+ """POST /sysctl/set — set a sysctl kernel parameter value.
+
+ Writes the value via `sysctl -w`, then verifies by reading it back.
+
+ Raises:
+ ValueError: When name or value is missing.
+ """
+ if not body:
+ raise ValueError("Request body required")
+ name = body.get("name", "").strip()
+ if not name:
+ raise ValueError("'name' is required")
+ if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
+ raise ValueError("'name' is not a valid sysctl key")
+ value = str(body.get("value", "")).strip()
+ if not value:
+ raise ValueError("'value' is required")
+
+ run(["sysctl", "-w", f"{name}={value}"], sudo=True)
+
+ # Verify by reading back via /proc/sys (no sudo needed for reads, avoid
+ # triggering sudoers for read-only sysctl which is not whitelisted)
+ proc_path = Path(f"/proc/sys/{name.replace('.', '/')}")
+ read_value = proc_path.read_text().strip()
+ if read_value != value:
+ raise RuntimeError(
+ f"sysctl verify failed: set {name}={value} but read back {read_value}"
+ )
+
+ logger.info("sysctl %s set to %s", name, value)
+ return {"name": name, "value": value}
diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py
index 29057ef..fa63bd9 100644
--- a/daemon/handlers/nginx.py
+++ b/daemon/handlers/nginx.py
@@ -8,6 +8,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
+from daemon.iface import (
+ DELETE_NGINX_DOMAINS_REMOVE,
+ GET_NGINX_CONFIG,
+ GET_NGINX_DOMAINS,
+ PATCH_NGINX_CONFIG,
+ POST_NGINX_APPLY,
+ POST_NGINX_CONFIG,
+ POST_NGINX_DOMAINS_ADD,
+ POST_NGINX_DOMAINS_UPDATE,
+ POST_NGINX_MANAGEMENT,
+ POST_NGINX_RELOAD,
+ POST_NGINX_SSL_APPLY,
+ POST_NGINX_TEST,
+)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
@@ -125,7 +139,7 @@ def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
- tmp = INCLUDE_FILE.with_suffix(".tmp")
+ tmp = Path("/tmp") / "vacuum-wall-include.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
@@ -143,7 +157,7 @@ def _write_ssl_snippet() -> None:
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
- tmp = SSL_SNIPPET.with_suffix(".tmp")
+ tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
@@ -225,6 +239,20 @@ def _write_all_sites() -> None:
os.replace(tmp, site)
+def _hash_password(password: str) -> str:
+ """Hash *password* using SHA-256 crypt via passlib.
+
+ Args:
+ password: Plain-text password to hash.
+
+ Returns:
+ The hashed password string suitable for ``.htpasswd``.
+ """
+ from passlib.hash import sha256_crypt
+
+ return sha256_crypt.hash(password)
+
+
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
@@ -233,10 +261,7 @@ def _write_htpasswd(user: str, password: str) -> None:
password: Plain-text password to hash.
"""
ensure_dirs(DATA_DIR)
- import crypt
-
- salt = os.urandom(16).hex()[:16]
- hashed = crypt.crypt(password, f"$5${salt}")
+ hashed = _hash_password(password)
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
@@ -268,7 +293,7 @@ def _get_nginx_state() -> dict[str, Any]:
# Routes
-@registry.register("GET", "/nginx/config")
+@registry.register(GET_NGINX_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config.
@@ -281,7 +306,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
-@registry.register("POST", "/nginx/config")
+@registry.register(POST_NGINX_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/config — replace the entire nginx config and refresh state.
@@ -295,7 +320,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
-@registry.register("PATCH", "/nginx/config")
+@registry.register(PATCH_NGINX_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config.
@@ -313,7 +338,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
-@registry.register("GET", "/nginx/domains")
+@registry.register(GET_NGINX_DOMAINS)
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains.
@@ -326,7 +351,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
return []
-@registry.register("POST", "/nginx/domains/add")
+@registry.register(POST_NGINX_DOMAINS_ADD)
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
@@ -369,7 +394,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
-@registry.register("DELETE", "/nginx/domains/remove")
+@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
@@ -394,7 +419,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
-@registry.register("POST", "/nginx/domains/update")
+@registry.register(POST_NGINX_DOMAINS_UPDATE)
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry.
@@ -422,7 +447,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
-@registry.register("POST", "/nginx/apply")
+@registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx.
@@ -440,7 +465,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
-@registry.register("POST", "/nginx/test")
+@registry.register(POST_NGINX_TEST)
def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying.
@@ -451,7 +476,7 @@ def test(_request: Any, _body: Any) -> dict[str, Any]:
return {"valid": valid, "output": output}
-@registry.register("POST", "/nginx/ssl-apply")
+@registry.register(POST_NGINX_SSL_APPLY)
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
_write_ssl_snippet()
@@ -459,7 +484,7 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
-@registry.register("POST", "/nginx/management")
+@registry.register(POST_NGINX_MANAGEMENT)
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/management — configure the management UI reverse proxy.
@@ -490,7 +515,7 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
return {"domain": domain}
-@registry.register("POST", "/nginx/reload")
+@registry.register(POST_NGINX_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
_reload_nginx()
diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py
index e2a1352..1efde1d 100644
--- a/daemon/handlers/wireguard.py
+++ b/daemon/handlers/wireguard.py
@@ -9,6 +9,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
+from daemon.iface import (
+ DELETE_WIREGUARD_PEERS_REMOVE,
+ GET_WIREGUARD_CONFIG,
+ GET_WIREGUARD_PEER_STATUS,
+ GET_WIREGUARD_PEERS,
+ GET_WIREGUARD_STATUS,
+ PATCH_WIREGUARD_CONFIG,
+ POST_WIREGUARD_APPLY,
+ POST_WIREGUARD_CONFIG,
+ POST_WIREGUARD_DOWN,
+ POST_WIREGUARD_GENERATE_CLIENT,
+ POST_WIREGUARD_INITIALIZE,
+ POST_WIREGUARD_PEERS_ADD,
+)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json
@@ -80,7 +94,7 @@ def _get_wg_state() -> dict[str, Any]:
# Routes
-@registry.register("GET", "/wireguard/config")
+@registry.register(GET_WIREGUARD_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/config — return WireGuard config with private key stripped."""
wg = _get_wg_state()
@@ -94,7 +108,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return safe
-@registry.register("POST", "/wireguard/config")
+@registry.register(POST_WIREGUARD_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key.
@@ -116,7 +130,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
return {"config_saved": True}
-@registry.register("PATCH", "/wireguard/config")
+@registry.register(PATCH_WIREGUARD_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/config — deep-merge patch into existing config.
@@ -136,7 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"config_saved": True}
-@registry.register("POST", "/wireguard/apply")
+@registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config()
@@ -157,7 +171,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
-@registry.register("POST", "/wireguard/down")
+@registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config()
@@ -168,7 +182,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
return {"down": True}
-@registry.register("GET", "/wireguard/status")
+@registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state()
@@ -177,7 +191,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
return {"up": False, "interface": {}, "peers": []}
-@registry.register("POST", "/wireguard/initialize")
+@registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config()
@@ -198,7 +212,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
return {"initialized": True, "config": safe}
-@registry.register("POST", "/wireguard/peers/add")
+@registry.register(POST_WIREGUARD_PEERS_ADD)
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one.
@@ -242,7 +256,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return peer_out
-@registry.register("DELETE", "/wireguard/peers/remove")
+@registry.register(DELETE_WIREGUARD_PEERS_REMOVE)
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/peers/remove — remove a peer by name.
@@ -266,7 +280,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"name": name}
-@registry.register("GET", "/wireguard/peers")
+@registry.register(GET_WIREGUARD_PEERS)
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peers — return configured peers with private keys stripped."""
wg = _get_wg_state()
@@ -282,7 +296,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result
-@registry.register("GET", "/wireguard/peer-status")
+@registry.register(GET_WIREGUARD_PEER_STATUS)
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peer-status — return runtime peer status from cache."""
wg = _get_wg_state()
@@ -291,7 +305,7 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
return []
-@registry.register("POST", "/wireguard/generate-client")
+@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
diff --git a/daemon/iface.py b/daemon/iface.py
new file mode 100644
index 0000000..7880c0d
--- /dev/null
+++ b/daemon/iface.py
@@ -0,0 +1,153 @@
+"""Shared walld interface definitions.
+
+This module is the **single source of truth** for all daemon API endpoints.
+Every endpoint is a frozen tuple of (method, path). Both the server's
+registry.register() and the client's request/get/post/patch/delete() accept
+an Endpoint in addition to a plain string path, so renaming an endpoint here
+automatically updates both sides.
+
+Usage:
+
+ # Server (daemon/handlers/)
+ from daemon.iface import GET_FIREWALL_ZONES
+
+ @registry.register(GET_FIREWALL_ZONES)
+ def get_zones(_request, _body):
+ ...
+
+ # Client (webui/api/)
+ from daemon.iface import GET_FIREWALL_ZONES
+ from daemon.client import get
+
+ data = get(GET_FIREWALL_ZONES)
+"""
+
+from __future__ import annotations
+
+Endpoint = tuple[str, str]
+PathLike = str | Endpoint
+
+
+def _ep(method: str, path: str) -> Endpoint:
+ return (method, path)
+
+
+# ---- Nginx / Proxy ----
+GET_NGINX_CONFIG: Endpoint = _ep("GET", "/nginx/config")
+POST_NGINX_CONFIG: Endpoint = _ep("POST", "/nginx/config")
+PATCH_NGINX_CONFIG: Endpoint = _ep("PATCH", "/nginx/config")
+GET_NGINX_DOMAINS: Endpoint = _ep("GET", "/nginx/domains")
+POST_NGINX_DOMAINS_ADD: Endpoint = _ep("POST", "/nginx/domains/add")
+DELETE_NGINX_DOMAINS_REMOVE: Endpoint = _ep("DELETE", "/nginx/domains/remove")
+POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
+POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
+POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
+POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
+POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
+POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
+
+# ---- Firewall ----
+GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
+GET_FIREWALL_ZONES: Endpoint = _ep("GET", "/firewall/zones")
+GET_FIREWALL_ZONES_INFO: Endpoint = _ep("GET", "/firewall/zones/info")
+GET_FIREWALL_ZONES_ALL: Endpoint = _ep("GET", "/firewall/zones/all")
+GET_FIREWALL_SERVICES: Endpoint = _ep("GET", "/firewall/services")
+GET_FIREWALL_CONFIG: Endpoint = _ep("GET", "/firewall/config")
+POST_FIREWALL_CONFIG: Endpoint = _ep("POST", "/firewall/config")
+PATCH_FIREWALL_CONFIG: Endpoint = _ep("PATCH", "/firewall/config")
+GET_FIREWALL_CONFIG_PENDING: Endpoint = _ep("GET", "/firewall/config/pending")
+POST_FIREWALL_CONFIG_APPLY: Endpoint = _ep("POST", "/firewall/config/apply")
+POST_FIREWALL_ZONES_CREATE: Endpoint = _ep("POST", "/firewall/zones/create")
+DELETE_FIREWALL_ZONES_DELETE: Endpoint = _ep("DELETE", "/firewall/zones/delete")
+POST_FIREWALL_ZONES_INTERFACES: Endpoint = _ep("POST", "/firewall/zones/interfaces")
+POST_FIREWALL_ZONES_SERVICES: Endpoint = _ep("POST", "/firewall/zones/services")
+POST_FIREWALL_RICH_RULES_ADD: Endpoint = _ep("POST", "/firewall/rich-rules/add")
+DELETE_FIREWALL_RICH_RULES_REMOVE: Endpoint = _ep(
+ "DELETE", "/firewall/rich-rules/remove"
+)
+GET_FIREWALL_RICH_RULES: Endpoint = _ep("GET", "/firewall/rich-rules")
+POST_FIREWALL_MASQUERADE: Endpoint = _ep("POST", "/firewall/masquerade")
+POST_FIREWALL_FORWARD_PORT_ADD: Endpoint = _ep("POST", "/firewall/forward-port/add")
+DELETE_FIREWALL_FORWARD_PORT_REMOVE: Endpoint = _ep(
+ "DELETE", "/firewall/forward-port/remove"
+)
+GET_FIREWALL_STATE: Endpoint = _ep("GET", "/firewall/state")
+
+# ---- WireGuard ----
+GET_WIREGUARD_CONFIG: Endpoint = _ep("GET", "/wireguard/config")
+POST_WIREGUARD_CONFIG: Endpoint = _ep("POST", "/wireguard/config")
+PATCH_WIREGUARD_CONFIG: Endpoint = _ep("PATCH", "/wireguard/config")
+POST_WIREGUARD_APPLY: Endpoint = _ep("POST", "/wireguard/apply")
+POST_WIREGUARD_DOWN: Endpoint = _ep("POST", "/wireguard/down")
+GET_WIREGUARD_STATUS: Endpoint = _ep("GET", "/wireguard/status")
+POST_WIREGUARD_INITIALIZE: Endpoint = _ep("POST", "/wireguard/initialize")
+POST_WIREGUARD_PEERS_ADD: Endpoint = _ep("POST", "/wireguard/peers/add")
+DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove")
+GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
+GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
+POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
+
+# ---- ACME / Certs ----
+GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
+GET_ACME_INFO: Endpoint = _ep("GET", "/acme/info")
+POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
+POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
+GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
+POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
+DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
+POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
+GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
+GET_ACME_PATHS: Endpoint = _ep("GET", "/acme/paths")
+POST_ACME_SELF_SIGNED: Endpoint = _ep("POST", "/acme/self-signed")
+
+# ---- Dnsmasq / DHCP ----
+GET_DNSMASQ_CONFIG: Endpoint = _ep("GET", "/dnsmasq/config")
+POST_DNSMASQ_CONFIG: Endpoint = _ep("POST", "/dnsmasq/config")
+PATCH_DNSMASQ_CONFIG: Endpoint = _ep("PATCH", "/dnsmasq/config")
+POST_DNSMASQ_APPLY: Endpoint = _ep("POST", "/dnsmasq/apply")
+GET_DNSMASQ_STATUS: Endpoint = _ep("GET", "/dnsmasq/status")
+POST_DNSMASQ_RANGES_ADD: Endpoint = _ep("POST", "/dnsmasq/ranges/add")
+DELETE_DNSMASQ_RANGES_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/ranges/remove")
+GET_DNSMASQ_LEASES: Endpoint = _ep("GET", "/dnsmasq/leases")
+POST_DNSMASQ_STATIC_LEASE_ADD: Endpoint = _ep("POST", "/dnsmasq/static-lease/add")
+DELETE_DNSMASQ_STATIC_LEASE_REMOVE: Endpoint = _ep(
+ "DELETE", "/dnsmasq/static-lease/remove"
+)
+POST_DNSMASQ_DNS_RECORD_ADD: Endpoint = _ep("POST", "/dnsmasq/dns-record/add")
+DELETE_DNSMASQ_DNS_RECORD_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/dns-record/remove")
+POST_DNSMASQ_UPSTREAMS: Endpoint = _ep("POST", "/dnsmasq/upstreams")
+POST_DNSMASQ_DOMAIN: Endpoint = _ep("POST", "/dnsmasq/domain")
+
+# ---- Network ----
+GET_NETWORK_INTERFACES: Endpoint = _ep("GET", "/network/interfaces")
+GET_NETWORK_INTERFACE_NAME: Endpoint = _ep("GET", "/network/interfaces/")
+POST_NETWORK_INTERFACE_NAME: Endpoint = _ep("POST", "/network/interfaces/")
+POST_NETWORK_INTERFACE_RELOAD: Endpoint = _ep(
+ "POST", "/network/interfaces//reload"
+)
+POST_NETWORK_APPLY: Endpoint = _ep("POST", "/network/apply")
+GET_NETWORK_INFER_DHCP_RANGES: Endpoint = _ep("GET", "/network/infer-dhcp-ranges")
+GET_NETWORK_INFER_ZONES: Endpoint = _ep("GET", "/network/infer-zones")
+POST_NETWORK_SYSCTL_SET: Endpoint = _ep("POST", "/network/sysctl/set")
+
+# ---- Logs ----
+GET_LOGS_JOURNAL: Endpoint = _ep("GET", "/logs/journal")
+GET_LOGS_NGINX_ACCESS: Endpoint = _ep("GET", "/logs/nginx/access")
+GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
+GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
+GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
+
+# ---- Server infra (not going through client) ----
+GET_HEALTH: Endpoint = _ep("GET", "/health")
+GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
+POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
+GET_WS: Endpoint = _ep("GET", "/ws")
+POST_BATCH: Endpoint = _ep("POST", "/batch")
+
+# Collect all endpoint module-level constants for __all__ verification
+_all_endpoints = [
+ name
+ for name, val in globals().items()
+ if isinstance(val, tuple) and len(val) == 2 and all(isinstance(x, str) for x in val)
+]
+__all__ = ["Endpoint", "PathLike", *sorted(_all_endpoints)]
diff --git a/daemon/server.py b/daemon/server.py
index 1410dbd..fb4aa9a 100644
--- a/daemon/server.py
+++ b/daemon/server.py
@@ -15,12 +15,14 @@ from typing import Any
from aiohttp import web
+from daemon.iface import PathLike
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"
+_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
class Handler:
@@ -53,20 +55,28 @@ class Registry:
"""Initialize an empty route registry."""
self._routes: dict[tuple[str, str], Callable] = {}
- def register(self, method: str, path: str):
+ def register(self, method: PathLike, path: str | None = None):
"""Decorator that registers a handler for the given method and path.
+ Accepts either two separate arguments (``method``, ``path``) or a
+ single :class:`daemon.iface.Endpoint` tuple.
+
Args:
- method: HTTP method (e.g. "GET", "POST").
- path: URL path to register the handler under.
+ method: HTTP method string, or an :class:`Endpoint` tuple.
+ path: URL path (omit when passing an :class:`Endpoint`).
Returns:
Decorator function wrapping the handler.
"""
+ if isinstance(method, tuple):
+ ep_method, ep_path = method
+ path = ep_path
+ method = ep_method
+
def decorator(fn: Callable) -> Callable:
- self._routes[(method.upper(), path)] = fn
- fn._handler = Handler(method, path) # type: ignore[attr-defined]
+ self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
+ fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
return fn
return decorator
@@ -111,6 +121,17 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
"""
state_store.populate(subsystems)
+ targets = subsystems or state_store.SUBSYSTEMS
+ for name in targets:
+ state_store.bump(name)
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ pass
+ else:
+ task = asyncio.create_task(broadcast_versions())
+ task.add_done_callback(_ws_tasks.discard)
+ _ws_tasks.add(task)
class NotFoundError(Exception):
@@ -295,10 +316,57 @@ def create_app() -> web.Application:
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("GET", "/ws", _handle_ws)
app.router.add_route("*", "/{tail:.*}", _catch_all)
return app
+# WebSocket subscribers
+_ws_subscribers: set[web.WebSocketResponse] = set()
+_ws_tasks: set[asyncio.Task[None]] = set()
+
+
+async def _handle_ws(request: web.Request) -> web.Response:
+ """WebSocket endpoint for real-time state change notifications.
+
+ On connect: sends current versions. On state change: broadcasts
+ updated subsystem versions. Clients disconnect to unsubscribe.
+ """
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ _ws_subscribers.add(ws)
+
+ await ws.send_json({"type": "init", "versions": state_store.get_versions()})
+
+ try:
+ async for msg in ws:
+ if msg.type == web.WSMsgType.ERROR:
+ break
+ if msg.type == web.WSMsgType.CLOSE:
+ break
+ finally:
+ _ws_subscribers.discard(ws)
+
+ return ws
+
+
+async def broadcast_versions() -> None:
+ """Broadcast updated subsystem versions to all WebSocket clients."""
+ updated = state_store.get_updated_versions()
+ if not updated or not _ws_subscribers:
+ return
+ data = json.dumps({"type": "versions", "updated": updated})
+ dead: set[web.WebSocketResponse] = set()
+ for ws in _ws_subscribers:
+ try:
+ await ws.send_str(data)
+ except Exception:
+ dead.add(ws)
+ _ws_subscribers.difference_update(dead)
+ if dead:
+ logger.warning("Removed %d dead WS subscribers", len(dead))
+
+
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
@@ -399,6 +467,8 @@ def main() -> None:
loop.run_until_complete(runner.setup())
site = web.UnixSite(runner, socket_path)
loop.run_until_complete(site.start())
+ tcp_site = web.TCPSite(runner, "127.0.0.1", _WS_PORT)
+ loop.run_until_complete(tcp_site.start())
os.chmod(socket_path, 0o660)
@@ -406,6 +476,7 @@ def main() -> None:
logger.info("Populating system state...")
state_store.populate()
logger.info("vacuum-walld listening on %s", socket_path)
+ logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
try:
loop.run_forever()