05524f3756
Phase 1 (critical bugs): - Fix firewall import string-to-list bug (system_import.py) - Add rich rules removal in firewall config apply (handlers/firewall.py) Phase 2 (security hardening): - Restrict sudo wildcards to specific paths (sudoers.d/vacuum-walld) - Fix TOCTOU: use /run/vacuum-wall/ for temp files (nginx, dnsmasq, network handlers) - Remove unnecessary sudo from wg genkey/pubkey (handlers/wireguard.py) Phase 3 (validation): - Validate poll intervals > 0 (daemon/server.py) - Restrict sysctl to whitelisted parameters (handlers/network.py) Phase 4 (defensive programming): - Enforce shell=False in run() and run_proc() (lib/common.py) - Track issuance tasks for graceful shutdown (handlers/acme.py) - Add nginx template marker consistency tests (tests/test_system_import.py)
378 lines
11 KiB
Python
378 lines
11 KiB
Python
"""Nginx proxy domain management API blueprint.
|
|
|
|
Exposed at /api/proxy/* and delegates to vacuum-walld.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from flask import Blueprint, request
|
|
|
|
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
|
from daemon.iface import (
|
|
DELETE_NGINX_BACKENDS_REMOVE,
|
|
DELETE_NGINX_DOMAINS_REMOVE,
|
|
GET_NGINX_BACKENDS,
|
|
GET_NGINX_CONFIG,
|
|
GET_NGINX_DOMAINS,
|
|
PATCH_NGINX_BACKENDS,
|
|
PATCH_NGINX_CONFIG,
|
|
POST_NGINX_APPLY,
|
|
POST_NGINX_BACKENDS_ADD,
|
|
POST_NGINX_CONFIG,
|
|
POST_NGINX_DOMAINS_ADD,
|
|
POST_NGINX_DOMAINS_UPDATE,
|
|
POST_NGINX_SSL_APPLY,
|
|
POST_NGINX_TEST,
|
|
)
|
|
from webui.api.common import _error, _ok
|
|
|
|
logger = logging.getLogger(__name__)
|
|
bp = Blueprint("proxy", __name__)
|
|
|
|
|
|
@bp.route("/ssl-apply", methods=["POST"])
|
|
def ssl_apply_bp():
|
|
"""Apply SSL snippet config.
|
|
|
|
POST /api/proxy/ssl-apply
|
|
|
|
Returns:
|
|
``{"ok": true}`` on success.
|
|
|
|
Raises:
|
|
RuntimeError: If nginx SSL snippet write fails.
|
|
"""
|
|
try:
|
|
post(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)
|
|
|
|
|
|
@bp.route("/config", methods=["GET"])
|
|
def get_config_bp():
|
|
"""Get the current nginx proxy configuration.
|
|
|
|
GET /api/proxy/config
|
|
|
|
Returns:
|
|
Current config dict from the daemon.
|
|
"""
|
|
try:
|
|
return _ok(get(GET_NGINX_CONFIG))
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to read proxy config: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/config", methods=["POST"])
|
|
def post_config():
|
|
"""Save the nginx proxy configuration.
|
|
|
|
POST /api/proxy/config
|
|
|
|
Body:
|
|
Any JSON object to merge into the config.
|
|
|
|
Returns:
|
|
``{"ok": true}`` on success.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
if not isinstance(body, dict):
|
|
return _error("Request body must be a JSON object", 400)
|
|
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)
|
|
|
|
|
|
@bp.route("/config", methods=["PATCH"])
|
|
def patch_config():
|
|
"""Partially update the nginx proxy configuration.
|
|
|
|
PATCH /api/proxy/config
|
|
|
|
Body:
|
|
JSON object with fields to patch.
|
|
|
|
Returns:
|
|
``{"ok": true}`` on success.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
if not isinstance(body, dict):
|
|
return _error("Request body must be a JSON object", 400)
|
|
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)
|
|
|
|
|
|
@bp.route("/domains", methods=["GET"])
|
|
def list_domains():
|
|
"""List all configured proxy domains.
|
|
|
|
GET /api/proxy/domains
|
|
|
|
Returns:
|
|
List of domain dicts from the daemon.
|
|
"""
|
|
try:
|
|
return _ok(get(GET_NGINX_DOMAINS))
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to list proxy domains: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/domains", methods=["POST"])
|
|
def add_domain_bp():
|
|
"""Add a new proxy domain referencing a backend.
|
|
|
|
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.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
domain = body.get("domain", "").strip()
|
|
if not domain:
|
|
return _error("'domain' is required", 400)
|
|
backend = body.get("backend", "").strip()
|
|
if not backend:
|
|
return _error("'backend' is required", 400)
|
|
|
|
payload = {
|
|
"domain": domain,
|
|
"backend": backend,
|
|
"force_ssl": body.get("force_ssl", True),
|
|
}
|
|
if body.get("cert") is not None:
|
|
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)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@bp.route("/apply", methods=["POST"])
|
|
def apply_bp():
|
|
"""Generate all nginx configs and reload nginx.
|
|
|
|
POST /api/proxy/apply
|
|
|
|
Returns:
|
|
``{"ok": true}`` on success.
|
|
"""
|
|
try:
|
|
post(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)
|
|
|
|
|
|
@bp.route("/test", methods=["POST"])
|
|
def test_bp():
|
|
"""Test nginx configuration without reloading.
|
|
|
|
POST /api/proxy/test
|
|
|
|
Returns:
|
|
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
|
|
"""
|
|
try:
|
|
result = post(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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backend CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@bp.route("/backends", methods=["GET"])
|
|
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)
|
|
|
|
|
|
@bp.route("/backends", methods=["PATCH"])
|
|
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)
|
|
|
|
|
|
@bp.route("/backends", methods=["POST"])
|
|
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)
|
|
|
|
|
|
@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)
|