docs: add docstrings to all API endpoints and daemon handlers

Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
This commit is contained in:
2026-05-30 16:15:45 +00:00
parent bd98830638
commit 2f215793e9
17 changed files with 1550 additions and 28 deletions
+89
View File
@@ -37,6 +37,15 @@ _ISSUANCE_TTL = 300 # seconds to keep completed requests
@dataclass
class IssueStep:
"""Single step in a certificate issuance workflow.
Attributes:
name: Machine-readable step identifier (e.g. "issue").
label: Human-readable description shown to the user.
status: Current state: "pending", "running", "done", or "error".
message: Optional detail or error message for the step.
"""
name: str
label: str
status: str = "pending"
@@ -45,6 +54,19 @@ class IssueStep:
@dataclass
class IssueRequest:
"""Tracked certificate issuance request.
Attributes:
request_id: Unique hex identifier for polling.
domain: Target domain for the certificate.
email: Optional ACME contact email.
webroot: Optional custom webroot path.
steps: Ordered list of issuance steps.
status: Overall status: "running", "completed", or "failed".
created_at: Unix timestamp when request was created.
expires_at: Unix timestamp when entry expires from store.
"""
request_id: str
domain: str
email: str | None = None
@@ -55,6 +77,7 @@ class IssueRequest:
expires_at: float | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize request to a JSON-compatible dictionary."""
return {
"request_id": self.request_id,
"domain": self.domain,
@@ -78,6 +101,14 @@ class IssueRequest:
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()
@@ -102,12 +133,14 @@ def _run_acme(args: list[str]) -> str:
def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
return _find_acme()
def _get_acme_email() -> str:
"""Read registered contact email from ACME account config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
@@ -122,12 +155,14 @@ def _get_acme_email() -> str:
def _get_state() -> dict[str, Any] | None:
"""Return the raw ACME entry from the shared state store."""
from lib.state import state as state_store
return state_store.get("acme")
def _get_acme_state() -> dict[str, Any]:
"""Return the ACME state or empty dict when missing."""
ac = _get_state()
if ac is None:
return {}
@@ -213,6 +248,7 @@ def _check_dns_resolves(domain: str) -> tuple[bool, str]:
def _check_acme_installed() -> tuple[bool, str]:
"""Verify acme.sh binary is installed and executable."""
try:
_find_acme_bin()
return True, "acme.sh found"
@@ -221,6 +257,7 @@ def _check_acme_installed() -> tuple[bool, str]:
def _check_email_configured() -> tuple[bool, str]:
"""Check whether an ACME contact email has been configured."""
email = _get_acme_email() or ""
if email:
return True, f"Contact email configured: {email}"
@@ -228,12 +265,14 @@ def _check_email_configured() -> tuple[bool, str]:
def _check_webroot() -> tuple[bool, str]:
"""Verify the ACME webroot directory exists and is writable."""
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
return True, "ACME webroot ready"
return False, "ACME webroot not ready or not writable"
def _check_challenge_config() -> tuple[bool, str]:
"""Check for the ACME HTTP-01 challenge nginx config file."""
from lib.nginx import SITES_DIR
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
@@ -298,12 +337,19 @@ def _validate(domain: str) -> dict[str, Any]:
@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")
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate.
Raises:
ValueError: When domain is missing.
NotFoundError: When no certificate exists for domain.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
@@ -316,6 +362,11 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
@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.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -326,6 +377,14 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Deduplicates in-progress requests. Spawns background task for actual issuance.
Raises:
ValueError: When domain is missing.
RuntimeError: When pre-flight checks fail.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -381,6 +440,12 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
@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.
Raises:
ValueError: When id is missing.
NotFoundError: When request_id is unknown.
"""
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
@@ -444,6 +509,14 @@ async def _run_issue(req: IssueRequest) -> None:
@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.
Args:
force: Force renewal regardless of expiry.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -462,6 +535,11 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -475,6 +553,11 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Raises:
ValueError: When email is missing.
"""
if not body:
raise ValueError("Request body required")
email = body.get("email", "").strip()
@@ -488,6 +571,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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()
if ac:
return {"email": ac.get("email", "")}
@@ -506,6 +590,11 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Raises:
ValueError: When domain is missing.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
+75
View File
@@ -35,12 +35,14 @@ DEFAULT_CFG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store."""
from lib.state import state as state_store
return state_store.get("dnsmasq")
def _get_config() -> dict[str, Any]:
"""Load dnsmasq config from JSON, merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
@@ -49,12 +51,14 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist dnsmasq config to JSON after merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render dnsmasq.conf from Jinja template and config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
@@ -71,6 +75,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_dnsmasq_state() -> dict[str, Any]:
"""Return cached dnsmasq state, or empty dict if unset."""
dm = _get_state()
if dm is None:
return {}
@@ -83,6 +88,11 @@ def _get_dnsmasq_state() -> dict[str, Any]:
@registry.register("GET", "/dnsmasq/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/config
Returns cached config if available, otherwise loads from disk.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
@@ -91,6 +101,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/config
Save full config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -100,6 +115,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register("PATCH", "/dnsmasq/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: PATCH /dnsmasq/config
Merge partial update into existing config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -111,6 +131,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/apply")
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/apply
Render config to dnsmasq.conf, write to disk, and reload dnsmasq service via sudo.
"""
cfg = _get_config()
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
@@ -129,6 +154,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("GET", "/dnsmasq/status")
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/status
Returns cached dnsmasq status object, or empty dict if unavailable.
"""
dm = _get_dnsmasq_state()
if dm and "status" in dm:
return dm["status"]
@@ -137,6 +167,11 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
@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
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -181,6 +216,11 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
@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
Remove DHCP range matching interface + start + end. Raises NotFoundError if missing.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -211,6 +251,11 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
@registry.register("GET", "/dnsmasq/leases")
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\
Endpoint: GET /dnsmasq/leases
Returns cached DHCP lease list from state, or empty list if unavailable.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("leases", [])
@@ -219,6 +264,11 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
@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
Add or update a static DHCP lease by MAC address. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -247,6 +297,11 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
@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
Remove static lease by MAC. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -267,6 +322,11 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
@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
Add or update a custom DNS record by name. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -295,6 +355,11 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
@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
Remove custom DNS record by name. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -313,6 +378,11 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
@registry.register("POST", "/dnsmasq/upstreams")
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/upstreams
Replace DNS upstream servers list. Raises ValueError if servers field missing.
"""
if not body or "servers" not in body:
raise ValueError("'servers' is required")
cfg = _get_config()
@@ -324,6 +394,11 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/domain")
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/domain
Set or clear the local DNS domain. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain")
+8
View File
@@ -36,26 +36,31 @@ def _get_state() -> dict[str, Any] | None:
def _ensure_config_file() -> None:
"""Initialize config file with defaults if missing."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def _get_config() -> dict[str, Any]:
"""Load the firewall config file."""
_ensure_config_file()
return load_json(CONFIG_FILE)
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist firewall config to disk."""
_ensure_config_file()
save_json(CONFIG_FILE, cfg, indent=2)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
@@ -65,6 +70,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
def _get_forward_ports(zone_name: str) -> list[str]:
"""Return forward-port entries for a zone as CLI-style strings."""
with suppress(Exception):
fps = _parse_zone_output(
zone_name,
@@ -255,6 +261,7 @@ def _config_apply() -> dict[str, Any]:
def _get_fw_state() -> dict[str, Any]:
"""Return firewall state from the state store, or empty dict if absent."""
fw = _get_state()
if fw is None:
return {}
@@ -263,6 +270,7 @@ def _get_fw_state() -> dict[str, Any]:
@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", [])
+24
View File
@@ -17,6 +17,16 @@ _MAX_LINES = 200
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
"""Return the last N lines of a file, optionally via sudo.
Args:
path: Absolute path to the file to read.
n: Number of trailing lines to return.
sudo: Whether to use sudo to access the file.
Returns:
Truncated file content or an error message string.
"""
try:
if sudo:
result = run_proc(["cat", path], sudo=True)
@@ -32,6 +42,15 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
"""Return recent journalctl output for a systemd unit via sudo.
Args:
unit: Systemd unit name to query.
n: Number of journal lines to return.
Returns:
Journal output or an error message string.
"""
try:
result = run_proc(
["journalctl", "--unit=" + unit, "-n", str(n)],
@@ -47,24 +66,29 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
@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")
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")
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")
def dnsmasq_log(_request, _body) -> str:
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
return _sudo_journalctl("dnsmasq")
@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))
+102
View File
@@ -50,12 +50,18 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store."""
from lib.state import state as state_store
return state_store.get("nginx")
def _get_config() -> dict[str, Any]:
"""Load the nginx config JSON, applying defaults for missing fields.
Returns:
The parsed config dict with ssl defaults filled in.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
@@ -66,10 +72,23 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk.
Args:
cfg: The config dictionary to save.
"""
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja.
Args:
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
Returns:
Rendered server block as a string.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -86,6 +105,12 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def _write_site(domain: str, conf_text: str) -> None:
"""Atomically write a single site config file into sites-enabled.
Args:
domain: Site name used as the filename.
conf_text: Rendered nginx server block content.
"""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
@@ -97,6 +122,7 @@ def _write_site(domain: str, conf_text: str) -> None:
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")
@@ -109,6 +135,7 @@ def _write_include_file() -> None:
def _write_ssl_snippet() -> None:
"""Render and install the shared SSL snippet to /etc/nginx/snippets/."""
cfg = _get_config()
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
@@ -126,6 +153,11 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
@@ -135,6 +167,10 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
@@ -143,6 +179,10 @@ def _reload_nginx() -> None:
def _write_all_sites() -> None:
"""Regenerate all site configs, management proxy, and ACME challenge site.
Removes stale .conf files that are no longer in config.
"""
ensure_dirs(SITES_DIR)
cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
@@ -186,6 +226,12 @@ def _write_all_sites() -> None:
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
Args:
user: The username to add or update.
password: Plain-text password to hash.
"""
ensure_dirs(DATA_DIR)
import crypt
@@ -211,6 +257,7 @@ def _write_htpasswd(user: str, password: str) -> None:
def _get_nginx_state() -> dict[str, Any]:
"""Return a shallow copy of the cached nginx state, or empty dict if unset."""
ng = _get_state()
if ng is None:
return {}
@@ -223,6 +270,11 @@ def _get_nginx_state() -> dict[str, Any]:
@registry.register("GET", "/nginx/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config.
Returns:
Full config dict from state cache, or fallback to file.
"""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
@@ -231,6 +283,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -240,6 +297,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@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.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
@@ -253,6 +315,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Returns:
Domains list from state cache, or empty list.
"""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
@@ -261,6 +328,12 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
@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.
Raises:
ValueError: When required fields (domain, backend_host, backend_port) are missing.
ValueError: When the domain already exists.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -298,6 +371,12 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -317,6 +396,12 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -339,6 +424,11 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/nginx/apply")
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx.
Raises:
RuntimeError: When the nginx config test fails.
"""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
@@ -352,12 +442,18 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Returns:
Dict with valid (bool) and output (str) from `nginx -t`.
"""
valid, output = _test_config()
return {"valid": valid, "output": output}
@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()
refresh_state(["nginx"])
return {"applied": True}
@@ -365,6 +461,11 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Raises:
ValueError: When request body or domain field is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -391,5 +492,6 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
@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()
return {"reloaded": True}
+39
View File
@@ -42,20 +42,24 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached WireGuard state from the global state store."""
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]:
"""Load and merge the WireGuard config with defaults."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the WireGuard config to disk."""
save_json(CONFIG_PATH, cfg)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render the WireGuard server config file from Jinja template."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -65,6 +69,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state()
if wg is None:
return {}
@@ -77,6 +82,7 @@ def _get_wg_state() -> dict[str, Any]:
@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()
if wg:
return wg.get("config", {})
@@ -90,6 +96,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -107,6 +118,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@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.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
if "interface" in body:
@@ -122,6 +138,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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()
conf_text = _generate_conf(cfg)
_save_config(cfg)
@@ -142,6 +159,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@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()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
@@ -152,6 +170,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
@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()
if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []})
@@ -160,6 +179,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
@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()
if cfg["interface"].get("private_key"):
return {"initialized": False, "reason": "already initialized"}
@@ -180,6 +200,11 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
@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.
Raises:
ValueError: When body is missing or name is empty.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -219,6 +244,12 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
Raises:
ValueError: When body is missing or name is empty.
NotFoundError: When peer does not exist.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -237,6 +268,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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()
if wg:
return wg.get("peers", [])
@@ -252,6 +284,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
@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()
if wg:
return wg.get("status", {}).get("peers", [])
@@ -260,6 +293,12 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
@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.
Raises:
ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()