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
+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")