feat(profiles): waf-sync — on-demand-vhost list for sbxwaf wake trigger (ref #896)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 13:09:46 +02:00
parent 9d4da5bf67
commit 2008b9c29d
4 changed files with 114 additions and 0 deletions

View File

@ -0,0 +1,58 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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

View File

@ -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

View File

@ -0,0 +1,32 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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")) == []

View File

@ -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"]