fix: critical bugs + security hardening
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)
This commit is contained in:
+128
-38
@@ -7,13 +7,17 @@ import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
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,
|
||||
@@ -135,23 +139,16 @@ def list_domains():
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
"""Add a new proxy domain.
|
||||
"""Add a new proxy domain referencing a backend.
|
||||
|
||||
POST /api/proxy/domains
|
||||
|
||||
Body fields (paths mode):
|
||||
Body fields:
|
||||
domain: Domain name.
|
||||
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
|
||||
backend: Backend name to proxy through.
|
||||
cert: Optional certificate type.
|
||||
force_ssl: Optional SSL redirect flag (default ``true``).
|
||||
|
||||
Body fields (legacy mode):
|
||||
domain: Domain name.
|
||||
backend_host: Upstream host.
|
||||
backend_port: Upstream port.
|
||||
backend_proto: Protocol (``http`` or ``https``, default ``http``).
|
||||
cert: Optional certificate type.
|
||||
extra_headers: Optional extra headers dict.
|
||||
auth: Optional domain-level auth override.
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
@@ -160,33 +157,20 @@ def add_domain_bp():
|
||||
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"]
|
||||
|
||||
paths = body.get("paths")
|
||||
if paths is not None:
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"paths": paths,
|
||||
"cert": body.get("cert"),
|
||||
"force_ssl": body.get("force_ssl", True),
|
||||
}
|
||||
else:
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"backend_host": backend_host,
|
||||
"backend_port": int(backend_port),
|
||||
"backend_proto": backend_proto,
|
||||
"cert": cert,
|
||||
"extra_headers": extra_headers,
|
||||
}
|
||||
try:
|
||||
post(POST_NGINX_DOMAINS_ADD, payload)
|
||||
logger.info("Proxy domain added via API: %s", domain)
|
||||
@@ -285,3 +269,109 @@ def test_bp():
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user