docs: add docstrings to all API endpoints and daemon handlers

Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
This commit is contained in:
2026-05-30 16:15:45 +00:00
parent bd98830638
commit 2f215793e9
17 changed files with 1550 additions and 28 deletions
+73 -5
View File
@@ -21,7 +21,11 @@ class NotFound(Exception):
class BadRequest(Exception):
"""Raised when the daemon returns HTTP 400."""
"""Exception raised when the daemon returns HTTP 400.
Used to signal client-side input or formatting errors from the
daemon for distinction from general failures.
"""
pass
@@ -30,6 +34,15 @@ _DEFAULT_SOCKET = None
def _get_socket_path() -> str:
"""Return the daemon Unix socket path.
Lazily resolves the path from the VACUUM_WALLD_SOCKET environment
variable or falls back to data/daemon.sock under the project
directory. The result is cached in _DEFAULT_SOCKET.
Returns:
Absolute path string for the daemon socket.
"""
global _DEFAULT_SOCKET
if _DEFAULT_SOCKET is None:
import os
@@ -44,6 +57,13 @@ def _get_socket_path() -> str:
def set_socket_path(path: str) -> None:
"""Override the default daemon socket path.
Useful for tests that need an alternate socket.
Args:
path: Absolute path to the Unix socket file.
"""
global _DEFAULT_SOCKET
_DEFAULT_SOCKET = path
@@ -110,22 +130,70 @@ def request(
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""GET request to daemon. Params are sent as URL query parameters."""
"""Send a GET request to the daemon.
Query parameters are passed as URL params rather than a JSON body.
Additional keyword arguments are forwarded to request().
Args:
path: URL path to request on the daemon.
params: Optional query parameters to append to the URL.
**kwargs: Extra arguments forwarded to request().
Returns:
The parsed JSON response data from the daemon.
"""
return request("GET", path, query_params=params, **kwargs)
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""POST request to daemon."""
"""Send a POST request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request().
Args:
path: URL path to request on the daemon.
body: Optional JSON-serializable payload.
**kwargs: Extra arguments forwarded to request().
Returns:
The parsed JSON response data from the daemon.
"""
return request("POST", path, json_body=body, **kwargs)
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""PATCH request to daemon."""
"""Send a PATCH request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request().
Args:
path: URL path to request on the daemon.
body: Optional JSON-serializable payload.
**kwargs: Extra arguments forwarded to request().
Returns:
The parsed JSON response data from the daemon.
"""
return request("PATCH", path, json_body=body, **kwargs)
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
"""DELETE request to daemon."""
"""Send a DELETE request to the daemon.
The body is transmitted as a JSON payload. Extra keyword arguments
are forwarded to request().
Args:
path: URL path to request on the daemon.
body: Optional JSON-serializable payload.
**kwargs: Extra arguments forwarded to request().
Returns:
The parsed JSON response data from the daemon.
"""
return request("DELETE", path, json_body=body, **kwargs)
+89
View File
@@ -37,6 +37,15 @@ _ISSUANCE_TTL = 300 # seconds to keep completed requests
@dataclass
class IssueStep:
"""Single step in a certificate issuance workflow.
Attributes:
name: Machine-readable step identifier (e.g. "issue").
label: Human-readable description shown to the user.
status: Current state: "pending", "running", "done", or "error".
message: Optional detail or error message for the step.
"""
name: str
label: str
status: str = "pending"
@@ -45,6 +54,19 @@ class IssueStep:
@dataclass
class IssueRequest:
"""Tracked certificate issuance request.
Attributes:
request_id: Unique hex identifier for polling.
domain: Target domain for the certificate.
email: Optional ACME contact email.
webroot: Optional custom webroot path.
steps: Ordered list of issuance steps.
status: Overall status: "running", "completed", or "failed".
created_at: Unix timestamp when request was created.
expires_at: Unix timestamp when entry expires from store.
"""
request_id: str
domain: str
email: str | None = None
@@ -55,6 +77,7 @@ class IssueRequest:
expires_at: float | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize request to a JSON-compatible dictionary."""
return {
"request_id": self.request_id,
"domain": self.domain,
@@ -78,6 +101,14 @@ class IssueRequest:
def _run_acme(args: list[str]) -> str:
"""Execute an acme.sh command and return combined output.
Returns:
Standard output (plus stderr).
Raises:
RuntimeError: On timeout or non-zero exit.
"""
from lib.state import _find_acme
acme_bin = _find_acme()
@@ -102,12 +133,14 @@ def _run_acme(args: list[str]) -> str:
def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
return _find_acme()
def _get_acme_email() -> str:
"""Read registered contact email from ACME account config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
@@ -122,12 +155,14 @@ def _get_acme_email() -> str:
def _get_state() -> dict[str, Any] | None:
"""Return the raw ACME entry from the shared state store."""
from lib.state import state as state_store
return state_store.get("acme")
def _get_acme_state() -> dict[str, Any]:
"""Return the ACME state or empty dict when missing."""
ac = _get_state()
if ac is None:
return {}
@@ -213,6 +248,7 @@ def _check_dns_resolves(domain: str) -> tuple[bool, str]:
def _check_acme_installed() -> tuple[bool, str]:
"""Verify acme.sh binary is installed and executable."""
try:
_find_acme_bin()
return True, "acme.sh found"
@@ -221,6 +257,7 @@ def _check_acme_installed() -> tuple[bool, str]:
def _check_email_configured() -> tuple[bool, str]:
"""Check whether an ACME contact email has been configured."""
email = _get_acme_email() or ""
if email:
return True, f"Contact email configured: {email}"
@@ -228,12 +265,14 @@ def _check_email_configured() -> tuple[bool, str]:
def _check_webroot() -> tuple[bool, str]:
"""Verify the ACME webroot directory exists and is writable."""
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
return True, "ACME webroot ready"
return False, "ACME webroot not ready or not writable"
def _check_challenge_config() -> tuple[bool, str]:
"""Check for the ACME HTTP-01 challenge nginx config file."""
from lib.nginx import SITES_DIR
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
@@ -298,12 +337,19 @@ def _validate(domain: str) -> dict[str, Any]:
@registry.register("GET", "/acme/list")
def list_certs(_request: Any, _body: Any) -> list[dict]:
"""GET /acme/list — return managed certificates."""
ac = _get_acme_state()
return ac.get("certs", [])
@registry.register("GET", "/acme/info")
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate.
Raises:
ValueError: When domain is missing.
NotFoundError: When no certificate exists for domain.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
@@ -316,6 +362,11 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
@registry.register("POST", "/acme/validate")
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/validate — run pre-flight checks for a domain.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -326,6 +377,14 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/acme/issue")
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/issue — create a new certificate issuance request.
Deduplicates in-progress requests. Spawns background task for actual issuance.
Raises:
ValueError: When domain is missing.
RuntimeError: When pre-flight checks fail.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -381,6 +440,12 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
@registry.register("GET", "/acme/issue/status")
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/issue/status — poll status of an issuance request.
Raises:
ValueError: When id is missing.
NotFoundError: When request_id is unknown.
"""
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
@@ -444,6 +509,14 @@ async def _run_issue(req: IssueRequest) -> None:
@registry.register("POST", "/acme/renew")
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
Args:
force: Force renewal regardless of expiry.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -462,6 +535,11 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("DELETE", "/acme/remove")
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/remove — remove a certificate from ACME management.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -475,6 +553,11 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/acme/email")
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/email — set the ACME contact email via account registration.
Raises:
ValueError: When email is missing.
"""
if not body:
raise ValueError("Request body required")
email = body.get("email", "").strip()
@@ -488,6 +571,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("GET", "/acme/email")
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state()
if ac:
return {"email": ac.get("email", "")}
@@ -506,6 +590,11 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("GET", "/acme/paths")
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
Raises:
ValueError: When domain is missing.
"""
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
+75
View File
@@ -35,12 +35,14 @@ DEFAULT_CFG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store."""
from lib.state import state as state_store
return state_store.get("dnsmasq")
def _get_config() -> dict[str, Any]:
"""Load dnsmasq config from JSON, merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
@@ -49,12 +51,14 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist dnsmasq config to JSON after merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render dnsmasq.conf from Jinja template and config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
@@ -71,6 +75,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_dnsmasq_state() -> dict[str, Any]:
"""Return cached dnsmasq state, or empty dict if unset."""
dm = _get_state()
if dm is None:
return {}
@@ -83,6 +88,11 @@ def _get_dnsmasq_state() -> dict[str, Any]:
@registry.register("GET", "/dnsmasq/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/config
Returns cached config if available, otherwise loads from disk.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
@@ -91,6 +101,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/config
Save full config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -100,6 +115,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register("PATCH", "/dnsmasq/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: PATCH /dnsmasq/config
Merge partial update into existing config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -111,6 +131,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/apply")
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/apply
Render config to dnsmasq.conf, write to disk, and reload dnsmasq service via sudo.
"""
cfg = _get_config()
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
@@ -129,6 +154,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("GET", "/dnsmasq/status")
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/status
Returns cached dnsmasq status object, or empty dict if unavailable.
"""
dm = _get_dnsmasq_state()
if dm and "status" in dm:
return dm["status"]
@@ -137,6 +167,11 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/ranges/add")
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/ranges/add
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -181,6 +216,11 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
@registry.register("DELETE", "/dnsmasq/ranges/remove")
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/ranges/remove
Remove DHCP range matching interface + start + end. Raises NotFoundError if missing.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
@@ -211,6 +251,11 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
@registry.register("GET", "/dnsmasq/leases")
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\
Endpoint: GET /dnsmasq/leases
Returns cached DHCP lease list from state, or empty list if unavailable.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("leases", [])
@@ -219,6 +264,11 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register("POST", "/dnsmasq/static-lease/add")
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/static-lease/add
Add or update a static DHCP lease by MAC address. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -247,6 +297,11 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
@registry.register("DELETE", "/dnsmasq/static-lease/remove")
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/static-lease/remove
Remove static lease by MAC. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
@@ -267,6 +322,11 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register("POST", "/dnsmasq/dns-record/add")
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/dns-record/add
Add or update a custom DNS record by name. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -295,6 +355,11 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
@registry.register("DELETE", "/dnsmasq/dns-record/remove")
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/dns-record/remove
Remove custom DNS record by name. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -313,6 +378,11 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
@registry.register("POST", "/dnsmasq/upstreams")
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/upstreams
Replace DNS upstream servers list. Raises ValueError if servers field missing.
"""
if not body or "servers" not in body:
raise ValueError("'servers' is required")
cfg = _get_config()
@@ -324,6 +394,11 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/dnsmasq/domain")
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/domain
Set or clear the local DNS domain. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain")
+8
View File
@@ -36,26 +36,31 @@ def _get_state() -> dict[str, Any] | None:
def _ensure_config_file() -> None:
"""Initialize config file with defaults if missing."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def _get_config() -> dict[str, Any]:
"""Load the firewall config file."""
_ensure_config_file()
return load_json(CONFIG_FILE)
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist firewall config to disk."""
_ensure_config_file()
save_json(CONFIG_FILE, cfg, indent=2)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
@@ -65,6 +70,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
def _get_forward_ports(zone_name: str) -> list[str]:
"""Return forward-port entries for a zone as CLI-style strings."""
with suppress(Exception):
fps = _parse_zone_output(
zone_name,
@@ -255,6 +261,7 @@ def _config_apply() -> dict[str, Any]:
def _get_fw_state() -> dict[str, Any]:
"""Return firewall state from the state store, or empty dict if absent."""
fw = _get_state()
if fw is None:
return {}
@@ -263,6 +270,7 @@ def _get_fw_state() -> dict[str, Any]:
@registry.register("GET", "/firewall/interfaces")
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /firewall/interfaces — return active interfaces from state."""
fw = _get_fw_state()
return fw.get("interfaces", [])
+24
View File
@@ -17,6 +17,16 @@ _MAX_LINES = 200
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
"""Return the last N lines of a file, optionally via sudo.
Args:
path: Absolute path to the file to read.
n: Number of trailing lines to return.
sudo: Whether to use sudo to access the file.
Returns:
Truncated file content or an error message string.
"""
try:
if sudo:
result = run_proc(["cat", path], sudo=True)
@@ -32,6 +42,15 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
"""Return recent journalctl output for a systemd unit via sudo.
Args:
unit: Systemd unit name to query.
n: Number of journal lines to return.
Returns:
Journal output or an error message string.
"""
try:
result = run_proc(
["journalctl", "--unit=" + unit, "-n", str(n)],
@@ -47,24 +66,29 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
@registry.register("GET", "/logs/journal")
def journal(_request, _body) -> str:
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
return _sudo_journalctl("vacuum-wall")
@registry.register("GET", "/logs/nginx/access")
def nginx_access(_request, _body) -> str:
"""GET /logs/nginx/access — return recent nginx access log lines."""
return _tail_file("/var/log/nginx/access.log", sudo=True)
@registry.register("GET", "/logs/nginx/error")
def nginx_error(_request, _body) -> str:
"""GET /logs/nginx/error — return recent nginx error log lines."""
return _tail_file("/var/log/nginx/error.log", sudo=True)
@registry.register("GET", "/logs/dnsmasq")
def dnsmasq_log(_request, _body) -> str:
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
return _sudo_journalctl("dnsmasq")
@registry.register("GET", "/logs/app")
def app_log(_request, _body) -> str:
"""GET /logs/app — return recent application log lines."""
return _tail_file(str(_APP_LOG_FILE))
+102
View File
@@ -50,12 +50,18 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store."""
from lib.state import state as state_store
return state_store.get("nginx")
def _get_config() -> dict[str, Any]:
"""Load the nginx config JSON, applying defaults for missing fields.
Returns:
The parsed config dict with ssl defaults filled in.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
@@ -66,10 +72,23 @@ def _get_config() -> dict[str, Any]:
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk.
Args:
cfg: The config dictionary to save.
"""
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja.
Args:
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
Returns:
Rendered server block as a string.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -86,6 +105,12 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def _write_site(domain: str, conf_text: str) -> None:
"""Atomically write a single site config file into sites-enabled.
Args:
domain: Site name used as the filename.
conf_text: Rendered nginx server block content.
"""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
@@ -97,6 +122,7 @@ def _write_site(domain: str, conf_text: str) -> None:
def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = INCLUDE_FILE.with_suffix(".tmp")
@@ -109,6 +135,7 @@ def _write_include_file() -> None:
def _write_ssl_snippet() -> None:
"""Render and install the shared SSL snippet to /etc/nginx/snippets/."""
cfg = _get_config()
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
@@ -126,6 +153,11 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
@@ -135,6 +167,10 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
@@ -143,6 +179,10 @@ def _reload_nginx() -> None:
def _write_all_sites() -> None:
"""Regenerate all site configs, management proxy, and ACME challenge site.
Removes stale .conf files that are no longer in config.
"""
ensure_dirs(SITES_DIR)
cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
@@ -186,6 +226,12 @@ def _write_all_sites() -> None:
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
Args:
user: The username to add or update.
password: Plain-text password to hash.
"""
ensure_dirs(DATA_DIR)
import crypt
@@ -211,6 +257,7 @@ def _write_htpasswd(user: str, password: str) -> None:
def _get_nginx_state() -> dict[str, Any]:
"""Return a shallow copy of the cached nginx state, or empty dict if unset."""
ng = _get_state()
if ng is None:
return {}
@@ -223,6 +270,11 @@ def _get_nginx_state() -> dict[str, Any]:
@registry.register("GET", "/nginx/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config.
Returns:
Full config dict from state cache, or fallback to file.
"""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
@@ -231,6 +283,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/nginx/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/config — replace the entire nginx config and refresh state.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -240,6 +297,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register("PATCH", "/nginx/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
@@ -253,6 +315,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("GET", "/nginx/domains")
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains.
Returns:
Domains list from state cache, or empty list.
"""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
@@ -261,6 +328,12 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register("POST", "/nginx/domains/add")
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
Raises:
ValueError: When required fields (domain, backend_host, backend_port) are missing.
ValueError: When the domain already exists.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -298,6 +371,12 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("DELETE", "/nginx/domains/remove")
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -317,6 +396,12 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/nginx/domains/update")
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -339,6 +424,11 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/nginx/apply")
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx.
Raises:
RuntimeError: When the nginx config test fails.
"""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
@@ -352,12 +442,18 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/nginx/test")
def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying.
Returns:
Dict with valid (bool) and output (str) from `nginx -t`.
"""
valid, output = _test_config()
return {"valid": valid, "output": output}
@registry.register("POST", "/nginx/ssl-apply")
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
_write_ssl_snippet()
refresh_state(["nginx"])
return {"applied": True}
@@ -365,6 +461,11 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/nginx/management")
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/management — configure the management UI reverse proxy.
Raises:
ValueError: When request body or domain field is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -391,5 +492,6 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
@registry.register("POST", "/nginx/reload")
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
_reload_nginx()
return {"reloaded": True}
+39
View File
@@ -42,20 +42,24 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached WireGuard state from the global state store."""
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]:
"""Load and merge the WireGuard config with defaults."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the WireGuard config to disk."""
save_json(CONFIG_PATH, cfg)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render the WireGuard server config file from Jinja template."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -65,6 +69,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state()
if wg is None:
return {}
@@ -77,6 +82,7 @@ def _get_wg_state() -> dict[str, Any]:
@registry.register("GET", "/wireguard/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/config — return WireGuard config with private key stripped."""
wg = _get_wg_state()
if wg:
return wg.get("config", {})
@@ -90,6 +96,11 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/wireguard/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
@@ -107,6 +118,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register("PATCH", "/wireguard/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/config — deep-merge patch into existing config.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
if "interface" in body:
@@ -122,6 +138,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("POST", "/wireguard/apply")
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config()
conf_text = _generate_conf(cfg)
_save_config(cfg)
@@ -142,6 +159,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/wireguard/down")
def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
@@ -152,6 +170,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("GET", "/wireguard/status")
def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []})
@@ -160,6 +179,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/wireguard/initialize")
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config()
if cfg["interface"].get("private_key"):
return {"initialized": False, "reason": "already initialized"}
@@ -180,6 +200,11 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register("POST", "/wireguard/peers/add")
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one.
Raises:
ValueError: When body is missing or name is empty.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -219,6 +244,12 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("DELETE", "/wireguard/peers/remove")
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/peers/remove — remove a peer by name.
Raises:
ValueError: When body is missing or name is empty.
NotFoundError: When peer does not exist.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
@@ -237,6 +268,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register("GET", "/wireguard/peers")
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peers — return configured peers with private keys stripped."""
wg = _get_wg_state()
if wg:
return wg.get("peers", [])
@@ -252,6 +284,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register("GET", "/wireguard/peer-status")
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /wireguard/peer-status — return runtime peer status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {}).get("peers", [])
@@ -260,6 +293,12 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register("POST", "/wireguard/generate-client")
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
Raises:
ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
+116 -10
View File
@@ -24,20 +24,46 @@ SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
class Handler:
"""Wrapper for a daemon handler function."""
"""Wrapper for a daemon handler function.
Attributes:
method: HTTP method (e.g. "GET", "POST").
path: URL path pattern.
"""
def __init__(self, method: str, path: str) -> None:
"""Initialize the handler metadata.
Args:
method: HTTP method.
path: URL path pattern.
"""
self.method = method.upper()
self.path = path
class Registry:
"""Route registry for daemon handlers."""
"""Route registry for daemon handlers.
Attributes:
_routes: Mapping of (method, path) tuples to handler callables.
"""
def __init__(self) -> None:
"""Initialize an empty route registry."""
self._routes: dict[tuple[str, str], Callable] = {}
def register(self, method: str, path: str):
"""Decorator that registers a handler for the given method and path.
Args:
method: HTTP method (e.g. "GET", "POST").
path: URL path to register the handler under.
Returns:
Decorator function wrapping the handler.
"""
def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn
fn._handler = Handler(method, path) # type: ignore[attr-defined]
@@ -46,6 +72,15 @@ class Registry:
return decorator
def get(self, method: str, path: str) -> Callable | None:
"""Look up a registered handler by method and path.
Args:
method: HTTP method to match.
path: URL path to match.
Returns:
The handler callable, or None if not registered.
"""
return self._routes.get((method.upper(), path))
@@ -53,7 +88,11 @@ registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
"""Refresh the pre-computed state for the given subsystems (or all)."""
"""Refresh the pre-computed state for the given subsystems (or all).
Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
"""
state_store.populate(subsystems)
@@ -64,15 +103,39 @@ class NotFoundError(Exception):
def ok(data: Any = None) -> web.Response:
"""Create a success JSON response.
Args:
data: Response payload. Defaults to None.
Returns:
A JSON Response with `ok` set to True.
"""
return web.json_response({"ok": True, "data": data})
def error(msg: str, code: int = 400) -> web.Response:
"""Create an error JSON response.
Args:
msg: Error message.
code: HTTP status code. Defaults to 400.
Returns:
A JSON Response with `ok` set to False.
"""
return web.json_response({"ok": False, "error": msg}, status=code)
async def _handle_request(request: web.Request) -> web.Response:
"""Dispatch a request to the appropriate handler."""
"""Dispatch a request to the appropriate handler.
Args:
request: The incoming HTTP request.
Returns:
The handler's response.
"""
handler_fn = registry.get(request.method, request.path)
if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404)
@@ -126,7 +189,14 @@ async def _handle_request(request: web.Request) -> web.Response:
async def _handle_batch(request: web.Request) -> web.Response:
"""Handle batch requests: execute operations in order, return keyed results."""
"""Handle batch requests: execute operations in order, return keyed results.
Args:
request: The incoming HTTP request containing operations.
Returns:
A JSON response keyed by operation IDs.
"""
try:
body = await request.json()
except json.JSONDecodeError:
@@ -175,6 +245,11 @@ async def _handle_batch(request: web.Request) -> web.Response:
def create_app() -> web.Application:
"""Create and configure the aiohttp application.
Returns:
A configured web.Application instance.
"""
app = web.Application()
app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
@@ -185,16 +260,32 @@ def create_app() -> web.Application:
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
Returns:
JSON response with process ID and socket path.
"""
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call."""
"""Return the entire state snapshot in one call.
Returns:
JSON response containing state data for all subsystems.
"""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system."""
"""Re-collect all state from system.
Args:
_request: The incoming request. JSON body may contain a "subsystems" list.
Returns:
JSON response with the updated state snapshot.
"""
try:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
@@ -207,12 +298,23 @@ async def refresh_status(_request: web.Request) -> web.Response:
async def _catch_all(request: web.Request) -> web.Response:
"""Catch-all for registered routes."""
"""Catch-all for registered routes.
Args:
request: The incoming HTTP request.
Returns:
The dispatched handler's response.
"""
return await _handle_request(request)
def _register_routes() -> None:
"""Import all handler modules to register routes."""
"""Import all handler modules to register routes.
Side effect: each imported module's `@registry.register` calls populate the
route registry with their handler endpoints.
"""
from daemon.handlers import (
acme, # noqa: F401
dnsmasq, # noqa: F401
@@ -224,7 +326,11 @@ def _register_routes() -> None:
def main() -> None:
"""Entry point for vacuum-walld."""
"""Entry point for vacuum-walld.
Sets up logging, registers routes, creates the aiohttp application,
and starts listening on the Unix socket.
"""
from lib.logging import setup_logging
setup_logging()
+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] = {
+58
View File
@@ -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("/<domain>", methods=["GET"])
def cert_details(domain: str):
"""GET /api/certs/<domain> — 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/<request_id>", methods=["GET"])
def issue_status(request_id: str):
"""GET /api/certs/issue/<request_id> — 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("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/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("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — 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:
+75
View File
@@ -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/<mac>", methods=["DELETE"])
def remove_static_lease_bp(mac):
"""DELETE /api/dhcp/static-lease/<mac> — 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/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — 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)
+215
View File
@@ -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/<name>", methods=["GET"])
def zone_details(name: str):
"""Retrieve details for a specific firewall zone.
Endpoint:
GET /api/firewall/zones/<name>
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/<name>", methods=["DELETE"])
def delete_zone_bp(name: str):
"""Delete a firewall zone by name.
Endpoint:
DELETE /api/firewall/zones/<name>
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/<name>/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/<name>/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/<name>/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/<name>/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/<zone>", methods=["GET"])
def list_rich_rules(zone: str):
"""List rich rules for a specific firewall zone.
Endpoint:
GET /api/firewall/rich-rules/<zone>
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/<zone>/<rule_id>", 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/<zone>/<rule_id>
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/<zone>/<int:port>/<proto>", 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/<zone>/<port>/<proto>
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",
+48
View File
@@ -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)
+104
View File
@@ -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/<domain>", methods=["PUT"])
def update_domain_bp(domain):
"""Update an existing proxy domain in-place.
PUT /api/proxy/domains/<domain>
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/<domain>", methods=["DELETE"])
def remove_domain_bp(domain):
"""Remove a proxy domain.
DELETE /api/proxy/domains/<domain>
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:
+122
View File
@@ -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/<name>", methods=["DELETE"])
def remove_peer_bp(name):
"""Remove a peer from the WireGuard configuration.
Endpoint: DELETE /api/wireguard/peers/<name>
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:
+160 -4
View File
@@ -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")