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")
|
||||
|
||||
+307
-154
@@ -9,11 +9,15 @@ from typing import Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_BACKENDS_REMOVE,
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_BACKENDS,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_BACKENDS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_BACKENDS_ADD,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
@@ -21,17 +25,21 @@ from daemon.iface import (
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.acme import find_cert_dir
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
config_hash,
|
||||
deep_merge,
|
||||
ensure_dirs,
|
||||
load_json,
|
||||
run,
|
||||
run_proc,
|
||||
save_json,
|
||||
)
|
||||
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
|
||||
from lib.nginx import _resolve_auth as _ngx_resolve_auth
|
||||
from lib.nginx import _resolve_paths as _ngx_resolve_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,66 +59,76 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_SSL: dict[str, Any] = {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": (
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-RSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
),
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"backends": {},
|
||||
"domains": {},
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Migrate legacy config formats to the new paths-based model."""
|
||||
if "management" in raw and raw["management"] is not None:
|
||||
mgmt = raw["management"]
|
||||
mgmt_domain = mgmt.get("domain", "")
|
||||
if mgmt_domain:
|
||||
domains = raw.setdefault("domains", {})
|
||||
if mgmt_domain not in domains:
|
||||
domains[mgmt_domain] = {
|
||||
"force_ssl": True,
|
||||
"paths": {},
|
||||
}
|
||||
dom = domains[mgmt_domain]
|
||||
paths = dom.setdefault("paths", {})
|
||||
if "/" not in paths:
|
||||
paths["/"] = {
|
||||
"backend": {
|
||||
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
}
|
||||
if mgmt.get("auth"):
|
||||
paths["/"]["auth"] = mgmt["auth"]
|
||||
if "/ws" not in paths:
|
||||
paths["/ws"] = {
|
||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
}
|
||||
del raw["management"]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Migrate legacy config to backends model. Returns (config, changed)."""
|
||||
c1 = _ensure_webui_backend(raw)
|
||||
c2 = _migrate_mgmt_domains(raw)
|
||||
return raw, c1 or c2
|
||||
|
||||
|
||||
def _ensure_webui_backend(raw: dict[str, Any]) -> bool:
|
||||
"""Create builtin webui backend if not yet migrated. Returns True if changed."""
|
||||
backends = raw.setdefault("backends", {})
|
||||
webui = backends.get("webui")
|
||||
if webui and webui.get("_migrated"):
|
||||
return False
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
# Harvest auth from legacy path-level auth if present
|
||||
for dom in raw.get("domains", {}).values():
|
||||
if "paths" not in dom and "backend" in dom:
|
||||
dom["paths"] = {
|
||||
"/": {
|
||||
"backend": dom.pop("backend"),
|
||||
"headers": dom.pop("headers", {}),
|
||||
}
|
||||
}
|
||||
return raw
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
if root.get("auth"):
|
||||
backends["webui"]["auth"] = root["auth"]
|
||||
break
|
||||
return True
|
||||
|
||||
|
||||
def _migrate_mgmt_domains(raw: dict[str, Any]) -> bool:
|
||||
"""Migrate legacy mgmt domains to backend refs. Returns True if anything changed."""
|
||||
backends = raw.get("backends", {})
|
||||
if not backends.get("webui", {}).get("_migrated"):
|
||||
return False
|
||||
domains = raw.setdefault("domains", {})
|
||||
changed = False
|
||||
for _name, dom in list(domains.items()):
|
||||
if dom.get("backend") == "webui":
|
||||
continue
|
||||
if dom.get("application") == "webui":
|
||||
del dom["application"]
|
||||
changed = True
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
ws = paths.get("/ws", {})
|
||||
root_backend = root.get("backend", {})
|
||||
ws_backend = ws.get("backend", {})
|
||||
is_mgmt_root = root.get("is_management") or (
|
||||
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
|
||||
)
|
||||
is_mgmt_ws = ws.get("is_websocket") or (
|
||||
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
|
||||
)
|
||||
if is_mgmt_root and is_mgmt_ws:
|
||||
dom["backend"] = "webui"
|
||||
dom.pop("paths", None)
|
||||
dom.pop("auth", None)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
@@ -128,9 +146,10 @@ def _get_config() -> dict[str, Any]:
|
||||
raw = deepcopy(DEFAULT_CONFIG)
|
||||
if "ssl" not in raw:
|
||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||
raw = _migrate_config(raw)
|
||||
_save_config(raw)
|
||||
return raw
|
||||
cfg, changed = _migrate_config(raw)
|
||||
if changed:
|
||||
_save_config(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
@@ -138,14 +157,119 @@ def _save_config(cfg: dict[str, Any]) -> None:
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend CRUD
|
||||
|
||||
|
||||
def _get_backends() -> dict[str, Any]:
|
||||
"""Return the backends dict from config, creating builtin webui if needed."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
webui = backends.get("webui")
|
||||
if not webui or not webui.get("_migrated"):
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
cfg["backends"] = backends
|
||||
_save_config(cfg)
|
||||
return backends
|
||||
|
||||
|
||||
def _validate_paths(paths: dict) -> None:
|
||||
"""Validate each path has backend with host, port, proto."""
|
||||
for path_str, path_cfg in paths.items():
|
||||
backend = path_cfg.get("backend")
|
||||
if not backend:
|
||||
raise ValueError(f"path {path_str!r} missing 'backend'")
|
||||
if not isinstance(backend, dict):
|
||||
raise ValueError(f"path {path_str!r} 'backend' must be a dict")
|
||||
if not backend.get("host"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'host'")
|
||||
if not backend.get("port"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'port'")
|
||||
if not backend.get("proto"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'proto'")
|
||||
|
||||
|
||||
def _add_backend(
|
||||
name: str, label: str, paths: dict, auth: dict | None = None, builtin: bool = False
|
||||
) -> None:
|
||||
"""Add a backend. Validate name uniqueness and path schema."""
|
||||
_validate_paths(paths)
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name in backends:
|
||||
raise ValueError(f"Backend {name!r} already exists")
|
||||
entry: dict[str, Any] = {"label": label, "paths": paths}
|
||||
if builtin:
|
||||
entry["builtin"] = True
|
||||
if auth is not None:
|
||||
if "htpasswd" in auth:
|
||||
htpasswd_path = Path(auth["htpasswd"])
|
||||
if not htpasswd_path.is_absolute():
|
||||
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
|
||||
entry["auth"] = auth
|
||||
backends[name] = entry
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
def _update_backend(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
paths: dict | None = None,
|
||||
auth: dict | None | bool = None,
|
||||
) -> None:
|
||||
"""Update a backend. Cannot edit builtin backends. auth=False removes auth."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot modify builtin backend")
|
||||
entry = backends[name]
|
||||
if label is not None:
|
||||
entry["label"] = label
|
||||
if paths is not None:
|
||||
_validate_paths(paths)
|
||||
entry["paths"] = paths
|
||||
if auth is False or auth is None:
|
||||
entry.pop("auth", None)
|
||||
elif isinstance(auth, dict):
|
||||
if "htpasswd" in auth:
|
||||
htpasswd_path = Path(auth["htpasswd"])
|
||||
if not htpasswd_path.is_absolute():
|
||||
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
|
||||
entry["auth"] = auth
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
def _remove_backend(name: str) -> None:
|
||||
"""Remove a non-builtin backend. Raise ConflictError if domains reference it."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot remove builtin backend")
|
||||
for dom_name, dom in cfg.get("domains", {}).items():
|
||||
if dom.get("backend") == name:
|
||||
raise ConflictError(
|
||||
f"Backend {name!r} is referenced by domain {dom_name!r}"
|
||||
)
|
||||
del backends[name]
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site generation
|
||||
|
||||
|
||||
def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any]) -> str:
|
||||
"""Render an nginx server block config from a domain entry via Jinja."""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||
paths = domain_cfg.get("paths", {})
|
||||
paths = _ngx_resolve_paths(domain_cfg, backends)
|
||||
has_management = any(p.get("is_management") for p in paths.values())
|
||||
# Resolve custom cert paths for cert=="file"
|
||||
cert_cfg = domain_cfg.get("cert")
|
||||
if isinstance(cert_cfg, dict):
|
||||
cert_path = cert_cfg.get("cert_path", "")
|
||||
@@ -160,7 +284,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
cert=domain_cfg.get("cert"),
|
||||
cert_path=cert_path,
|
||||
cert_key_path=cert_key_path,
|
||||
domain_auth=domain_cfg.get("auth"),
|
||||
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
|
||||
has_management=has_management,
|
||||
acme_cert_dir=acme_cert_dir,
|
||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||
@@ -184,11 +308,11 @@ def _write_include_file() -> None:
|
||||
"""Write the system include file that references all per-site configs."""
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
tmp = Path("/run/vacuum-wall/include.tmp")
|
||||
tmp.parent.mkdir(exist_ok=True)
|
||||
tmp.write_text(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
|
||||
run(["cp", "--", str(tmp), str(INCLUDE_FILE)], sudo=True)
|
||||
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@@ -202,11 +326,11 @@ def _write_ssl_snippet() -> None:
|
||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||
content = tmpl.render(ssl=ssl_cfg)
|
||||
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
tmp = Path("/run/vacuum-wall/ssl-snippet.tmp")
|
||||
tmp.parent.mkdir(exist_ok=True)
|
||||
tmp.write_text(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
|
||||
run(["cp", "--", str(tmp), str(SSL_SNIPPET)], sudo=True)
|
||||
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@@ -234,11 +358,12 @@ def _write_all_sites() -> None:
|
||||
"""Regenerate all site configs and ACME challenge site."""
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = _get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||
written: set[str] = set()
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
dom_copy = dict(dom, domain=name)
|
||||
conf = _generate_server_conf(dom_copy)
|
||||
conf = _generate_server_conf(dom_copy, backends)
|
||||
_write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
|
||||
@@ -260,6 +385,10 @@ def _write_all_sites() -> None:
|
||||
os.replace(tmp, site)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt via passlib."""
|
||||
from passlib.hash import sha256_crypt
|
||||
@@ -327,8 +456,6 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/config — deep-merge partial updates into current config."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
@@ -347,10 +474,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(POST_NGINX_DOMAINS_ADD)
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
|
||||
|
||||
Accepts either legacy backend_* fields or a ``paths`` map.
|
||||
"""
|
||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -360,54 +484,24 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
|
||||
paths = body.get("paths")
|
||||
backend_name = body.get("backend", "").strip()
|
||||
if not backend_name:
|
||||
raise ValueError("'backend' is required")
|
||||
if backend_name not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {backend_name!r} not found")
|
||||
|
||||
cert = body.get("cert")
|
||||
force_ssl = body.get("force_ssl", True)
|
||||
|
||||
if paths is not None:
|
||||
entry: dict[str, Any] = {
|
||||
"paths": paths,
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
else:
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not backend_host:
|
||||
raise ValueError("'backend_host' is required")
|
||||
if backend_port is None:
|
||||
raise ValueError("'backend_port' is required")
|
||||
entry = {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"headers": extra_headers or {},
|
||||
}
|
||||
},
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
entry: dict[str, Any] = {
|
||||
"backend": backend_name,
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
|
||||
# Handle auth credentials
|
||||
auth_user = body.get("auth_user", "").strip()
|
||||
auth_pass = body.get("auth_pass", "")
|
||||
if auth_user and auth_pass:
|
||||
_write_htpasswd(auth_user, auth_pass)
|
||||
auth = {
|
||||
"user": auth_user,
|
||||
"htpasswd": str(HTPASSWD_FILE),
|
||||
}
|
||||
root_path = entry.get("paths", {}).get("/")
|
||||
if root_path:
|
||||
root_path["auth"] = auth
|
||||
auth = body.get("auth")
|
||||
if auth is not None:
|
||||
entry["auth"] = auth
|
||||
|
||||
cfg["domains"][domain] = entry
|
||||
@@ -449,48 +543,107 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise NotFoundError(f"Domain {domain!r} not configured")
|
||||
entry = cfg["domains"][domain]
|
||||
|
||||
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
|
||||
path_to_remove = body.get("path")
|
||||
if (
|
||||
path_to_remove is not None
|
||||
and "paths" not in body
|
||||
and "backend" not in body
|
||||
and "headers" not in body
|
||||
):
|
||||
paths = entry.get("paths", {})
|
||||
if path_to_remove in paths:
|
||||
del paths[path_to_remove]
|
||||
if not paths:
|
||||
entry.pop("paths", None)
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain, "path_removed": path_to_remove}
|
||||
new_backend = body.get("backend")
|
||||
if new_backend:
|
||||
new_backend = new_backend.strip()
|
||||
if new_backend not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {new_backend!r} not found")
|
||||
entry["backend"] = new_backend
|
||||
|
||||
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
|
||||
|
||||
if "paths" in updates:
|
||||
entry["paths"] = updates["paths"]
|
||||
else:
|
||||
paths = entry.setdefault("paths", {})
|
||||
if "backend" in updates:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["backend"] = updates["backend"]
|
||||
if "headers" in updates:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["headers"] = updates["headers"]
|
||||
|
||||
for key, val in updates.items():
|
||||
if key in ("backend", "headers", "paths"):
|
||||
continue
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
if "cert" in body:
|
||||
if body["cert"] is None:
|
||||
entry.pop("cert", None)
|
||||
else:
|
||||
entry[key] = val
|
||||
entry["cert"] = body["cert"]
|
||||
if "force_ssl" in body:
|
||||
entry["force_ssl"] = body["force_ssl"]
|
||||
if "auth" in body:
|
||||
if body["auth"] is None:
|
||||
entry.pop("auth", None)
|
||||
else:
|
||||
entry["auth"] = body["auth"]
|
||||
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register(GET_NGINX_BACKENDS)
|
||||
def get_backends(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /nginx/backends — return backends with secrets stripped."""
|
||||
backends = _get_backends()
|
||||
result: dict[str, Any] = {}
|
||||
for name, be in backends.items():
|
||||
entry = deepcopy(be)
|
||||
entry.pop("_migrated", None)
|
||||
if "auth" in entry:
|
||||
entry["has_auth"] = entry["auth"] is not None
|
||||
del entry["auth"]
|
||||
else:
|
||||
entry["has_auth"] = False
|
||||
result[name] = entry
|
||||
return result
|
||||
|
||||
|
||||
@registry.register(PATCH_NGINX_BACKENDS)
|
||||
def patch_backends(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/backends — deep-merge partial updates into a backend entry."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot modify builtin backend")
|
||||
update_data = {k: v for k, v in body.items() if k != "name"}
|
||||
if "auth" in update_data and (
|
||||
update_data["auth"] is False or update_data["auth"] is None
|
||||
):
|
||||
backends[name].pop("auth", None)
|
||||
update_data.pop("auth")
|
||||
backends[name] = deep_merge(backends[name], update_data)
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(POST_NGINX_BACKENDS_ADD)
|
||||
def add_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/backends/add — add a new backend."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
label = body.get("label", "").strip()
|
||||
paths = body.get("paths")
|
||||
auth = body.get("auth")
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
if not label:
|
||||
raise ValueError("'label' is required")
|
||||
if not paths:
|
||||
raise ValueError("'paths' is required")
|
||||
_add_backend(name, label, paths, auth=auth if auth else None)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(DELETE_NGINX_BACKENDS_REMOVE)
|
||||
def remove_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /nginx/backends/remove — remove a non-builtin backend."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
_remove_backend(name)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(POST_NGINX_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/apply — render all configs, test, and reload nginx."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user