diff --git a/packages/secubox-profiles/api/nginxgen.py b/packages/secubox-profiles/api/nginxgen.py new file mode 100644 index 00000000..bb4f9968 --- /dev/null +++ b/packages/secubox-profiles/api/nginxgen.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: profiles — génération des snippets nginx @waker (réveil-sur-accès) +CyberMind — https://cybermind.fr + +Un vhost sleepable routé (portal_domain) reçoit un error_page 502/504 -> @waker +(le waker sert le splash + tire le réveil). always-on/manual/sans-portal : rien +(un 502 reste un 502). Écrit atomiquement, purge les snippets orphelins. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from .lifecycle import effective_lifecycle +from .manifest import Manifest + +_SNIPPET = """# generated by secubox-wakectl nginx-sync — do not edit +error_page 502 504 = @waker; +location @waker {{ + internal; + proxy_pass http://unix:{sock}:/_wake/{domain}; + proxy_set_header Host $host; +}} +""" + + +def render_snippet(m: Manifest, *, waker_sock: str = "/run/secubox/waker.sock") -> str | None: + """Snippet nginx pour `m`, ou None si `m` n'est pas un vhost sleepable routé + (portal_domain absent, ou effective_lifecycle hors eager/on-demand).""" + if m.portal_domain is None or effective_lifecycle(m) not in ("eager", "on-demand"): + return None + return _SNIPPET.format(sock=waker_sock, domain=m.portal_domain) + + +def _write_atomic(path: Path, text: str) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + +def sync_snippets(*, manifests: dict[str, Manifest], out_dir: Path, + waker_sock: str = "/run/secubox/waker.sock") -> list[str]: + """Écrit `.waker.conf` pour chaque module sleepable routé, purge les + `*.waker.conf` orphelins (dont le domaine ne correspond plus à aucun module + voulu — jamais les autres fichiers du répertoire), retourne les domaines + écrits triés.""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + want: dict[str, str] = {} + for m in manifests.values(): + snip = render_snippet(m, waker_sock=waker_sock) + if snip is not None and m.portal_domain is not None: + want[m.portal_domain] = snip + for dom, snip in want.items(): + _write_atomic(out_dir / f"{dom}.waker.conf", snip) + for p in out_dir.glob("*.waker.conf"): + if p.name[: -len(".waker.conf")] not in want: + p.unlink() + return sorted(want) diff --git a/packages/secubox-profiles/api/wake.py b/packages/secubox-profiles/api/wake.py index 0e00a505..4c34f03a 100644 --- a/packages/secubox-profiles/api/wake.py +++ b/packages/secubox-profiles/api/wake.py @@ -89,7 +89,15 @@ def main(argv: list[str] | None = None) -> int: sp = sub.add_parser("wake") sp.add_argument("module") sp.add_argument("--json", action="store_true") + sp2 = sub.add_parser("nginx-sync") + sp2.add_argument("--out", default="/etc/nginx/secubox-waker.d") args = p.parse_args(argv) + if args.cmd == "nginx-sync": + from . import nginxgen + doms = nginxgen.sync_snippets(manifests=load_all(Path(args.root) / "modules.d"), + out_dir=Path(args.out)) + print(f"nginx-sync: {len(doms)} vhost(s) — {', '.join(doms) or 'aucun'}") + return 0 if not _running_as_root(): print("wake doit être lancé en root (il pilote systemd/LXC).", file=sys.stderr) return 1 diff --git a/packages/secubox-profiles/tests/test_nginxgen.py b/packages/secubox-profiles/tests/test_nginxgen.py new file mode 100644 index 00000000..ec19f69b --- /dev/null +++ b/packages/secubox-profiles/tests/test_nginxgen.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: profiles — tests génération snippets nginx @waker +CyberMind — https://cybermind.fr +""" +from __future__ import annotations + + +def test_render_only_for_sleepable_portal(tmp_path): + from api.nginxgen import render_snippet + from api.manifest import Manifest + on_demand = Manifest(id="d", category="infra", runtime="native", exposure="public", + units=("d.service",), portal_domain="d.gk2", lifecycle="on-demand") + always = Manifest(id="a", category="infra", runtime="native", exposure="public", + units=("a.service",), portal_domain="a.gk2", lifecycle="always-on") + noportal = Manifest(id="n", category="infra", runtime="native", exposure="lan", + units=("n.service",), lifecycle="on-demand") + assert render_snippet(on_demand) is not None and "@waker" in render_snippet(on_demand) + assert render_snippet(always) is None + assert render_snippet(noportal) is None + + +def test_sync_writes_and_prunes(tmp_path): + from api.nginxgen import sync_snippets + from api.manifest import Manifest + out = tmp_path / "snips"; out.mkdir() + (out / "stale.gk2.waker.conf").write_text("old") # should be pruned + manifests = {"d": Manifest(id="d", category="infra", runtime="native", exposure="public", + units=("d.service",), portal_domain="d.gk2", lifecycle="on-demand")} + written = sync_snippets(manifests=manifests, out_dir=out) + assert written == ["d.gk2"] + assert (out / "d.gk2.waker.conf").exists() + assert not (out / "stale.gk2.waker.conf").exists() diff --git a/packages/secubox-profiles/tests/test_wake.py b/packages/secubox-profiles/tests/test_wake.py index 8a1d17af..0169d537 100644 --- a/packages/secubox-profiles/tests/test_wake.py +++ b/packages/secubox-profiles/tests/test_wake.py @@ -94,3 +94,18 @@ def test_main_woken_exit_0(monkeypatch, tmp_path): monkeypatch.setattr(w, "_running_as_root", lambda: True) monkeypatch.setattr(w, "wake", lambda *a, **k: {"status": "woken", "module": "demo"}) assert w.main(["--root", str(tmp_path), "wake", "demo"]) == 0 + + +def test_main_nginx_sync_writes_snippet_without_root(tmp_path): + # nginx-sync est un aller-retour config->fichiers, pas un pilotage + # systemd/LXC : il ne doit pas exiger root (contrairement à `wake`). + import api.wake as w + (tmp_path / "modules.d").mkdir() + (tmp_path / "modules.d" / "demo.toml").write_text( + 'id="demo"\ncategory="infra"\nruntime="native"\nexposure="public"\n' + 'units=["demo.service"]\nlifecycle="on-demand"\n' + 'portal.domain="demo.gk2"\n') + out = tmp_path / "snips" + rc = w.main(["--root", str(tmp_path), "nginx-sync", "--out", str(out)]) + assert rc == 0 + assert (out / "demo.gk2.waker.conf").exists()