From 2008b9c29d0ddf9727bfbac1aeb8087c3c5c4068 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 20 Jul 2026 13:09:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(profiles):=20waf-sync=20=E2=80=94=20on-dem?= =?UTF-8?q?and-vhost=20list=20for=20sbxwaf=20wake=20trigger=20(ref=20#896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Gerald KERMA --- packages/secubox-profiles/api/wafsync.py | 58 +++++++++++++++++++ packages/secubox-profiles/api/wake.py | 8 +++ .../secubox-profiles/tests/test_wafsync.py | 32 ++++++++++ packages/secubox-profiles/tests/test_wake.py | 16 +++++ 4 files changed, 114 insertions(+) create mode 100644 packages/secubox-profiles/api/wafsync.py create mode 100644 packages/secubox-profiles/tests/test_wafsync.py diff --git a/packages/secubox-profiles/api/wafsync.py b/packages/secubox-profiles/api/wafsync.py new file mode 100644 index 00000000..f3cfb1eb --- /dev/null +++ b/packages/secubox-profiles/api/wafsync.py @@ -0,0 +1,58 @@ +# 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 de la liste on-demand-vhosts pour sbxwaf +CyberMind — https://cybermind.fr + +sbxwaf (Go) lit /etc/secubox/waf/on-demand-vhosts.json : l'ensemble des vhosts +sleepables (portal_domain d'un module eager/on-demand). Pour une requête vers +un de ces vhosts sans route active, sbxwaf proxy vers le waker au lieu de +retourner 421. Remplace le nginx-sync de la Tâche 7 (pivot d'architecture : +le déclencheur de réveil est sbxwaf, pas nginx). +""" +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from .lifecycle import effective_lifecycle +from .manifest import Manifest + + +def ondemand_vhosts(manifests: dict[str, Manifest]) -> list[str]: + """Domaines portail (triés) des modules sleepables (eager/on-demand) et + routés (portal_domain non nul). always-on/manual/sans-portail : exclus.""" + return sorted( + m.portal_domain + for m in manifests.values() + if m.portal_domain is not None and effective_lifecycle(m) in ("eager", "on-demand") + ) + + +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 write_ondemand(*, manifests: dict[str, Manifest], out_path: Path) -> list[str]: + """Écrit atomiquement la liste JSON des vhosts on-demand vers `out_path`, + retourne la liste écrite.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + doms = ondemand_vhosts(manifests) + _write_atomic(out_path, json.dumps(doms)) + return doms diff --git a/packages/secubox-profiles/api/wake.py b/packages/secubox-profiles/api/wake.py index 4c34f03a..a6e9ea6d 100644 --- a/packages/secubox-profiles/api/wake.py +++ b/packages/secubox-profiles/api/wake.py @@ -91,6 +91,8 @@ def main(argv: list[str] | None = None) -> int: sp.add_argument("--json", action="store_true") sp2 = sub.add_parser("nginx-sync") sp2.add_argument("--out", default="/etc/nginx/secubox-waker.d") + sp3 = sub.add_parser("waf-sync") + sp3.add_argument("--out", default="/etc/secubox/waf/on-demand-vhosts.json") args = p.parse_args(argv) if args.cmd == "nginx-sync": from . import nginxgen @@ -98,6 +100,12 @@ def main(argv: list[str] | None = None) -> int: out_dir=Path(args.out)) print(f"nginx-sync: {len(doms)} vhost(s) — {', '.join(doms) or 'aucun'}") return 0 + if args.cmd == "waf-sync": + from . import wafsync + doms = wafsync.write_ondemand(manifests=load_all(Path(args.root) / "modules.d"), + out_path=Path(args.out)) + print(f"waf-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_wafsync.py b/packages/secubox-profiles/tests/test_wafsync.py new file mode 100644 index 00000000..5592e2cb --- /dev/null +++ b/packages/secubox-profiles/tests/test_wafsync.py @@ -0,0 +1,32 @@ +# 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 liste on-demand-vhosts (sbxwaf wake trigger) +CyberMind — https://cybermind.fr +""" +from __future__ import annotations + + +def test_ondemand_vhosts_selects_sleepable_portals(): + from api.wafsync import ondemand_vhosts + from api.manifest import Manifest + def m(mid, lc, dom): + return Manifest(id=mid, category="infra", runtime="native", exposure="public", + units=(f"{mid}.service",), portal_domain=dom, lifecycle=lc) + ms = {"a": m("a","on-demand","a.gk2"), "b": m("b","always-on","b.gk2"), + "c": m("c","eager","c.gk2"), "d": m("d","on-demand",None)} + assert ondemand_vhosts(ms) == ["a.gk2", "c.gk2"] # sorted; b=always-on, d=no portal + +def test_write_ondemand_atomic(tmp_path): + from api.wafsync import write_ondemand + from api.manifest import Manifest + import json + ms = {"a": Manifest(id="a", category="infra", runtime="native", exposure="public", + units=("a.service",), portal_domain="a.gk2", lifecycle="on-demand")} + out = tmp_path / "on-demand-vhosts.json" + assert write_ondemand(manifests=ms, out_path=out) == ["a.gk2"] + assert json.loads(out.read_text()) == ["a.gk2"] + assert list(tmp_path.glob("*.tmp")) == [] diff --git a/packages/secubox-profiles/tests/test_wake.py b/packages/secubox-profiles/tests/test_wake.py index 0169d537..41167c36 100644 --- a/packages/secubox-profiles/tests/test_wake.py +++ b/packages/secubox-profiles/tests/test_wake.py @@ -109,3 +109,19 @@ def test_main_nginx_sync_writes_snippet_without_root(tmp_path): rc = w.main(["--root", str(tmp_path), "nginx-sync", "--out", str(out)]) assert rc == 0 assert (out / "demo.gk2.waker.conf").exists() + + +def test_main_waf_sync_writes_json_without_root(tmp_path): + # waf-sync est un aller-retour config->fichier, pas un pilotage + # systemd/LXC : il ne doit pas exiger root (comme nginx-sync). + import api.wake as w + import json + (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 / "on-demand-vhosts.json" + rc = w.main(["--root", str(tmp_path), "waf-sync", "--out", str(out)]) + assert rc == 0 + assert json.loads(out.read_text()) == ["demo.gk2"]