refactor: overhaul daemon server, client, and handlers

This commit is contained in:
2026-06-16 03:35:58 +00:00
parent c5813d68b3
commit 4fc0fb3f72
10 changed files with 683 additions and 195 deletions
+89 -42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+26 -12
View File
@@ -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.