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
+6 -1
View File
@@ -55,7 +55,9 @@ _WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
# In-memory store for active issuance requests.
_ISSUANCES: dict[str, "IssueRequest"] = {}
_ISSUANCE_TTL = 300 # seconds to keep completed requests
_ISSUANCE_TASKS: dict[str, asyncio.Task] = {}
def _find_issuance(domain: str) -> "IssueRequest | None":
@@ -783,7 +785,8 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
_ISSUANCES[request_id] = req
# Spawn background task
_task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own
_task = asyncio.create_task(_run_issue(req))
_ISSUANCE_TASKS[request_id] = _task
return {"request_id": request_id, "domain": domain}
@@ -853,6 +856,8 @@ async def _run_issue(req: IssueRequest) -> None:
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
finally:
_ISSUANCE_TASKS.pop(req.request_id, None)
@registry.register(POST_ACME_RENEW)
+4 -4
View File
@@ -189,10 +189,10 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
tmp = Path("/tmp") / "vacuum-wall-dnsmasq.tmp"
with open(tmp, "w") as f:
f.write(conf_text)
run(["cp", str(tmp), DNSMASQ_CONF], sudo=True)
tmp = Path("/run/vacuum-wall/dnsmasq.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(conf_text)
run(["cp", "--", str(tmp), str(DNSMASQ_CONF)], sudo=True)
tmp.unlink(missing_ok=True)
run(["systemctl", "restart", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and restarted")
+16
View File
@@ -219,6 +219,22 @@ def _config_apply() -> dict[str, Any]:
sudo=True,
)
current_rules = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("rich-rules", [])
for rule_str in current_rules:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--remove-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
for rule_entry in zone_cfg.get("rich_rules", []):
rule_str = (
rule_entry.get("rule", "")
+24 -2
View File
@@ -43,16 +43,33 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
RUNTIME_DIR = Path("/run/vacuum-wall")
_ALLOWED_SYSCTL_KEYS: set[str] = {
"net.ipv4.ip_forward",
"net.ipv4.conf.all.forwarding",
"net.ipv4.conf.all.accept_redirects",
"net.ipv4.conf.default.accept_redirects",
"net.ipv4.conf.all.send_redirects",
"net.ipv4.conf.default.send_redirects",
"net.ipv4.conf.all.rp_filter",
"net.ipv4.icmp_echo_ignore_all",
"net.ipv4.tcp_syncookies",
}
def _copy_and_reload(iface_name: str) -> None:
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
validate_interface_name(iface_name)
src = DATA_DIR / f"99-{iface_name}.network"
runtime_src = RUNTIME_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
RUNTIME_DIR.mkdir(exist_ok=True)
runtime_src.write_text(src.read_text())
run(["mkdir", "-p", str(dst_dir)], sudo=True)
dst = dst_dir / f"99-{iface_name}.network"
run(["cp", str(src), str(dst)], sudo=True)
run(["cp", "--", str(runtime_src), str(dst)], sudo=True)
runtime_src.unlink(missing_ok=True)
# Remove lower-priority .network files that match this interface
# (they would override our config due to higher systemd priority)
@@ -253,9 +270,12 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cleaned.append(f)
for f in generated:
tmp = RUNTIME_DIR / f.name
run(["cp", "--", str(f), str(tmp)], sudo=False)
dst = sys_dir / f.name
run(["mkdir", "-p", str(sys_dir)], sudo=True)
run(["cp", str(f), str(dst)], sudo=True)
run(["cp", "--", str(tmp), str(dst)], sudo=True)
tmp.unlink(missing_ok=True)
_full_reload()
@@ -325,6 +345,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'name' is required")
if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
raise ValueError("'name' is not a valid sysctl key")
if name not in _ALLOWED_SYSCTL_KEYS:
raise ValueError("'name' is not a permitted sysctl key")
value = str(body.get("value", "")).strip()
if not value:
raise ValueError("'value' is required")
+301 -148
View File
@@ -9,11 +9,15 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
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,
@@ -21,17 +25,21 @@ from daemon.iface import (
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.acme import find_cert_dir
from lib.common import (
_APPLY_HASH_KEY,
config_hash,
deep_merge,
ensure_dirs,
load_json,
run,
run_proc,
save_json,
)
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
from lib.nginx import _resolve_auth as _ngx_resolve_auth
from lib.nginx import _resolve_paths as _ngx_resolve_paths
logger = logging.getLogger(__name__)
@@ -51,66 +59,76 @@ ENV = Environment(
trim_blocks=True,
)
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-RSA-CHACHA20-POLY1305"
),
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG: dict[str, Any] = {
"backends": {},
"domains": {},
"ssl": {**DEFAULT_SSL},
}
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model."""
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"]
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# ---------------------------------------------------------------------------
# Migration
def _migrate_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migrate legacy config to backends model. Returns (config, changed)."""
c1 = _ensure_webui_backend(raw)
c2 = _migrate_mgmt_domains(raw)
return raw, c1 or c2
def _ensure_webui_backend(raw: dict[str, Any]) -> bool:
"""Create builtin webui backend if not yet migrated. Returns True if changed."""
backends = raw.setdefault("backends", {})
webui = backends.get("webui")
if webui and webui.get("_migrated"):
return False
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():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
return raw
paths = dom.get("paths", {})
root = paths.get("/", {})
if root.get("auth"):
backends["webui"]["auth"] = root["auth"]
break
return True
def _migrate_mgmt_domains(raw: dict[str, Any]) -> bool:
"""Migrate legacy mgmt domains to backend refs. Returns True if anything changed."""
backends = raw.get("backends", {})
if not backends.get("webui", {}).get("_migrated"):
return False
domains = raw.setdefault("domains", {})
changed = False
for _name, dom in list(domains.items()):
if dom.get("backend") == "webui":
continue
if dom.get("application") == "webui":
del dom["application"]
changed = True
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)
changed = True
return changed
# ---------------------------------------------------------------------------
# Config helpers
def _get_state() -> dict[str, Any] | None:
@@ -128,9 +146,10 @@ def _get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
_save_config(raw)
return raw
cfg, changed = _migrate_config(raw)
if changed:
_save_config(cfg)
return cfg
def _save_config(cfg: dict[str, Any]) -> None:
@@ -138,14 +157,119 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
# ---------------------------------------------------------------------------
# Backend CRUD
def _get_backends() -> dict[str, Any]:
"""Return the backends dict from config, creating builtin webui if needed."""
cfg = _get_config()
backends = cfg.get("backends", {})
webui = backends.get("webui")
if not webui or not webui.get("_migrated"):
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
cfg["backends"] = backends
_save_config(cfg)
return backends
def _validate_paths(paths: dict) -> None:
"""Validate each path has backend with host, port, proto."""
for path_str, path_cfg in paths.items():
backend = path_cfg.get("backend")
if not backend:
raise ValueError(f"path {path_str!r} missing 'backend'")
if not isinstance(backend, dict):
raise ValueError(f"path {path_str!r} 'backend' must be a dict")
if not backend.get("host"):
raise ValueError(f"path {path_str!r} 'backend' missing 'host'")
if not backend.get("port"):
raise ValueError(f"path {path_str!r} 'backend' missing 'port'")
if not backend.get("proto"):
raise ValueError(f"path {path_str!r} 'backend' missing 'proto'")
def _add_backend(
name: str, label: str, paths: dict, auth: dict | None = None, builtin: bool = False
) -> None:
"""Add a backend. Validate name uniqueness and path schema."""
_validate_paths(paths)
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name in backends:
raise ValueError(f"Backend {name!r} already exists")
entry: dict[str, Any] = {"label": label, "paths": paths}
if builtin:
entry["builtin"] = True
if auth is not None:
if "htpasswd" in auth:
htpasswd_path = Path(auth["htpasswd"])
if not htpasswd_path.is_absolute():
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
entry["auth"] = auth
backends[name] = entry
_save_config(cfg)
def _update_backend(
name: str,
label: str | None = None,
paths: dict | None = None,
auth: dict | None | bool = None,
) -> None:
"""Update a backend. Cannot edit builtin backends. auth=False removes auth."""
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot modify builtin backend")
entry = backends[name]
if label is not None:
entry["label"] = label
if paths is not None:
_validate_paths(paths)
entry["paths"] = paths
if auth is False or auth is None:
entry.pop("auth", None)
elif isinstance(auth, dict):
if "htpasswd" in auth:
htpasswd_path = Path(auth["htpasswd"])
if not htpasswd_path.is_absolute():
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
entry["auth"] = auth
_save_config(cfg)
def _remove_backend(name: str) -> None:
"""Remove a non-builtin backend. Raise ConflictError if domains reference it."""
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot remove builtin backend")
for dom_name, dom in cfg.get("domains", {}).items():
if dom.get("backend") == name:
raise ConflictError(
f"Backend {name!r} is referenced by domain {dom_name!r}"
)
del backends[name]
_save_config(cfg)
# ---------------------------------------------------------------------------
# Site generation
def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja."""
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", {})
paths = _ngx_resolve_paths(domain_cfg, backends)
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")
if isinstance(cert_cfg, dict):
cert_path = cert_cfg.get("cert_path", "")
@@ -160,7 +284,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=_ngx_resolve_auth(domain_cfg, backends),
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
@@ -184,11 +308,11 @@ def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
with open(tmp, "w") as f:
f.write(content)
tmp = Path("/run/vacuum-wall/include.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
run(["cp", "--", str(tmp), str(INCLUDE_FILE)], sudo=True)
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
tmp.unlink(missing_ok=True)
@@ -202,11 +326,11 @@ def _write_ssl_snippet() -> None:
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
with open(tmp, "w") as f:
f.write(content)
tmp = Path("/run/vacuum-wall/ssl-snippet.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
run(["cp", "--", str(tmp), str(SSL_SNIPPET)], sudo=True)
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
tmp.unlink(missing_ok=True)
@@ -234,11 +358,12 @@ def _write_all_sites() -> None:
"""Regenerate all site configs and ACME challenge site."""
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")
@@ -260,6 +385,10 @@ def _write_all_sites() -> None:
os.replace(tmp, site)
# ---------------------------------------------------------------------------
# Auth helpers
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib."""
from passlib.hash import sha256_crypt
@@ -327,8 +456,6 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config."""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
@@ -347,10 +474,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register(POST_NGINX_DOMAINS_ADD)
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
Accepts either legacy backend_* fields or a ``paths`` map.
"""
"""POST /nginx/domains/add — add a new reverse-proxy domain entry."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -360,54 +484,24 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
paths = body.get("paths")
backend_name = body.get("backend", "").strip()
if not backend_name:
raise ValueError("'backend' is required")
if backend_name not in cfg.get("backends", {}):
raise ValueError(f"Backend {backend_name!r} not found")
cert = body.get("cert")
force_ssl = body.get("force_ssl", True)
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
else:
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
extra_headers = body.get("extra_headers")
if not backend_host:
raise ValueError("'backend_host' is required")
if backend_port is None:
raise ValueError("'backend_port' is required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"backend": backend_name,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
# Handle auth credentials
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "")
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
root_path = entry.get("paths", {}).get("/")
if root_path:
root_path["auth"] = auth
auth = body.get("auth")
if auth is not None:
entry["auth"] = auth
cfg["domains"][domain] = entry
@@ -449,48 +543,107 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise NotFoundError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
path_to_remove = body.get("path")
if (
path_to_remove is not None
and "paths" not in body
and "backend" not in body
and "headers" not in body
):
paths = entry.get("paths", {})
if path_to_remove in paths:
del paths[path_to_remove]
if not paths:
entry.pop("paths", None)
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain, "path_removed": path_to_remove}
new_backend = body.get("backend")
if new_backend:
new_backend = new_backend.strip()
if new_backend not in cfg.get("backends", {}):
raise ValueError(f"Backend {new_backend!r} not found")
entry["backend"] = new_backend
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
if "paths" in updates:
entry["paths"] = updates["paths"]
if "cert" in body:
if body["cert"] is None:
entry.pop("cert", None)
else:
paths = entry.setdefault("paths", {})
if "backend" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = updates["backend"]
if "headers" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = updates["headers"]
for key, val in updates.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
entry["cert"] = body["cert"]
if "force_ssl" in body:
entry["force_ssl"] = body["force_ssl"]
if "auth" in body:
if body["auth"] is None:
entry.pop("auth", None)
else:
entry[key] = val
entry["auth"] = body["auth"]
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain}
@registry.register(GET_NGINX_BACKENDS)
def get_backends(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/backends — return backends with secrets stripped."""
backends = _get_backends()
result: dict[str, Any] = {}
for name, be in backends.items():
entry = deepcopy(be)
entry.pop("_migrated", None)
if "auth" in entry:
entry["has_auth"] = entry["auth"] is not None
del entry["auth"]
else:
entry["has_auth"] = False
result[name] = entry
return result
@registry.register(PATCH_NGINX_BACKENDS)
def patch_backends(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/backends — deep-merge partial updates into a backend entry."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot modify builtin backend")
update_data = {k: v for k, v in body.items() if k != "name"}
if "auth" in update_data and (
update_data["auth"] is False or update_data["auth"] is None
):
backends[name].pop("auth", None)
update_data.pop("auth")
backends[name] = deep_merge(backends[name], update_data)
_save_config(cfg)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(POST_NGINX_BACKENDS_ADD)
def add_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/backends/add — add a new backend."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
label = body.get("label", "").strip()
paths = body.get("paths")
auth = body.get("auth")
if not name:
raise ValueError("'name' is required")
if not label:
raise ValueError("'label' is required")
if not paths:
raise ValueError("'paths' is required")
_add_backend(name, label, paths, auth=auth if auth else None)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(DELETE_NGINX_BACKENDS_REMOVE)
def remove_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/backends/remove — remove a non-builtin backend."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
_remove_backend(name)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx."""
+5 -7
View File
@@ -171,11 +171,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config()
conf_text = _generate_conf(cfg)
_save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / "wg0.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
local_tmp = Path("/run/vacuum-wall/wg0.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
@@ -221,9 +219,9 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config()
if cfg["interface"].get("private_key"):
return {"initialized": False, "reason": "already initialized"}
res = run_proc([WG_BIN, "genkey"], sudo=True)
res = run_proc([WG_BIN, "genkey"], sudo=False)
private_key = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
public_key = res2.stdout.strip()
cfg["interface"]["private_key"] = private_key
cfg["interface"]["public_key"] = public_key
+4
View File
@@ -45,6 +45,10 @@ POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
GET_NGINX_BACKENDS: Endpoint = _ep("GET", "/nginx/backends")
PATCH_NGINX_BACKENDS: Endpoint = _ep("PATCH", "/nginx/backends")
POST_NGINX_BACKENDS_ADD: Endpoint = _ep("POST", "/nginx/backends/add")
DELETE_NGINX_BACKENDS_REMOVE: Endpoint = _ep("DELETE", "/nginx/backends/remove")
# ---- Firewall ----
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
+10 -1
View File
@@ -34,7 +34,15 @@ if _RAW_POLL:
if ":" in pair:
name, _, val = pair.partition(":")
try:
_POLL_OVERRIDE[name.strip()] = int(val.strip())
parsed = int(val.strip())
if parsed <= 0:
logger.warning(
"Invalid poll interval value %r for %r (must be > 0), skipping",
val,
name,
)
continue
_POLL_OVERRIDE[name.strip()] = parsed
except ValueError:
logger.warning(
"Invalid poll interval value %r for %r, skipping", val, name
@@ -560,6 +568,7 @@ def main() -> None:
# Import system configs → JSON (blocking — OK at startup)
from lib.system_import import import_all
reconciled = import_all()
if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
+66 -8
View File
@@ -105,6 +105,34 @@ Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firew
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
## System Config Import
On daemon startup, `lib/system_import.py` reconciles live system configurations
with the declarative JSON configs. This ensures that configurations created
by `scripts/install.sh` or edited manually in system files are imported into
the JSON source of truth, preventing drift.
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
import function:
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
block between comment markers) → `config/dnsmasq/config.json`. Only writes
if config doesn't exist or differs.
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf`
`config/wireguard/config.json`. Skips if configs match.
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
(install-time files) → `config/network/config.json`. Only adds/updates
interfaces; doesn't remove interfaces without a file (they may be pending apply).
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf`
`config/nginx/config.json`. Only touches vacuum-wall-managed files
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones`
`config/firewall/config.json`. Only writes if no config file exists
(firewalld state always takes precedence).
Import failures are silently logged as warnings — they never abort daemon startup.
The returned list of updated subsystems is logged for debugging.
## Cross-Subsystem Sync Event Bus
When a subsystem's configuration changes, related subsystems are automatically
@@ -116,14 +144,19 @@ subsystems — no handler calls into another handler's logic directly.
1. A mutation handler saves its config (e.g., adding a DHCP range).
2. The handler emits a `SyncEvent` on the event bus.
3. Subscribers react by updating related subsystem configs:
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services and `masquerade` to the
firewall zone for each interface serving a DHCP range.
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
for each interface serving a DHCP range. Back-propagates gateway (interface
IP) into DHCP ranges so clients receive their default route.
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
WireGuard interface and UDP 51820 rich rule.
- **FirewallToDhcpSync**: Detects stale DHCP ranges for interfaces not in
any zone (logs warnings, does not auto-remove).
- **NetworkToAllSync**: Suggests DHCP ranges and syncs firewall zone
interface assignments when network config changes.
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
entries when no active peers exist.
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
ranges. Syncs firewall zone interface assignments — adding new interfaces
and removing stale ones no longer in network config.
4. The handler refreshes state for the originating subsystem plus all
transitively affected subsystems.
@@ -145,6 +178,28 @@ Minimal. The sync happens transparently in the backend. The "pending changes"
indicator on the firewall page will show pending when DHCP or WireGuard saves
(since sync writes JSON but does not call firewall-cmd).
## System Config Import
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
to reconcile any drift between system configuration files and the declarative
JSON configs. This is invoked from `daemon/server.py` during initialization.
Each subsystem import function parses the corresponding live system config and
updates the JSON config if they differ:
| Subsystem | Source | Condition |
|---|---|---|
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
All imports are **idempotent** and **non-destructive**: they only write when
configs differ, skip on failure (logged as warnings), and never abort daemon
startup. This ensures that manual edits to system files (e.g., during install
or troubleshooting) are reconciled into the declarative JSON source of truth.
## Directory Structure
### Config — Declarative Settings
@@ -185,7 +240,9 @@ data/
├── networkd/ # Generated 50-<name>.network files
```
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
## File System Layout
@@ -199,6 +256,7 @@ The following file system locations are used for integration with system service
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
+11 -5
View File
@@ -486,12 +486,18 @@ Some subsystems depend on each other. When you modify one, related subsystems
are updated automatically through the event bus.
| Trigger Subsystem | Affected Subsystem | What Happens |
|-------------------|-------------------|--------------|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services and `masquerade`. Removing the last range removes them. |
| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, and UDP 51820 rule. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Zones with dhcp service but no range are logged as warnings. |
| network (interface config) | firewall | Zone interface assignments in firewall config are updated to match. |
|---|---|---|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, UDP 51820 rule, and inter-zone accept rules for each peer's allowed_ips subnets. Cleanup runs when no active peers exist. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
Note: The firewall "Apply" button is still needed to push config changes to
firewalld. Sync only updates the declarative JSON.
Additionally, on daemon startup, `lib/system_import.py` reconciles live system
configs (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative
JSON. This prevents drift when configs were created by the install script or
edited manually in system files. Reconciliation only writes when the existing
JSON differs or is missing — no data is lost on re-run.
+1
View File
@@ -117,6 +117,7 @@ The installer performs the following steps automatically:
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
- `vacuum-wall.service` — the Flask WebUI backend.
+2 -2
View File
@@ -55,7 +55,7 @@ The app starts from `webui/static/app.js`:
```javascript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
// 1. Register subsystem models
modelRegister('firewall', {
@@ -1151,7 +1151,7 @@ Render the toast notification container. Include in the main render root. See AP
## Versioned Imports
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=8`) to invalidate browser cache when the framework changes. Page imports also include version pins (e.g., `?v=9`). The server handles caching headers; the version query string ensures browser cache invalidation.
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
+4 -2
View File
@@ -93,15 +93,17 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
├── lib/ # Subsystem abstraction layer
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip)
│ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings
│ ├── network.py # systemd-networkd rendering & parsing
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
│ ├── sync.py # Cross-subsystem event bus
│ ├── acme.py # Certificate management (ACME helpers)
── wireguard.py # VPN tunnel and peer management
── wireguard.py # VPN tunnel and peer management
│ └── system_import.py # Startup reconciler (imports live system configs into JSON)
├── webui/ # Flask web application
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules (blueprints)
+15 -13
View File
@@ -24,28 +24,29 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
| Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) |
| Nginx | `nginx -s reload` | Graceful nginx configuration reload |
| Nginx | `nginx -t` | Nginx configuration syntax validation |
| Nginx file ops | `cp -- * /etc/nginx/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp -- * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp -- * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
| Nginx status | `systemctl is-active nginx` | Check nginx service status |
| Nginx file ops | `cp * /etc/nginx/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Networkd | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files |
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
| Dnsmasq file ops | `cp * /etc/dnsmasq.d/*` | Copy rendered config files |
| Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management |
| WireGuard file ops | `cp -- * /etc/wireguard/*` | Copy rendered config files |
| WireGuard file ops | `cp * /etc/wireguard/*` | Copy rendered config files |
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
| Certificates | (none) | acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used) |
| Network queries | `ip -o link show` | List network interfaces |
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
| Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) |
| Networkd | `networkctl status *` | Query interface status from networkd |
| Networkd | `networkctl reload *` | Reload networkd for a specific interface |
| Networkd | `networkctl reload` | Reload networkd for all interfaces |
| Networkd file ops | `cp -- * /etc/systemd/network/*` | Copy rendered network unit files |
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
| Networkd file ops | `cp * /etc/systemd/network/*` | Copy rendered network unit files |
| Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files |
| Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
| Sysctl | `sysctl -w *` | Set kernel parameters |
@@ -101,7 +102,8 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
| Directive | Value | Effect |
|---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
| `ReadWritePaths` | project dir, `/tmp`, `/run/vacuum-wall`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
| `RuntimeDirectory` | `vacuum-wall` (daemon only) | Creates `/run/vacuum-wall` owned by the daemon user; removed on stop |
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
+2
View File
@@ -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,
+169 -132
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,
"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)
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)
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] = val
entry[key] = kwargs[key]
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))
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
View File
@@ -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"):
+1 -1
View File
@@ -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
+5 -6
View File
@@ -9,9 +9,8 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active nginx
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
@@ -22,12 +21,12 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/misc/dnsmasq.leases
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf
# WireGuard management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/wireguard/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf
# Network interface queries
@@ -39,7 +38,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reconfigure *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/systemd/network/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/systemd/network/*.network
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network
+5 -1
View File
@@ -18,9 +18,13 @@ Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }}
# Runtime directory for temp files used during config apply
RuntimeDirectory=vacuum-wall
RuntimeDirectoryMode=0750
# Security hardening
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+1 -1
View File
@@ -462,7 +462,7 @@ class TestProxyDomains:
mock_post.return_value = {"domain": "ex.com"}
resp = client.post(
"/api/proxy/domains",
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
json={"domain": "ex.com", "backend": "webui"},
)
assert resp.status_code == 200
+632 -25
View File
@@ -59,13 +59,27 @@ class TestGetConfig:
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "localhost",
"port": 80,
"proto": "http",
}
}
},
}
},
"domains": {"example.com": {"backend": "myapp"}},
}
nginx.save_config(cfg)
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"] == "myapp"
assert (
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
== "localhost"
loaded["backends"]["myapp"]["paths"]["/"]["backend"]["host"] == "localhost"
)
@@ -76,46 +90,76 @@ class TestGetDomains:
def test_returns_domain_list(self, temp_data_dir):
cfg = {
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "localhost",
"port": 8080,
"proto": "http",
}
},
"/api": {
"backend": {
"host": "localhost",
"port": 8081,
"proto": "http",
}
},
},
}
},
"domains": {
"example.com": {
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
"force_ssl": True,
}
}
"example.com": {"backend": "myapp", "force_ssl": True},
},
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 1
assert len(result) == 2
assert result[0]["domain"] == "example.com"
assert result[0]["backend_name"] == "myapp"
assert result[0]["path"] == "/"
class TestAddDomain:
@patch("lib.nginx.get_config")
def test_add_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
def test_add_domain(self, temp_data_dir):
cfg = {
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "10.0.0.5",
"port": 8080,
"proto": "http",
}
}
},
}
},
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config()
assert "example.com" in cfg["domains"]
assert (
cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
)
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
nginx.save_config(cfg)
nginx.add_domain("example.com", "myapp")
loaded = nginx.get_config()
assert "example.com" in loaded["domains"]
assert loaded["domains"]["example.com"]["backend"] == "myapp"
assert loaded["domains"]["example.com"]["force_ssl"] is True
@patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"backends": {"myapp": {}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(mock_get.return_value)
with pytest.raises(ValueError):
nginx.add_domain("example.com", "10.0.0.5", 8080)
nginx.add_domain("example.com", "myapp")
class TestRemoveDomain:
@@ -380,3 +424,566 @@ class TestHashPasswordFallback:
result = nginx._hash_password("test")
assert isinstance(result, str)
assert len(result) > 0
# ---------------------------------------------------------------------------
# Backend CRUD and resolution tests (daemon handler)
# NOTE: The daemon handler modules cannot be called as-is because they
# require sudo and live service paths. Instead we test through `lib.nginx`
# public API where possible, and unit-test the handler-internal functions
# by importing them directly.
# ---------------------------------------------------------------------------
class TestResolvePaths:
def test_resolves_from_backend(self, temp_data_dir):
"""Paths resolve from backends[domain_cfg['backend']].paths."""
backends = {
"myapp": {
"label": "My App",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/api": {"backend": {"host": "b", "port": 8080, "proto": "http"}},
},
}
}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_paths(domain_cfg, backends)
assert resolved == backends["myapp"]["paths"]
def test_returns_empty_when_backend_missing(self, temp_data_dir):
"""Empty dict when backend ref not found in backends."""
resolved = nginx._resolve_paths({"backend": "nonexistent"}, {})
assert resolved == {}
def test_returns_empty_when_no_backend_key(self, temp_data_dir):
"""Empty dict when domain has no backend key."""
resolved = nginx._resolve_paths({}, {"myapp": {"paths": {"/": {}}}})
assert resolved == {}
class TestResolveAuth:
def test_domain_auth_wins(self, temp_data_dir):
"""Domain-level auth overrides backend auth."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {
"backend": "myapp",
"auth": {"user": "domain", "htpasswd": "/domain/.htpasswd"},
}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved == {"user": "domain", "htpasswd": "/domain/.htpasswd"}
def test_domain_auth_null_disables(self, temp_data_dir):
"""Domain auth set to None disables all auth."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {"backend": "myapp", "auth": None}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved is None
def test_backend_auth_applies(self, temp_data_dir):
"""Backend auth applies when domain has no auth key."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved == {"user": "backend", "htpasswd": "/backend/.htpasswd"}
def test_no_auth_when_absent_everywhere(self, temp_data_dir):
"""None when neither domain nor backend define auth."""
backends = {"myapp": {"paths": {}}}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved is None
class TestBackendCRUD:
"""Test daemon handler backend CRUD operations directly."""
def test_validate_paths_valid(self, temp_data_dir):
"""_validate_paths accepts correct schemas."""
from daemon.handlers.nginx import _validate_paths
paths = {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/ws": {
"backend": {"host": "b", "port": 9091, "proto": "http"},
"is_websocket": True,
},
}
_validate_paths(paths) # no exception
def test_validate_paths_missing_host(self, temp_data_dir):
"""_validate_paths raises when backend.host missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'host'"):
_validate_paths({"/": {"backend": {"port": 80, "proto": "http"}}})
def test_validate_paths_missing_port(self, temp_data_dir):
"""_validate_paths raises when backend.port missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'port'"):
_validate_paths({"/": {"backend": {"host": "a", "proto": "http"}}})
def test_validate_paths_missing_proto(self, temp_data_dir):
"""_validate_paths raises when backend.proto missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'proto'"):
_validate_paths({"/": {"backend": {"host": "a", "port": 80}}})
def test_validate_paths_no_backend(self, temp_data_dir):
"""_validate_paths raises when path lacks backend dict."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'backend'"):
_validate_paths({"/": {"something": "else"}})
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_add_backend(self, mock_save, mock_get, temp_data_dir):
"""_add_backend creates a new backend entry."""
from daemon.handlers.nginx import _add_backend
mock_get.return_value = {
"backends": {},
"domains": {},
"ssl": {},
}
_add_backend(
"test",
"Test Label",
{
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
},
)
saved_cfg = mock_save.call_args[0][0]
assert "test" in saved_cfg["backends"]
assert saved_cfg["backends"]["test"]["label"] == "Test Label"
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_add_backend_duplicate_raises(self, mock_save, mock_get, temp_data_dir):
"""_add_backend raises ValueError for duplicate name."""
from daemon.handlers.nginx import _add_backend
mock_get.return_value = {
"backends": {"test": {"label": "Existing"}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="already exists"):
_add_backend(
"test",
"New Label",
{
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
},
)
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_update_backend(self, mock_save, mock_get, temp_data_dir):
"""_update_backend modifies label and paths."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {
"backends": {
"test": {
"label": "Old",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}}
},
}
},
"domains": {},
"ssl": {},
}
_update_backend(
"test",
label="New",
paths={
"/api": {"backend": {"host": "b", "port": 9000, "proto": "http"}},
},
)
saved_cfg = mock_save.call_args[0][0]
assert saved_cfg["backends"]["test"]["label"] == "New"
assert "/api" in saved_cfg["backends"]["test"]["paths"]
@patch("daemon.handlers.nginx._get_config")
def test_update_backend_notfound_raises(self, mock_get, temp_data_dir):
"""_update_backend raises KeyError for unknown backend."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
with pytest.raises(KeyError):
_update_backend("nonexistent", label="X")
@patch("daemon.handlers.nginx._get_config")
def test_update_backend_builtin_raises(self, mock_get, temp_data_dir):
"""_update_backend raises ValueError for builtin backend."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="Cannot modify"):
_update_backend("webui", label="Hacked")
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_remove_backend(self, mock_save, mock_get, temp_data_dir):
"""_remove_backend deletes non-builtin backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {
"backends": {"test": {"label": "Test", "paths": {}}},
"domains": {},
"ssl": {},
}
_remove_backend("test")
saved_cfg = mock_save.call_args[0][0]
assert "test" not in saved_cfg["backends"]
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_notfound_raises(self, mock_get, temp_data_dir):
"""_remove_backend raises KeyError for unknown backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
with pytest.raises(KeyError):
_remove_backend("nonexistent")
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_builtin_raises(self, mock_get, temp_data_dir):
"""_remove_backend raises ValueError for builtin backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="Cannot remove"):
_remove_backend("webui")
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_referenced_raises_conflict(self, mock_get, temp_data_dir):
"""_remove_backend raises ConflictError when domains reference it."""
from daemon.handlers.nginx import _remove_backend
from daemon.server import ConflictError
mock_get.return_value = {
"backends": {"myapp": {"label": "App", "paths": {}}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {},
}
with pytest.raises(ConflictError, match="referenced by domain"):
_remove_backend("myapp")
class TestMigration:
def test_ensure_webui_backend_creates(self, temp_data_dir):
"""_ensure_webui_backend creates webui backend if missing."""
cfg = {}
nginx._ensure_webui_backend(cfg)
assert "webui" in cfg["backends"]
assert cfg["backends"]["webui"]["_migrated"] is True
assert cfg["backends"]["webui"]["label"] == "Vacuum Wall WebUI"
assert cfg["backends"]["webui"]["builtin"] is True
def test_ensure_webui_backend_skips_migrated(self, temp_data_dir):
"""_ensure_webui_backend skips if _migrated is true."""
cfg = {
"backends": {
"webui": {
"label": "Custom",
"_migrated": True,
"paths": {},
}
}
}
nginx._ensure_webui_backend(cfg)
# Label unchanged — not recreated
assert cfg["backends"]["webui"]["label"] == "Custom"
def test_migrate_mgmt_domains_application_webui(self, temp_data_dir):
"""Domains with application='webui' and mgmt paths get backend='webui'."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"mgmt.example.com": {
"application": "webui",
"paths": {
"/": {"is_management": True},
"/ws": {"is_websocket": True},
},
"auth": {"user": "admin"},
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["mgmt.example.com"]
assert dom["backend"] == "webui"
assert "application" not in dom
assert "paths" not in dom
assert "auth" not in dom
def test_migrate_mgmt_domains_strips_application_only(self, temp_data_dir):
"""application='webui' stripped even when paths don't match mgmt shape."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"odd.example.com": {
"application": "webui",
"paths": {"/": {"is_management": True}},
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["odd.example.com"]
assert "application" not in dom
assert "backend" not in dom # paths didn't match full mgmt shape
assert "paths" in dom # not removed
def test_migrate_mgmt_domains_detects_webui_paths(self, temp_data_dir):
"""Domains matching webui path shape get migrated to backend."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"mgmt.example.com": {
"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,
},
}
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["mgmt.example.com"]
assert dom["backend"] == "webui"
assert "paths" not in dom
def test_migrate_mgmt_domains_skips_non_mgmt(self, temp_data_dir):
"""Non-management domains are left untouched."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"app.example.com": {
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"}
}
}
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["app.example.com"]
assert "backend" not in dom
assert "paths" in dom
def test_migrate_config_full(self, temp_data_dir):
"""_migrate_config runs _ensure_webui_backend then _migrate_mgmt_domains."""
cfg = {
"domains": {
"mgmt.example.com": {
"application": "webui",
"paths": {
"/": {"is_management": True},
"/ws": {"is_websocket": True},
},
}
}
}
result = nginx._migrate_config(cfg)
assert result is cfg
assert "webui" in cfg["backends"]
assert cfg["domains"]["mgmt.example.com"]["backend"] == "webui"
class TestDomainSwap:
"""Domain can change which backend it references."""
def test_swap_backend(self, temp_data_dir):
"""update_domain allows changing backend field."""
cfg = {
"backends": {
"webui": {"label": "WebUI", "paths": {}},
"myapp": {"label": "App", "paths": {}},
},
"domains": {"example.com": {"backend": "webui", "force_ssl": True}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(cfg)
nginx.update_domain("example.com", backend="myapp")
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"] == "myapp"
def test_swap_backend_nonexistent_raises(self, temp_data_dir):
"""update_domain raises ValueError when new backend not found."""
cfg = {
"backends": {"webui": {"label": "WebUI", "paths": {}}},
"domains": {"example.com": {"backend": "webui"}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(cfg)
with pytest.raises(ValueError, match="Backend 'nonexistent' not found"):
nginx.update_domain("example.com", backend="nonexistent")
class TestGetDomainsWithBackends:
"""get_domains resolves paths from backends, includes backend_name."""
def test_flattens_by_backend_paths(self, temp_data_dir):
"""Each backend path becomes a separate entry."""
cfg = {
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/api": {
"backend": {"host": "b", "port": 8080, "proto": "http"}
},
"/ws": {
"backend": {"host": "c", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
}
},
"domains": {"example.com": {"backend": "myapp", "force_ssl": True}},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 3
paths = {r["path"] for r in result}
assert paths == {"/", "/api", "/ws"}
for r in result:
assert r["backend_name"] == "myapp"
assert r["domain"] == "example.com"
def test_skips_domains_without_backend(self, temp_data_dir):
"""Domains without a backend key are skipped."""
cfg = {
"backends": {},
"domains": {
"good.com": {"backend": "webui"},
"bad.com": {"some": "orphan"},
},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
domains = {r["domain"] for r in result}
assert "good.com" in domains
assert "bad.com" not in domains
def test_includes_management_and_websocket_flags(self, temp_data_dir):
"""is_management and is_websocket flags propagate to entries."""
cfg = {
"backends": {
"webui": {
"label": "WebUI",
"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,
},
},
}
},
"domains": {"mgmt.local": {"backend": "webui"}},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
entries_by_path = {r["path"]: r for r in result}
assert entries_by_path["/"]["is_management"] is True
assert entries_by_path["/ws"]["is_websocket"] is True
class TestGenerateServerConfWithBackends:
"""generate_server_conf resolves paths via backends parameter."""
def test_with_backends_param(self, temp_data_dir):
"""When backends provided, paths resolve from backend."""
backends = {
"myapp": {
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 8080, "proto": "http"}
}
}
}
}
domain_cfg = {"domain": "example.com", "backend": "myapp", "force_ssl": True}
out = nginx.generate_server_conf(domain_cfg, backends)
assert "proxy_pass http://10.0.0.1:8080;" in out
class TestPatchConfigPreservesBackends:
"""PATCH_NGINX_CONFIG (deep_merge) does not overwrite backends fully."""
def test_ssl_patch_keeps_backends(self, temp_data_dir):
"""Patching ssl settings preserves backends dict (deep_merge semantics)."""
from lib.common import deep_merge
current = {
"backends": {"myapp": {"label": "App", "paths": {}}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {"protocols": "TLSv1.2"},
}
patch = {"ssl": {"protocols": "TLSv1.3"}}
merged = deep_merge(current, patch)
assert merged["backends"]["myapp"]["label"] == "App"
assert merged["domains"]["example.com"]["backend"] == "myapp"
assert merged["ssl"]["protocols"] == "TLSv1.3"
+27
View File
@@ -642,3 +642,30 @@ class TestCfgsEqual:
a = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
b = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
assert system_import._cfgs_equal(a, b)
# ──────────────────────────────────────────────────────────────────────
# Template marker consistency
# ──────────────────────────────────────────────────────────────────────
class TestTemplateMarker:
"""Verify nginx templates contain the expected auto-generated marker."""
def test_acme_challenge_has_marker(self):
content = (
Path(__file__).resolve().parent.parent
/ "system"
/ "nginx"
/ "acme-challenge.conf"
).read_text()
assert "# Auto-generated by Vacuum Wall" in content
def test_server_block_has_marker(self):
content = (
Path(__file__).resolve().parent.parent
/ "system"
/ "nginx"
/ "server_block.conf"
).read_text()
assert "# Auto-generated by Vacuum Wall" in content
+124 -34
View File
@@ -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)
paths = body.get("paths")
if paths is not None:
payload = {
"domain": domain,
"paths": paths,
"cert": body.get("cert"),
"backend": backend,
"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,
}
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)
@@ -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)
+13 -1
View File
@@ -7,6 +7,7 @@ import RulesPage from '/static/pages/rules.js?v=9';
import NatPage from '/static/pages/nat.js?v=9';
import DhcpPage from '/static/pages/dhcp.js?v=9';
import ProxyPage from '/static/pages/proxy.js?v=9';
import BackendsPage from '/static/pages/backends.js?v=9';
import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9';
@@ -21,6 +22,7 @@ const Nav = [
{ path: '/nat', label: 'NAT' },
{ path: '/dhcp', label: 'DHCP' },
{ path: '/proxy', label: 'Proxy' },
{ path: '/backends', label: 'Backends' },
{ path: '/certs', label: 'Certs' },
{ path: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' },
@@ -97,6 +99,15 @@ modelRegister('nginx', {
},
});
modelRegister('backends', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/backends');
if (!r.ok) throw new Error(r.error);
return r.data || {};
},
});
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
@@ -156,7 +167,7 @@ modelRegister('logs', {
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
@@ -170,6 +181,7 @@ const Pages = {
nat: NatPage,
dhcp: DhcpPage,
proxy: ProxyPage,
backends: BackendsPage,
certs: CertsPage,
wireguard: WireguardPage,
logs: LogsPage,
+217
View File
@@ -0,0 +1,217 @@
import { html, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch } from '/static/hoover/index.js?v=8';
import { openModal, closeModal } from '/static/hoover/components/modal.js?v=8';
// ---------------------------------------------------------------------------
// Add a path row element to the paths container
// ---------------------------------------------------------------------------
function _addPathRow(container, data) {
const row = document.createElement('div');
row.className = 'path-row-row';
row.innerHTML = `
<input class="form-input path-field" type="text" placeholder="Path" value="${data ? esc(data.path) : '/'}" />
<input class="form-input path-field" type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
<input class="form-input path-field" type="number" placeholder="Port" value="${data ? (data.backend || {}).port || '' : ''}" />
<select class="form-select path-field">
<option value="http"${(data && (data.backend || {}).proto === 'http') || !data ? ' selected' : ''}>http</option>
<option value="https"${data && (data.backend || {}).proto === 'https' ? ' selected' : ''}>https</option>
</select>
<label><input type="checkbox" class="path-ws"${data && data.is_websocket ? ' checked' : ''} /> ws</label>
<label><input type="checkbox" class="path-mgmt"${data && data.is_management ? ' checked' : ''} /> mgmt</label>
<button type="button" class="btn btn-sm btn-outline path-remove"><i>×</i></button>
`;
row.querySelector('.path-remove').addEventListener('click', () => {
if (container.querySelectorAll('.path-row-row').length > 1) {
row.remove();
} else {
toast('At least one path required', 'warning');
}
});
container.appendChild(row);
return row;
}
// ---------------------------------------------------------------------------
// Collect paths from the paths container
// ---------------------------------------------------------------------------
function _collectPaths(container) {
const result = {};
const seen = new Set();
container.querySelectorAll('.path-row-row').forEach(row => {
const inputs = row.querySelectorAll('.path-field');
const path = (inputs[0].value || '').trim() || '/';
const host = (inputs[1].value || '').trim();
const port = parseInt(inputs[2].value);
const proto = inputs[3].value;
if (!host || !port) return;
if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; }
seen.add(path);
const entry = { backend: { host, port, proto } };
if (row.querySelector('.path-ws').checked) entry.is_websocket = true;
if (row.querySelector('.path-mgmt').checked) entry.is_management = true;
result[path] = entry;
});
return result;
}
// ---------------------------------------------------------------------------
// Open backend form modal
// ---------------------------------------------------------------------------
function openBackendModal(state, backend) {
const isEdit = !!backend;
const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend';
openModal((modalContent) => {
const uniqueId = Date.now();
const pathsId = 'paths-' + uniqueId;
// Build form HTML
const authVal = isEdit && backend.data.has_auth ? 'htpasswd' : 'none';
modalContent.innerHTML = `
<h3 class="modal-title">${esc(title)}</h3>
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" id="name-${uniqueId}" type="text" placeholder="my-app"
value="${isEdit ? esc(backend.name) : ''}"
${isEdit ? 'readonly' : ''} />
</div>
<div class="form-group">
<label class="form-label">Label</label>
<input class="form-input" id="label-${uniqueId}" type="text" placeholder="My App"
value="${isEdit ? esc(backend.data.label || '') : ''}" />
</div>
<div class="form-group">
<label class="form-label">Auth</label>
<select class="form-select" id="auth-${uniqueId}">
<option value="none"${authVal === 'none' ? ' selected' : ''}>No auth</option>
<option value="htpasswd"${authVal === 'htpasswd' ? ' selected' : ''}>HTTP Basic Auth</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Paths</label>
<div id="${pathsId}" class="paths-container"></div>
</div>
<div style="padding-top:12px;text-align:right;border-top:1px solid #dee2e6;" class="modal-actions-bar">
<button type="button" class="btn btn-outline modal-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="submit-${uniqueId}">${isEdit ? 'Save' : 'Add'}</button>
</div>
`;
// Add paths container refs
const pathsContainer = modalContent.querySelector('#' + pathsId);
// Add initial path rows
if (isEdit && backend.data.paths) {
Object.entries(backend.data.paths).forEach(([path, cfg]) => {
_addPathRow(pathsContainer, { path, ...cfg });
});
} else {
_addPathRow(pathsContainer, null);
}
// "Add path" button
const addPathBtn = document.createElement('button');
addPathBtn.type = 'button';
addPathBtn.className = 'btn btn-sm btn-outline';
addPathBtn.style.marginTop = '8px';
addPathBtn.textContent = '+ Add Path';
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null));
pathsContainer.parentNode.querySelector('.form-label')
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
// Cancel button
modalContent.querySelector('.modal-cancel').addEventListener('click', () => closeModal());
// Submit button
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
const nameInput = modalContent.querySelector('#name-' + uniqueId);
const labelInput = modalContent.querySelector('#label-' + uniqueId);
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
const name = (nameInput.value || '').trim();
const label = (labelInput.value || '').trim();
const authType = authSelect.value;
const paths = _collectPaths(pathsContainer);
if (!name) { toast('Name is required', 'error'); return; }
if (!label) { toast('Label is required', 'error'); return; }
if (!Object.keys(paths).length) { toast('At least one valid path is required', 'error'); return; }
const body = { name, label, paths };
if (authType === 'htpasswd') {
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
} else {
body.auth = null;
}
const method = isEdit ? 'PATCH' : 'POST';
const res = await apiFetch('/api/proxy/backends', { method, body });
if (res.ok) {
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
closeModal();
await modelFetch('backends');
modelFetch('nginx');
} else {
toast(res.error || 'Failed', 'error');
}
});
});
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default definePage({
init() {
return {
backends: getModel('backends'),
};
},
render(state) {
const guard = renderGuard(state.backends, 'Backends', 'Reusable proxy backend templates', state.backends.data);
if (guard) return guard;
const backends = state.backends.data || {};
const entries = Object.entries(backends);
const rows = entries.map(([name, b]) =>
html`<tr key=${name}>
<td><strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td>
<td>
${b.builtin ? html`<${Badge} text="builtin" variant="warning" />` : ''}
${b.has_auth ? html`<${Badge} text="auth" variant="info" />` : ''}
</td>
<td>
${b.builtin
? ''
: html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name, data: b })}>Edit</button>`
}
${b.builtin
? ''
: html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(name)}
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
label="Delete" />`
}
</td>
</tr>`
);
const actions = html`
<button class="btn btn-primary" onClick=${() => openBackendModal(state)}>Add Backend</button>
`;
return [
PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }),
entries.length
? Table({
columns: ['Name', 'Label', 'Paths', 'Flags', 'Actions'],
rows,
})
: Empty({ text: 'No backends configured. The built-in "webui" backend is created automatically after migration.' }),
];
},
});
+110 -103
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup } from '/static/hoover/index.js?v=8';
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=8';
// ---------------------------------------------------------------------------
@@ -43,19 +43,32 @@ function certValueFromSelect(raw) {
}
// ---------------------------------------------------------------------------
// Add Domain modal — paths-based body with cert selector
// Build backend select options from backends model
// ---------------------------------------------------------------------------
function buildBackendOptions(backends) {
const opts = [['', '(select backend)']];
const entries = Object.entries(backends || {});
entries.sort((a, b) => (a[1].label || a[0]).localeCompare(b[1].label || b[0]));
entries.forEach(([name, b]) => {
const label = b.label || name;
const suffix = b.builtin ? ' (builtin)' : '';
opts.push([name, label + suffix]);
});
return opts;
}
// ---------------------------------------------------------------------------
// Add Domain modal — backend selector
// ---------------------------------------------------------------------------
function addDomain(state) {
const certs = state.acme ? (state.acme.data.certs || []) : [];
const certOptions = buildCertOptions(certs);
const backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends);
openModal((inner) => {
formModal(inner, 'Add Proxy Domain', [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Path', id: 'p-path', placeholder: '/' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Backend', id: 'p-backend', tag: 'select', options: backendOptions },
{ label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions },
], [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
@@ -64,27 +77,20 @@ function addDomain(state) {
cls: 'btn-primary',
action: 's',
handler: async () => {
const path = ($val('p-path') || '/').trim() || '/';
const domain = ($val('p-domain') || '').trim();
const backend = ($val('p-backend') || '').trim();
const rawCert = $val('p-cert');
const body = {
domain: ($val('p-domain') || '').trim(),
paths: {
[path]: {
backend: {
host: ($val('p-host') || '').trim(),
port: parseInt($val('p-port')),
proto: ($val('p-proto') || 'http').trim() || 'http',
},
headers: {},
},
},
cert: certValueFromSelect(rawCert),
};
if (!body.domain) { toast('Domain is required', 'error'); return; }
const p = body.paths ? Object.values(body.paths)[0] : {};
const be = p && p.backend;
if (!be || !be.host || !be.port) { toast('Host and port are required', 'error'); return; }
if (!domain) { toast('Domain is required', 'error'); return; }
if (!backend) { toast('Backend is required', 'error'); return; }
const body = {
domain,
backend,
force_ssl: true,
};
const certVal = certValueFromSelect(rawCert);
if (certVal) body.cert = certVal;
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
if (res.ok) {
@@ -112,27 +118,38 @@ function addDomain(state) {
}
// ---------------------------------------------------------------------------
// Edit Domain modal — updates backend for the root path, with cert selector
// Edit Domain modal — cert and force_ssl only (backend is read-only)
// ---------------------------------------------------------------------------
function editDomain(d, state) {
const certs = state.acme ? (state.acme.data.certs || []) : [];
const certOptions = buildCertOptions(certs);
const backends = state.backends ? (state.backends.data || {}) : {};
const backend = backends[d.backend_name] || {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const certMap = certLookup(state.acme ? state.acme.data : null);
const domainCert = certMap[d.domain];
let selectedCert = '';
if (d._cert) {
selectedCert = `acme|${d._cert.domain}`;
if (domainCert) {
selectedCert = `acme|${domainCert.domain}`;
} else if (d.cert) {
selectedCert = d.cert;
}
const be = d.backend || {};
// Build path summary rows (read-only)
const paths = backend.paths || {};
const pathKeys = Object.keys(paths);
const pathSummary = pathKeys.map(p => {
const pcfg = paths[p];
const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—';
openModal((inner) => {
formModal(inner, 'Edit: ' + d.domain + ' ' + d.path, [
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
formModal(inner, 'Edit: ' + esc(d.domain), [
{ label: 'Domain', id: 'pe-domain', value: d.domain },
{ label: 'Backend', id: 'pe-backend', value: (d.backend_name || '-') + ' (' + (backend.label || '—') + ')' },
{ label: 'Paths', id: 'pe-paths', tag: 'textarea', value: pathSummary, readonly: true },
{ label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
{ label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl },
], [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
@@ -141,19 +158,11 @@ function editDomain(d, state) {
action: 's',
handler: async () => {
const rawCert = $val('pe-cert');
const body = {
backend: {
host: ($val('pe-host') || '').trim(),
port: parseInt($val('pe-port')),
proto: ($val('pe-proto') || 'http').trim(),
},
cert: certValueFromSelect(rawCert),
};
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
if (!body.backend || !body.backend.host || !body.backend.port) {
toast('Host and port are required', 'error');
return;
}
const body = {};
body.cert = certValueFromSelect(rawCert);
body.force_ssl = forceSsl;
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
if (res.ok) {
@@ -167,19 +176,33 @@ function editDomain(d, state) {
},
]);
// Set cert select value
const certSelect = inner.querySelector('#pe-cert');
if (certSelect) {
certSelect.value = selectedCert;
if (certSelect) certSelect.value = selectedCert;
// Make read-only fields actually read-only
const domainInput = inner.querySelector('#pe-domain');
if (domainInput) {
domainInput.readOnly = true;
domainInput.style.background = '#f5f5f5';
}
const backendInput = inner.querySelector('#pe-backend');
if (backendInput) {
backendInput.readOnly = true;
backendInput.style.background = '#f5f5f5';
}
});
}
// ---------------------------------------------------------------------------
// Path detail row
// Row for a domain (grouped by domain name)
// ---------------------------------------------------------------------------
function pathRow(d, certs, domainPaths, state) {
const be = d.backend || {};
const cert = certs[d.domain];
function domainRow(domainName, domainPaths, state) {
const d = domainPaths[0];
const certMap = certLookup(state.acme ? state.acme.data : null);
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
const cert = certMap[d.domain];
let certBadge, certTitle;
if (cert) {
certBadge = certStatusBadge({
@@ -198,46 +221,36 @@ function pathRow(d, certs, domainPaths, state) {
certTitle = 'No certificate';
}
const isWs = d.is_websocket;
const isMgmt = d.is_management;
const multiPath = (domainPaths || []).length > 1;
let actions;
if (isWs) {
actions = ActionButton({
url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({ path: d.path }),
label: 'Delete',
cls: 'btn btn-sm btn-danger',
successMsg: 'Path removed',
refresh: ['nginx', 'acme'],
// Build paths summary
const pathSummaries = domainPaths.map(p => {
const be = p.backend || {};
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
const flags = [];
if (p.is_websocket) flags.push('ws');
if (p.is_management) flags.push('mgmt');
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
} else {
actions = ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d, state),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
removeSuccess: 'Removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
});
}
const flagBadges = [];
if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' }));
if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' }));
return html`<tr key=${d.domain + ':' + d.path} class="path-row">
<td>${esc(d.domain)}</td>
<td><code>${esc(d.path)}</code></td>
<td>${esc(be.host || '-')}</td>
<td>${be.port || '-'}</td>
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
<td>${flagBadges}</td>
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
<td>
<${Badge} text=${esc(d.backend_name || '-')} variant="primary" />
<span class="text-muted" style="margin-left:4px">${esc(backend.label || '')}</span>
</td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${actions}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
<td>
<${ActionCell}
editLabel="Edit"
editClick=${() => editDomain(d, state)}
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
removeMessage=${'Remove ' + enc(domainName) + '?'}
removeSuccess="Domain removed"
removeRefresh=["nginx", "acme"]
removeLabel="Delete" />
</td>
</tr>`;
}
@@ -248,30 +261,24 @@ export default definePage({
init() {
return {
nginx: getModel('nginx'),
backends: getModel('backends'),
acme: getModel('acme'),
};
},
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const domains = state.nginx.data.domains || [];
const certs = certLookup(state.acme.data);
// Group by domain for multi-path awareness
// Group by domain name
const groups = {};
for (const d of domains) {
if (!groups[d.domain]) groups[d.domain] = [];
groups[d.domain].push(d);
}
// Attach domain-level cert info to each entry
const enriched = domains.map(d => ({
...d,
_cert: certs[d.domain] || null,
}));
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain], state));
const rows = Object.values(groups).map(paths => domainRow(paths[0].domain, paths, state));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
@@ -287,7 +294,7 @@ export default definePage({
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
domains.length
? Table({
columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),