feat(profiles): secubox-waker activator — splash + one-wake lock + rate cap (ref #896)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 12:23:05 +02:00
parent a88dd2beae
commit ec02088607
3 changed files with 163 additions and 0 deletions

View File

@ -0,0 +1,99 @@
# 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 secubox-waker : activator (réveil-sur-accès + splash)
CyberMind https://cybermind.fr
nginx route un vhost on-demand DOWN vers /_wake/<vhost>. Le waker : résout
vhost->module, prend un verrou par-module (UN seul réveil pour N requêtes
concurrentes), et soit signale « up » (nginx re-proxifie), soit tire le réveil
(sudo->secubox-wakectl) et sert le splash 503+Retry-After. Cap de fréquence de
réveil contre les tempêtes. Ne pilote JAMAIS le système en direct (webui->ctl).
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import time
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, Response
from .lifecycle import effective_lifecycle, wake_budget
from .manifest import Manifest, load_all
from .observe import is_on
from .observe import observe as _observe_one
DEFAULT_ROOT = Path("/etc/secubox")
_TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "waking.html"
_WAKE_MIN_INTERVAL_S = 20.0
_locks: dict[str, asyncio.Lock] = {}
_last_wake: dict[str, float] = {}
def _root() -> Path:
"""Racine de config — surchargeable en test via SECUBOX_PROFILES_ROOT
(même motif que api/web.py::_root)."""
return Path(os.environ.get("SECUBOX_PROFILES_ROOT", str(DEFAULT_ROOT)))
def _lock(mid: str) -> asyncio.Lock:
lk = _locks.get(mid)
if lk is None:
lk = asyncio.Lock()
_locks[mid] = lk
return lk
def _resolve(vhost: str, manifests: dict[str, Manifest]) -> str | None:
for mid, m in manifests.items():
if m.portal_domain == vhost:
return mid
return None
def _fire_wake(mid: str) -> None:
# webui->ctl : le réveil privilégié passe par le ctl root, jamais en process.
subprocess.Popen(["sudo", "-n", "/usr/sbin/secubox-wakectl", "wake", mid, "--json"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _splash(module: str, budget: float, retry: int) -> HTMLResponse:
html = _TEMPLATE.read_text(encoding="utf-8").format(
module=module, budget=int(budget), retry=retry)
return HTMLResponse(html, status_code=503,
headers={"Retry-After": str(retry), "Cache-Control": "no-store"})
def create_app() -> FastAPI:
app = FastAPI(title="SecuBox Waker", docs_url=None, redoc_url=None)
@app.get("/_wake/{vhost}")
async def wake_vhost(vhost: str) -> Response:
manifests = load_all(_root() / "modules.d")
mid = _resolve(vhost, manifests)
if mid is None:
return Response(status_code=404)
m = manifests[mid]
if effective_lifecycle(m) in ("always-on", "manual"):
return Response(status_code=502) # not a sleepable vhost -> real error
if is_on(_observe_one(m)):
return Response(status_code=200, headers={"X-Sbx-Wake": "up"})
async with _lock(mid):
now = time.monotonic()
if now - _last_wake.get(mid, 0.0) >= _WAKE_MIN_INTERVAL_S:
_last_wake[mid] = now
_fire_wake(mid)
budget = wake_budget(m)
return _splash(mid, budget, retry=max(3, int(budget / 5)))
return app
app = create_app()

View File

@ -0,0 +1,12 @@
<!-- 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. -->
<!doctype html><html lang="fr"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="{retry}">
<title>Démarrage — {module}</title>
<style>body{{background:#0a0a0f;color:#00d4ff;font-family:"Courier Prime",monospace;
text-align:center;padding-top:20vh}}.b{{color:#c9a84c}}</style></head>
<body><h1>⏳ {module} se réveille…</h1>
<p>Budget estimé : <span class="b">~{budget}s</span>. Cette page se recharge seule.</p>
</body></html>

View File

@ -0,0 +1,52 @@
# 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 secubox-waker (activator : splash + one-wake lock + budget)
CyberMind https://cybermind.fr
"""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_waker_up_backend_signals_proxy(monkeypatch, tmp_path):
import api.waker as waker
from api.observe import Actual
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
(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]\ndomain="demo.gk2"\n')
monkeypatch.setattr(waker, "_observe_one", lambda m: Actual(enabled=True, active=True))
c = TestClient(waker.app)
r = c.get("/_wake/demo.gk2")
assert r.status_code == 200 and r.headers.get("X-Sbx-Wake") == "up"
def test_waker_down_backend_serves_splash_and_fires_one_wake(monkeypatch, tmp_path):
import api.waker as waker
from api.observe import Actual
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
(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]\ndomain="demo.gk2"\n')
monkeypatch.setattr(waker, "_observe_one", lambda m: Actual(enabled=False, active=False))
fired = []
monkeypatch.setattr(waker, "_fire_wake", lambda mid: fired.append(mid))
c = TestClient(waker.app)
r = c.get("/_wake/demo.gk2")
assert r.status_code == 503
assert "Retry-After" in r.headers and r.headers["Cache-Control"] == "no-store"
assert fired == ["demo"] # exactly one wake fired
def test_waker_unknown_vhost_404(monkeypatch, tmp_path):
import api.waker as waker
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
(tmp_path / "modules.d").mkdir()
c = TestClient(waker.app)
assert c.get("/_wake/nope.gk2").status_code == 404