diff --git a/daemon/client.py b/daemon/client.py index 232c651..bd6264c 100644 --- a/daemon/client.py +++ b/daemon/client.py @@ -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) diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index ae9f4db..f27becf 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -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"] diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index 7c7ae78..a6bec75 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -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") diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index a381ba2..ba0abc3 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -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", []) diff --git a/daemon/handlers/logs.py b/daemon/handlers/logs.py index 18ec719..e1bd25c 100644 --- a/daemon/handlers/logs.py +++ b/daemon/handlers/logs.py @@ -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)) diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 13a6f54..29057ef 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -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} diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index 6d960d3..e2a1352 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -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() diff --git a/daemon/server.py b/daemon/server.py index 9232f83..6488eb0 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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() diff --git a/lib/nginx.py b/lib/nginx.py index 8ec8cf8..aaf0320 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -59,6 +59,14 @@ DEFAULT_CONFIG: dict[str, Any] = { def get_config() -> dict[str, Any]: + """Load the current nginx config, initializing with defaults if needed. + + Ensure config and sites directories exist, then return a copy of the + JSON file. On missing file or missing keys, populate from defaults. + + Returns: + The complete config dict with ``domains``, ``ssl``, and ``management`` keys. + """ ensure_dirs(CONFIG_DIR, SITES_DIR) raw = load_json(CONFIG_FILE) if not raw: @@ -69,10 +77,19 @@ def get_config() -> dict[str, Any]: def save_config(cfg: dict[str, Any]) -> None: + """Persist *cfg* to the nginx config file atomically.""" save_json(CONFIG_FILE, cfg) def get_domains() -> list[dict[str, Any]]: + """Return a list of all configured proxy domains with status. + + Each entry includes the domain name, backend info, SSL flag, and + whether a site config file currently exists on disk. + + Returns: + List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``. + """ cfg = get_config() result: list[dict[str, Any]] = [] for name, dom in cfg.get("domains", {}).items(): @@ -101,6 +118,19 @@ def add_domain( cert: str | None = None, extra_headers: dict[str, str] | None = None, ) -> None: + """Add a new proxy domain with the given backend and optional settings. + + Args: + domain: Domain name to add. + backend_host: Upstream host to proxy to. + backend_port: Upstream port. + backend_proto: Protocol (``http`` or ``https``). + cert: Optional certificate type identifier. + extra_headers: Optional dict of extra headers to forward. + + Raises: + ValueError: If the domain is already configured. + """ cfg = get_config() if domain in cfg["domains"]: raise ValueError(f"Domain {domain!r} already configured") @@ -128,6 +158,7 @@ def add_domain( def remove_domain(domain: str) -> None: + """Remove *domain* from the config and delete its site file.""" cfg = get_config() cfg["domains"].pop(domain, None) save_config(cfg) @@ -138,6 +169,15 @@ def remove_domain(domain: str) -> None: def update_domain(domain: str, **kwargs: Any) -> None: + """Update fields of an existing domain entry in-place. + + Args: + domain: Domain name to update. + **kwargs: Key-value pairs to merge into the domain config. + + Raises: + KeyError: If the domain is not configured. + """ cfg = get_config() if domain not in cfg["domains"]: raise KeyError(f"Domain {domain!r} not configured") @@ -157,6 +197,14 @@ def update_domain(domain: str, **kwargs: Any) -> None: def generate_server_conf(domain_cfg: dict[str, Any]) -> str: + """Render the Jinja template for a standard domain server block. + + Args: + domain_cfg: Domain entry dict including the ``domain`` key. + + Returns: + The complete nginx server-block configuration as a string. + """ tmpl = ENV.get_template("nginx/server_block.conf") return tmpl.render( domain=domain_cfg["domain"], @@ -173,6 +221,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: def _generate_management_conf(management: dict[str, Any]) -> str: + """Render the Jinja template for the management WebUI server block. + + Args: + management: Management proxy config dict containing ``domain`` and optional ``auth``. + + Returns: + The complete nginx server-block configuration for the management UI. + """ tmpl = ENV.get_template("nginx/server_block.conf") return tmpl.render( domain=management.get("domain"), @@ -196,6 +252,12 @@ def _generate_management_conf(management: dict[str, Any]) -> str: def write_site(domain: str, conf_text: str) -> None: + """Atomically write *conf_text* to the site config file for *domain*. + + Args: + domain: Domain name (becomes the ``.conf`` file). + conf_text: Nginx server-block configuration text. + """ ensure_dirs(SITES_DIR) path = SITES_DIR / f"{domain}.conf" tmp = path.with_suffix(".tmp") @@ -207,7 +269,7 @@ def write_site(domain: str, conf_text: str) -> None: def write_acme_challenge() -> None: - """Write the ACME HTTP-01 challenge catch-all nginx config. + """Write the catch-all nginx config for ACME HTTP-01 challenges. Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME webroot for any domain not yet covered by a dedicated server block. @@ -226,6 +288,12 @@ def write_acme_challenge() -> None: def write_all_sites() -> None: + """Regenerate all site configs from the current config state. + + Writes server blocks for every configured domain and the management + proxy (if any), removes orphaned site files, and ensures the ACME + challenge config is present. + """ ensure_dirs(SITES_DIR) cfg = get_config() @@ -252,6 +320,11 @@ def write_all_sites() -> None: def write_include_file() -> None: + """Write the nginx include file that pulls in managed site configs. + + The include file is installed at ``/etc/nginx/conf.d/vacuum-wall.conf`` + and must be owned by root. + """ tmpl = ENV.get_template("nginx/include.conf") content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf")) tmp = INCLUDE_FILE.with_suffix(".tmp") @@ -264,6 +337,11 @@ def write_include_file() -> None: def write_ssl_snippet() -> None: + """Write the shared SSL settings snippet to ``/etc/nginx/snippets/``. + + The snippet is populated from the ``ssl`` section of the nginx config + and installed with root ownership. + """ cfg = get_config() ssl_cfg = cfg.get("ssl", {}) ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"]) @@ -287,6 +365,12 @@ def write_ssl_snippet() -> None: def test_config() -> tuple[bool, str]: + """Run ``nginx -t`` and return the pass/fail result. + + Returns: + Tuple of ``(ok, message)`` where ``ok`` is ``True`` if the + config test passed and ``message`` contains output or a summary. + """ result = subprocess.run( ["sudo", "nginx", "-t"], capture_output=True, text=True, check=False ) @@ -302,6 +386,11 @@ def test_config() -> tuple[bool, str]: def apply() -> None: + """Generate all configs, test them, and reload nginx. + + Raises: + RuntimeError: If the nginx config test fails. + """ write_ssl_snippet() write_all_sites() write_include_file() @@ -329,6 +418,15 @@ def set_management_proxy( auth_user: str | None = None, auth_pass: str | None = None, ) -> None: + """Configure the management reverse proxy for the WebUI. + + Args: + domain: Management domain name. + flask_host: Upstream Flask host (default ``127.0.0.1``). + flask_port: Upstream Flask port (default ``9090``). + auth_user: Optional basic-auth username. + auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``. + """ cfg = get_config() entry: dict[str, Any] = { "domain": domain, @@ -356,7 +454,12 @@ def set_management_proxy( def write_htpasswd(user: str, password: str) -> None: - """Append (or create) an htpasswd entry for *user*.""" + """Append (or create) an htpasswd entry for *user*. + + Args: + user: Username for the htpasswd entry. + password: Plain-text password to hash and store. + """ ensure_dirs(DATA_DIR) hashed = _hash_password(password) existing: dict[str, str] = {} @@ -381,6 +484,14 @@ def write_htpasswd(user: str, password: str) -> None: def _hash_password(password: str) -> str: + """Hash *password* using Apache ``apr1`` format via passlib, with crypt fallback. + + Args: + password: Plain-text password to hash. + + Returns: + The hashed password string suitable for ``.htpasswd``. + """ try: from passlib.hash import apache_passwd diff --git a/lib/state.py b/lib/state.py index ae9d4da..40746da 100644 --- a/lib/state.py +++ b/lib/state.py @@ -40,23 +40,53 @@ class State: Each subsystem's value is a dict collected from the corresponding ``collect_*`` function. A value of ``None`` means the subsystem has not been populated yet or the last collection failed. + + Attributes: + SUBSYSTEMS: Ordered list of subsystem names. + _data: Dict mapping subsystem names to their state data. """ - SUBSYSTEMS: ClassVar[list[str]] = ["firewall", "dnsmasq", "nginx", "acme", "wireguard"] + SUBSYSTEMS: ClassVar[list[str]] = [ + "firewall", + "dnsmasq", + "nginx", + "acme", + "wireguard", + ] def __init__(self) -> None: + """Initialize the state store with empty subsystem slots.""" self._data: dict[str, dict[str, Any] | None] = { name: None for name in self.SUBSYSTEMS } def get(self, subsystem: str) -> dict[str, Any] | None: + """Get state data for *subsystem*. + + Args: + subsystem: Subsystem name. + + Returns: + State dict, or ``None`` if not populated. + """ return self._data.get(subsystem) def set(self, subsystem: str, data: dict[str, Any] | None) -> None: + """Set state data for *subsystem*. + + Args: + subsystem: Subsystem name. + data: State data, or ``None`` to clear. + """ self._data[subsystem] = data def populate(self, subsystems: list[str] | None = None) -> None: - """Collect state for *subsystems* (all if None).""" + """Collect state for *subsystems* (all if ``None``). + + Args: + subsystems: List of subsystem names to collect. Collects all + subsystems when ``None``. + """ targets = subsystems or self.SUBSYSTEMS for name in targets: collector = _COLLECTORS.get(name) @@ -73,6 +103,11 @@ class State: self._data[name] = None def is_populated(self) -> bool: + """Check whether all subsystem states have been populated. + + Returns: + ``True`` if every subsystem has non-``None`` state data. + """ return all(v is not None for v in self._data.values()) @@ -88,6 +123,15 @@ _COLLECTORS: dict[str, Any] = {} def register_collector(subsystem: str, fn: Any) -> Any: + """Register *fn* as the state collector for *subsystem*. + + Args: + subsystem: Subsystem name to register for. + fn: Collector function to register. + + Returns: + The *fn* function (for decorator usage). + """ _COLLECTORS[subsystem] = fn return fn @@ -98,10 +142,19 @@ def register_collector(subsystem: str, fn: Any) -> Any: def _now_iso() -> str: + """Return the current UTC time as an ISO 8601 string.""" return datetime.now(UTC).isoformat() def _fp_to_str(fp: dict[str, Any]) -> str: + """Convert a port-forward dict to a compact string representation. + + Args: + fp: Port-forward entry containing port and proto keys. + + Returns: + Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``). + """ parts = [f"port={fp['port']}", f"proto={fp['proto']}"] if "toaddr" in fp: parts.append(f"toaddr={fp['toaddr']}") @@ -111,7 +164,12 @@ def _fp_to_str(fp: dict[str, Any]) -> str: def _collect_firewall() -> dict[str, Any]: - """Return the complete current state of firewalld.""" + """Return the complete current state of firewalld. + + Returns: + Dict containing firewall zones, interfaces, rules, config, and + pending changes. + """ zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split() active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) active = _parse_active_zones(active_raw) @@ -221,7 +279,11 @@ register_collector("firewall", _collect_firewall) def _collect_dnsmasq() -> dict[str, Any]: - """Collect dnsmasq status, config, and leases.""" + """Collect dnsmasq status, config, and leases. + + Returns: + Dict containing config, service status, leases, and timestamp. + """ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" CONFIG_PATH = CONFIG_DIR / "config.json" DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" @@ -311,7 +373,11 @@ register_collector("dnsmasq", _collect_dnsmasq) def _collect_nginx() -> dict[str, Any]: - """Collect nginx config and domains list.""" + """Collect nginx config and domains list. + + Returns: + Dict containing config, domains, and timestamp. + """ CONFIG_DIR = PROJECT_DIR / "config" / "nginx" CONFIG_FILE = CONFIG_DIR / "config.json" SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled" @@ -381,6 +447,14 @@ register_collector("nginx", _collect_nginx) def _find_acme() -> str: + """Locate the ``acme.sh`` binary on the filesystem. + + Returns: + Absolute path to the ``acme.sh`` executable. + + Raises: + FileNotFoundError: If acme.sh cannot be found. + """ acme_home = PROJECT_DIR / "data" / "acme" candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")] for path in candidates: @@ -393,6 +467,17 @@ def _find_acme() -> str: def _run_acme(args: list[str]) -> str: + """Run ``acme.sh`` with *args* and return combined output. + + Args: + args: Command-line arguments to pass after the home/config flags. + + Returns: + Combined stdout/stderr output. + + Raises: + RuntimeError: If acme.sh exits non-zero or times out. + """ acme_bin = _find_acme() acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args] @@ -421,6 +506,14 @@ def _run_acme(args: list[str]) -> str: def _days_until(date_str: str) -> int | None: + """Parse a date string and return days until *date_str* from now. + + Args: + date_str: Date string in common ACME formats. + + Returns: + Number of days remaining, or ``None`` if empty or unparseable. + """ if not date_str: return None for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): @@ -438,6 +531,14 @@ def _days_until(date_str: str) -> int | None: def _parse_acme_list_output(raw: str) -> list[dict]: + """Parse ``acme.sh --list`` output into a list of certificate dicts. + + Args: + raw: Raw output string from ``acme.sh --list``. + + Returns: + List of dicts with certificate entry fields. + """ entries: list[dict] = [] for line in raw.strip().splitlines(): line = line.strip() @@ -455,11 +556,24 @@ def _parse_acme_list_output(raw: str) -> list[dict]: def _has_auto_renew(domain: str) -> bool: + """Check whether *domain* has an auto-renew configuration file. + + Args: + domain: Domain name to check. + + Returns: + ``True`` if a corresponding ``acme.sh`` config file exists. + """ acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) return bool(Path(acme_home_env) / f"{domain}.conf") def _get_acme_email() -> str: + """Read the ACME ``acme.sh`` email from the account config file. + + Returns: + Email string, or empty string if not found. + """ acme_home_default = str(PROJECT_DIR / "data" / "acme") try: acme_home = Path(os.environ.get("ACME_HOME", acme_home_default)) @@ -475,7 +589,11 @@ def _get_acme_email() -> str: def _collect_acme() -> dict[str, Any]: - """Collect ACME certificate list and email.""" + """Collect ACME certificate list and email. + + Returns: + Dict containing certificate details and registered email. + """ email = _get_acme_email() certs: list[dict[str, Any]] = [] @@ -529,7 +647,11 @@ register_collector("acme", _collect_acme) def _collect_wireguard() -> dict[str, Any]: - """Collect WireGuard config, status, and peers.""" + """Collect WireGuard config, status, and peers. + + Returns: + Dict containing interface config, runtime status, and peers. + """ CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" DEFAULT_CONFIG: dict[str, Any] = { diff --git a/webui/api/certs.py b/webui/api/certs.py index 202cc52..36db799 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -16,6 +16,11 @@ bp = Blueprint("certs", __name__) @bp.route("/list", methods=["GET"]) def list_certs_bp(): + """GET /api/certs/list — list all managed ACME certificates. + + Returns: + Response containing the list of certificates or an error message. + """ try: return _ok(get("/acme/list")) except RuntimeError as exc: @@ -25,6 +30,14 @@ def list_certs_bp(): @bp.route("/", methods=["GET"]) def cert_details(domain: str): + """GET /api/certs/ — get details for a specific certificate. + + Args: + domain: Domain name to look up. + + Returns: + Response containing certificate info or an error message. + """ try: return _ok(get("/acme/info", {"domain": domain})) except NotFound as exc: @@ -37,6 +50,13 @@ def cert_details(domain: str): @bp.route("/validate", methods=["POST"]) def validate(): + """POST /api/certs/validate — run pre-flight checks for certificate issuance. + + Expects JSON body with ``{``domain``}``. + + Returns: + Response containing validation results or an error message. + """ body = request.get_json(silent=True) or {} domain = body.get("domain", "").strip() if not domain: @@ -54,6 +74,13 @@ def validate(): @bp.route("/issue/start", methods=["POST"]) def issue_start(): + """POST /api/certs/issue/start — create a new certificate issuance request. + + Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``. + + Returns: + Response containing an issuance request ID or an error message. + """ body = request.get_json(silent=True) or {} domain = body.get("domain", "").strip() if not domain: @@ -81,6 +108,14 @@ def issue_start(): @bp.route("/issue/", methods=["GET"]) def issue_status(request_id: str): + """GET /api/certs/issue/ — poll status of a certificate issuance request. + + Args: + request_id: Issuance request identifier returned by issue_start. + + Returns: + Response containing issuance status or an error message. + """ try: result = get("/acme/issue/status", {"id": request_id}) return _ok(result) @@ -94,6 +129,14 @@ def issue_status(request_id: str): @bp.route("//renew", methods=["POST"]) def renew_bp(domain: str): + """POST /api/certs//renew — renew an existing certificate. + + Args: + domain: Domain name whose certificate should be renewed. + + Returns: + Response confirming renewal or an error message. + """ try: logger.info("Certificate renewal requested for '%s' via API", domain) post("/acme/renew", {"domain": domain}) @@ -109,6 +152,14 @@ def renew_bp(domain: str): @bp.route("/", methods=["DELETE"]) def remove_bp(domain: str): + """DELETE /api/certs/ — remove a certificate from ACME management. + + Args: + domain: Domain name whose certificate should be removed. + + Returns: + Response confirming removal or an error message. + """ try: delete("/acme/remove", {"domain": domain}) logger.info("Certificate removed for '%s' via API", domain) @@ -123,6 +174,13 @@ def remove_bp(domain: str): @bp.route("/email", methods=["POST"]) def set_email_bp(): + """POST /api/certs/email — set the ACME account email address. + + Expects JSON body with ``{``email``}``. + + Returns: + Response confirming the email was set or an error message. + """ body = request.get_json(silent=True) or {} email = body.get("email", "").strip() if not email: diff --git a/webui/api/dhcp.py b/webui/api/dhcp.py index c5635ac..bcc3b3b 100644 --- a/webui/api/dhcp.py +++ b/webui/api/dhcp.py @@ -21,6 +21,11 @@ bp = Blueprint("dhcp", __name__) @bp.route("/config", methods=["GET"]) def get_config_bp(): + """GET /api/dhcp/config — Retrieve the current dnsmasq configuration. + + Returns: + JSON response with the config or an error. + """ try: return _ok(get("/dnsmasq/config")) except RuntimeError as exc: @@ -30,6 +35,14 @@ def get_config_bp(): @bp.route("/config", methods=["POST"]) def post_config(): + """POST /api/dhcp/config — Save a full replacement dnsmasq configuration. + + Args: + request: JSON body containing the complete config object. + + Returns: + JSON response with success status or an error. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -46,6 +59,14 @@ def post_config(): @bp.route("/config", methods=["PATCH"]) def patch_config(): + """PATCH /api/dhcp/config — Partially update the dnsmasq configuration. + + Args: + request: JSON body containing the fields to update. + + Returns: + JSON response with success status or an error. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -62,6 +83,8 @@ def patch_config(): @bp.route("/apply", methods=["POST"]) def apply_bp(): + """POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service.""" + try: post("/dnsmasq/apply") logger.info("dnsmasq config applied via API") @@ -78,6 +101,8 @@ def apply_bp(): @bp.route("/status", methods=["GET"]) def status_bp(): + """GET /api/dhcp/status — Retrieve dnsmasq service status.""" + try: return _ok(get("/dnsmasq/status")) except RuntimeError as exc: @@ -92,6 +117,14 @@ def status_bp(): @bp.route("/ranges", methods=["POST"]) def add_range_bp(): + """POST /api/dhcp/ranges — Add a DHCP address range for an interface. + + Args: + request: JSON body with `interface`, `start`, `end`, and optional `lease_time`. + + Returns: + JSON response with success status or an error. + """ body = request.get_json(silent=True) or {} iface = body.get("interface", "").strip() or None start = body.get("start", "").strip() @@ -121,6 +154,14 @@ def add_range_bp(): @bp.route("/ranges", methods=["DELETE"]) def remove_range_bp(): + """DELETE /api/dhcp/ranges — Remove a DHCP address range. + + Args: + request: JSON body with `interface`, `start`, and `end`. + + Returns: + JSON response with success status or an error. + """ body = request.get_json(silent=True) or {} iface = body.get("interface", "").strip() or "" start = body.get("start", "").strip() @@ -148,6 +189,8 @@ def remove_range_bp(): @bp.route("/leases", methods=["GET"]) def leases_bp(): + """GET /api/dhcp/leases — Retrieve the current DHCP lease table.""" + try: return _ok(get("/dnsmasq/leases")) except RuntimeError as exc: @@ -162,6 +205,14 @@ def leases_bp(): @bp.route("/static-lease", methods=["POST"]) def add_static_lease_bp(): + """POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address. + + Args: + request: JSON body with `mac`, `ip`, and optional `hostname`. + + Returns: + JSON response with lease details or an error. + """ body = request.get_json(silent=True) or {} mac = body.get("mac", "").strip() ip = body.get("ip", "").strip() @@ -182,6 +233,14 @@ def add_static_lease_bp(): @bp.route("/static-lease/", methods=["DELETE"]) def remove_static_lease_bp(mac): + """DELETE /api/dhcp/static-lease/ — Remove a static DHCP lease by MAC address. + + Args: + mac: MAC address of the static lease to remove. + + Returns: + JSON response with success status or an error. + """ try: delete("/dnsmasq/static-lease/remove", {"mac": mac}) logger.info("Static lease removed via API: %s", mac) @@ -201,6 +260,14 @@ def remove_static_lease_bp(mac): @bp.route("/dns-record", methods=["POST"]) def add_dns_record_bp(): + """POST /api/dhcp/dns-record — Add a DNS record. + + Args: + request: JSON body with `name`, `address`, and optional `hostname`. + + Returns: + JSON response with record details or an error. + """ body = request.get_json(silent=True) or {} name = body.get("name", "").strip() address = body.get("address", "").strip() @@ -224,6 +291,14 @@ def add_dns_record_bp(): @bp.route("/dns-record/", methods=["DELETE"]) def remove_dns_record_bp(name): + """DELETE /api/dhcp/dns-record/ — Remove a DNS record by name. + + Args: + name: Name of the DNS record to remove. + + Returns: + JSON response with success status or an error. + """ try: delete("/dnsmasq/dns-record/remove", {"name": name}) logger.info("DNS record removed via API: %s", name) diff --git a/webui/api/firewall.py b/webui/api/firewall.py index 07439d6..1adbcba 100644 --- a/webui/api/firewall.py +++ b/webui/api/firewall.py @@ -21,6 +21,16 @@ bp = Blueprint("firewall", __name__) @bp.route("/config", methods=["GET"]) def config_list(): + """Retrieve the current firewall declarative configuration. + + Returns JSON containing the full firewall config from the daemon. + + Endpoint: + GET /api/firewall/config + + Returns: + JSON response with the config data or an error message. + """ try: return _ok(get("/firewall/config")) except RuntimeError as exc: @@ -30,6 +40,20 @@ def config_list(): @bp.route("/config", methods=["POST"]) def config_save(): + """Save a new firewall declarative configuration. + + Validates that the request body contains a ``zones`` dict, forwards + to the daemon, and returns the pending state including unmanaged zones. + + Endpoint: + POST /api/firewall/config + + Args: + body: JSON with ``zones`` dict mapping zone names to zone configs. + + Returns: + JSON with ``config_saved`` flag and pending apply information. + """ body = request.get_json(silent=True) or {} if "zones" not in body: return _error("'zones' key is required", 400) @@ -64,6 +88,20 @@ def config_save(): @bp.route("/config", methods=["PATCH"]) def patch_config(): + """Partially update the firewall declarative configuration. + + Accepts a JSON body and forwards it as a patch to the daemon config + endpoint, returning the updated pending state. + + Endpoint: + PATCH /api/firewall/config + + Args: + body: JSON object with configuration fields to patch. + + Returns: + JSON with ``config_saved`` flag and pending apply information. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -95,6 +133,17 @@ def patch_config(): @bp.route("/config/apply", methods=["POST"]) def config_apply_bp(): + """Apply any pending firewall configuration changes. + + Triggers the daemon to apply saved declarative config to the live + firewalld instance. + + Endpoint: + POST /api/firewall/config/apply + + Returns: + JSON with ``applied_zones`` list or an error message. + """ try: result = post("/firewall/config/apply") logger.info("Firewall config applied: %s", result.get("applied_zones", [])) @@ -106,6 +155,17 @@ def config_apply_bp(): @bp.route("/config/pending", methods=["GET"]) def config_pending_bp(): + """Check the pending firewall configuration state. + + Returns information about unsaved changes, whether an apply is + needed, and any unmanaged zones detected on the system. + + Endpoint: + GET /api/firewall/config/pending + + Returns: + JSON with pending changes and apply status. + """ try: return _ok(get("/firewall/config/pending")) except RuntimeError as exc: @@ -120,6 +180,14 @@ def config_pending_bp(): @bp.route("/zones", methods=["GET"]) def list_zones(): + """List all active and available firewall zones. + + Endpoint: + GET /api/firewall/zones + + Returns: + JSON with ``active`` zones dict and ``available`` zones list. + """ try: data = get("/firewall/zones") return _ok( @@ -132,6 +200,17 @@ def list_zones(): @bp.route("/zones/", methods=["GET"]) def zone_details(name: str): + """Retrieve details for a specific firewall zone. + + Endpoint: + GET /api/firewall/zones/ + + Args: + name: Name of the zone to look up. + + Returns: + JSON with zone configuration details or 404 error. + """ try: info = get("/firewall/zones/info", {"zone": name}) return _ok(info) @@ -145,6 +224,17 @@ def zone_details(name: str): @bp.route("/zones", methods=["POST"]) def create_zone_bp(): + """Create a new firewall zone. + + Endpoint: + POST /api/firewall/zones + + Args: + body: JSON with ``name`` (required) and optional ``target`` string. + + Returns: + JSON confirmation or error if the zone already exists. + """ body = request.get_json(silent=True) or {} zone_name = body.get("name", "").strip() target = body.get("target", "default").strip() or "default" @@ -164,6 +254,17 @@ def create_zone_bp(): @bp.route("/zones/", methods=["DELETE"]) def delete_zone_bp(name: str): + """Delete a firewall zone by name. + + Endpoint: + DELETE /api/firewall/zones/ + + Args: + name: Name of the zone to delete. + + Returns: + JSON confirmation or 404 if the zone does not exist. + """ try: delete("/firewall/zones/delete", {"zone": name}) logger.info("Zone '%s' deleted via API", name) @@ -183,6 +284,20 @@ def delete_zone_bp(name: str): @bp.route("/zones//interfaces", methods=["POST"]) def set_zone_interfaces_bp(name: str): + """Set the network interfaces assigned to a firewall zone. + + Replaces all existing interfaces for the zone with the provided list. + + Endpoint: + POST /api/firewall/zones//interfaces + + Args: + name: Zone name. + body: JSON with ``interfaces`` list of interface names. + + Returns: + JSON confirmation with zone and updated interfaces list. + """ body = request.get_json(silent=True) or {} interfaces = body.get("interfaces", []) if not isinstance(interfaces, list): @@ -209,6 +324,20 @@ def set_zone_interfaces_bp(name: str): @bp.route("/zones//services", methods=["POST"]) def set_zone_services_bp(name: str): + """Set the allowed services for a firewall zone. + + Replaces all existing services for the zone with the provided list. + + Endpoint: + POST /api/firewall/zones//services + + Args: + name: Zone name. + body: JSON with ``services`` list of service names. + + Returns: + JSON confirmation with zone and updated services list. + """ body = request.get_json(silent=True) or {} services = body.get("services", []) if not isinstance(services, list): @@ -234,6 +363,14 @@ def set_zone_services_bp(name: str): @bp.route("/services", methods=["GET"]) def list_services(): + """List all available firewall services. + + Endpoint: + GET /api/firewall/services + + Returns: + JSON with the list of available service names. + """ try: return _ok(get("/firewall/services")) except RuntimeError as exc: @@ -243,6 +380,14 @@ def list_services(): @bp.route("/interfaces", methods=["GET"]) def list_interfaces(): + """List all available network interfaces. + + Endpoint: + GET /api/firewall/interfaces + + Returns: + JSON with the list of available interface names. + """ try: return _ok(get("/firewall/interfaces")) except RuntimeError as exc: @@ -257,6 +402,17 @@ def list_interfaces(): @bp.route("/rich-rules", methods=["POST"]) def add_rich_rule_bp(): + """Add a rich rule to a firewall zone. + + Endpoint: + POST /api/firewall/rich-rules + + Args: + body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string). + + Returns: + JSON with zone, generated rule ID, and rule string. + """ body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() rule = body.get("rule", "").strip() @@ -276,6 +432,17 @@ def add_rich_rule_bp(): @bp.route("/rich-rules/", methods=["GET"]) def list_rich_rules(zone: str): + """List rich rules for a specific firewall zone. + + Endpoint: + GET /api/firewall/rich-rules/ + + Args: + zone: Zone name to list rules for. + + Returns: + JSON with list of rich rule entries for the zone. + """ try: return _ok(get("/firewall/rich-rules", {"zone": zone})) except RuntimeError as exc: @@ -285,6 +452,18 @@ def list_rich_rules(zone: str): @bp.route("/rich-rules//", methods=["DELETE"]) def remove_rich_rule_bp(zone: str, rule_id: str): + """Remove a rich rule from a firewall zone by ID. + + Endpoint: + DELETE /api/firewall/rich-rules// + + Args: + zone: Zone name. + rule_id: Rule identifier. + + Returns: + JSON confirmation or 404 if the rule does not exist. + """ try: delete("/firewall/rich-rules/remove", {"zone": zone, "id": rule_id}) logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone) @@ -304,6 +483,17 @@ def remove_rich_rule_bp(zone: str, rule_id: str): @bp.route("/masquerade", methods=["POST"]) def set_masquerade_bp(): + """Enable or disable masquerade (NAT) on a firewall zone. + + Endpoint: + POST /api/firewall/masquerade + + Args: + body: JSON with ``zone`` (zone name) and ``enable`` (boolean). + + Returns: + JSON confirmation with zone and masquerade status. + """ body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() enable = body.get("enable") @@ -332,6 +522,18 @@ def set_masquerade_bp(): @bp.route("/forward-port", methods=["POST"]) def add_forward_port_bp(): + """Add a port forwarding rule to a firewall zone. + + Endpoint: + POST /api/firewall/forward-port + + Args: + body: JSON with ``zone`` (zone name), ``port`` (int), ``proto`` + (tcp/udp), optional ``toaddr`` and ``toport``. + + Returns: + JSON confirmation with zone, generated ID, port, and protocol. + """ body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() port = body.get("port") @@ -373,6 +575,19 @@ def add_forward_port_bp(): @bp.route("/forward-port///", methods=["DELETE"]) def remove_forward_port_bp(zone: str, port: int, proto: str): + """Remove a port forwarding rule from a firewall zone. + + Endpoint: + DELETE /api/firewall/forward-port/// + + Args: + zone: Zone name. + port: Port number. + proto: Protocol (tcp/udp). + + Returns: + JSON confirmation or 404 if the rule does not exist. + """ try: delete( "/firewall/forward-port/remove", diff --git a/webui/api/logs.py b/webui/api/logs.py index ae85597..97b2a02 100644 --- a/webui/api/logs.py +++ b/webui/api/logs.py @@ -19,12 +19,28 @@ _LOG_LINE_TEMPLATE = """\ def _render_log_lines(text: str) -> str: + """Render raw log text into styled HTML log-line divs. + + Args: + text: Raw log content with newline-separated lines. + + Returns: + HTML string with color-coded log-line elements. + """ lines = text.rstrip("\n").split("\n") if text.strip() else [] return render_template_string(_LOG_LINE_TEMPLATE, lines=lines) @bp.route("/journal") def journal(): + """GET /api/logs/journal — Return systemd journal log lines. + + Fetches the daemon's journal log content via vacuum-walld and + renders it as styled HTML log-line elements. + + Returns: + HTML string containing rendered journal log lines. + """ try: text = get("/logs/journal") return _render_log_lines(text) @@ -34,6 +50,14 @@ def journal(): @bp.route("/nginx/access") def nginx_access(): + """GET /api/logs/nginx/access — Return nginx access log lines. + + Fetches the nginx access log content via vacuum-walld and + renders it as styled HTML log-line elements. + + Returns: + HTML string containing rendered access log lines. + """ try: text = get("/logs/nginx/access") return _render_log_lines(text) @@ -43,6 +67,14 @@ def nginx_access(): @bp.route("/nginx/error") def nginx_error(): + """GET /api/logs/nginx/error — Return nginx error log lines. + + Fetches the nginx error log content via vacuum-walld and + renders it as styled HTML log-line elements. + + Returns: + HTML string containing rendered error log lines. + """ try: text = get("/logs/nginx/error") return _render_log_lines(text) @@ -52,6 +84,14 @@ def nginx_error(): @bp.route("/dnsmasq") def dnsmasq(): + """GET /api/logs/dnsmasq — Return dnsmasq log lines. + + Fetches the dnsmasq log content via vacuum-walld and + renders it as styled HTML log-line elements. + + Returns: + HTML string containing rendered dnsmasq log lines. + """ try: text = get("/logs/dnsmasq") return _render_log_lines(text) @@ -61,6 +101,14 @@ def dnsmasq(): @bp.route("/app") def app_log(): + """GET /api/logs/app — Return application log lines. + + Fetches the application log content via vacuum-walld and + renders it as styled HTML log-line elements. + + Returns: + HTML string containing rendered application log lines. + """ try: text = get("/logs/app") return _render_log_lines(text) diff --git a/webui/api/proxy.py b/webui/api/proxy.py index 06a73b4..0dec32c 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -16,6 +16,16 @@ bp = Blueprint("proxy", __name__) @bp.route("/ssl-apply", methods=["POST"]) def ssl_apply_bp(): + """Apply SSL snippet config. + + POST /api/proxy/ssl-apply + + Returns: + ``{"ok": true}`` on success. + + Raises: + RuntimeError: If nginx SSL snippet write fails. + """ try: post("/nginx/ssl-apply") logger.info("SSL snippet written via API") @@ -27,6 +37,13 @@ def ssl_apply_bp(): @bp.route("/config", methods=["GET"]) def get_config_bp(): + """Get the current nginx proxy configuration. + + GET /api/proxy/config + + Returns: + Current config dict from the daemon. + """ try: return _ok(get("/nginx/config")) except RuntimeError as exc: @@ -36,6 +53,16 @@ def get_config_bp(): @bp.route("/config", methods=["POST"]) def post_config(): + """Save the nginx proxy configuration. + + POST /api/proxy/config + + Body: + Any JSON object to merge into the config. + + Returns: + ``{"ok": true}`` on success. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -53,6 +80,16 @@ def post_config(): @bp.route("/config", methods=["PATCH"]) def patch_config(): + """Partially update the nginx proxy configuration. + + PATCH /api/proxy/config + + Body: + JSON object with fields to patch. + + Returns: + ``{"ok": true}`` on success. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -70,6 +107,13 @@ def patch_config(): @bp.route("/domains", methods=["GET"]) def list_domains(): + """List all configured proxy domains. + + GET /api/proxy/domains + + Returns: + List of domain dicts from the daemon. + """ try: return _ok(get("/nginx/domains")) except RuntimeError as exc: @@ -79,6 +123,21 @@ def list_domains(): @bp.route("/domains", methods=["POST"]) def add_domain_bp(): + """Add a new proxy domain. + + POST /api/proxy/domains + + Body fields: + domain: Domain name. + backend_host: Upstream host. + backend_port: Upstream port. + backend_proto: Protocol (``http`` or ``https``, default ``http``). + cert: Optional certificate type. + extra_headers: Optional extra headers dict. + + Returns: + ``{"domain": ...}`` on success. + """ body = request.get_json(silent=True) or {} domain = body.get("domain", "").strip() backend_host = body.get("backend_host", "").strip() @@ -116,6 +175,16 @@ def add_domain_bp(): @bp.route("/domains/", methods=["PUT"]) def update_domain_bp(domain): + """Update an existing proxy domain in-place. + + PUT /api/proxy/domains/ + + Body fields: + Fields to merge into the domain config. + + Returns: + ``{"domain": ...}`` on success. + """ body = request.get_json(silent=True) or {} if not body: return _error("Request body must be a JSON object with fields to update", 400) @@ -136,6 +205,13 @@ def update_domain_bp(domain): @bp.route("/domains/", methods=["DELETE"]) def remove_domain_bp(domain): + """Remove a proxy domain. + + DELETE /api/proxy/domains/ + + Returns: + ``{"domain": ...}`` on success. + """ try: delete("/nginx/domains/remove", {"domain": domain}) logger.info("Proxy domain removed via API: %s", domain) @@ -150,6 +226,13 @@ def remove_domain_bp(domain): @bp.route("/apply", methods=["POST"]) def apply_bp(): + """Generate all nginx configs and reload nginx. + + POST /api/proxy/apply + + Returns: + ``{"ok": true}`` on success. + """ try: post("/nginx/apply") logger.info("nginx config applied via API") @@ -161,6 +244,13 @@ def apply_bp(): @bp.route("/test", methods=["POST"]) def test_bp(): + """Test nginx configuration without reloading. + + POST /api/proxy/test + + Returns: + ``{"valid": true, "output": ...}`` on success. Returns 400 if test fails. + """ try: result = post("/nginx/test") if result.get("valid"): @@ -173,6 +263,20 @@ def test_bp(): @bp.route("/management", methods=["POST"]) def management_bp(): + """Configure the management reverse proxy for the WebUI. + + POST /api/proxy/management + + Body fields: + domain: Management domain name. + flask_host: Upstream Flask host (default ``127.0.0.1``). + flask_port: Upstream Flask port (default 9090). + auth_user: Optional basic-auth username. + auth_pass: Optional basic-auth password. + + Returns: + ``{"ok": true}`` on success. + """ body = request.get_json(silent=True) or {} domain = body.get("domain", "").strip() if not domain: diff --git a/webui/api/wireguard.py b/webui/api/wireguard.py index 6851596..61e83e8 100644 --- a/webui/api/wireguard.py +++ b/webui/api/wireguard.py @@ -16,6 +16,14 @@ bp = Blueprint("wireguard", __name__) @bp.route("/config", methods=["GET"]) def get_config_bp(): + """Get the current WireGuard configuration. + + Endpoint: GET /api/wireguard/config + + Returns: + JSON response with the WireGuard config on success, or an error + response on failure. + """ try: return _ok(get("/wireguard/config")) except RuntimeError as exc: @@ -25,6 +33,18 @@ def get_config_bp(): @bp.route("/config", methods=["POST"]) def post_config(): + """Create or fully replace the WireGuard configuration. + + Endpoint: POST /api/wireguard/config + + Args: + body: JSON body with the configuration. If an ``interface`` key + is present, the private key will be stripped before forwarding. + + Returns: + Success response on acceptance, 400 on validation failure, or 500 + on server error. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -45,6 +65,18 @@ def post_config(): @bp.route("/config", methods=["PATCH"]) def patch_config(): + """Partially update the WireGuard configuration. + + Endpoint: PATCH /api/wireguard/config + + Args: + body: JSON body with the fields to update. If an ``interface`` + key is present, the private key will be stripped before forwarding. + + Returns: + Success response on acceptance, 400 on validation failure, or 500 + on server error. + """ body = request.get_json(silent=True) or {} if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) @@ -66,6 +98,13 @@ def patch_config(): @bp.route("/apply", methods=["POST"]) def apply_bp(): + """Apply the current WireGuard configuration to the live tunnel. + + Endpoint: POST /api/wireguard/apply + + Returns: + Success response on acceptance, or 500 on server error. + """ try: post("/wireguard/apply") logger.info("WireGuard tunnel applied via API") @@ -77,6 +116,13 @@ def apply_bp(): @bp.route("/up", methods=["POST"]) def up_bp(): + """Bring the WireGuard tunnel interface up. + + Endpoint: POST /api/wireguard/up + + Returns: + Success response on acceptance, or 500 on server error. + """ try: post("/wireguard/apply") logger.info("WireGuard tunnel started via API") @@ -88,6 +134,13 @@ def up_bp(): @bp.route("/down", methods=["POST"]) def down_bp(): + """Bring the WireGuard tunnel interface down. + + Endpoint: POST /api/wireguard/down + + Returns: + Success response on acceptance, or 500 on server error. + """ try: post("/wireguard/down") logger.info("WireGuard tunnel brought down via API") @@ -99,6 +152,14 @@ def down_bp(): @bp.route("/status", methods=["GET"]) def status_bp(): + """Get the current WireGuard tunnel status. + + Endpoint: GET /api/wireguard/status + + Returns: + JSON response with the tunnel status on success, or an error + response on failure. + """ try: return _ok(get("/wireguard/status")) except RuntimeError as exc: @@ -108,6 +169,13 @@ def status_bp(): @bp.route("/initialize", methods=["POST"]) def initialize_bp(): + """Initialize WireGuard for first-time use. + + Endpoint: POST /api/wireguard/initialize + + Returns: + Success response on acceptance, or 500 on server error. + """ try: post("/wireguard/initialize") logger.info("WireGuard initialized via API") @@ -119,6 +187,21 @@ def initialize_bp(): @bp.route("/peers", methods=["POST"]) def add_peer_bp(): + """Add a new peer to the WireGuard configuration. + + Endpoint: POST /api/wireguard/peers + + Args: + name: Peer display name (required). + endpoint: Optional peer endpoint address. + allowed_ips: Optional list of allowed IP CIDRs. + persistent_keepalive: Optional keepalive interval in seconds. + preshared_key: Optional pre-shared key in hex. + + Returns: + JSON response with the created peer on success, 400 on validation + failure, or 500 on server error. + """ body = request.get_json(silent=True) or {} name = body.get("name", "").strip() if not name: @@ -146,6 +229,17 @@ def add_peer_bp(): @bp.route("/peers/", methods=["DELETE"]) def remove_peer_bp(name): + """Remove a peer from the WireGuard configuration. + + Endpoint: DELETE /api/wireguard/peers/ + + Args: + name: Peer name to remove (from URL path). + + Returns: + Success response with peer name on removal, 404 if peer not found, + or 500 on server error. + """ try: delete("/wireguard/peers/remove", {"name": name}) logger.info("WireGuard peer '%s' removed via API", name) @@ -160,6 +254,14 @@ def remove_peer_bp(name): @bp.route("/peers", methods=["GET"]) def peers_bp(): + """List all configured WireGuard peers. + + Endpoint: GET /api/wireguard/peers + + Returns: + JSON response with the peers list on success, or an error response + on failure. + """ try: return _ok(get("/wireguard/peers")) except RuntimeError as exc: @@ -169,6 +271,14 @@ def peers_bp(): @bp.route("/peer-status", methods=["GET"]) def peer_status_bp(): + """Get real-time status information for all WireGuard peers. + + Endpoint: GET /api/wireguard/peer-status + + Returns: + JSON response with peer status on success, or an error response + on failure. + """ try: return _ok(get("/wireguard/peer-status")) except RuntimeError as exc: @@ -178,6 +288,18 @@ def peer_status_bp(): @bp.route("/generate-client", methods=["POST"]) def generate_client_bp(): + """Generate a WireGuard client configuration file for a peer. + + Endpoint: POST /api/wireguard/generate-client + + Args: + name: Peer name (required). + server_endpoint: Server endpoint address for the client config (required). + + Returns: + JSON response with the generated config string on success, 404 if + peer not found, 400 on validation failure, or 500 on server error. + """ body = request.get_json(silent=True) or {} name = body.get("name", "").strip() if not name: diff --git a/webui/server.py b/webui/server.py index d64a3ac..808c3f4 100644 --- a/webui/server.py +++ b/webui/server.py @@ -49,6 +49,11 @@ _reloading = False def _sighup_handler(signum, frame): + """Handle SIGHUP by reloading modules then restarting via SIGTERM. + + Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints, + and requests systemd restart by sending SIGTERM with default handler. + """ global _reloading if _reloading: return @@ -99,11 +104,20 @@ for name, _ in BLUEPRINTS: @app.before_request def _log_request_start(): + """Record request start time for duration tracking.""" request._start_time = time.monotonic() @app.after_request def _log_request_finish(response): + """Log request duration and status code after response generation. + + Args: + response: The HTTP response object. + + Returns: + The unchanged response object. + """ elapsed_ms = ( time.monotonic() - getattr(request, "_start_time", time.monotonic()) ) * 1000 @@ -124,6 +138,14 @@ def _log_request_finish(response): @app.template_filter("timestamp") def timestamp_filter(value): + """Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``. + + Args: + value: ISO timestamp string (may end with ``Z``). + + Returns: + Formatted date string, or original value on parse failure. + """ if not value: return "" try: @@ -135,6 +157,14 @@ def timestamp_filter(value): @app.template_filter("bytes") def bytes_filter(value): + """Convert a byte count to a human-readable size string (B/KB/MB…). + + Args: + value: Numeric byte count. + + Returns: + Formatted size string, or original value on parse failure. + """ try: num = float(value) except (ValueError, TypeError): @@ -150,6 +180,14 @@ def bytes_filter(value): @app.template_filter("duration") def duration_filter(value): + """Convert a duration in seconds to a human-readable string. + + Args: + value: Duration in seconds. + + Returns: + Formatted string (e.g. ``3d 2h 15m 30s``), or original value on failure. + """ try: total = int(float(value)) except (ValueError, TypeError): @@ -172,6 +210,14 @@ def duration_filter(value): @app.template_filter("json_pretty") def json_pretty_filter(value): + """Serialize *value* as indented JSON for template display. + + Args: + value: Any JSON-serializable object. + + Returns: + Pretty-printed JSON string with 2-space indent. + """ import json try: @@ -186,7 +232,15 @@ def json_pretty_filter(value): def _safely(fn, default=None): - """Call *fn* and return *default* on any exception.""" + """Call *fn* and return *default* on any exception. + + Args: + fn: Zero-argument callable to execute. + default: Fallback value returned when *fn* raises. + + Returns: + The result of ``fn()``, or *default* if an exception occurred. + """ try: return fn() except Exception as exc: @@ -195,7 +249,15 @@ def _safely(fn, default=None): def _get_service_status(dnsmasq_info, wg_info): - """Build a service status dict for the dashboard template.""" + """Build a service status dict for the dashboard template. + + Args: + dnsmasq_info: Dnsmasq status payload from the daemon. + wg_info: WireGuard status payload from the daemon. + + Returns: + Dict mapping service names to ``{running: bool}``. + """ services = {} if dnsmasq_info: services["Dnsmasq"] = { @@ -209,17 +271,24 @@ def _get_service_status(dnsmasq_info, wg_info): def _fw_config_get() -> dict[str, Any]: - """Read firewall config via daemon.""" + """Read the current firewall config from the daemon.""" return get("/firewall/config") def _load_status_all() -> dict[str, Any]: - """Load all system state in one call.""" + """Load all subsystem status from the daemon in a single call.""" return get("/status/all") @app.route("/") def root_redirect(): + """Redirect root URL to the dashboard. + + GET / + + Returns: + Redirect response to the dashboard page. + """ from flask import redirect, url_for return redirect(url_for("dashboard")) @@ -227,6 +296,21 @@ def root_redirect(): @app.route("/dashboard") def dashboard(): + """Render the main dashboard overview page. + + GET / + + Template context: + active_zones (dict): Active firewalld zones and bound interfaces. + interfaces (list): Available network interfaces with zone bindings. + dnsmasq (dict): Dnsmasq status information. + domains (list): Configured proxy domains. + certs (list): ACME certificate inventory. + wg_status (dict): WireGuard tunnel status. + services (dict): Service running indicators (Dnsmasq, WireGuard). + firewall_config (dict): Declarative firewall JSON config. + firewall_pending (dict): Pending firewall rules awaiting apply. + """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} dm_state = all_status.get("dnsmasq", {}) or {} @@ -257,6 +341,16 @@ def dashboard(): @app.route("/interfaces") def interfaces_page(): + """Render the network interfaces management page. + + GET /interfaces + + Template context: + interfaces (list): Available network interfaces. + zones (list): Zone names bound to interfaces. + firewall_config (dict): Declarative firewall JSON config. + firewall_pending (dict): Pending firewall rules awaiting apply. + """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} return render_template( @@ -270,6 +364,16 @@ def interfaces_page(): @app.route("/zones") def zones_page(): + """Render the firewall zones management page. + + GET /zones + + Template context: + zones (list): All zone configurations. + services (list): Available service identifiers for zone policies. + firewall_config (dict): Declarative firewall JSON config. + firewall_pending (dict): Pending firewall rules awaiting apply. + """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} return render_template( @@ -283,6 +387,14 @@ def zones_page(): @app.route("/rules") def rules_page(): + """Render the firewall rich-rules editor page. + + GET /rules + + Template context: + zones (list): Zone names containing rich rules. + rules (dict | None): Zone name → rich rule mappings (``None`` if empty). + """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} zones = list(fw_state.get("zones", {}).keys()) @@ -296,6 +408,13 @@ def rules_page(): @app.route("/nat") def nat_page(): + """Render the NAT rules management page. + + GET /nat + + Template context: + zones (list): Zone configurations containing NAT rules. + """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} return render_template("nat.html", zones=list(fw_state.get("zones", {}).values())) @@ -303,6 +422,15 @@ def nat_page(): @app.route("/dhcp") def dhcp_page(): + """Render the DHCP/Dnsmasq configuration page. + + GET /dhcp + + Template context: + config (dict): Dnsmasq configuration settings. + status (dict): Dnsmasq runtime status. + leases (list): Current DHCP lease table. + """ all_status = _safely(_load_status_all, {}) dm_state = all_status.get("dnsmasq", {}) or {} return render_template( @@ -315,6 +443,14 @@ def dhcp_page(): @app.route("/proxy") def proxy_page(): + """Render the reverse proxy / SSL termination management page. + + GET /proxy + + Template context: + domains (list): Configured proxy domains with upstream targets. + config (dict): Nginx configuration settings. + """ all_status = _safely(_load_status_all, {}) ng_state = all_status.get("nginx", {}) or {} return render_template( @@ -326,6 +462,14 @@ def proxy_page(): @app.route("/certs") def certs_page(): + """Render the SSL certificate management page. + + GET /certs + + Template context: + certs (list): ACME certificate inventory. + email (str): Configured ACME registration email. + """ all_status = _safely(_load_status_all, {}) ac_state = all_status.get("acme", {}) or {} return render_template( @@ -337,6 +481,14 @@ def certs_page(): @app.route("/wireguard") def wireguard_page(): + """Render the WireGuard VPN management page. + + GET /wireguard + + Template context: + config (dict): WireGuard tunnel configuration. + status (dict): WireGuard runtime status. + """ all_status = _safely(_load_status_all, {}) wg_state = all_status.get("wireguard", {}) or {} return render_template( @@ -348,6 +500,10 @@ def wireguard_page(): @app.route("/logs") def logs_page(): + """Render the system logs viewer page. + + GET /logs + """ return render_template("logs.html")