refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
This commit is contained in:
+75
-239
@@ -3,11 +3,11 @@
|
||||
Exposed at /api/certs/* and delegates to vacuum-walld.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
|
||||
from daemon.client import delete, get, post # noqa: F401 (resolved via module globals)
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||
DELETE_ACME_REMOVE,
|
||||
@@ -22,269 +22,105 @@ from daemon.iface import (
|
||||
POST_ACME_RENEW,
|
||||
POST_ACME_VALIDATE,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route, void_transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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(GET_ACME_LIST))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list certificates: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
def _validate_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
domain = ((request.get_json(silent=True) or {}).get("domain") or "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@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(GET_ACME_INFO, {"domain": domain}))
|
||||
except NotFound as exc:
|
||||
logger.info("Cert for '%s' not found: %s", domain, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get cert info for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _issue_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = (body.get("domain") or "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
try:
|
||||
result = post(POST_ACME_VALIDATE, {"domain": domain})
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Validation rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to validate cert for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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") or "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
raise ValueError("'domain' is required")
|
||||
email = (body.get("email") or "").strip() or None
|
||||
webroot = body.get("webroot")
|
||||
try:
|
||||
logger.info("Certificate issuance requested for '%s' via API", domain)
|
||||
result = post(
|
||||
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
|
||||
)
|
||||
logger.info(
|
||||
"Certificate issuance started for '%s' (id=%s)",
|
||||
domain,
|
||||
result.get("request_id"),
|
||||
)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
except Conflict as exc:
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
return {"domain": domain, "webroot": body.get("webroot"), "email": email}
|
||||
|
||||
|
||||
@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(GET_ACME_ISSUE_STATUS, {"id": request_id})
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Issuance request '%s' not found: %s", request_id, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
|
||||
return _error(str(exc), 500)
|
||||
def _email_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
email = ((request.get_json(silent=True) or {}).get("email") or "").strip()
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@bp.route("/<domain>/renew", methods=["POST"])
|
||||
def renew_bp(domain: str):
|
||||
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
|
||||
|
||||
Returns:
|
||||
Response containing a renewal request ID (poll it at
|
||||
``/api/certs/renew/<request_id>``) or an error message.
|
||||
"""
|
||||
try:
|
||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||
result = post(POST_ACME_RENEW, {"domain": domain})
|
||||
logger.info(
|
||||
"Certificate renewal started for '%s' (id=%s)",
|
||||
domain,
|
||||
result.get("request_id"),
|
||||
)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to renew cert for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
def _register_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = (body.get("email") or "").strip()
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
return {"email": email, "server": (body.get("server") or "").strip()}
|
||||
|
||||
|
||||
@bp.route("/renew/<request_id>", methods=["GET"])
|
||||
def renew_status(request_id: str):
|
||||
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
|
||||
|
||||
Args:
|
||||
request_id: Renewal request identifier returned by renew_bp.
|
||||
|
||||
Returns:
|
||||
Response containing renewal status or an error message.
|
||||
"""
|
||||
try:
|
||||
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Renewal request '%s' not found: %s", request_id, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
|
||||
return _error(str(exc), 500)
|
||||
def _email_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"email": sent["email"]}
|
||||
|
||||
|
||||
@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(DELETE_ACME_REMOVE, {"domain": domain})
|
||||
logger.info("Certificate removed for '%s' via API", domain)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Cert '%s' not found: %s", domain, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove cert '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(GET_ACME_LIST, bp)
|
||||
def list_certs_bp():
|
||||
"""GET /api/certs/list — List all managed ACME certificates."""
|
||||
|
||||
|
||||
@bp.route("/email", methods=["POST"])
|
||||
@daemon_route(GET_ACME_INFO, bp, rule="/<domain>")
|
||||
def cert_details():
|
||||
"""GET /api/certs/<domain> — Get details for a specific certificate."""
|
||||
|
||||
|
||||
@daemon_route(POST_ACME_VALIDATE, bp, body=_validate_body)
|
||||
def validate():
|
||||
"""POST /api/certs/validate — Run pre-flight checks for issuance."""
|
||||
|
||||
|
||||
@daemon_route(POST_ACME_ISSUE, bp, rule="/issue/start", body=_issue_body)
|
||||
def issue_start():
|
||||
"""POST /api/certs/issue/start — Create a new certificate issuance request."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
GET_ACME_ISSUE_STATUS, bp, rule="/issue/<request_id>", params={"id": "request_id"}
|
||||
)
|
||||
def issue_status():
|
||||
"""GET /api/certs/issue/<request_id> — Poll status of an issuance request."""
|
||||
|
||||
|
||||
@daemon_route(POST_ACME_RENEW, bp, rule="/<domain>/renew")
|
||||
def renew_bp():
|
||||
"""POST /api/certs/<domain>/renew — Start an (async) certificate renewal."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
GET_ACME_RENEW_STATUS, bp, rule="/renew/<request_id>", params={"id": "request_id"}
|
||||
)
|
||||
def renew_status():
|
||||
"""GET /api/certs/renew/<request_id> — Poll status of a certificate renewal."""
|
||||
|
||||
|
||||
@daemon_route(DELETE_ACME_REMOVE, bp, rule="/<domain>", transform=void_transform)
|
||||
def remove_bp():
|
||||
"""DELETE /api/certs/<domain> — Remove a certificate from ACME management."""
|
||||
|
||||
|
||||
@daemon_route(POST_ACME_EMAIL, bp, body=_email_body, transform=_email_echo)
|
||||
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") or "").strip()
|
||||
if not email:
|
||||
return _error("'email' is required", 400)
|
||||
try:
|
||||
post(POST_ACME_EMAIL, {"email": email})
|
||||
logger.info("ACME email set via API: %s", email)
|
||||
return _ok({"email": email})
|
||||
except BadRequest as exc:
|
||||
logger.info("ACME email set rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set ACME email: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/certs/email — Set the ACME account email address."""
|
||||
|
||||
|
||||
@bp.route("/account", methods=["GET"])
|
||||
@daemon_route(GET_ACME_ACCOUNT, bp)
|
||||
def account():
|
||||
"""GET /api/certs/account — return ACME account information.
|
||||
|
||||
Returns:
|
||||
Response containing account status or an error message.
|
||||
"""
|
||||
try:
|
||||
result = get(GET_ACME_ACCOUNT)
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get ACME account: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/certs/account — Return ACME account information."""
|
||||
|
||||
|
||||
@bp.route("/account/register", methods=["POST"])
|
||||
@daemon_route(POST_ACME_ACCOUNT_REGISTER, bp, body=_register_body)
|
||||
def register_account():
|
||||
"""POST /api/certs/account/register — register a new ACME account.
|
||||
|
||||
Expects JSON body with ``{``email``, ``server``?}``.
|
||||
|
||||
Returns:
|
||||
Response confirming registration or an error message.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = (body.get("email") or "").strip()
|
||||
if not email:
|
||||
return _error("'email' is required", 400)
|
||||
server = (body.get("server") or "").strip()
|
||||
try:
|
||||
result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server})
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to register ACME account: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/certs/account/register — Register a new ACME account."""
|
||||
|
||||
|
||||
@bp.route("/account", methods=["DELETE"])
|
||||
@daemon_route(DELETE_ACME_ACCOUNT_DEACTIVATE, bp, rule="/account", body=NO_BODY)
|
||||
def deactivate_account():
|
||||
"""DELETE /api/certs/account — deactivate the ACME account.
|
||||
|
||||
Returns:
|
||||
Response confirming deactivation or an error message.
|
||||
"""
|
||||
try:
|
||||
result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE)
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to deactivate ACME account: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""DELETE /api/certs/account — Deactivate the ACME account."""
|
||||
|
||||
+185
-6
@@ -1,14 +1,36 @@
|
||||
"""Shared API response helpers.
|
||||
"""Shared API response helpers + daemon-proxy route factory.
|
||||
|
||||
Used by all API blueprints to produce consistent JSON responses
|
||||
per the API response contract: ``{"ok": true, "data": <value>}`` /
|
||||
``{"ok": false, "error": "msg"}``.
|
||||
Used by all API blueprints to produce consistent JSON responses per the
|
||||
API response contract (``{"ok": true, "data": <value>}`` /
|
||||
``{"ok": false, "error": "msg"}``) and to collapse the repetitive
|
||||
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
|
||||
single declarative ``daemon_route`` decorator.
|
||||
|
||||
The factory dispatches to the ``daemon.client`` verb imported into the
|
||||
blueprint's own module namespace (resolved via ``sys.modules`` at request
|
||||
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
|
||||
"""
|
||||
|
||||
from flask import jsonify
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from daemon.client import BadRequest, Conflict, NotFound
|
||||
from daemon.iface import Endpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
|
||||
NO_BODY = object()
|
||||
|
||||
Verb = Callable[..., Any]
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
def _ok(data: Any = None):
|
||||
"""Return a success JSON response."""
|
||||
return jsonify({"ok": True, "data": data})
|
||||
|
||||
@@ -16,3 +38,160 @@ def _ok(data=None):
|
||||
def _error(msg: str, code: int = 400):
|
||||
"""Return an error JSON response with the given HTTP status code."""
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _derive_rule(path: str) -> str:
|
||||
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
|
||||
daemon endpoint path by dropping the leading subsystem segment.
|
||||
|
||||
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
|
||||
``/issue/status``.
|
||||
"""
|
||||
parts = path.lstrip("/").split("/")
|
||||
if len(parts) <= 1:
|
||||
return "/"
|
||||
return "/" + "/".join(parts[1:])
|
||||
|
||||
|
||||
def _map_view_args(
|
||||
view_args: dict[str, Any], params: dict[str, str] | None
|
||||
) -> dict[str, Any]:
|
||||
"""Map Flask view args onto daemon body keys.
|
||||
|
||||
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
|
||||
not listed maps to itself (identity), so path params are always
|
||||
forwarded and only renamed where the daemon expects a different key.
|
||||
"""
|
||||
result = dict(view_args)
|
||||
for body_key, view_arg_name in (params or {}).items():
|
||||
result.pop(view_arg_name, None)
|
||||
result[body_key] = view_args[view_arg_name]
|
||||
return result
|
||||
|
||||
|
||||
def daemon_route(
|
||||
endpoint: Endpoint,
|
||||
bp: Blueprint,
|
||||
rule: str | None = None,
|
||||
methods: tuple[str, ...] | list[str] | None = None,
|
||||
*,
|
||||
params: dict[str, str] | None = None,
|
||||
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
body: Any | None = None,
|
||||
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
"""Decorator factory for thin daemon-proxy routes.
|
||||
|
||||
Args:
|
||||
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
|
||||
is the daemon HTTP verb.
|
||||
bp: The target blueprint.
|
||||
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
|
||||
to the endpoint path minus its leading subsystem segment.
|
||||
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
|
||||
override where the UI verb differs from the daemon verb
|
||||
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
|
||||
params: ``{body_key: view_arg_name}`` renames for path params.
|
||||
precheck: ``(json, view_args) -> None`` run before dispatch; raise
|
||||
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
|
||||
validation the daemon does not perform). ``json`` is the raw
|
||||
``request.get_json(silent=True)`` result.
|
||||
body: How to build the daemon request body for non-GET verbs.
|
||||
``None`` (default): ``{**json, **mapped_view_args}``;
|
||||
``NO_BODY``: send no body argument;
|
||||
a callable ``(request, view_args) -> dict``: custom body (raise
|
||||
``ValueError`` for 400);
|
||||
a ``dict``: fixed body (merged with mapped view args).
|
||||
transform: ``(data, view_args, sent_body) -> data`` applied to the
|
||||
daemon result before wrapping in ``_ok()``; may raise a typed
|
||||
exception to emit an error (e.g. 400 when a result is invalid).
|
||||
|
||||
Returns:
|
||||
A decorator that registers the route and returns a view function
|
||||
whose ``__name__``/``__doc__`` are inherited from the decorated
|
||||
function.
|
||||
"""
|
||||
if rule is None:
|
||||
rule = _derive_rule(endpoint[1])
|
||||
if methods is None:
|
||||
methods = [endpoint[0]]
|
||||
daemon_method = endpoint[0]
|
||||
|
||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# The decorated view lives in the blueprint's module; its ``__globals__``
|
||||
# is that module's namespace. Resolving the daemon verb here (instead of
|
||||
# ``daemon.client`` directly) means tests patching
|
||||
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
|
||||
module_globals = fn.__globals__
|
||||
|
||||
def view(**view_args: Any) -> Any:
|
||||
try:
|
||||
json: Any = request.get_json(silent=True)
|
||||
if precheck is not None:
|
||||
precheck(json, view_args)
|
||||
mapped = _map_view_args(view_args, params)
|
||||
verb_fn: Verb = module_globals[daemon_method.lower()]
|
||||
if daemon_method == "GET":
|
||||
sent_body = mapped
|
||||
data = (
|
||||
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
|
||||
)
|
||||
elif body is NO_BODY:
|
||||
sent_body = None
|
||||
data = verb_fn(endpoint)
|
||||
elif callable(body):
|
||||
built = body(request, view_args)
|
||||
sent_body = built
|
||||
data = (
|
||||
verb_fn(endpoint, built)
|
||||
if built is not None
|
||||
else verb_fn(endpoint)
|
||||
)
|
||||
elif isinstance(body, dict):
|
||||
sent_body = {**body, **mapped}
|
||||
data = verb_fn(endpoint, sent_body)
|
||||
else:
|
||||
base = json if isinstance(json, dict) else {}
|
||||
sent_body = {**base, **mapped}
|
||||
data = verb_fn(endpoint, sent_body)
|
||||
if transform is not None:
|
||||
data = transform(data, view_args, sent_body)
|
||||
return _ok(data)
|
||||
except (BadRequest, ValueError) as exc:
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
|
||||
return _error(str(exc), 404)
|
||||
except Conflict as exc:
|
||||
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error(
|
||||
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
|
||||
)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
view.__name__ = fn.__name__
|
||||
view.__doc__ = fn.__doc__
|
||||
bp.add_url_rule(rule, view_func=view, methods=list(methods))
|
||||
return view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
|
||||
"""Precheck: reject a non-dict JSON body (400).
|
||||
|
||||
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
|
||||
"""
|
||||
if json is not None and not isinstance(json, dict):
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
|
||||
|
||||
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
|
||||
"""Transform: discard the daemon result and return ``data: null``.
|
||||
|
||||
Matches routes that historically responded ``_ok(None)`` (the daemon
|
||||
result was intentionally ignored by the caller).
|
||||
"""
|
||||
return None
|
||||
|
||||
+137
-270
@@ -3,11 +3,16 @@
|
||||
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||
delete,
|
||||
get,
|
||||
patch,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
@@ -23,252 +28,139 @@ from daemon.iface import (
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("dhcp", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# Config / status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
@daemon_route(GET_DNSMASQ_CONFIG, bp)
|
||||
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(GET_DNSMASQ_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||
)
|
||||
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)
|
||||
try:
|
||||
post(POST_DNSMASQ_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("DHCP config save rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
@daemon_route(
|
||||
PATCH_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||
)
|
||||
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)
|
||||
try:
|
||||
patch(PATCH_DNSMASQ_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("DHCP config patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration."""
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
@daemon_route(POST_DNSMASQ_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||
def apply_bp():
|
||||
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
|
||||
|
||||
try:
|
||||
post(POST_DNSMASQ_APPLY)
|
||||
logger.info("dnsmasq config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply dnsmasq config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
@daemon_route(GET_DNSMASQ_STATUS, bp)
|
||||
def status_bp():
|
||||
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
|
||||
|
||||
try:
|
||||
return _ok(get(GET_DNSMASQ_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get DHCP status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DHCP ranges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/ranges", methods=["POST"])
|
||||
def _add_range_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
iface = (body.get("interface") or "").strip() or None
|
||||
start = (body.get("start") or "").strip()
|
||||
end = (body.get("end") or "").strip()
|
||||
if not start or not end:
|
||||
raise ValueError("'start' and 'end' are required")
|
||||
return {
|
||||
"interface": iface or "",
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": body.get("lease_time", "12h"),
|
||||
}
|
||||
|
||||
|
||||
def _remove_range_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
iface = (body.get("interface") or "").strip() or ""
|
||||
start = (body.get("start") or "").strip()
|
||||
end = (body.get("end") or "").strip()
|
||||
if not start or not end:
|
||||
raise ValueError("'start' and 'end' are required")
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
bp,
|
||||
rule="/ranges",
|
||||
body=_add_range_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
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()
|
||||
end = body.get("end", "").strip()
|
||||
lease_time = body.get("lease_time", "12h")
|
||||
if not start or not end:
|
||||
return _error("'start' and 'end' are required", 400)
|
||||
try:
|
||||
post(
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
{
|
||||
"interface": iface or "",
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
},
|
||||
)
|
||||
logger.info("DHCP range added via API: %s-%s", start, end)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Add DHCP range rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add DHCP range: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface."""
|
||||
|
||||
|
||||
@bp.route("/ranges", methods=["DELETE"])
|
||||
@daemon_route(
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
bp,
|
||||
rule="/ranges",
|
||||
body=_remove_range_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
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()
|
||||
end = body.get("end", "").strip()
|
||||
if not start or not end:
|
||||
return _error("'start' and 'end' are required", 400)
|
||||
try:
|
||||
delete(
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
{"interface": iface, "start": start, "end": end},
|
||||
)
|
||||
logger.info("DHCP range removed via API: %s-%s", start, end)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Remove DHCP range not found: %s", exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove DHCP range: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""DELETE /api/dhcp/ranges — Remove a DHCP address range."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/leases", methods=["GET"])
|
||||
@daemon_route(GET_DNSMASQ_LEASES, bp)
|
||||
def leases_bp():
|
||||
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
|
||||
|
||||
try:
|
||||
return _ok(get(GET_DNSMASQ_LEASES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read lease table: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static leases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _add_static_lease_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
mac = body.get("mac", "").strip()
|
||||
ip = body.get("ip", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
mac = (body.get("mac") or "").strip()
|
||||
ip = (body.get("ip") or "").strip()
|
||||
if not mac or not ip:
|
||||
return _error("'mac' and 'ip' are required", 400)
|
||||
try:
|
||||
post(
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
)
|
||||
logger.info("Static lease added via API: %s -> %s", mac, ip)
|
||||
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add static lease rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add static lease: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
raise ValueError("'mac' and 'ip' are required")
|
||||
return {"mac": mac, "ip": ip, "hostname": body.get("hostname")}
|
||||
|
||||
|
||||
@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.
|
||||
def _static_lease_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"mac": sent["mac"], "ip": sent["ip"], "hostname": sent["hostname"]}
|
||||
|
||||
Args:
|
||||
mac: MAC address of the static lease to remove.
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
try:
|
||||
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
|
||||
logger.info("Static lease removed via API: %s", mac)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Static lease '%s' not found: %s", mac, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove static lease '%s': %s", mac, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
bp,
|
||||
rule="/static-lease",
|
||||
body=_add_static_lease_body,
|
||||
transform=_static_lease_echo,
|
||||
)
|
||||
def add_static_lease_bp():
|
||||
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
|
||||
bp,
|
||||
rule="/static-lease/<mac>",
|
||||
transform=void_transform,
|
||||
)
|
||||
def remove_static_lease_bp():
|
||||
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -276,35 +168,42 @@ 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.
|
||||
"""
|
||||
def _add_dns_record_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
address = body.get("address", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
name = (body.get("name") or "").strip()
|
||||
address = (body.get("address") or "").strip()
|
||||
if not name or not address:
|
||||
return _error("'name' and 'address' are required", 400)
|
||||
try:
|
||||
post(
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
{"name": name, "address": address, "hostname": hostname},
|
||||
)
|
||||
logger.info("DNS record added via API: %s -> %s", name, address)
|
||||
return _ok({"name": name, "address": address, "hostname": hostname})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add DNS record rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add DNS record: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
raise ValueError("'name' and 'address' are required")
|
||||
return {"name": name, "address": address, "hostname": body.get("hostname")}
|
||||
|
||||
|
||||
def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {
|
||||
"name": sent["name"],
|
||||
"address": sent["address"],
|
||||
"hostname": sent["hostname"],
|
||||
}
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
bp,
|
||||
rule="/dns-record",
|
||||
body=_add_dns_record_body,
|
||||
transform=_dns_record_echo,
|
||||
)
|
||||
def add_dns_record_bp():
|
||||
"""POST /api/dhcp/dns-record — Add a DNS record."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
bp,
|
||||
rule="/dns-record/<name>",
|
||||
transform=void_transform,
|
||||
)
|
||||
def remove_dns_record_bp():
|
||||
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,48 +211,16 @@ def add_dns_record_bp():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/domain", methods=["POST"])
|
||||
def _set_domain_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
return {"domain": (request.get_json(silent=True) or {}).get("domain")}
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_DNSMASQ_DOMAIN,
|
||||
bp,
|
||||
precheck=require_dict_body,
|
||||
body=_set_domain_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
def set_domain_bp():
|
||||
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
|
||||
|
||||
Args:
|
||||
request: JSON body with `domain` field (string or null to clear).
|
||||
|
||||
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)
|
||||
try:
|
||||
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
|
||||
logger.info("DNS domain updated via API: %s", body.get("domain"))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Set DNS domain rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set DNS domain: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
|
||||
logger.info("DNS record removed via API: %s", name)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("DNS record '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove DNS record '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/dhcp/domain — Set or clear the DNS search domain."""
|
||||
|
||||
+249
-524
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||
delete,
|
||||
get,
|
||||
patch,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
@@ -30,169 +36,179 @@ from daemon.iface import (
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("firewall", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body builders / prechecks / transforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config_save_precheck(json: Any, _va: Any) -> None:
|
||||
body = json or {}
|
||||
if "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
if not isinstance(body["zones"], dict):
|
||||
raise ValueError("'zones' must be a dict")
|
||||
|
||||
|
||||
def _interfaces_precheck(json: Any, _va: Any) -> None:
|
||||
if not isinstance((json or {}).get("interfaces", []), list):
|
||||
raise ValueError("'interfaces' must be a list")
|
||||
|
||||
|
||||
def _services_precheck(json: Any, _va: Any) -> None:
|
||||
if not isinstance((json or {}).get("services", []), list):
|
||||
raise ValueError("'services' must be a list")
|
||||
|
||||
|
||||
def _pending_data() -> dict[str, Any] | None:
|
||||
try:
|
||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||
return {
|
||||
"pending": pending.get("pending", []),
|
||||
"needs_apply": pending.get("needs_apply", False),
|
||||
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
# The save already succeeded; the follow-up read is best-effort so a
|
||||
# failure degrades to a bare ``config_saved`` rather than a 500.
|
||||
logger.warning("Failed to read pending state after config save: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _config_saved(_data: Any, _va: Any, _sent: Any) -> Any:
|
||||
return {"config_saved": True, **(_pending_data() or {})}
|
||||
|
||||
|
||||
def _create_zone_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone_name = (body.get("name") or "").strip()
|
||||
if not zone_name:
|
||||
raise ValueError("Zone name is required")
|
||||
target = (body.get("target") or "").strip() or "default"
|
||||
return {"name": zone_name, "target": target}
|
||||
|
||||
|
||||
def _zones_list(data: Any, _va: Any, _sent: Any) -> Any:
|
||||
return {"active": data.get("active", {}), "available": data.get("available", [])}
|
||||
|
||||
|
||||
def _zone_interfaces_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "interfaces": sent.get("interfaces", [])}
|
||||
|
||||
|
||||
def _zone_services_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "services": sent.get("services", [])}
|
||||
|
||||
|
||||
def _add_rich_rule_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = (body.get("zone") or "").strip()
|
||||
rule = (body.get("rule") or "").strip()
|
||||
if not zone or not rule:
|
||||
raise ValueError("Both 'zone' and 'rule' are required")
|
||||
return {"zone": zone, "rule": rule}
|
||||
|
||||
|
||||
def _rich_rule_add_echo(data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "id": data["id"], "rule": sent["rule"]}
|
||||
|
||||
|
||||
def _rich_rule_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "id": sent["id"]}
|
||||
|
||||
|
||||
def _masquerade_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = (body.get("zone") or "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
raise ValueError("'zone' and 'enable' (bool) are required")
|
||||
return {"zone": zone, "enable": bool(enable)}
|
||||
|
||||
|
||||
def _masquerade_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "masquerade": sent["enable"]}
|
||||
|
||||
|
||||
def _add_forward_port_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = (body.get("zone") or "").strip()
|
||||
port = body.get("port")
|
||||
proto = (body.get("proto") or "").strip()
|
||||
toaddr = body.get("toaddr")
|
||||
toport = body.get("toport")
|
||||
if not zone or port is None or not proto:
|
||||
raise ValueError("'zone', 'port', and 'proto' are required")
|
||||
try:
|
||||
port_int = int(port)
|
||||
except ValueError:
|
||||
raise ValueError("'port' must be an integer") from None
|
||||
toport_int = None
|
||||
if toport is not None:
|
||||
try:
|
||||
toport_int = int(toport)
|
||||
except ValueError:
|
||||
raise ValueError("'toport' must be an integer") from None
|
||||
return {
|
||||
"zone": zone,
|
||||
"port": port_int,
|
||||
"proto": proto,
|
||||
"toaddr": str(toaddr) if toaddr else None,
|
||||
"toport": toport_int,
|
||||
}
|
||||
|
||||
|
||||
def _forward_port_add_echo(data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {
|
||||
"zone": sent["zone"],
|
||||
"id": data["id"],
|
||||
"port": sent["port"],
|
||||
"proto": sent["proto"],
|
||||
}
|
||||
|
||||
|
||||
def _forward_port_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"zone": sent["zone"], "port": sent["port"], "proto": sent["proto"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declarative config (two-step: save -> apply)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_CONFIG, bp)
|
||||
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(GET_FIREWALL_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read firewall config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/config — Retrieve the current firewall config."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
|
||||
)
|
||||
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)
|
||||
if not isinstance(body["zones"], dict):
|
||||
return _error("'zones' must be a dict", 400)
|
||||
try:
|
||||
post(POST_FIREWALL_CONFIG, body)
|
||||
try:
|
||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||
pending_data = {
|
||||
"pending": pending.get("pending", []),
|
||||
"needs_apply": pending.get("needs_apply", False),
|
||||
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
pending_data = None
|
||||
logger.warning("Failed to read pending state after config save: %s", exc)
|
||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||
return _ok(
|
||||
{
|
||||
"config_saved": True,
|
||||
**(pending_data or {}),
|
||||
}
|
||||
)
|
||||
except BadRequest as exc:
|
||||
logger.info("Firewall config save rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save firewall config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/config — Save a new firewall declarative configuration."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
@daemon_route(
|
||||
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
|
||||
)
|
||||
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)
|
||||
try:
|
||||
patch(PATCH_FIREWALL_CONFIG, body)
|
||||
try:
|
||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||
pending_data = {
|
||||
"pending": pending.get("pending", []),
|
||||
"needs_apply": pending.get("needs_apply", False),
|
||||
"unmanaged_zones": pending.get("unmanaged_zones", {}),
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
pending_data = None
|
||||
logger.warning("Failed to read pending state after config patch: %s", exc)
|
||||
return _ok(
|
||||
{
|
||||
"config_saved": True,
|
||||
**(pending_data or {}),
|
||||
}
|
||||
)
|
||||
except BadRequest as exc:
|
||||
logger.info("Firewall config patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch firewall config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""PATCH /api/firewall/config — Partially update the firewall configuration."""
|
||||
|
||||
|
||||
@bp.route("/config/apply", methods=["POST"])
|
||||
@daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
|
||||
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(POST_FIREWALL_CONFIG_APPLY)
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply firewall config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/config/apply — Apply pending firewall config changes."""
|
||||
|
||||
|
||||
@bp.route("/config/pending", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
|
||||
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(GET_FIREWALL_CONFIG_PENDING))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to check pending config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/config/pending — Check the pending firewall config state."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -200,21 +216,9 @@ def config_pending_bp():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/state", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_STATE, bp)
|
||||
def get_state():
|
||||
"""Retrieve current firewall state from the state store.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/state
|
||||
|
||||
Returns:
|
||||
JSON with firewall state data or an error message.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_FIREWALL_STATE))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get firewall state: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/state — Retrieve current firewall state."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -222,182 +226,67 @@ def get_state():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
|
||||
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(GET_FIREWALL_ZONES)
|
||||
return _ok(
|
||||
{"active": data.get("active", {}), "available": data.get("available", [])}
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list zones: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/zones — List active and available firewall 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(GET_FIREWALL_ZONES_INFO, {"zone": name})
|
||||
return _ok(info)
|
||||
except NotFound as exc:
|
||||
logger.info("Zone '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get zone '%s' info: %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
|
||||
)
|
||||
def zone_details():
|
||||
"""GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_FIREWALL_ZONES_CREATE,
|
||||
bp,
|
||||
rule="/zones",
|
||||
body=_create_zone_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
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"
|
||||
if not zone_name:
|
||||
return _error("Zone name is required", 400)
|
||||
try:
|
||||
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
|
||||
logger.info("Zone '%s' created via API", zone_name)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Zone '%s' creation rejected: %s", zone_name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to create zone '%s': %s", zone_name, exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/zones — Create a new firewall zone."""
|
||||
|
||||
|
||||
@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(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
|
||||
logger.info("Zone '%s' deleted via API", name)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Zone '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to delete zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
DELETE_FIREWALL_ZONES_DELETE,
|
||||
bp,
|
||||
rule="/zones/<name>",
|
||||
params={"zone": "name"},
|
||||
transform=void_transform,
|
||||
)
|
||||
def delete_zone_bp():
|
||||
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone interfaces
|
||||
# Zone interfaces / services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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):
|
||||
return _error("'interfaces' must be a list", 400)
|
||||
try:
|
||||
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
|
||||
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
|
||||
return _ok({"zone": name, "interfaces": interfaces})
|
||||
except BadRequest as exc:
|
||||
logger.info("Set interfaces for zone '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Zone '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
bp,
|
||||
rule="/zones/<name>/interfaces",
|
||||
params={"zone": "name"},
|
||||
precheck=_interfaces_precheck,
|
||||
transform=_zone_interfaces_echo,
|
||||
)
|
||||
def set_zone_interfaces_bp():
|
||||
"""POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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):
|
||||
return _error("'services' must be a list", 400)
|
||||
try:
|
||||
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
|
||||
return _ok({"zone": name, "services": services})
|
||||
except BadRequest as exc:
|
||||
logger.info("Set services for zone '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Zone '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set services for zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
bp,
|
||||
rule="/zones/<name>/services",
|
||||
params={"zone": "name"},
|
||||
precheck=_services_precheck,
|
||||
transform=_zone_services_echo,
|
||||
)
|
||||
def set_zone_services_bp():
|
||||
"""POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -405,38 +294,14 @@ def set_zone_services_bp(name: str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/services", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_SERVICES, bp)
|
||||
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(GET_FIREWALL_SERVICES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list services: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/services — List all available firewall services."""
|
||||
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
@daemon_route(GET_FIREWALL_INTERFACES, bp)
|
||||
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(GET_FIREWALL_INTERFACES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list interfaces: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/firewall/interfaces — List all available network interfaces."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -444,80 +309,31 @@ def list_interfaces():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/rich-rules", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_FIREWALL_RICH_RULES_ADD,
|
||||
bp,
|
||||
rule="/rich-rules",
|
||||
body=_add_rich_rule_body,
|
||||
transform=_rich_rule_add_echo,
|
||||
)
|
||||
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()
|
||||
if not zone or not rule:
|
||||
return _error("Both 'zone' and 'rule' are required", 400)
|
||||
try:
|
||||
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
|
||||
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
|
||||
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/rich-rules — Add a rich rule to a firewall zone."""
|
||||
|
||||
|
||||
@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(GET_FIREWALL_RICH_RULES, {"zone": zone}))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
|
||||
def list_rich_rules():
|
||||
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
|
||||
|
||||
|
||||
@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(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
|
||||
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
|
||||
return _ok({"zone": zone, "id": rule_id})
|
||||
except NotFound as exc:
|
||||
logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
bp,
|
||||
rule="/rich-rules/<zone>/<rule_id>",
|
||||
params={"id": "rule_id"},
|
||||
transform=_rich_rule_remove_echo,
|
||||
)
|
||||
def remove_rich_rule_bp():
|
||||
"""DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -525,38 +341,11 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/masquerade", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo
|
||||
)
|
||||
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")
|
||||
if not zone or enable is None:
|
||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||
try:
|
||||
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
|
||||
logger.info(
|
||||
"Masquerade %s on zone '%s' via API",
|
||||
"enabled" if enable else "disabled",
|
||||
zone,
|
||||
)
|
||||
return _ok({"zone": zone, "masquerade": bool(enable)})
|
||||
except BadRequest as exc:
|
||||
logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -564,86 +353,22 @@ def set_masquerade_bp():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/forward-port", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
bp,
|
||||
rule="/forward-port",
|
||||
body=_add_forward_port_body,
|
||||
transform=_forward_port_add_echo,
|
||||
)
|
||||
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")
|
||||
proto = body.get("proto", "").strip()
|
||||
toaddr = body.get("toaddr")
|
||||
toport = body.get("toport")
|
||||
if not zone or port is None or not proto:
|
||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
||||
try:
|
||||
port_int = int(port)
|
||||
except ValueError:
|
||||
return _error("'port' must be an integer", 400)
|
||||
toport_int = None
|
||||
if toport is not None:
|
||||
try:
|
||||
toport_int = int(toport)
|
||||
except ValueError:
|
||||
return _error("'toport' must be an integer", 400)
|
||||
toaddr_str = str(toaddr) if toaddr else None
|
||||
try:
|
||||
entry = post(
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
{
|
||||
"zone": zone,
|
||||
"port": port_int,
|
||||
"proto": proto,
|
||||
"toaddr": toaddr_str,
|
||||
"toport": toport_int,
|
||||
},
|
||||
)
|
||||
return _ok({"zone": zone, "id": entry["id"], "port": port_int, "proto": proto})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add forward port rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add forward port: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/firewall/forward-port — Add a port forwarding rule to a zone."""
|
||||
|
||||
|
||||
@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(
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
{"zone": zone, "port": port, "proto": proto},
|
||||
)
|
||||
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
||||
return _ok({"zone": zone, "port": port, "proto": proto})
|
||||
except NotFound as exc:
|
||||
logger.info(
|
||||
"Forward port %s/%s not found in zone '%s': %s", port, proto, zone, exc
|
||||
)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
bp,
|
||||
rule="/forward-port/<zone>/<int:port>/<proto>",
|
||||
transform=_forward_port_remove_echo,
|
||||
)
|
||||
def remove_forward_port_bp():
|
||||
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
|
||||
|
||||
+7
-36
@@ -3,11 +3,9 @@
|
||||
Wraps raw log text in the standard JSON response contract.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import NotFound, get
|
||||
from daemon.client import get # noqa: F401 (resolved via module globals at dispatch)
|
||||
from daemon.iface import (
|
||||
GET_LOGS_APP,
|
||||
GET_LOGS_DNSMASQ,
|
||||
@@ -15,58 +13,31 @@ from daemon.iface import (
|
||||
GET_LOGS_NGINX_ACCESS,
|
||||
GET_LOGS_NGINX_ERROR,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import daemon_route
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("logs", __name__)
|
||||
|
||||
|
||||
@bp.route("/journal")
|
||||
@daemon_route(GET_LOGS_JOURNAL, bp)
|
||||
def journal():
|
||||
"""GET /api/logs/journal — Return systemd journal log lines."""
|
||||
try:
|
||||
return _ok(get(GET_LOGS_JOURNAL))
|
||||
except RuntimeError:
|
||||
return _error("error reading journal", 500)
|
||||
|
||||
|
||||
@bp.route("/nginx/access")
|
||||
@daemon_route(GET_LOGS_NGINX_ACCESS, bp)
|
||||
def nginx_access():
|
||||
"""GET /api/logs/nginx/access — Return nginx access log lines."""
|
||||
try:
|
||||
return _ok(get(GET_LOGS_NGINX_ACCESS))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _error("error reading log", 500)
|
||||
|
||||
|
||||
@bp.route("/nginx/error")
|
||||
@daemon_route(GET_LOGS_NGINX_ERROR, bp)
|
||||
def nginx_error():
|
||||
"""GET /api/logs/nginx/error — Return nginx error log lines."""
|
||||
try:
|
||||
return _ok(get(GET_LOGS_NGINX_ERROR))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _error("error reading log", 500)
|
||||
|
||||
|
||||
@bp.route("/dnsmasq")
|
||||
@daemon_route(GET_LOGS_DNSMASQ, bp)
|
||||
def dnsmasq():
|
||||
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
|
||||
try:
|
||||
return _ok(get(GET_LOGS_DNSMASQ))
|
||||
except RuntimeError:
|
||||
return _error("error reading journal", 500)
|
||||
|
||||
|
||||
@bp.route("/app")
|
||||
@daemon_route(GET_LOGS_APP, bp)
|
||||
def app_log():
|
||||
"""GET /api/logs/app — Return application log lines."""
|
||||
try:
|
||||
return _ok(get(GET_LOGS_APP))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _error("error reading log", 500)
|
||||
|
||||
+45
-130
@@ -4,11 +4,14 @@ Exposes /api/network/* and delegates to vacuum-walld for interface
|
||||
IP configuration via systemd-networkd.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import NotFound, get, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
|
||||
get,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
GET_NETWORK_INFER_ZONES,
|
||||
@@ -19,150 +22,62 @@ from daemon.iface import (
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
)
|
||||
from lib.common import validate_interface_name
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import daemon_route
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("network", __name__)
|
||||
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
def _check_iface(_json: Any, view_args: dict[str, Any]) -> None:
|
||||
"""Validate the interface name path param (400 on a bad name)."""
|
||||
validate_interface_name(view_args["name"])
|
||||
|
||||
|
||||
def _applied(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
|
||||
return {"name": view_args["name"], "applied": True}
|
||||
|
||||
|
||||
def _reloaded(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
|
||||
return {"name": view_args["name"], "reloaded": True}
|
||||
|
||||
|
||||
@daemon_route(GET_NETWORK_INTERFACES, bp)
|
||||
def list_interfaces():
|
||||
"""List all interfaces with their network config and runtime state.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/interfaces
|
||||
|
||||
Returns:
|
||||
JSON with interface config + runtime state.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INTERFACES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list network interfaces: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/network/interfaces — List interfaces with config + runtime state."""
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>", methods=["GET"])
|
||||
def get_interface(name: str):
|
||||
"""Get config + runtime state for a specific interface.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/interfaces/<name>
|
||||
|
||||
Returns:
|
||||
JSON with interface config and runtime state.
|
||||
"""
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Interface '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(GET_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface)
|
||||
def get_interface():
|
||||
"""GET /api/network/interfaces/<name> — Config + runtime state for one interface."""
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>", methods=["POST"])
|
||||
def save_interface(name: str):
|
||||
"""Save and apply network config for an interface.
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/interfaces/<name>
|
||||
|
||||
Args:
|
||||
body: JSON with addresses, gateway, dns, routes.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
body = {**(request.get_json(silent=True) or {}), "name": name}
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
post(POST_NETWORK_INTERFACE_NAME, body)
|
||||
logger.info("Interface '%s' config saved", name)
|
||||
return _ok({"name": name, "applied": True})
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface, transform=_applied
|
||||
)
|
||||
def save_interface():
|
||||
"""POST /api/network/interfaces/<name> — Save and apply an interface's config."""
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>/reload", methods=["POST"])
|
||||
def reload_interface(name: str):
|
||||
"""Reload networkd for a single interface.
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/interfaces/<name>/reload
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
|
||||
logger.info("Interface '%s' reloaded", name)
|
||||
return _ok({"name": name, "reloaded": True})
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to reload interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
bp,
|
||||
precheck=_check_iface,
|
||||
body={},
|
||||
transform=_reloaded,
|
||||
)
|
||||
def reload_interface():
|
||||
"""POST /api/network/interfaces/<name>/reload — Reload networkd for one interface."""
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
@daemon_route(POST_NETWORK_APPLY, bp)
|
||||
def apply_all():
|
||||
"""Apply network config for ALL interfaces (full sync).
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/apply
|
||||
|
||||
Returns:
|
||||
JSON with number of interfaces applied.
|
||||
"""
|
||||
try:
|
||||
result = post(POST_NETWORK_APPLY, {})
|
||||
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply network config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/network/apply — Apply network config for ALL interfaces."""
|
||||
|
||||
|
||||
@bp.route("/infer-dhcp-ranges", methods=["GET"])
|
||||
@daemon_route(GET_NETWORK_INFER_DHCP_RANGES, bp)
|
||||
def infer_dhcp_ranges():
|
||||
"""Suggest candidate DHCP ranges based on static interface IPs.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/infer-dhcp-ranges
|
||||
|
||||
Returns:
|
||||
JSON with per-interface suggested DHCP ranges.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to infer DHCP ranges: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/network/infer-dhcp-ranges — Suggest candidate DHCP ranges."""
|
||||
|
||||
|
||||
@bp.route("/infer-zones", methods=["GET"])
|
||||
@daemon_route(GET_NETWORK_INFER_ZONES, bp)
|
||||
def infer_zones():
|
||||
"""Suggest firewalld zone assignments for configured interfaces.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/infer-zones
|
||||
|
||||
Returns:
|
||||
JSON with per-interface suggested zone names.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INFER_ZONES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to infer zones: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/network/infer-zones — Suggest firewalld zone assignments."""
|
||||
|
||||
+112
-296
@@ -3,11 +3,17 @@
|
||||
Exposed at /api/proxy/* and delegates to vacuum-walld.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||
BadRequest,
|
||||
delete,
|
||||
get,
|
||||
patch,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_BACKENDS_REMOVE,
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
@@ -24,144 +30,59 @@ from daemon.iface import (
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("proxy", __name__)
|
||||
|
||||
|
||||
@bp.route("/ssl-apply", methods=["POST"])
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@daemon_route(POST_NGINX_SSL_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||
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(POST_NGINX_SSL_APPLY)
|
||||
logger.info("SSL snippet written via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to write SSL snippet: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/proxy/ssl-apply — Apply the SSL snippet config."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
@daemon_route(GET_NGINX_CONFIG, bp)
|
||||
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(GET_NGINX_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read proxy config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/proxy/config — Get the current nginx proxy configuration."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
@daemon_route(
|
||||
POST_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||
)
|
||||
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)
|
||||
try:
|
||||
post(POST_NGINX_CONFIG, body)
|
||||
logger.info("Proxy config saved: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Proxy config save rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save proxy config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/proxy/config — Save the nginx proxy configuration."""
|
||||
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
@daemon_route(
|
||||
PATCH_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
|
||||
)
|
||||
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)
|
||||
try:
|
||||
patch(PATCH_NGINX_CONFIG, body)
|
||||
logger.info("Proxy config patched: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Proxy config patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch proxy config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""PATCH /api/proxy/config — Partially update the nginx proxy configuration."""
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["GET"])
|
||||
@daemon_route(GET_NGINX_DOMAINS, bp)
|
||||
def list_domains():
|
||||
"""List all configured proxy domains.
|
||||
|
||||
GET /api/proxy/domains
|
||||
|
||||
Returns:
|
||||
List of domain dicts from the daemon.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NGINX_DOMAINS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list proxy domains: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/proxy/domains — List all configured proxy domains."""
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
"""Add a new proxy domain referencing a backend.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POST /api/proxy/domains
|
||||
|
||||
Body fields:
|
||||
domain: Domain name.
|
||||
backend: Backend name to proxy through.
|
||||
cert: Optional certificate type.
|
||||
force_ssl: Optional SSL redirect flag (default ``true``).
|
||||
auth: Optional domain-level auth override.
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
"""
|
||||
def _add_domain_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
domain = (body.get("domain") or "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
backend = body.get("backend", "").strip()
|
||||
raise ValueError("'domain' is required")
|
||||
backend = (body.get("backend") or "").strip()
|
||||
if not backend:
|
||||
return _error("'backend' is required", 400)
|
||||
|
||||
payload = {
|
||||
raise ValueError("'backend' is required")
|
||||
payload: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"backend": backend,
|
||||
"force_ssl": body.get("force_ssl", True),
|
||||
@@ -170,105 +91,62 @@ def add_domain_bp():
|
||||
payload["cert"] = body["cert"]
|
||||
if body.get("auth") is not None:
|
||||
payload["auth"] = body["auth"]
|
||||
|
||||
try:
|
||||
post(POST_NGINX_DOMAINS_ADD, payload)
|
||||
logger.info("Proxy domain added via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add proxy domain '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
return payload
|
||||
|
||||
|
||||
@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)
|
||||
try:
|
||||
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
|
||||
logger.info("Proxy domain '%s' updated via API", domain)
|
||||
return _ok({"domain": domain})
|
||||
except BadRequest as exc:
|
||||
logger.info("Update domain '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Domain '%s' not found: %s", domain, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to update domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
def _domain_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"domain": sent.get("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(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
|
||||
logger.info("Proxy domain removed via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except NotFound as exc:
|
||||
logger.info("Domain '%s' not found: %s", domain, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
bp,
|
||||
rule="/domains",
|
||||
body=_add_domain_body,
|
||||
transform=_domain_echo,
|
||||
)
|
||||
def add_domain_bp():
|
||||
"""POST /api/proxy/domains — Add a new proxy domain referencing a backend."""
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def _update_domain_precheck(json: Any, _va: Any) -> None:
|
||||
if not json:
|
||||
raise ValueError("Request body must be a JSON object with fields to update")
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
bp,
|
||||
rule="/domains/<domain>",
|
||||
methods=["PUT"],
|
||||
precheck=_update_domain_precheck,
|
||||
transform=_domain_echo,
|
||||
)
|
||||
def update_domain_bp():
|
||||
"""PUT /api/proxy/domains/<domain> — Update an existing proxy domain in-place."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/<domain>", transform=_domain_echo
|
||||
)
|
||||
def remove_domain_bp():
|
||||
"""DELETE /api/proxy/domains/<domain> — Remove a proxy domain."""
|
||||
|
||||
|
||||
@daemon_route(POST_NGINX_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||
def apply_bp():
|
||||
"""Generate all nginx configs and reload nginx.
|
||||
|
||||
POST /api/proxy/apply
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` on success.
|
||||
"""
|
||||
try:
|
||||
post(POST_NGINX_APPLY)
|
||||
logger.info("nginx config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply nginx config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/proxy/apply — Generate all nginx configs and reload nginx."""
|
||||
|
||||
|
||||
@bp.route("/test", methods=["POST"])
|
||||
def _test_transform(data: Any, _va: Any, _sent: Any) -> Any:
|
||||
if data.get("valid"):
|
||||
return {"valid": True, "output": data.get("output", "")}
|
||||
raise BadRequest(data.get("output", "unknown error"))
|
||||
|
||||
|
||||
@daemon_route(POST_NGINX_TEST, bp, body=NO_BODY, transform=_test_transform)
|
||||
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(POST_NGINX_TEST)
|
||||
if result.get("valid"):
|
||||
return _ok({"valid": True, "output": result.get("output", "")})
|
||||
return _error(result.get("output", "unknown error"), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("nginx config test failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/proxy/test — Test nginx configuration without reloading."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -276,102 +154,40 @@ def test_bp():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["GET"])
|
||||
def _backend_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"backend": sent.get("name")}
|
||||
|
||||
|
||||
@daemon_route(GET_NGINX_BACKENDS, bp)
|
||||
def list_backends():
|
||||
"""List all configured backends.
|
||||
|
||||
GET /api/proxy/backends
|
||||
|
||||
Returns:
|
||||
Dict of backend configs with secrets stripped.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NGINX_BACKENDS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list backends: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/proxy/backends — List all configured backends (secrets stripped)."""
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["PATCH"])
|
||||
@daemon_route(
|
||||
PATCH_NGINX_BACKENDS, bp, precheck=require_dict_body, transform=_backend_echo
|
||||
)
|
||||
def patch_backend_bp():
|
||||
"""Partially update a backend entry.
|
||||
|
||||
PATCH /api/proxy/backends
|
||||
|
||||
Body fields:
|
||||
name: Backend name.
|
||||
label: Optional new label.
|
||||
paths: Optional new paths dict.
|
||||
auth: Optional new auth config.
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
patch(PATCH_NGINX_BACKENDS, body)
|
||||
logger.info("Backend '%s' patched via API", body.get("name"))
|
||||
return _ok({"backend": body.get("name")})
|
||||
except BadRequest as exc:
|
||||
logger.info("Backend patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch backend: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""PATCH /api/proxy/backends — Partially update a backend entry."""
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["POST"])
|
||||
def _add_backend_precheck(json: Any, _va: Any) -> None:
|
||||
if not ((json or {}).get("name") or "").strip():
|
||||
raise ValueError("'name' is required")
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_NGINX_BACKENDS_ADD,
|
||||
bp,
|
||||
rule="/backends",
|
||||
precheck=_add_backend_precheck,
|
||||
transform=_backend_echo,
|
||||
)
|
||||
def add_backend_bp():
|
||||
"""Add a new backend.
|
||||
|
||||
POST /api/proxy/backends
|
||||
|
||||
Body fields:
|
||||
name: Backend name (slug, unique).
|
||||
label: Human-readable label.
|
||||
paths: Path-to-config map.
|
||||
auth: Optional auth config.
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
post(POST_NGINX_BACKENDS_ADD, body)
|
||||
logger.info("Backend added via API: %s", name)
|
||||
return _ok({"backend": name})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add backend '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add backend '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/proxy/backends — Add a new backend."""
|
||||
|
||||
|
||||
@bp.route("/backends/<name>", methods=["DELETE"])
|
||||
def remove_backend_bp(name):
|
||||
"""Remove a non-builtin backend.
|
||||
|
||||
DELETE /api/proxy/backends/<name>
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
try:
|
||||
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
|
||||
logger.info("Backend removed via API: %s", name)
|
||||
return _ok({"backend": name})
|
||||
except BadRequest as exc:
|
||||
logger.info("Remove backend '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except Conflict as exc:
|
||||
logger.info("Remove backend '%s' conflict: %s", name, exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove backend '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
DELETE_NGINX_BACKENDS_REMOVE, bp, rule="/backends/<name>", transform=_backend_echo
|
||||
)
|
||||
def remove_backend_bp():
|
||||
"""DELETE /api/proxy/backends/<name> — Remove a non-builtin backend."""
|
||||
|
||||
+16
-83
@@ -3,13 +3,12 @@
|
||||
Exposed at /api/status/* and delegates all operations to vacuum-walld.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from flask import Blueprint
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import get, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
|
||||
get,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
GET_STATUS_PENDING,
|
||||
GET_SYSTEM_METRICS,
|
||||
@@ -17,97 +16,31 @@ from daemon.iface import (
|
||||
POST_STATUS_CANCEL_ALL,
|
||||
POST_STATUS_REFRESH,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("status", __name__)
|
||||
|
||||
|
||||
@bp.route("/pending", methods=["GET"])
|
||||
@daemon_route(GET_STATUS_PENDING, bp)
|
||||
def pending():
|
||||
"""Retrieve aggregate pending changes across all subsystems.
|
||||
|
||||
Endpoint:
|
||||
GET /api/status/pending
|
||||
|
||||
Returns:
|
||||
JSON response with per-subsystem pending status and total change count.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_STATUS_PENDING))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get pending status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/status/pending — Per-subsystem pending status + total change count."""
|
||||
|
||||
|
||||
@bp.route("/apply-all", methods=["POST"])
|
||||
@daemon_route(POST_STATUS_APPLY_ALL, bp)
|
||||
def apply_all():
|
||||
"""Apply pending changes for all subsystems in dependency order.
|
||||
|
||||
Endpoint:
|
||||
POST /api/status/apply-all
|
||||
Body:
|
||||
{"force": true} (optional) — overrides the firewall safety guards
|
||||
(management lockout, interface coverage) for this apply.
|
||||
|
||||
Returns:
|
||||
JSON response with applied subsystems list and any errors encountered.
|
||||
"""
|
||||
body = request.get_json(silent=True)
|
||||
try:
|
||||
return _ok(post(POST_STATUS_APPLY_ALL, body))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply all pending changes: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/status/apply-all — Apply pending changes in dependency order."""
|
||||
|
||||
|
||||
@bp.route("/cancel-all", methods=["POST"])
|
||||
@daemon_route(POST_STATUS_CANCEL_ALL, bp, body=NO_BODY)
|
||||
def cancel_all():
|
||||
"""Revert pending changes for all subsystems to the last applied config.
|
||||
|
||||
Endpoint:
|
||||
POST /api/status/cancel-all
|
||||
|
||||
Returns:
|
||||
JSON response with the reverted subsystems, skipped subsystems
|
||||
(label -> reason), and any errors encountered.
|
||||
"""
|
||||
try:
|
||||
return _ok(post(POST_STATUS_CANCEL_ALL))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to cancel all pending changes: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/status/cancel-all — Revert pending changes to last applied config."""
|
||||
|
||||
|
||||
@bp.route("/refresh", methods=["POST"])
|
||||
@daemon_route(POST_STATUS_REFRESH, bp)
|
||||
def refresh():
|
||||
"""Re-collect state from the daemon, optionally filtered by subsystem.
|
||||
|
||||
Endpoint:
|
||||
POST /api/status/refresh
|
||||
Body:
|
||||
{"subsystems": ["firewall"]} or {} for all.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return _ok(post(POST_STATUS_REFRESH, body))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to refresh state: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/status/refresh — Re-collect state, optionally filtered by subsystem."""
|
||||
|
||||
|
||||
@bp.route("/system-metrics", methods=["GET"])
|
||||
@daemon_route(GET_SYSTEM_METRICS, bp, rule="/system-metrics")
|
||||
def system_metrics():
|
||||
"""Retrieve system-wide metrics.
|
||||
|
||||
Endpoint:
|
||||
GET /api/status/system-metrics
|
||||
|
||||
Returns:
|
||||
JSON response with CPU load, memory usage, and network traffic stats.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_SYSTEM_METRICS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get system metrics: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/status/system-metrics — System-wide CPU/memory/network metrics."""
|
||||
|
||||
+215
-436
@@ -3,11 +3,16 @@
|
||||
Exposed at /api/wireguard/* and delegates to vacuum-walld.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
||||
from daemon.client import ( # noqa: F401 (resolved via module globals)
|
||||
delete,
|
||||
get,
|
||||
patch,
|
||||
post,
|
||||
)
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_CLASSES,
|
||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||
@@ -30,473 +35,247 @@ from daemon.iface import (
|
||||
POST_WIREGUARD_INITIALIZE,
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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(GET_WIREGUARD_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body builders / prechecks / transforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _wg_config_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
post(POST_WIREGUARD_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("WireGuard config save rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
return body
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _add_peer_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
patch(PATCH_WIREGUARD_CONFIG, body)
|
||||
logger.info("WireGuard config patched: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("WireGuard config patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(POST_WIREGUARD_APPLY)
|
||||
logger.info("WireGuard tunnel applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(POST_WIREGUARD_APPLY)
|
||||
logger.info("WireGuard tunnel started via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to start WireGuard tunnel: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(POST_WIREGUARD_DOWN)
|
||||
logger.info("WireGuard tunnel brought down via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(GET_WIREGUARD_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(POST_WIREGUARD_INITIALIZE)
|
||||
logger.info("WireGuard initialized via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to initialize WireGuard: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
peer = post(
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
{
|
||||
"name": name,
|
||||
"endpoint": body.get("endpoint"),
|
||||
"allowed_ips": body.get("allowed_ips", []),
|
||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||
"preshared_key": body.get("preshared_key"),
|
||||
"description": body.get("description"),
|
||||
"access_class": body.get("access_class"),
|
||||
},
|
||||
)
|
||||
logger.info("WireGuard peer '%s' added via API", name)
|
||||
return _ok(peer)
|
||||
except BadRequest as exc:
|
||||
logger.info("Add peer '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add peer '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
raise ValueError("'name' is required")
|
||||
return {
|
||||
"name": name,
|
||||
"endpoint": body.get("endpoint"),
|
||||
"allowed_ips": body.get("allowed_ips", []),
|
||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||
"preshared_key": body.get("preshared_key"),
|
||||
"description": body.get("description"),
|
||||
"access_class": body.get("access_class"),
|
||||
}
|
||||
|
||||
|
||||
@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(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
|
||||
logger.info("WireGuard peer '%s' removed via API", name)
|
||||
return _ok({"name": name})
|
||||
except NotFound as exc:
|
||||
logger.info("WireGuard peer '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove peer '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(GET_WIREGUARD_PEERS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list WireGuard peers: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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(GET_WIREGUARD_PEER_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard peer status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _gen_client_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("Field 'name' is required", 400)
|
||||
raise ValueError("Field 'name' is required")
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
if not server_endpoint:
|
||||
return _error("Field 'server_endpoint' is required", 400)
|
||||
try:
|
||||
result = post(
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
{
|
||||
"name": name,
|
||||
"server_endpoint": server_endpoint,
|
||||
},
|
||||
)
|
||||
logger.info("Client config generated for peer '%s' via API", name)
|
||||
return _ok({"config": result.get("config", "")})
|
||||
except NotFound as exc:
|
||||
logger.info("Peer '%s' not found for client config: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to generate client config for '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
raise ValueError("Field 'server_endpoint' is required")
|
||||
return {"name": name, "server_endpoint": server_endpoint}
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["GET"])
|
||||
def _delete_class_body(request: Any, _va: Any) -> dict[str, Any]:
|
||||
body = request.get_json(silent=True) or {}
|
||||
key = (body.get("key") or "").strip()
|
||||
if not key:
|
||||
raise ValueError("'key' is required")
|
||||
return {"key": key}
|
||||
|
||||
|
||||
def _class_key_precheck(json: Any, va: dict[str, Any]) -> None:
|
||||
require_dict_body(json, va)
|
||||
if not ((json or {}).get("key") or "").strip():
|
||||
raise ValueError("'key' is required")
|
||||
|
||||
|
||||
def _peer_name_echo(_data: Any, _va: Any, sent: Any) -> Any:
|
||||
return {"name": sent["name"]}
|
||||
|
||||
|
||||
def _config_echo(data: Any, _va: Any, _sent: Any) -> Any:
|
||||
return {"config": data.get("config", "")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@daemon_route(GET_WIREGUARD_CONFIG, bp)
|
||||
def get_config_bp():
|
||||
"""GET /api/wireguard/config — Get the current WireGuard configuration."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_WIREGUARD_CONFIG,
|
||||
bp,
|
||||
precheck=require_dict_body,
|
||||
body=_wg_config_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
def post_config():
|
||||
"""POST /api/wireguard/config — Create or fully replace the configuration."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
bp,
|
||||
precheck=require_dict_body,
|
||||
body=_wg_config_body,
|
||||
transform=void_transform,
|
||||
)
|
||||
def patch_config():
|
||||
"""PATCH /api/wireguard/config — Partially update the configuration."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunnel control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@daemon_route(POST_WIREGUARD_APPLY, bp, body=NO_BODY, transform=void_transform)
|
||||
def apply_bp():
|
||||
"""POST /api/wireguard/apply — Apply the current configuration to the tunnel."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_WIREGUARD_APPLY, bp, rule="/up", body=NO_BODY, transform=void_transform
|
||||
)
|
||||
def up_bp():
|
||||
"""POST /api/wireguard/up — Bring the WireGuard tunnel interface up."""
|
||||
|
||||
|
||||
@daemon_route(POST_WIREGUARD_DOWN, bp, body=NO_BODY, transform=void_transform)
|
||||
def down_bp():
|
||||
"""POST /api/wireguard/down — Bring the WireGuard tunnel interface down."""
|
||||
|
||||
|
||||
@daemon_route(GET_WIREGUARD_STATUS, bp)
|
||||
def status_bp():
|
||||
"""GET /api/wireguard/status — Get the current WireGuard tunnel status."""
|
||||
|
||||
|
||||
@daemon_route(POST_WIREGUARD_INITIALIZE, bp, body=NO_BODY, transform=void_transform)
|
||||
def initialize_bp():
|
||||
"""POST /api/wireguard/initialize — Initialize WireGuard for first-time use."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@daemon_route(POST_WIREGUARD_PEERS_ADD, bp, rule="/peers", body=_add_peer_body)
|
||||
def add_peer_bp():
|
||||
"""POST /api/wireguard/peers — Add a new peer to the configuration."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
DELETE_WIREGUARD_PEERS_REMOVE, bp, rule="/peers/<name>", transform=_peer_name_echo
|
||||
)
|
||||
def remove_peer_bp():
|
||||
"""DELETE /api/wireguard/peers/<name> — Remove a peer from the configuration."""
|
||||
|
||||
|
||||
@daemon_route(GET_WIREGUARD_PEERS, bp)
|
||||
def peers_bp():
|
||||
"""GET /api/wireguard/peers — List all configured WireGuard peers."""
|
||||
|
||||
|
||||
@daemon_route(GET_WIREGUARD_PEER_STATUS, bp)
|
||||
def peer_status_bp():
|
||||
"""GET /api/wireguard/peer-status — Get real-time status for all peers."""
|
||||
|
||||
|
||||
@daemon_route(
|
||||
POST_WIREGUARD_GENERATE_CLIENT, bp, body=_gen_client_body, transform=_config_echo
|
||||
)
|
||||
def generate_client_bp():
|
||||
"""POST /api/wireguard/generate-client — Generate a client config for a peer."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@daemon_route(GET_WIREGUARD_CLASSES, bp)
|
||||
def list_classes_bp():
|
||||
"""List all access classes.
|
||||
|
||||
Endpoint: GET /api/wireguard/classes
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_WIREGUARD_CLASSES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list access classes: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""GET /api/wireguard/classes — List all access classes."""
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["POST"])
|
||||
@daemon_route(POST_WIREGUARD_CLASSES, bp, precheck=_class_key_precheck)
|
||||
def create_class_bp():
|
||||
"""Create a new access class.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = post(POST_WIREGUARD_CLASSES, body)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Create access class rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except Conflict as exc:
|
||||
logger.info("Create access class conflict: %s", exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to create access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""POST /api/wireguard/classes — Create a new access class."""
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["PATCH"])
|
||||
@daemon_route(
|
||||
PATCH_WIREGUARD_CLASSES, bp, rule="/classes", precheck=_class_key_precheck
|
||||
)
|
||||
def update_class_bp():
|
||||
"""Update an access class.
|
||||
|
||||
Endpoint: PATCH /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = patch(PATCH_WIREGUARD_CLASSES, body)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Update access class rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Access class not found: %s", exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to update access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""PATCH /api/wireguard/classes — Update an access class."""
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["DELETE"])
|
||||
@daemon_route(
|
||||
DELETE_WIREGUARD_CLASSES,
|
||||
bp,
|
||||
rule="/classes",
|
||||
precheck=require_dict_body,
|
||||
body=_delete_class_body,
|
||||
)
|
||||
def delete_class_bp():
|
||||
"""Delete an access class.
|
||||
|
||||
Endpoint: DELETE /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
|
||||
logger.info("Access class '%s' deleted via API", key)
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Access class not found: %s", exc)
|
||||
return _error(str(exc), 404)
|
||||
except Conflict as exc:
|
||||
logger.info("Delete access class conflict: %s", exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to delete access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
"""DELETE /api/wireguard/classes — Delete an access class."""
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/up", methods=["POST"])
|
||||
def class_up_bp(key):
|
||||
"""Bring up a single access class's WireGuard tunnel.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/<key>/up
|
||||
"""
|
||||
try:
|
||||
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' tunnel brought up via API", key)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring up class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_WIREGUARD_CLASSES_UP,
|
||||
bp,
|
||||
rule="/classes/<key>/up",
|
||||
params={"class_key": "key"},
|
||||
body={},
|
||||
transform=void_transform,
|
||||
)
|
||||
def class_up_bp():
|
||||
"""POST /api/wireguard/classes/<key>/up — Bring up a class's tunnel."""
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/down", methods=["POST"])
|
||||
def class_down_bp(key):
|
||||
"""Bring down a single access class's WireGuard tunnel.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/<key>/down
|
||||
"""
|
||||
try:
|
||||
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' tunnel brought down via API", key)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring down class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||
bp,
|
||||
rule="/classes/<key>/down",
|
||||
methods=["POST"],
|
||||
params={"class_key": "key"},
|
||||
body={},
|
||||
transform=void_transform,
|
||||
)
|
||||
def class_down_bp():
|
||||
"""POST /api/wireguard/classes/<key>/down — Bring down a class's tunnel."""
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/status", methods=["GET"])
|
||||
def class_status_bp(key):
|
||||
"""Get status for a single access class's tunnel.
|
||||
|
||||
Endpoint: GET /api/wireguard/classes/<key>/status
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get class '%s' status: %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
GET_WIREGUARD_CLASS_STATUS,
|
||||
bp,
|
||||
rule="/classes/<key>/status",
|
||||
params={"class_key": "key"},
|
||||
)
|
||||
def class_status_bp():
|
||||
"""GET /api/wireguard/classes/<key>/status — Get status for a class's tunnel."""
|
||||
|
||||
|
||||
@bp.route("/classes/keys/<key>", methods=["POST"])
|
||||
def class_init_keys_bp(key):
|
||||
"""Generate key pair for a single access class.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/keys/<key>
|
||||
"""
|
||||
try:
|
||||
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' keys generated via API", key)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Class '%s' not found for keys: %s", key, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to generate keys for class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
@daemon_route(
|
||||
POST_WIREGUARD_CLASS_INIT_KEYS,
|
||||
bp,
|
||||
rule="/classes/keys/<key>",
|
||||
params={"class_key": "key"},
|
||||
body={},
|
||||
transform=void_transform,
|
||||
)
|
||||
def class_init_keys_bp():
|
||||
"""POST /api/wireguard/classes/keys/<key> — Generate keys for a class."""
|
||||
|
||||
@@ -151,7 +151,7 @@ async function openApplyModal(successMsg) {
|
||||
<div class="modal-body">${rows}</div>
|
||||
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
|
||||
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. leaving an interface in no zone, or removing https/ssh from the default zone)</span></span>
|
||||
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
|
||||
</label>` : ''}
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
|
||||
</div>`);
|
||||
|
||||
Reference in New Issue
Block a user