refactor: overhaul daemon server, client, and handlers
This commit is contained in:
+72
-52
@@ -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 ``<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(
|
||||
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 <param> segments from body/query params so the daemon
|
||||
# receives a concrete path instead of a template.
|
||||
# Merge body and query params for <param> substitution. query_params
|
||||
# takes precedence on key conflicts, so callers should avoid passing
|
||||
# the same key in both dicts.
|
||||
combined = {**(json_body or {}), **(query_params or {})}
|
||||
formatted_path = _format_path(resolved_path, combined)
|
||||
|
||||
sp = socket_path or _get_socket_path()
|
||||
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{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)
|
||||
|
||||
+89
-42
@@ -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,
|
||||
}
|
||||
|
||||
+47
-15
@@ -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
|
||||
|
||||
+44
-21
@@ -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:
|
||||
|
||||
+16
-9
@@ -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))
|
||||
|
||||
+117
-21
@@ -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-<name>.network file to /etc/systemd/network/ and reload."""
|
||||
src = DATA_DIR / f"50-{iface_name}.network"
|
||||
"""Copy generated 99-<name>.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/<name>")
|
||||
@registry.register(GET_NETWORK_INTERFACE_NAME)
|
||||
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /network/interfaces/<name> — 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/<name>")
|
||||
@registry.register(POST_NETWORK_INTERFACE_NAME)
|
||||
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name> — 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/<name>/reload")
|
||||
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
|
||||
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name>/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}
|
||||
|
||||
+43
-18
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+153
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user