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:
2026-07-10 17:05:37 +00:00
parent 803258cf18
commit 05524f3756
27 changed files with 1805 additions and 521 deletions
+174 -137
View File
@@ -1,7 +1,7 @@
"""Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Manages per-domain SSL reverse proxy configurations, certificate
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
Manages per-domain SSL reverse proxy configurations backed by named backends,
certificate bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import logging
@@ -47,74 +47,121 @@ DEFAULT_SSL: dict[str, Any] = {
"prefer_server_ciphers": False,
}
WEBUI_BACKEND: dict[str, Any] = {
"label": "Vacuum Wall WebUI",
"builtin": True,
"auth": {
"user": "admin",
"htpasswd": str(HTPASSWD_FILE),
},
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
}
DEFAULT_CONFIG: dict[str, Any] = {
"backends": {},
"domains": {},
"ssl": {**DEFAULT_SSL},
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Resolution
def _resolve_paths(
domain_cfg: dict[str, Any], backends: dict[str, Any]
) -> dict[str, Any]:
"""Resolve effective paths from backends[domain_cfg['backend']].paths."""
backend_name = domain_cfg.get("backend", "")
if backend_name and backend_name in backends:
return backends[backend_name].get("paths", {})
return {}
def _resolve_auth(
domain_cfg: dict[str, Any], backends: dict[str, Any]
) -> dict[str, Any] | None:
"""Resolve effective auth: domain auth -> backend auth -> None."""
if "auth" in domain_cfg:
return domain_cfg.get("auth")
backend_name = domain_cfg.get("backend", "")
if backend_name and backend_name in backends:
backend_auth = backends[backend_name].get("auth")
if backend_auth is not None:
return backend_auth
return None
# ---------------------------------------------------------------------------
# Migration
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model.
Handles two migrations:
1. Legacy ``management`` top-level key -> path entry under its domain.
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
Args:
raw: Config dict as loaded from disk.
Returns:
The migrated config dict.
"""
# Migrate management key
if "management" in raw and raw["management"] is not None:
mgmt = raw["management"]
mgmt_domain = mgmt.get("domain", "")
if mgmt_domain:
domains = raw.setdefault("domains", {})
if mgmt_domain not in domains:
domains[mgmt_domain] = {
"force_ssl": True,
"paths": {},
}
dom = domains[mgmt_domain]
paths = dom.setdefault("paths", {})
if "/" not in paths:
paths["/"] = {
"backend": {
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
"port": mgmt.get("backend", {}).get("port", 9090),
"proto": "http",
},
"is_management": True,
}
if mgmt.get("auth"):
paths["/"]["auth"] = mgmt["auth"]
# Add WebSocket path if not present
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# Migrate domain entries without paths
for dom in raw.get("domains", {}).values():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
"""Migrate legacy config to backends model."""
_ensure_webui_backend(raw)
_migrate_mgmt_domains(raw)
return raw
def _ensure_webui_backend(raw: dict[str, Any]) -> None:
"""Create the builtin webui backend if not yet migrated."""
backends = raw.setdefault("backends", {})
webui = backends.get("webui")
if webui and webui.get("_migrated"):
return
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
# Harvest auth from legacy path-level auth if present
for dom in raw.get("domains", {}).values():
paths = dom.get("paths", {})
root = paths.get("/", {})
if root.get("auth"):
backends["webui"]["auth"] = root["auth"]
break
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
"""Migrate legacy management domains to backend references."""
backends = raw.get("backends", {})
if not backends.get("webui", {}).get("_migrated"):
return
domains = raw.setdefault("domains", {})
for _name, dom in list(domains.items()):
if dom.get("backend") == "webui":
continue
if dom.get("application") == "webui":
del dom["application"]
paths = dom.get("paths", {})
root = paths.get("/", {})
ws = paths.get("/ws", {})
root_backend = root.get("backend", {})
ws_backend = ws.get("backend", {})
is_mgmt_root = root.get("is_management") or (
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
)
is_mgmt_ws = ws.get("is_websocket") or (
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
)
if is_mgmt_root and is_mgmt_ws:
dom["backend"] = "webui"
dom.pop("paths", None)
dom.pop("auth", None)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
@@ -122,7 +169,7 @@ def get_config() -> dict[str, Any]:
legacy formats, then returns the config dict.
Returns:
The complete config dict with ``domains`` and ``ssl`` keys.
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
@@ -130,6 +177,8 @@ def get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
raw = _migrate_config(raw)
save_config(raw)
return raw
@@ -143,20 +192,21 @@ def save_config(cfg: dict[str, Any]) -> None:
def get_domains() -> list[dict[str, Any]]:
"""Return a list of all configured proxy domains flattened by path.
Each path within a domain becomes a separate entry with domain-level
settings repeated.
Each path within a domain becomes a separate entry. Paths are resolved
from the domain's referenced backend.
Returns:
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
``force_ssl``, and path-level flags.
``force_ssl``, ``backend_name``, and path-level flags.
"""
cfg = get_config()
backends = cfg.get("backends", {})
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
paths = dom.get("paths", {})
if not paths:
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = _resolve_paths(dom, backends)
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
@@ -164,6 +214,7 @@ def get_domains() -> list[dict[str, Any]]:
"backend": pcfg.get("backend", {}),
"online": site.exists(),
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
}
if pcfg.get("is_management"):
entry["is_management"] = True
@@ -180,57 +231,37 @@ def get_domains() -> list[dict[str, Any]]:
def add_domain(
domain: str,
backend_host: str | None = None,
backend_port: int | None = None,
backend_proto: str = "http",
backend_name: str,
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
paths: dict[str, dict[str, Any]] | None = None,
force_ssl: bool = True,
auth: dict[str, Any] | None = None,
) -> None:
"""Add a new proxy domain with the given backend and optional settings.
"""Add a new proxy domain that references an existing backend.
Args:
domain: Domain name to add.
backend_host: Upstream host to proxy to (legacy mode).
backend_port: Upstream port (legacy mode).
backend_proto: Protocol (``http`` or ``https``; legacy mode).
backend_name: Name of the backend to proxy through.
cert: Optional certificate type identifier.
extra_headers: Optional dict of extra headers to forward (legacy mode).
paths: Optional path-to-config map (new mode). Each path entry must
have a ``backend`` key with ``host``, ``port``, and ``proto``.
force_ssl: Whether to enforce HTTPS redirect.
auth: Optional domain-level auth override.
Raises:
ValueError: If the domain is already configured.
ValueError: If the domain is already configured or backend not found.
"""
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
if backend_name not in cfg.get("backends", {}):
raise ValueError(f"Backend {backend_name!r} not found")
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
else:
if not backend_host or backend_port is None:
raise ValueError("'backend_host' and 'backend_port' are required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
entry: dict[str, Any] = {
"backend": backend_name,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
if auth is not None:
entry["auth"] = auth
cfg["domains"][domain] = entry
save_config(cfg)
@@ -249,12 +280,10 @@ def remove_domain(domain: str) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
"""Update fields of an existing domain entry in-place.
"""Update domain-level fields of an existing domain entry.
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
``paths``) and paths-level shorthand (``backend``, ``headers`` for
the root path). When ``paths`` is provided, it is fully replaced.
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
Only domain-level keys are accepted: ``backend``, ``cert``, ``force_ssl``,
``auth``. Path changes must be made on the backend.
Args:
domain: Domain name to update.
@@ -262,36 +291,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
Raises:
KeyError: If the domain is not configured.
ValueError: If a new backend is specified but doesn't exist.
"""
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
# If paths is given, replace entirely
if "paths" in kwargs:
entry["paths"] = kwargs["paths"]
else:
# Legacy: top-level backend/headers -> paths["/"]
paths = entry.setdefault("paths", {})
if "backend" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = kwargs["backend"]
if "headers" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = kwargs["headers"]
new_backend = kwargs.get("backend")
if new_backend:
if new_backend not in cfg.get("backends", {}):
raise ValueError(f"Backend {new_backend!r} not found")
entry["backend"] = new_backend
# Remove legacy top-level keys from domain entry
entry.pop("backend", None)
entry.pop("headers", None)
allowed = ("backend", "cert", "force_ssl", "auth")
for key in allowed:
if key in kwargs and key != "backend":
if key == "auth" and kwargs[key] is None:
entry.pop("auth", None)
else:
entry[key] = kwargs[key]
for key, val in kwargs.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
entry[key] = val
save_config(cfg)
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
@@ -301,11 +321,15 @@ def update_domain(domain: str, **kwargs: Any) -> None:
# ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def generate_server_conf(
domain_cfg: dict[str, Any], backends: dict[str, Any] | None = None
) -> str:
"""Render the Jinja template for a domain server block.
Args:
domain_cfg: Domain entry dict including the ``domain`` key.
backends: Optional backends dict for path/auth resolution.
When omitted, falls back to reading from domain inline paths.
Returns:
The complete nginx server-block configuration as a string.
@@ -313,7 +337,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
paths = domain_cfg.get("paths", {})
if backends is not None:
paths = _resolve_paths(domain_cfg, backends)
domain_auth = _resolve_auth(domain_cfg, backends)
else:
paths = domain_cfg.get("paths", {})
domain_auth = domain_cfg.get("auth")
has_management = any(p.get("is_management") for p in paths.values())
# Resolve custom cert paths for cert=="file"
cert_cfg = domain_cfg.get("cert")
@@ -330,7 +361,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
cert=domain_cfg.get("cert"),
cert_path=cert_path,
cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
domain_auth=domain_auth,
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
@@ -382,19 +413,20 @@ def write_acme_challenge() -> None:
def write_all_sites() -> None:
"""Regenerate all site configs from the current config state.
Writes server blocks for every configured domain (now unified, including
any management paths), removes orphaned site files, and ensures the ACME
challenge config is present.
Writes server blocks for every configured domain using backend-resolved
paths, removes orphaned site files, and ensures the ACME challenge
config is present.
"""
ensure_dirs(SITES_DIR)
cfg = get_config()
backends = cfg.get("backends", {})
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy)
conf = generate_server_conf(dom_copy, backends)
write_site(name, conf)
written.add(f"{name}.conf")
@@ -548,6 +580,11 @@ def _hash_password(password: str) -> str:
__all__ = [
"WEBUI_BACKEND",
"_ensure_webui_backend",
"_migrate_mgmt_domains",
"_resolve_auth",
"_resolve_paths",
"add_domain",
"apply",
"generate_server_conf",