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()