docs: add docstrings to all API endpoints and daemon handlers

Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
This commit is contained in:
2026-05-30 16:15:45 +00:00
parent bd98830638
commit 2f215793e9
17 changed files with 1550 additions and 28 deletions
+113 -2
View File
@@ -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 ``<domain>.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
+129 -7
View File
@@ -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] = {