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:
@@ -80,6 +80,7 @@ def run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -115,6 +116,7 @@ def run_proc(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
input=input,
|
||||
|
||||
+174
-137
@@ -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",
|
||||
|
||||
+7
-1
@@ -656,10 +656,15 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
# Build flattened domains list (one entry per path)
|
||||
from lib.nginx import _resolve_paths as _ngx_resolve_paths
|
||||
|
||||
backends = cfg.get("backends", {})
|
||||
domains: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
if "backend" not in dom:
|
||||
continue
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
paths = dom.get("paths", {})
|
||||
paths = _ngx_resolve_paths(dom, backends)
|
||||
if not paths:
|
||||
continue
|
||||
for ppath, pcfg in paths.items():
|
||||
@@ -669,6 +674,7 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists() if SITES_DIR.exists() else False,
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
"backend_name": dom["backend"],
|
||||
"cert": dom.get("cert"),
|
||||
}
|
||||
if pcfg.get("is_management"):
|
||||
|
||||
@@ -840,7 +840,7 @@ def import_firewall() -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
output = run("firewall-cmd --list-all-zones", sudo=True)
|
||||
output = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||
except RuntimeError:
|
||||
logger.warning("Import failed for firewall: firewall-cmd unavailable")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user