diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index 7024ba5..57219d5 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -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) diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index d2665c1..5cff0a2 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -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") diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index e7681ba..d5adbca 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -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", "") diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index 85bf6cc..1f725eb 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -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-.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") diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 8babd83..90538b5 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -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 {}, - } - }, - "force_ssl": force_ssl, - } - if cert is not None: - entry["cert"] = cert + entry: dict[str, Any] = { + "backend": backend_name, + "force_ssl": force_ssl, + } + if cert is not None: + entry["cert"] = cert - # 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"] - 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) + if "cert" in body: + if body["cert"] is None: + entry.pop("cert", None) else: - entry[key] = 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["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.""" diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index 771840f..c376af1 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -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 diff --git a/daemon/iface.py b/daemon/iface.py index c96b808..6586ba0 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -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") diff --git a/daemon/server.py b/daemon/server.py index f128d75..d7837a8 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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)) diff --git a/docs/architecture.md b/docs/architecture.md index ca5a3a8..b3d7366 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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,28 +144,33 @@ 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. + transitively affected subsystems. ### Guard Rails - **Idempotency**: Each subscriber reads current state, computes desired state, - writes the diff. Running twice is safe. + writes the diff. Running twice is safe. - **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle. - Re-entrant emits for the same key are silently dropped. + Re-entrant emits for the same key are silently dropped. - **Firewall-cmd separation**: Sync subscribers only write JSON config. They - do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to - push to firewalld. + do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to + push to firewalld. - **Error handling**: Subscriber exceptions are caught, logged as warnings, - and do NOT abort the originating handler. + and do NOT abort the originating handler. ### Frontend Impact @@ -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-.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-.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. diff --git a/docs/config.md b/docs/config.md index 6cdd42b..79ed30a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index 8b26ddf..ce2d3c7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/docs/hoover.md b/docs/hoover.md index 1fae449..c880593 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -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. diff --git a/docs/overview.md b/docs/overview.md index 62d89db..31d5150 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -93,15 +93,17 @@ After installation, access the management interface at `https://.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) diff --git a/docs/security.md b/docs/security.md index a427f3f..7fed00b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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` | diff --git a/lib/common.py b/lib/common.py index 185a194..896dbff 100644 --- a/lib/common.py +++ b/lib/common.py @@ -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, diff --git a/lib/nginx.py b/lib/nginx.py index 19df751..061e261 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -1,7 +1,7 @@ """Nginx server-block generator for Vacuum Wall SSL proxy firewall. -Manages per-domain SSL reverse proxy configurations, certificate -bootstrap, basic-auth htpasswd files, and nginx reload cycles. +Manages per-domain SSL reverse proxy configurations backed by named backends, +certificate bootstrap, basic-auth htpasswd files, and nginx reload cycles. """ import logging @@ -47,74 +47,121 @@ DEFAULT_SSL: dict[str, Any] = { "prefer_server_ciphers": False, } +WEBUI_BACKEND: dict[str, Any] = { + "label": "Vacuum Wall WebUI", + "builtin": True, + "auth": { + "user": "admin", + "htpasswd": str(HTPASSWD_FILE), + }, + "paths": { + "/": { + "backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"}, + "is_management": True, + }, + "/ws": { + "backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"}, + "is_websocket": True, + }, + }, +} + DEFAULT_CONFIG: dict[str, Any] = { + "backends": {}, "domains": {}, "ssl": {**DEFAULT_SSL}, } -# ------------------------------------------------------------------ -# Public API -# ------------------------------------------------------------------ +# --------------------------------------------------------------------------- +# Resolution + + +def _resolve_paths( + domain_cfg: dict[str, Any], backends: dict[str, Any] +) -> dict[str, Any]: + """Resolve effective paths from backends[domain_cfg['backend']].paths.""" + backend_name = domain_cfg.get("backend", "") + if backend_name and backend_name in backends: + return backends[backend_name].get("paths", {}) + return {} + + +def _resolve_auth( + domain_cfg: dict[str, Any], backends: dict[str, Any] +) -> dict[str, Any] | None: + """Resolve effective auth: domain auth -> backend auth -> None.""" + if "auth" in domain_cfg: + return domain_cfg.get("auth") + backend_name = domain_cfg.get("backend", "") + if backend_name and backend_name in backends: + backend_auth = backends[backend_name].get("auth") + if backend_auth is not None: + return backend_auth + return None + + +# --------------------------------------------------------------------------- +# Migration def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]: - """Migrate legacy config formats to the new paths-based model. - - Handles two migrations: - 1. Legacy ``management`` top-level key -> path entry under its domain. - 2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``. - - Args: - raw: Config dict as loaded from disk. - - Returns: - The migrated config dict. - """ - # Migrate management key - if "management" in raw and raw["management"] is not None: - mgmt = raw["management"] - mgmt_domain = mgmt.get("domain", "") - if mgmt_domain: - domains = raw.setdefault("domains", {}) - if mgmt_domain not in domains: - domains[mgmt_domain] = { - "force_ssl": True, - "paths": {}, - } - dom = domains[mgmt_domain] - paths = dom.setdefault("paths", {}) - if "/" not in paths: - paths["/"] = { - "backend": { - "host": mgmt.get("backend", {}).get("host", "127.0.0.1"), - "port": mgmt.get("backend", {}).get("port", 9090), - "proto": "http", - }, - "is_management": True, - } - if mgmt.get("auth"): - paths["/"]["auth"] = mgmt["auth"] - # Add WebSocket path if not present - if "/ws" not in paths: - paths["/ws"] = { - "backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"}, - "is_websocket": True, - } - del raw["management"] - - # Migrate domain entries without paths - for dom in raw.get("domains", {}).values(): - if "paths" not in dom and "backend" in dom: - dom["paths"] = { - "/": { - "backend": dom.pop("backend"), - "headers": dom.pop("headers", {}), - } - } + """Migrate legacy config to backends model.""" + _ensure_webui_backend(raw) + _migrate_mgmt_domains(raw) return raw +def _ensure_webui_backend(raw: dict[str, Any]) -> None: + """Create the builtin webui backend if not yet migrated.""" + backends = raw.setdefault("backends", {}) + webui = backends.get("webui") + if webui and webui.get("_migrated"): + return + backends["webui"] = deepcopy(WEBUI_BACKEND) + backends["webui"]["_migrated"] = True + # Harvest auth from legacy path-level auth if present + for dom in raw.get("domains", {}).values(): + paths = dom.get("paths", {}) + root = paths.get("/", {}) + if root.get("auth"): + backends["webui"]["auth"] = root["auth"] + break + + +def _migrate_mgmt_domains(raw: dict[str, Any]) -> None: + """Migrate legacy management domains to backend references.""" + backends = raw.get("backends", {}) + if not backends.get("webui", {}).get("_migrated"): + return + domains = raw.setdefault("domains", {}) + for _name, dom in list(domains.items()): + if dom.get("backend") == "webui": + continue + if dom.get("application") == "webui": + del dom["application"] + paths = dom.get("paths", {}) + root = paths.get("/", {}) + ws = paths.get("/ws", {}) + root_backend = root.get("backend", {}) + ws_backend = ws.get("backend", {}) + is_mgmt_root = root.get("is_management") or ( + root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090 + ) + is_mgmt_ws = ws.get("is_websocket") or ( + ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091 + ) + if is_mgmt_root and is_mgmt_ws: + dom["backend"] = "webui" + dom.pop("paths", None) + dom.pop("auth", None) + + +# ------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------ + + def get_config() -> dict[str, Any]: """Load the current nginx config, initializing with defaults if needed. @@ -122,7 +169,7 @@ def get_config() -> dict[str, Any]: legacy formats, then returns the config dict. Returns: - The complete config dict with ``domains`` and ``ssl`` keys. + The complete config dict with ``backends``, ``domains``, and ``ssl`` keys. """ ensure_dirs(CONFIG_DIR, SITES_DIR) raw = load_json(CONFIG_FILE) @@ -130,6 +177,8 @@ def get_config() -> dict[str, Any]: raw = deepcopy(DEFAULT_CONFIG) if "ssl" not in raw: raw["ssl"] = deepcopy(DEFAULT_SSL) + if "backends" not in raw: + raw["backends"] = {} raw = _migrate_config(raw) save_config(raw) return raw @@ -143,20 +192,21 @@ def save_config(cfg: dict[str, Any]) -> None: def get_domains() -> list[dict[str, Any]]: """Return a list of all configured proxy domains flattened by path. - Each path within a domain becomes a separate entry with domain-level - settings repeated. + Each path within a domain becomes a separate entry. Paths are resolved + from the domain's referenced backend. Returns: List of dicts with ``domain``, ``path``, ``backend``, ``online``, - ``force_ssl``, and path-level flags. + ``force_ssl``, ``backend_name``, and path-level flags. """ cfg = get_config() + backends = cfg.get("backends", {}) result: list[dict[str, Any]] = [] for name, dom in cfg.get("domains", {}).items(): - site = SITES_DIR / f"{name}.conf" - paths = dom.get("paths", {}) - if not paths: + if "backend" not in dom: continue + site = SITES_DIR / f"{name}.conf" + paths = _resolve_paths(dom, backends) for ppath, pcfg in paths.items(): entry: dict[str, Any] = { "domain": name, @@ -164,6 +214,7 @@ def get_domains() -> list[dict[str, Any]]: "backend": pcfg.get("backend", {}), "online": site.exists(), "force_ssl": dom.get("force_ssl", True), + "backend_name": dom["backend"], } if pcfg.get("is_management"): entry["is_management"] = True @@ -180,57 +231,37 @@ def get_domains() -> list[dict[str, Any]]: def add_domain( domain: str, - backend_host: str | None = None, - backend_port: int | None = None, - backend_proto: str = "http", + backend_name: str, cert: str | None = None, - extra_headers: dict[str, str] | None = None, - paths: dict[str, dict[str, Any]] | None = None, + force_ssl: bool = True, + auth: dict[str, Any] | None = None, ) -> None: - """Add a new proxy domain with the given backend and optional settings. + """Add a new proxy domain that references an existing backend. Args: domain: Domain name to add. - backend_host: Upstream host to proxy to (legacy mode). - backend_port: Upstream port (legacy mode). - backend_proto: Protocol (``http`` or ``https``; legacy mode). + backend_name: Name of the backend to proxy through. cert: Optional certificate type identifier. - extra_headers: Optional dict of extra headers to forward (legacy mode). - paths: Optional path-to-config map (new mode). Each path entry must - have a ``backend`` key with ``host``, ``port``, and ``proto``. + force_ssl: Whether to enforce HTTPS redirect. + auth: Optional domain-level auth override. Raises: - ValueError: If the domain is already configured. + ValueError: If the domain is already configured or backend not found. """ cfg = get_config() if domain in cfg["domains"]: raise ValueError(f"Domain {domain!r} already configured") + if backend_name not in cfg.get("backends", {}): + raise ValueError(f"Backend {backend_name!r} not found") - if paths is not None: - entry: dict[str, Any] = { - "paths": paths, - "force_ssl": True, - } - if cert is not None: - entry["cert"] = cert - else: - if not backend_host or backend_port is None: - raise ValueError("'backend_host' and 'backend_port' are required") - entry = { - "paths": { - "/": { - "backend": { - "host": backend_host, - "port": int(backend_port), - "proto": backend_proto, - }, - "headers": extra_headers or {}, - } - }, - "force_ssl": True, - } - if cert is not None: - entry["cert"] = cert + entry: dict[str, Any] = { + "backend": backend_name, + "force_ssl": force_ssl, + } + if cert is not None: + entry["cert"] = cert + if auth is not None: + entry["auth"] = auth cfg["domains"][domain] = entry save_config(cfg) @@ -249,12 +280,10 @@ def remove_domain(domain: str) -> None: def update_domain(domain: str, **kwargs: Any) -> None: - """Update fields of an existing domain entry in-place. + """Update domain-level fields of an existing domain entry. - Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``, - ``paths``) and paths-level shorthand (``backend``, ``headers`` for - the root path). When ``paths`` is provided, it is fully replaced. - When ``backend`` is provided, it updates ``paths["/"]["backend"]``. + Only domain-level keys are accepted: ``backend``, ``cert``, ``force_ssl``, + ``auth``. Path changes must be made on the backend. Args: domain: Domain name to update. @@ -262,36 +291,27 @@ def update_domain(domain: str, **kwargs: Any) -> None: Raises: KeyError: If the domain is not configured. + ValueError: If a new backend is specified but doesn't exist. """ cfg = get_config() if domain not in cfg["domains"]: raise KeyError(f"Domain {domain!r} not configured") entry = cfg["domains"][domain] - # If paths is given, replace entirely - if "paths" in kwargs: - entry["paths"] = kwargs["paths"] - else: - # Legacy: top-level backend/headers -> paths["/"] - paths = entry.setdefault("paths", {}) - if "backend" in kwargs: - root = paths.setdefault("/", {"backend": {}, "headers": {}}) - root["backend"] = kwargs["backend"] - if "headers" in kwargs: - root = paths.setdefault("/", {"backend": {}, "headers": {}}) - root["headers"] = kwargs["headers"] + new_backend = kwargs.get("backend") + if new_backend: + if new_backend not in cfg.get("backends", {}): + raise ValueError(f"Backend {new_backend!r} not found") + entry["backend"] = new_backend - # Remove legacy top-level keys from domain entry - entry.pop("backend", None) - entry.pop("headers", None) + allowed = ("backend", "cert", "force_ssl", "auth") + for key in allowed: + if key in kwargs and key != "backend": + if key == "auth" and kwargs[key] is None: + entry.pop("auth", None) + else: + entry[key] = kwargs[key] - for key, val in kwargs.items(): - if key in ("backend", "headers", "paths"): - continue - if isinstance(val, dict) and key in entry: - entry[key].update(val) - else: - entry[key] = val save_config(cfg) logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys())) @@ -301,11 +321,15 @@ def update_domain(domain: str, **kwargs: Any) -> None: # ------------------------------------------------------------------ -def generate_server_conf(domain_cfg: dict[str, Any]) -> str: +def generate_server_conf( + domain_cfg: dict[str, Any], backends: dict[str, Any] | None = None +) -> str: """Render the Jinja template for a domain server block. Args: domain_cfg: Domain entry dict including the ``domain`` key. + backends: Optional backends dict for path/auth resolution. + When omitted, falls back to reading from domain inline paths. Returns: The complete nginx server-block configuration as a string. @@ -313,7 +337,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: tmpl = ENV.get_template("nginx/server_block.conf") acme_home_path = PROJECT_DIR / "data" / "acme" acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path)) - paths = domain_cfg.get("paths", {}) + + if backends is not None: + paths = _resolve_paths(domain_cfg, backends) + domain_auth = _resolve_auth(domain_cfg, backends) + else: + paths = domain_cfg.get("paths", {}) + domain_auth = domain_cfg.get("auth") + has_management = any(p.get("is_management") for p in paths.values()) # Resolve custom cert paths for cert=="file" cert_cfg = domain_cfg.get("cert") @@ -330,7 +361,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: cert=domain_cfg.get("cert"), cert_path=cert_path, cert_key_path=cert_key_path, - domain_auth=domain_cfg.get("auth"), + domain_auth=domain_auth, has_management=has_management, acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), @@ -382,19 +413,20 @@ def write_acme_challenge() -> None: def write_all_sites() -> None: """Regenerate all site configs from the current config state. - Writes server blocks for every configured domain (now unified, including - any management paths), removes orphaned site files, and ensures the ACME - challenge config is present. + Writes server blocks for every configured domain using backend-resolved + paths, removes orphaned site files, and ensures the ACME challenge + config is present. """ ensure_dirs(SITES_DIR) cfg = get_config() + backends = cfg.get("backends", {}) existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set() written: set[str] = set() for name, dom in cfg.get("domains", {}).items(): dom_copy = dict(dom, domain=name) - conf = generate_server_conf(dom_copy) + conf = generate_server_conf(dom_copy, backends) write_site(name, conf) written.add(f"{name}.conf") @@ -548,6 +580,11 @@ def _hash_password(password: str) -> str: __all__ = [ + "WEBUI_BACKEND", + "_ensure_webui_backend", + "_migrate_mgmt_domains", + "_resolve_auth", + "_resolve_paths", "add_domain", "apply", "generate_server_conf", diff --git a/lib/state.py b/lib/state.py index 9054910..cd961d7 100644 --- a/lib/state.py +++ b/lib/state.py @@ -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"): diff --git a/lib/system_import.py b/lib/system_import.py index 043b8cb..7c44a2b 100644 --- a/lib/system_import.py +++ b/lib/system_import.py @@ -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 diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index 91414a6..aa9c898 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -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 diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index 12d3e27..a22a2fb 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index 77b623d..4e5fa32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_nginx.py b/tests/test_nginx.py index a3c029e..bfae50f 100644 --- a/tests/test_nginx.py +++ b/tests/test_nginx.py @@ -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 = { - "domains": { - "example.com": { - "backend": {"host": "localhost", "port": 8080, "proto": "http"}, - "force_ssl": True, + "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": "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" diff --git a/tests/test_system_import.py b/tests/test_system_import.py index 752a159..940248c 100644 --- a/tests/test_system_import.py +++ b/tests/test_system_import.py @@ -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 diff --git a/webui/api/proxy.py b/webui/api/proxy.py index af90004..83757d9 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -7,13 +7,17 @@ import logging from flask import Blueprint, request -from daemon.client import BadRequest, NotFound, delete, get, patch, post +from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post from daemon.iface import ( + DELETE_NGINX_BACKENDS_REMOVE, DELETE_NGINX_DOMAINS_REMOVE, + GET_NGINX_BACKENDS, GET_NGINX_CONFIG, GET_NGINX_DOMAINS, + PATCH_NGINX_BACKENDS, PATCH_NGINX_CONFIG, POST_NGINX_APPLY, + POST_NGINX_BACKENDS_ADD, POST_NGINX_CONFIG, POST_NGINX_DOMAINS_ADD, POST_NGINX_DOMAINS_UPDATE, @@ -135,23 +139,16 @@ def list_domains(): @bp.route("/domains", methods=["POST"]) def add_domain_bp(): - """Add a new proxy domain. + """Add a new proxy domain referencing a backend. POST /api/proxy/domains - Body fields (paths mode): + Body fields: domain: Domain name. - paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``). + backend: Backend name to proxy through. cert: Optional certificate type. force_ssl: Optional SSL redirect flag (default ``true``). - - Body fields (legacy mode): - domain: Domain name. - backend_host: Upstream host. - backend_port: Upstream port. - backend_proto: Protocol (``http`` or ``https``, default ``http``). - cert: Optional certificate type. - extra_headers: Optional extra headers dict. + auth: Optional domain-level auth override. Returns: ``{"domain": ...}`` on success. @@ -160,33 +157,20 @@ def add_domain_bp(): domain = body.get("domain", "").strip() if not domain: return _error("'domain' is required", 400) + backend = body.get("backend", "").strip() + if not backend: + return _error("'backend' is required", 400) + + payload = { + "domain": domain, + "backend": backend, + "force_ssl": body.get("force_ssl", True), + } + if body.get("cert") is not None: + payload["cert"] = body["cert"] + if body.get("auth") is not None: + payload["auth"] = body["auth"] - paths = body.get("paths") - if paths is not None: - payload = { - "domain": domain, - "paths": paths, - "cert": body.get("cert"), - "force_ssl": body.get("force_ssl", True), - } - else: - backend_host = body.get("backend_host", "").strip() - backend_port = body.get("backend_port") - backend_proto = body.get("backend_proto", "http").strip() or "http" - cert = body.get("cert") - extra_headers = body.get("extra_headers") - if not backend_host: - return _error("'backend_host' is required", 400) - if backend_port is None: - return _error("'backend_port' is required", 400) - payload = { - "domain": domain, - "backend_host": backend_host, - "backend_port": int(backend_port), - "backend_proto": backend_proto, - "cert": cert, - "extra_headers": extra_headers, - } try: post(POST_NGINX_DOMAINS_ADD, payload) logger.info("Proxy domain added via API: %s", domain) @@ -285,3 +269,109 @@ def test_bp(): except RuntimeError as exc: logger.error("nginx config test failed: %s", exc) return _error(str(exc), 500) + + +# --------------------------------------------------------------------------- +# Backend CRUD +# --------------------------------------------------------------------------- + + +@bp.route("/backends", methods=["GET"]) +def list_backends(): + """List all configured backends. + + GET /api/proxy/backends + + Returns: + Dict of backend configs with secrets stripped. + """ + try: + return _ok(get(GET_NGINX_BACKENDS)) + except RuntimeError as exc: + logger.error("Failed to list backends: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/backends", methods=["PATCH"]) +def patch_backend_bp(): + """Partially update a backend entry. + + PATCH /api/proxy/backends + + Body fields: + name: Backend name. + label: Optional new label. + paths: Optional new paths dict. + auth: Optional new auth config. + + Returns: + ``{"backend": ...}`` on success. + """ + body = request.get_json(silent=True) or {} + if not isinstance(body, dict): + return _error("Request body must be a JSON object", 400) + try: + patch(PATCH_NGINX_BACKENDS, body) + logger.info("Backend '%s' patched via API", body.get("name")) + return _ok({"backend": body.get("name")}) + except BadRequest as exc: + logger.info("Backend patch rejected: %s", exc) + return _error(str(exc), 400) + except RuntimeError as exc: + logger.error("Failed to patch backend: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/backends", methods=["POST"]) +def add_backend_bp(): + """Add a new backend. + + POST /api/proxy/backends + + Body fields: + name: Backend name (slug, unique). + label: Human-readable label. + paths: Path-to-config map. + auth: Optional auth config. + + Returns: + ``{"backend": ...}`` on success. + """ + body = request.get_json(silent=True) or {} + name = body.get("name", "").strip() + if not name: + return _error("'name' is required", 400) + try: + post(POST_NGINX_BACKENDS_ADD, body) + logger.info("Backend added via API: %s", name) + return _ok({"backend": name}) + except BadRequest as exc: + logger.info("Add backend '%s' rejected: %s", name, exc) + return _error(str(exc), 400) + except RuntimeError as exc: + logger.error("Failed to add backend '%s': %s", name, exc) + return _error(str(exc), 500) + + +@bp.route("/backends/", methods=["DELETE"]) +def remove_backend_bp(name): + """Remove a non-builtin backend. + + DELETE /api/proxy/backends/ + + 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) diff --git a/webui/static/app.js b/webui/static/app.js index bac37ac..81aada2 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -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, diff --git a/webui/static/pages/backends.js b/webui/static/pages/backends.js new file mode 100644 index 0000000..6fb214c --- /dev/null +++ b/webui/static/pages/backends.js @@ -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 = ` + + + + + + + + `; + 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 = ` + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + `; + + // 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` + ${esc(name)} + ${esc(b.label || name)} + ${Object.keys(b.paths || {}).length} + + ${b.builtin ? html`<${Badge} text="builtin" variant="warning" />` : ''} + ${b.has_auth ? html`<${Badge} text="auth" variant="info" />` : ''} + + + ${b.builtin + ? '' + : html`` + } + ${b.builtin + ? '' + : html`<${ConfirmDelete} + url=${'/api/proxy/backends/' + enc(name)} + message=${'Remove backend ' + enc(name) + '?'} + success="Backend removed" + refresh=["backends", "nginx"] + label="Delete" />` + } + + ` + ); + + const actions = html` + + `; + + 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.' }), + ]; + }, +}); \ No newline at end of file diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index 586b790..1c9f81d 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -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; + // 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(' → '); + }); - 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'], - }); - } 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` - ${esc(d.domain)} - ${esc(d.path)} - ${esc(be.host || '-')} - ${be.port || '-'} - <${Badge} text=${be.proto || 'http'} variant="info" /> - ${flagBadges} + return html` + ${esc(domainName)} + + <${Badge} text=${esc(d.backend_name || '-')} variant="primary" /> + ${esc(backend.label || '')} + + ${pathSummaries} ${certBadge} - ${actions} + ${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`} + + <${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" /> + `; } @@ -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``, @@ -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.' }),