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