refactor: overhaul daemon server, client, and handlers

This commit is contained in:
2026-06-16 03:35:58 +00:00
parent c5813d68b3
commit 4fc0fb3f72
10 changed files with 683 additions and 195 deletions
+72 -52
View File
@@ -5,14 +5,19 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
import json import json
import logging import logging
import re
import urllib.parse import urllib.parse
from typing import Any from typing import Any
import requests import requests
import requests_unixsocket import requests_unixsocket
from daemon.iface import PathLike
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_param_re = re.compile(r"<(\w+)>")
class NotFound(Exception): class NotFound(Exception):
"""Raised when the daemon returns HTTP 404.""" """Raised when the daemon returns HTTP 404."""
@@ -68,9 +73,41 @@ def set_socket_path(path: str) -> None:
_DEFAULT_SOCKET = path _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:
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( def request(
method: str, method: PathLike,
path: str, path: str | None = None,
json_body: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None, query_params: dict[str, Any] | None = None,
socket_path: str | None = None, socket_path: str | None = None,
@@ -78,27 +115,40 @@ def request(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Make a request to the daemon and return the parsed response body. """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 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. of a JSON body. For other methods, json_body is sent as JSON.
Raises RuntimeError on non-2xx responses or connection errors. Raises RuntimeError on non-2xx responses or connection errors.
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400. 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() 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() sess = requests_unixsocket.Session()
try: try:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"timeout": timeout, "timeout": timeout,
} }
if method == "GET": if resolved_method == "GET":
if query_params: if query_params:
kwargs["params"] = query_params kwargs["params"] = query_params
else: else:
if json_body is not None: if json_body is not None:
kwargs["json"] = json_body kwargs["json"] = json_body
resp = sess.request( resp = sess.request(
method, resolved_method,
url, url,
**kwargs, **kwargs,
) )
@@ -112,7 +162,9 @@ def request(
except requests.ConnectionError as exc: except requests.ConnectionError as exc:
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
except requests.Timeout as 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: except requests.HTTPError as exc:
try: try:
data = resp.json() data = resp.json()
@@ -129,72 +181,40 @@ def request(
return data.get("data") 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. """Send a GET request to the daemon.
Query parameters are passed as URL params rather than a JSON body. *path* can be a :class:`Endpoint` tuple from :mod:`daemon.iface`
Additional keyword arguments are forwarded to request(). (e.g., ``GET_FIREWALL_ZONES``), or a plain string path.
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.
""" """
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. """Send a POST request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
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.
""" """
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. """Send a PATCH request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
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.
""" """
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. """Send a DELETE request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request(). are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
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.
""" """
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]: 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'. Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
Returns a dict mapping each id to its result. Returns a dict mapping each id to its result.
""" """
return post("/batch", {"ops": ops}, **kwargs) return request("POST", "/batch", json_body={"ops": ops}, **kwargs)
+89 -42
View File
@@ -12,7 +12,21 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 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 daemon.server import NotFoundError, refresh_state, registry
from lib.state import _run_acme
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,38 +113,6 @@ class IssueRequest:
# Internal helpers # 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: def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary.""" """Return the path to the acme.sh binary."""
from lib.state import _find_acme 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 # 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]: def list_certs(_request: Any, _body: Any) -> list[dict]:
"""GET /acme/list — return managed certificates.""" """GET /acme/list — return managed certificates."""
ac = _get_acme_state() ac = _get_acme_state()
return ac.get("certs", []) 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: def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate. """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}") 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]: def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/validate — run pre-flight checks for a domain. """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) 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]: async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/issue — create a new certificate issuance request. """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} 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]: def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/issue/status — poll status of an issuance request. """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) 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]: def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain. """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()} 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]: def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/remove — remove a certificate from ACME management. """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} 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]: def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/email — set the ACME contact email via account registration. """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} return {"email": email}
@registry.register("GET", "/acme/email") @registry.register(GET_ACME_EMAIL)
def get_email(_request: Any, _body: Any) -> dict[str, Any]: def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email.""" """GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state() ac = _get_acme_state()
@@ -582,7 +564,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
return {"email": email} 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]: 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. """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", "ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.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,
}
+47 -15
View File
@@ -8,6 +8,22 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader 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 daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json 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 = [ interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r 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") tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render( return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), 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, dhcp=dhcp_cfg,
dns=dns_cfg, dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None, fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
@@ -86,7 +118,7 @@ def _get_dnsmasq_state() -> dict[str, Any]:
# Routes # Routes
@registry.register("GET", "/dnsmasq/config") @registry.register(GET_DNSMASQ_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\ """\
Endpoint: GET /dnsmasq/config Endpoint: GET /dnsmasq/config
@@ -99,7 +131,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config() 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]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/config 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} 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]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: PATCH /dnsmasq/config 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} return {"config_saved": True}
@registry.register("POST", "/dnsmasq/apply") @registry.register(POST_DNSMASQ_APPLY)
def apply_config(_request: Any, _body: Any) -> dict[str, Any]: def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/apply Endpoint: POST /dnsmasq/apply
@@ -152,7 +184,7 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True} return {"applied": True}
@registry.register("GET", "/dnsmasq/status") @registry.register(GET_DNSMASQ_STATUS)
def get_status(_request: Any, _body: Any) -> dict[str, Any]: def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\ """\
Endpoint: GET /dnsmasq/status Endpoint: GET /dnsmasq/status
@@ -165,7 +197,7 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
return {} 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]: def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/ranges/add 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} 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]: def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: DELETE /dnsmasq/ranges/remove 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} 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]]: def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\ """\
Endpoint: GET /dnsmasq/leases Endpoint: GET /dnsmasq/leases
@@ -262,7 +294,7 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
return [] 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]: def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/static-lease/add 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} 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]: def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: DELETE /dnsmasq/static-lease/remove 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} 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]: def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/dns-record/add 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} 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]: def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: DELETE /dnsmasq/dns-record/remove 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} 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]: def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/upstreams 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"]} 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]: def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\ """\
Endpoint: POST /dnsmasq/domain Endpoint: POST /dnsmasq/domain
+44 -21
View File
@@ -10,6 +10,29 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 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 daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json from lib.common import load_json, run, save_json
from lib.firewall import ( from lib.firewall import (
@@ -269,14 +292,14 @@ def _get_fw_state() -> dict[str, Any]:
return fw return fw
@registry.register("GET", "/firewall/interfaces") @registry.register(GET_FIREWALL_INTERFACES)
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /firewall/interfaces — return active interfaces from state.""" """GET /firewall/interfaces — return active interfaces from state."""
fw = _get_fw_state() fw = _get_fw_state()
return fw.get("interfaces", []) return fw.get("interfaces", [])
@registry.register("GET", "/firewall/zones") @registry.register(GET_FIREWALL_ZONES)
def get_zones(_request: Any, _body: Any) -> dict[str, Any]: def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state() fw = _get_fw_state()
active = fw.get("active_zones", {}) 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())} 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]: def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") 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] 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]]: def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
fw = _get_fw_state() fw = _get_fw_state()
active = fw.get("active_zones", {}) active = fw.get("active_zones", {})
@@ -308,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result return result
@registry.register("GET", "/firewall/services") @registry.register(GET_FIREWALL_SERVICES)
def get_services(_request: Any, _body: Any) -> list[str]: def get_services(_request: Any, _body: Any) -> list[str]:
fw = _get_fw_state() fw = _get_fw_state()
return fw.get("available_services", []) 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]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config() 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]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zones" not in body: if not body or "zones" not in body:
raise ValueError("'zones' key is required") 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} 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]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body must be a JSON object") 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} 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]: def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state() fw = _get_fw_state()
return fw.get("pending", {}) 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]: def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply() result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", [])) 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 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]: def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") 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} 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]: def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]]: def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") 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 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]: def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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)} 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]: def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") 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} 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]: def get_state(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_state() fw = _get_state()
if fw is None: if fw is None:
+16 -9
View File
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
import logging import logging
from pathlib import Path 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 from lib.common import run_proc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,9 +43,9 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
lines = f.readlines() lines = f.readlines()
return "".join(lines[-n:]) return "".join(lines[-n:])
except FileNotFoundError: except FileNotFoundError:
return "(log file not found)\n" raise NotFoundError("log file not found") from None
except PermissionError: except PermissionError:
return "(permission denied)\n" raise RuntimeError("permission denied") from None
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str: 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() output = result.stdout.strip()
return output if output else f"(no journal entries for {unit})\n" return output if output else f"(no journal entries for {unit})\n"
except Exception as exc: 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: def journal(_request, _body) -> str:
"""GET /logs/journal — return vacuum-wall daemon journal entries.""" """GET /logs/journal — return vacuum-wall daemon journal entries."""
return _sudo_journalctl("vacuum-wall") return _sudo_journalctl("vacuum-wall")
@registry.register("GET", "/logs/nginx/access") @registry.register(GET_LOGS_NGINX_ACCESS)
def nginx_access(_request, _body) -> str: def nginx_access(_request, _body) -> str:
"""GET /logs/nginx/access — return recent nginx access log lines.""" """GET /logs/nginx/access — return recent nginx access log lines."""
return _tail_file("/var/log/nginx/access.log", sudo=True) 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: def nginx_error(_request, _body) -> str:
"""GET /logs/nginx/error — return recent nginx error log lines.""" """GET /logs/nginx/error — return recent nginx error log lines."""
return _tail_file("/var/log/nginx/error.log", sudo=True) 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: def dnsmasq_log(_request, _body) -> str:
"""GET /logs/dnsmasq — return recent dnsmasq journal entries.""" """GET /logs/dnsmasq — return recent dnsmasq journal entries."""
return _sudo_journalctl("dnsmasq") return _sudo_journalctl("dnsmasq")
@registry.register("GET", "/logs/app") @registry.register(GET_LOGS_APP)
def app_log(_request, _body) -> str: def app_log(_request, _body) -> str:
"""GET /logs/app — return recent application log lines.""" """GET /logs/app — return recent application log lines."""
return _tail_file(str(_APP_LOG_FILE)) return _tail_file(str(_APP_LOG_FILE))
+117 -21
View File
@@ -6,13 +6,25 @@ via config/network/config.json and generated .network files.
import contextlib import contextlib
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import Any 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 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.dnsmasq import set_upstreams
from lib.network import ( from lib.network import (
KNOWN_INTERFACE_KEYS,
collect_upstream_dns, collect_upstream_dns,
generate_network_files, generate_network_files,
get_config, get_config,
@@ -31,15 +43,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd"
def _copy_and_reload(iface_name: str) -> None: def _copy_and_reload(iface_name: str) -> None:
"""Copy generated 50-<name>.network file to /etc/systemd/network/ and reload.""" """Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
src = DATA_DIR / f"50-{iface_name}.network" validate_interface_name(iface_name)
src = DATA_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network") dst_dir = Path("/etc/systemd/network")
run(["mkdir", "-p", str(dst_dir)], sudo=True) 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) 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) 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: def _full_reload() -> None:
"""Reload networkd for all interfaces.""" """Reload networkd for all interfaces."""
run(["networkctl", "reload"], sudo=True) 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]: def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/interfaces — return all interface config + runtime state.""" """GET /network/interfaces — return all interface config + runtime state."""
cfg = get_config() cfg = get_config()
@@ -68,15 +113,17 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"runtime": runtime.get(name, {}), "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/<name>") @registry.register(GET_NETWORK_INTERFACE_NAME)
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /network/interfaces/<name> — return config for one interface.""" """GET /network/interfaces/<name> — return config for one interface."""
if not body or "name" not in body: if not body or "name" not in body:
raise ValueError("Interface name is required") raise ValueError("Interface name is required")
name = body["name"] name = validate_interface_name(body["name"])
cfg = get_config() cfg = get_config()
ifaces = cfg.get("interfaces", {}) ifaces = cfg.get("interfaces", {})
if name not in ifaces: 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/<name>") @registry.register(POST_NETWORK_INTERFACE_NAME)
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name> — save config, render, apply.""" """POST /network/interfaces/<name> — save config, render, apply."""
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
name = body.get("name", "").strip() name = validate_interface_name(body.get("name", ""))
if not name:
raise ValueError("'name' is required")
iface_cfg = {k: v for k, v in body.items() if k not in ("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): with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--all"], sudo=True) raw = run(["networkctl", "status", "--all"], sudo=True)
runtime = parse_networkctl_status(raw) 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) content = render_network_file(name, iface_cfg)
DATA_DIR.mkdir(parents=True, exist_ok=True) 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 # Deploy to system. In containerized environments this may fail
# (e.g. read-only /run/sudo timestamps) — don't let that block the save. # (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} return {"name": name, "applied": deployed}
@registry.register("POST", "/network/interfaces/<name>/reload") @registry.register(POST_NETWORK_INTERFACE_RELOAD)
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name>/reload — reload networkd for interface.""" """POST /network/interfaces/<name>/reload — reload networkd for interface."""
if not body or "name" not in body: if not body or "name" not in body:
raise ValueError("'name' is required in request body") raise ValueError("'name' is required in request body")
name = body["name"] name = validate_interface_name(body["name"])
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
run(["networkctl", "reconfigure", name], sudo=True) 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} 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]: def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /network/apply — apply ALL interfaces (full sync).""" """POST /network/apply — apply ALL interfaces (full sync)."""
cfg = get_config() cfg = get_config()
@@ -164,14 +218,21 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
generated = result.get("generated", []) generated = result.get("generated", [])
cleaned = result.get("cleaned", []) 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} 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") sys_dir = Path("/etc/systemd/network")
if sys_dir.exists(): if sys_dir.exists():
for f in sys_dir.iterdir(): for f in sys_dir.iterdir():
if f.name.endswith(".network") and f.name not in expected_names: if f.name.endswith(".network") and f.name not in expected_names:
with contextlib.suppress(Exception): iface_from_file = _extract_iface_from_filename(f.name)
run(["rm", str(f)], sudo=True) 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: for f in generated:
dst = sys_dir / f.name 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]: def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs.""" """GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
cfg = get_config() cfg = get_config()
@@ -209,9 +270,44 @@ def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
return {"ranges": ranges} 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]: def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-zones — suggest firewalld zones from interface config.""" """GET /network/infer-zones — suggest firewalld zones from interface config."""
cfg = get_config() cfg = get_config()
zones = infer_zones(cfg) zones = infer_zones(cfg)
return {"zones": zones} 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}
+43 -18
View File
@@ -8,6 +8,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader 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 daemon.server import NotFoundError, refresh_state, registry
from lib.common import ensure_dirs, load_json, run, run_proc, save_json 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.""" """Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf") tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.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: with open(tmp, "w") as f:
f.write(content) f.write(content)
os.chmod(tmp, 0o644) os.chmod(tmp, 0o644)
@@ -143,7 +157,7 @@ def _write_ssl_snippet() -> None:
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"]) ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf") tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg) 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: with open(tmp, "w") as f:
f.write(content) f.write(content)
os.chmod(tmp, 0o644) os.chmod(tmp, 0o644)
@@ -225,6 +239,20 @@ def _write_all_sites() -> None:
os.replace(tmp, site) 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: def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing. """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. password: Plain-text password to hash.
""" """
ensure_dirs(DATA_DIR) ensure_dirs(DATA_DIR)
import crypt hashed = _hash_password(password)
salt = os.urandom(16).hex()[:16]
hashed = crypt.crypt(password, f"$5${salt}")
existing: dict[str, str] = {} existing: dict[str, str] = {}
if HTPASSWD_FILE.exists(): if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f: with open(HTPASSWD_FILE) as f:
@@ -268,7 +293,7 @@ def _get_nginx_state() -> dict[str, Any]:
# Routes # Routes
@registry.register("GET", "/nginx/config") @registry.register(GET_NGINX_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config. """GET /nginx/config — return current nginx config.
@@ -281,7 +306,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config() 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]: 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. """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} 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]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config. """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} 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]]: def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains. """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 [] 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]: def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry. """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} 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]: def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config. """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} 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]: def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry. """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} return {"domain": domain}
@registry.register("POST", "/nginx/apply") @registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx. """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} return {"applied": True}
@registry.register("POST", "/nginx/test") @registry.register(POST_NGINX_TEST)
def test(_request: Any, _body: Any) -> dict[str, Any]: def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying. """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} 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]: def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet.""" """POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
_write_ssl_snippet() _write_ssl_snippet()
@@ -459,7 +484,7 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True} 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]: def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/management — configure the management UI reverse proxy. """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} return {"domain": domain}
@registry.register("POST", "/nginx/reload") @registry.register(POST_NGINX_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]: def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP).""" """POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
_reload_nginx() _reload_nginx()
+26 -12
View File
@@ -9,6 +9,20 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader 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 daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json 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 # Routes
@registry.register("GET", "/wireguard/config") @registry.register(GET_WIREGUARD_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/config — return WireGuard config with private key stripped.""" """GET /wireguard/config — return WireGuard config with private key stripped."""
wg = _get_wg_state() wg = _get_wg_state()
@@ -94,7 +108,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return safe 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]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key. """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} 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]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/config — deep-merge patch into existing config. """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} return {"config_saved": True}
@registry.register("POST", "/wireguard/apply") @registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo.""" """POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config() cfg = _get_config()
@@ -157,7 +171,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True} return {"applied": True}
@registry.register("POST", "/wireguard/down") @registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]: def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo.""" """POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config() cfg = _get_config()
@@ -168,7 +182,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
return {"down": True} return {"down": True}
@registry.register("GET", "/wireguard/status") @registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]: def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache.""" """GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state() wg = _get_wg_state()
@@ -177,7 +191,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
return {"up": False, "interface": {}, "peers": []} return {"up": False, "interface": {}, "peers": []}
@registry.register("POST", "/wireguard/initialize") @registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]: def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent).""" """POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config() cfg = _get_config()
@@ -198,7 +212,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
return {"initialized": True, "config": safe} 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]: def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one. """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 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]: def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/peers/remove — remove a peer by name. """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} return {"name": name}
@registry.register("GET", "/wireguard/peers") @registry.register(GET_WIREGUARD_PEERS)
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]: def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peers — return configured peers with private keys stripped.""" """GET /wireguard/peers — return configured peers with private keys stripped."""
wg = _get_wg_state() wg = _get_wg_state()
@@ -282,7 +296,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result 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]]: def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peer-status — return runtime peer status from cache.""" """GET /wireguard/peer-status — return runtime peer status from cache."""
wg = _get_wg_state() wg = _get_wg_state()
@@ -291,7 +305,7 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
return [] 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]: 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. """POST /wireguard/generate-client — render client-side WireGuard config for a peer.
+153
View File
@@ -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/<name>")
POST_NETWORK_INTERFACE_NAME: Endpoint = _ep("POST", "/network/interfaces/<name>")
POST_NETWORK_INTERFACE_RELOAD: Endpoint = _ep(
"POST", "/network/interfaces/<name>/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)]
+76 -5
View File
@@ -15,12 +15,14 @@ from typing import Any
from aiohttp import web from aiohttp import web
from daemon.iface import PathLike
from lib.state import state as state_store from lib.state import state as state_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock" SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
class Handler: class Handler:
@@ -53,20 +55,28 @@ class Registry:
"""Initialize an empty route registry.""" """Initialize an empty route registry."""
self._routes: dict[tuple[str, str], Callable] = {} 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. """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: Args:
method: HTTP method (e.g. "GET", "POST"). method: HTTP method string, or an :class:`Endpoint` tuple.
path: URL path to register the handler under. path: URL path (omit when passing an :class:`Endpoint`).
Returns: Returns:
Decorator function wrapping the handler. 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: def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
fn._handler = Handler(method, path) # type: ignore[attr-defined] fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
return fn return fn
return decorator 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. subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
""" """
state_store.populate(subsystems) 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): 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("GET", "/status/all", get_status_all)
app.router.add_route("POST", "/status/refresh", refresh_status) app.router.add_route("POST", "/status/refresh", refresh_status)
app.router.add_route("POST", "/batch", _handle_batch) app.router.add_route("POST", "/batch", _handle_batch)
app.router.add_route("GET", "/ws", _handle_ws)
app.router.add_route("*", "/{tail:.*}", _catch_all) app.router.add_route("*", "/{tail:.*}", _catch_all)
return app 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: async def _health(_request: web.Request) -> web.Response:
"""Return the health check response. """Return the health check response.
@@ -399,6 +467,8 @@ def main() -> None:
loop.run_until_complete(runner.setup()) loop.run_until_complete(runner.setup())
site = web.UnixSite(runner, socket_path) site = web.UnixSite(runner, socket_path)
loop.run_until_complete(site.start()) 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) os.chmod(socket_path, 0o660)
@@ -406,6 +476,7 @@ def main() -> None:
logger.info("Populating system state...") logger.info("Populating system state...")
state_store.populate() state_store.populate()
logger.info("vacuum-walld listening on %s", socket_path) logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
try: try:
loop.run_forever() loop.run_forever()