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
+73 -5
View File
@@ -21,7 +21,11 @@ class NotFound(Exception):
class BadRequest(Exception):
"""Raised when the daemon returns HTTP 400."""
"""Exception raised when the daemon returns HTTP 400.
Used to signal client-side input or formatting errors from the
daemon for distinction from general failures.
"""
pass
@@ -30,6 +34,15 @@ _DEFAULT_SOCKET = None
def _get_socket_path() -> str:
"""Return the daemon Unix socket path.
Lazily resolves the path from the VACUUM_WALLD_SOCKET environment
variable or falls back to data/daemon.sock under the project
directory. The result is cached in _DEFAULT_SOCKET.
Returns:
Absolute path string for the daemon socket.
"""
global _DEFAULT_SOCKET
if _DEFAULT_SOCKET is None:
import os
@@ -44,6 +57,13 @@ def _get_socket_path() -> str:
def set_socket_path(path: str) -> None:
"""Override the default daemon socket path.
Useful for tests that need an alternate socket.
Args:
path: Absolute path to the Unix socket file.
"""
global _DEFAULT_SOCKET
_DEFAULT_SOCKET = path
@@ -110,22 +130,70 @@ def request(
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""GET request to daemon. Params are sent as URL query parameters."""
"""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.
"""
return request("GET", path, query_params=params, **kwargs)
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""POST request to daemon."""
"""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.
"""
return request("POST", path, json_body=body, **kwargs)
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""PATCH request to daemon."""
"""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.
"""
return request("PATCH", path, json_body=body, **kwargs)
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""DELETE request to daemon."""
"""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.
"""
return request("DELETE", path, json_body=body, **kwargs)
+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()
+116 -10
View File
@@ -24,20 +24,46 @@ SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
class Handler:
"""Wrapper for a daemon handler function."""
"""Wrapper for a daemon handler function.
Attributes:
method: HTTP method (e.g. "GET", "POST").
path: URL path pattern.
"""
def __init__(self, method: str, path: str) -> None:
"""Initialize the handler metadata.
Args:
method: HTTP method.
path: URL path pattern.
"""
self.method = method.upper()
self.path = path
class Registry:
"""Route registry for daemon handlers."""
"""Route registry for daemon handlers.
Attributes:
_routes: Mapping of (method, path) tuples to handler callables.
"""
def __init__(self) -> None:
"""Initialize an empty route registry."""
self._routes: dict[tuple[str, str], Callable] = {}
def register(self, method: str, path: str):
"""Decorator that registers a handler for the given method and path.
Args:
method: HTTP method (e.g. "GET", "POST").
path: URL path to register the handler under.
Returns:
Decorator function wrapping the handler.
"""
def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn
fn._handler = Handler(method, path) # type: ignore[attr-defined]
@@ -46,6 +72,15 @@ class Registry:
return decorator
def get(self, method: str, path: str) -> Callable | None:
"""Look up a registered handler by method and path.
Args:
method: HTTP method to match.
path: URL path to match.
Returns:
The handler callable, or None if not registered.
"""
return self._routes.get((method.upper(), path))
@@ -53,7 +88,11 @@ registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
"""Refresh the pre-computed state for the given subsystems (or all)."""
"""Refresh the pre-computed state for the given subsystems (or all).
Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
"""
state_store.populate(subsystems)
@@ -64,15 +103,39 @@ class NotFoundError(Exception):
def ok(data: Any = None) -> web.Response:
"""Create a success JSON response.
Args:
data: Response payload. Defaults to None.
Returns:
A JSON Response with `ok` set to True.
"""
return web.json_response({"ok": True, "data": data})
def error(msg: str, code: int = 400) -> web.Response:
"""Create an error JSON response.
Args:
msg: Error message.
code: HTTP status code. Defaults to 400.
Returns:
A JSON Response with `ok` set to False.
"""
return web.json_response({"ok": False, "error": msg}, status=code)
async def _handle_request(request: web.Request) -> web.Response:
"""Dispatch a request to the appropriate handler."""
"""Dispatch a request to the appropriate handler.
Args:
request: The incoming HTTP request.
Returns:
The handler's response.
"""
handler_fn = registry.get(request.method, request.path)
if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404)
@@ -126,7 +189,14 @@ async def _handle_request(request: web.Request) -> web.Response:
async def _handle_batch(request: web.Request) -> web.Response:
"""Handle batch requests: execute operations in order, return keyed results."""
"""Handle batch requests: execute operations in order, return keyed results.
Args:
request: The incoming HTTP request containing operations.
Returns:
A JSON response keyed by operation IDs.
"""
try:
body = await request.json()
except json.JSONDecodeError:
@@ -175,6 +245,11 @@ async def _handle_batch(request: web.Request) -> web.Response:
def create_app() -> web.Application:
"""Create and configure the aiohttp application.
Returns:
A configured web.Application instance.
"""
app = web.Application()
app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
@@ -185,16 +260,32 @@ def create_app() -> web.Application:
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
Returns:
JSON response with process ID and socket path.
"""
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call."""
"""Return the entire state snapshot in one call.
Returns:
JSON response containing state data for all subsystems.
"""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system."""
"""Re-collect all state from system.
Args:
_request: The incoming request. JSON body may contain a "subsystems" list.
Returns:
JSON response with the updated state snapshot.
"""
try:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
@@ -207,12 +298,23 @@ async def refresh_status(_request: web.Request) -> web.Response:
async def _catch_all(request: web.Request) -> web.Response:
"""Catch-all for registered routes."""
"""Catch-all for registered routes.
Args:
request: The incoming HTTP request.
Returns:
The dispatched handler's response.
"""
return await _handle_request(request)
def _register_routes() -> None:
"""Import all handler modules to register routes."""
"""Import all handler modules to register routes.
Side effect: each imported module's `@registry.register` calls populate the
route registry with their handler endpoints.
"""
from daemon.handlers import (
acme, # noqa: F401
dnsmasq, # noqa: F401
@@ -224,7 +326,11 @@ def _register_routes() -> None:
def main() -> None:
"""Entry point for vacuum-walld."""
"""Entry point for vacuum-walld.
Sets up logging, registers routes, creates the aiohttp application,
and starts listening on the Unix socket.
"""
from lib.logging import setup_logging
setup_logging()