mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
feat(profiles): nginx @waker snippet generator + nginx-sync verb (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
39fa581e66
commit
8bf5152899
73
packages/secubox-profiles/api/nginxgen.py
Normal file
73
packages/secubox-profiles/api/nginxgen.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# 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 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 `<domain>.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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
37
packages/secubox-profiles/tests/test_nginxgen.py
Normal file
37
packages/secubox-profiles/tests/test_nginxgen.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 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 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user