feat(profiles): package waker/sleeper + lifecycle policy, v0.8.0 (ref #896)

Wires the scale-to-zero services into debian/ packaging: secubox-wakectl
entry point (/usr/sbin), templates/waking.html now ships, secubox-waker
and secubox-sleeper systemd units registered via dh_installsystemd
--name=, enabled/restarted (try-restart, preserving runtime state) in
postinst alongside secubox-profiles.service. postinst now also runs
secubox-wakectl waf-sync/health-sync so sbxwaf and the health monitor
have their lists from first install (nginx-sync stays unwired per the
2026-07-20 pivot).

Hardens secubox-sleeper.service with ProtectSystem=strict and an
explicit ReadWritePaths covering the actuator's real write set, traced
through api/actuate.py/snapshot.py/audit.py: /run/secubox,
/var/lib/secubox/profiles/rollback, /var/log/secubox, /data/lxc, and
/etc/secubox/waf (haproxy-routes.json, written on a routed module's
STOP — missed by a naive reading of the write set).

Documents the lifecycle/wake_class policy, the waker/sleeper mechanism
and a pilot procedure in README.md, wiki/Architecture.md and
.claude/MODULE-COMPLIANCE.md. Bumps changelog to 0.8.0.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 17:06:47 +02:00
parent dc08a8c139
commit d67e2c03b7
10 changed files with 256 additions and 18 deletions

View File

@ -91,6 +91,24 @@ packages/secubox-<module>/
└── README.md # Module documentation
```
### Lifecycle Policy — scale-to-zero (ref #896)
Every module known to `secubox-profiles` (its manifest under
`/etc/secubox/modules.d/<id>.toml`) declares a `lifecycle`: `always-on`
(never stopped — forced for any `protected` module regardless of what it
declares), `eager` (starts at boot, may be idled), `on-demand` (off by
default, woken on first access), or `manual` (operator-only, via the
`/profiles/` panel). An optional `wake_class` (`normal`/`urgent`) tunes the
idle threshold and the wake-splash budget. This is a **module-wide**
guideline, not a `secubox-profiles`-internal detail: a module choosing
`on-demand`/`eager` is opting into being stopped by `secubox-sleeper.service`
and woken via `secubox-waker.service` (fronted by `sbxwaf`, which proxies a
request for a routeless on-demand vhost to the waker instead of returning
421). See `packages/secubox-profiles/README.md` (§ Scale-to-zero) and
`wiki/Architecture.md` (§ Scale-to-zero) for the full policy table, the
wake/sleep mechanism, and the pilot procedure for switching a module to
`on-demand`.
### Web UI Requirements (Pattern 12)
All module frontends MUST include:

View File

@ -1,6 +1,8 @@
# secubox-profiles
Inventaire et profils des modules SecuBox. **Phase 1 : lecture seule.**
**0.4.0+ : actuation (apply/rollback). 0.8.0 (ref #896) : scale-to-zero
(lifecycle/wake_class, waker, sleeper).**
## Commandes
@ -68,6 +70,11 @@ toutes les routes (`Depends(require_jwt)`).
| GET | `/api/v1/profiles/diff?profile=<nom>` | plan de changements (**n'applique rien**) — 404 si profil inconnu, 409 si `ProtectedViolation` |
| POST | `/api/v1/profiles/profiles/{name}/members` | `{"id","on"}` — ajoute/retire un module de la liste `on` d'un profil (404 id/profil inconnu) |
| POST | `/api/v1/profiles/pins` | `{"id","pin":"on"\|"off"\|null}` — pose/lève un pin ; **409 et rien d'écrit** si `pin="off"` sur un module protégé |
| POST | `/api/v1/profiles/active` | `{"name"}` — change le profil actif (fichier `active`, pas d'actuation) |
| POST | `/api/v1/profiles/apply` | applique le profil actif (webui→ctl : sudo `secubox-profilectl apply --yes --json`) — snapshot 4R + audit, rollback auto si échec |
| POST | `/api/v1/profiles/rollback` | recharge le dernier snapshot 4R (webui→ctl : `secubox-profilectl rollback --yes --json`) |
| POST | `/api/v1/profiles/wake` | `{"id"}` — réveille manuellement UN module on-demand (webui→ctl, **synchrone** : `secubox-wakectl wake <id> --json`) |
| POST | `/api/v1/profiles/sleep` | `{"id"}` — endort manuellement UN module sleepable (webui→ctl : `secubox-profilectl apply --only <id> --yes --json`) |
Écritures limitées à `/etc/secubox/profiles/<nom>.toml` et `pins.toml`
(atomique : fichier temporaire + `os.replace`) — c'est ce qui rend Phase 1
@ -82,19 +89,88 @@ Panel webui : `/profiles/` (hybrid-dark, cf. `.claude/WEBUI-PANEL-GUIDELINES.md`
## Manifeste
id = "peertube"
category = "media" # media|security|network|infra|dev|mesh
runtime = "lxc" # native|lxc
exposure = "public" # public|lan|internal
units = ["secubox-peertube.service"]
lxc = "peertube"
portal = { domain = "peertube.gk2.secubox.in" }
priority = 40 # 0-100
protected = false # true = jamais éteignable
needs = ["auth"]
id = "peertube"
category = "media" # media|security|network|infra|dev|mesh
runtime = "lxc" # native|lxc
exposure = "public" # public|lan|internal
units = ["secubox-peertube.service"]
lxc = "peertube"
portal = { domain = "peertube.gk2.secubox.in" }
priority = 40 # 0-100
protected = false # true = jamais éteignable
needs = ["auth"]
lifecycle = "on-demand" # always-on|eager|on-demand|manual (défaut: eager)
wake_class = "normal" # normal|urgent (défaut: normal)
Un manifeste corrigé à la main fait autorité : `scan` ne l'écrase pas sans `--force`.
## Scale-to-zero — lifecycle, waker, sleeper (ref #896)
Chaque module déclare un `lifecycle` dans son manifeste
(`/etc/secubox/modules.d/<id>.toml`) :
| `lifecycle` | Démarre au boot ? | Rendormi si idle ? | Réveillé sur accès ? |
|---|---|---|---|
| `always-on` | oui, toujours | jamais | — (jamais éteint) |
| `eager` | oui | oui (si idle et sans réponse `/idle`) | oui (déjà up en général) |
| `on-demand` | non | oui | oui — `sbxwaf` proxy vers le waker |
| `manual` | non | non | non — opérateur uniquement (`/wake` panel) |
Un module `protected = true` est **toujours** `always-on`, quoi que déclare
son `lifecycle` (`effective_lifecycle`, `api/lifecycle.py`) — le cœur ne dort
jamais. `wake_class = "urgent"` multiplie le seuil d'inactivité avant sommeil
(×4) et réduit le budget de réveil affiché (15s au lieu de 45s) — pour les
modules qu'on préfère garder chauds plus longtemps mais réveiller vite s'ils
dorment quand même.
**Le sommeil (`secubox-sleeper.service`, daemon root, tick 30s)** — construit
un plan STOP pour chaque module `eager`/`on-demand` observé up, idle depuis
`idle_threshold()`, sans connexion active, et sans verrou de réveil en cours
(`/run/secubox/waker-active.json`) — et le confie à l'actionneur 0.7.0
(`apply_plan` : snapshot 4R, audit, timeout dérivé, rollback si échec). Ne
dort **jamais** sur un signal indéterminé (fichier de signaux absent/vide,
sonde `/idle` muette) — l'incertitude protège toujours l'état "up".
**Le réveil (`secubox-waker.service`, `User=secubox`, socket
`/run/secubox/waker.sock`)** — `sbxwaf` (Go, `packages/secubox-toolbox-ng`)
proxy une requête vers un vhost on-demand sans route active vers
`/_wake/<vhost>` au lieu de répondre 421. Le waker résout vhost→module, pose
un verrou par module (une requête déclenche le réveil, N requêtes
concurrentes attendent le même réveil), tire `sudo→systemd-run→
secubox-wakectl wake <id>` (fire-and-forget, webui→ctl — le waker ne pilote
jamais systemd/LXC en direct) et sert le splash `templates/waking.html`
(503 + `Retry-After`, palette hybrid-dark, auto-refresh) le temps du réveil.
**Config générée pour les consommateurs** (`secubox-wakectl waf-sync` /
`health-sync`, exécutés au postinst et re-exécutables à la main après un
`scan`) :
| Fichier | Consommateur | Contenu |
|---|---|---|
| `/etc/secubox/waf/on-demand-vhosts.json` | `sbxwaf` | domaines portail des modules `eager`/`on-demand` routés — décide "proxy vers le waker" vs 421 |
| `/etc/secubox/health/sleepable-modules.json` | `secubox-hub` (moniteur santé) | ids des modules sleepables — distingue "volontairement endormi" de "en panne" |
`api/nginxgen.py`/`nginx-sync` (Tâche 7) restent dans l'arbre mais **ne sont
plus câblés** — PIVOT (2026-07-20) : le déclencheur de réveil est `sbxwaf`,
pas un `location @waker` nginx.
**Procédure pilote** (un module `on-demand` à la fois, avant généralisation) :
1. Éditer `/etc/secubox/modules.d/<id>.toml` : `lifecycle = "on-demand"`
(et `wake_class` si le module doit rester chaud plus longtemps).
2. `secubox-wakectl waf-sync && secubox-wakectl health-sync` (ou attendre le
prochain postinst — mais après une simple édition manuelle du manifeste,
régénérer à la main pour que `sbxwaf` voie le nouveau vhost tout de suite).
3. `secubox-profilectl apply --only <id> --yes --json` pour l'endormir une
première fois (ou attendre le prochain tick idle du sleeper).
4. Visiter le vhost à froid : la première requête doit voir le splash 503
puis, après `wake_budget()` secondes, le service réel.
5. Surveiller `/var/log/secubox/audit.log` (entrées `wake-on-access` /
`idle-sleep`) et `journalctl -u secubox-waker -u secubox-sleeper`.
6. `lifecycle = "manual"` si le pilote révèle un réveil trop lent/fragile
pour un accès non annoncé — le module reste éteignable/allumable
seulement depuis le panel.
## Tests
python3 -m pytest packages/secubox-profiles/tests -q

View File

@ -1,3 +1,36 @@
secubox-profiles (0.8.0-1~bookworm1) bookworm; urgency=medium
* Scale-to-zero (ref #896): ships the lifecycle/wake_class manifest policy
(always-on/eager/on-demand/manual; normal/urgent — api/lifecycle.py),
the secubox-waker activator (wake-on-access splash + one-wake lock,
webui->ctl via systemd-run) and the secubox-sleeper idle daemon (idle
detection off sbxwaf per-vhost signals, apply_plan-driven STOP, never
sleeps on an indeterminate signal).
* sbxwaf-side wake trigger (packages/secubox-toolbox-ng, packages/secubox-waf-ng):
on-demand vhosts without an active route now proxy to the waker instead
of 421 — supersedes the nginx `@waker` path (Task 7, now unwired;
api/nginxgen.py stays in the tree, dead code, for a possible future
dual-front-end). New `secubox-wakectl waf-sync`/`health-sync` write
/etc/secubox/waf/on-demand-vhosts.json and
/etc/secubox/health/sleepable-modules.json — run at postinst so sbxwaf
and the health monitor have their lists from first install.
* webui: `/profiles/` panel gains manual sleep/wake buttons (POST
/wake, /sleep) alongside the existing lifecycle column in `/status`.
* secubox-watchdog awareness: a sleeping eager/on-demand module is a
DELIBERATE state, never auto-revived (watchdog_should_manage).
* Packaging: new `secubox-waker.service` + `secubox-sleeper.service` units
(enable --now + try-restart in postinst, preserving runtime state on
upgrade), new `secubox-wakectl` entry point (/usr/sbin), templates/
now ships (waking.html — the splash the waker serves). Sudoers grants
for the waker/sleeper's webui->ctl calls were already shipped in 0.7.x.
secubox-sleeper.service (the one unit that drives the actuator
in-process, unlike waker/profiles which delegate via systemd-run) now
runs ProtectSystem=strict with an explicit ReadWritePaths= covering the
actuator's real write set (snapshot 4R, audit log, LXC autostart config,
HAProxy portal routes) instead of an unconfined root process.
-- Gerald KERMA <devel@cybermind.fr> Mon, 20 Jul 2026 17:03:19 +0200
secubox-profiles (0.7.0-1~bookworm1) bookworm; urgency=medium
* apply: observed state (not command return code) arbitrates STOP/START —

View File

@ -1,6 +1,8 @@
api/*.py usr/lib/secubox/profiles/api/
templates/waking.html usr/lib/secubox/profiles/templates/
profiles/*.toml usr/share/secubox/profiles/
sbin/secubox-profilectl usr/sbin/
sbin/secubox-wakectl usr/sbin/
www/profiles/index.html usr/share/secubox/www/profiles/
menu.d/11-profiles.json usr/share/secubox/menu.d/
nginx/profiles.conf etc/nginx/secubox-routes.d/

View File

@ -25,12 +25,48 @@ case "$1" in
fi
done
# secubox-sleeper.service runs ProtectSystem=strict with an explicit
# ReadWritePaths= (see debian/secubox-sleeper.service) — every
# directory it lists MUST already exist before the unit's first
# start, or systemd's sandbox will not make it writable. Pre-create
# this package's own two leaves here (shared parents /var/lib/secubox
# and /var/log/secubox themselves come from secubox-core, 0755
# secubox:secubox — reasserted here matching the repo-wide
# shared-parent convention, never narrowed to 0750). /run/secubox
# comes from RuntimeDirectory=secubox; /data/lxc and /etc/secubox/waf
# are provisioned by lxc/secubox-waf-ng respectively, not here.
install -d -o secubox -g secubox -m 0755 /var/log/secubox
install -d -o root -g root -m 0750 /var/lib/secubox/profiles/rollback
# Own dedicated socket, own service — never served by the aggregator
# (the profile engine must not depend on something it may later
# restart). Restart (not just enable) so an upgrade picks up new code.
systemctl daemon-reload 2>/dev/null || true
systemctl enable secubox-profiles.service 2>/dev/null || true
systemctl restart secubox-profiles.service 2>/dev/null || true
# secubox-waker + secubox-sleeper (ref #896, scale-to-zero): `enable
# --now` is safe/idempotent (no-op if already enabled+running — the
# first-install case starts them), `try-restart` afterwards is a
# no-op if inactive and gracefully restarts (not a hard kill) an
# ALREADY-running instance so an upgrade picks up new code without
# forcibly disrupting an in-flight wake/sleep decision.
systemctl enable --now secubox-waker.service secubox-sleeper.service 2>/dev/null || true
systemctl try-restart secubox-waker.service secubox-sleeper.service 2>/dev/null || true
# Config sync for the sbxwaf-side wake trigger + the health monitor
# (ref #896 — supersedes the nginx-sync path of Task 7, PIVOT
# 2026-07-20): waf-sync writes the on-demand vhost list sbxwaf reads
# to decide "proxy to the waker instead of 421"; health-sync writes
# the sleepable-module-id set secubox-hub reads to distinguish
# "voluntarily asleep" from "down". Both are idempotent, best-effort
# (an empty modules.d before the first `scan` just yields an empty
# list — never a postinst failure).
[ -d /etc/secubox/waf ] || install -d -m 0750 /etc/secubox/waf
install -d -o root -g root -m 0755 /etc/secubox/health
/usr/sbin/secubox-wakectl waf-sync 2>/dev/null || true
/usr/sbin/secubox-wakectl health-sync 2>/dev/null || true
systemctl reload nginx 2>/dev/null || true
;;
esac

View File

@ -1,8 +1,8 @@
#!/bin/sh
set -e
if [ "$1" = "remove" ]; then
systemctl stop secubox-profiles.service || true
systemctl disable secubox-profiles.service || true
systemctl stop secubox-profiles.service secubox-waker.service secubox-sleeper.service || true
systemctl disable secubox-profiles.service secubox-waker.service secubox-sleeper.service || true
systemctl reload nginx 2>/dev/null || true
fi
#DEBHELPER#

View File

@ -8,3 +8,15 @@
override_dh_auto_install:
install -d debian/secubox-profiles/etc/sudoers.d
install -m 0440 sudoers.d/secubox-profiles debian/secubox-profiles/etc/sudoers.d/secubox-profiles
# Three distinct units ship in this package (own socket each — see
# debian/secubox-profiles.service, debian/secubox-waker.service,
# debian/secubox-sleeper.service): dh_installsystemd's default naming only
# auto-picks up debian/<pkgname>.service, so the other two need an explicit
# --name= each. Default enable+start (no --no-enable) matches how
# secubox-profiles.service already behaves (postinst also enables/restarts
# it manually — same, deliberately redundant-but-safe pattern kept below).
override_dh_installsystemd:
dh_installsystemd --name=secubox-profiles
dh_installsystemd --name=secubox-waker
dh_installsystemd --name=secubox-sleeper

View File

@ -22,19 +22,33 @@ UMask=0027
# hands each idle module straight to api.apply.apply_plan, the SAME code
# path as secubox-wakectl/secubox-profilectl apply/rollback — same trust
# level, just driven by a timer loop instead of an operator or a wake
# request. No ProtectSystem=strict either: apply_plan drives
# systemctl/lxc-start/lxc-stop AND edits /data/lxc/<container>/config
# (lxc.start.auto) and /etc/secubox/waf/haproxy-routes.json directly (see
# api/actuate.py) — sandboxing this service would just reproduce, one layer
# up, the exact EROFS lesson that motivated wrapping the OTHER two ctls in
# systemd-run in the first place.
# request.
NoNewPrivileges=true
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
RuntimeDirectoryMode=0775
# ProtectSystem=strict makes / read-only by default; the actuator's actual
# write set (traced through api/actuate.py, api/snapshot.py, api/audit.py —
# apply_plan's STOP path is the only one the sleeper ever drives) is listed
# explicitly below so this sandbox stays FUNCTIONAL, not just hardened:
# - /run/secubox — the socket dir + api.sleeper.WAKE_LOCK_FILE
# (read, but ReadWritePaths is directory-granular) and waker-active.json
# - /var/lib/secubox/profiles/rollback — snapshot.SNAP_DIR, the 4R chain
# shared with secubox-profilectl/secubox-wakectl (actuate_paths.py)
# - /var/log/secubox — audit.AUDIT_LOG (append-only audit.log)
# - /data/lxc — actuate.py::_lxc_autostart edits
# <lxcpath>/<container>/config (lxc.start.auto) before lxc-stop
# - /etc/secubox/waf — actuate.py::ROUTES_FILE
# (haproxy-routes.json): a STOP on a routed module calls
# _portal_remove, which rewrites this file (temp+rename in the SAME
# directory) — omitting it here would reproduce, one layer up, the
# exact EROFS lesson that motivated wrapping the OTHER two ctls in
# systemd-run in the first place (secubox-profilectl 0.6.1).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/run/secubox /var/lib/secubox/profiles/rollback /var/log/secubox /data/lxc /etc/secubox/waf
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# 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 :: secubox-wakectl (ref #896) — réveille un module on-demand
# (wake), ou (re)génère les listes de config consommées par sbxwaf/health
# (nginx-sync, waf-sync, health-sync). Même motif que secubox-profilectl :
# wrapper mince, toute la logique vit dans api/wake.py.
set -euo pipefail
readonly MODULE="profiles"
readonly VERSION="0.8.0"
cd /usr/lib/secubox/profiles
exec /usr/bin/python3 -m api.wake "$@"

View File

@ -173,6 +173,38 @@ Détails complets : [`docs/MODULE-GUIDELINES.md`](../docs/MODULE-GUIDELINES.md)
---
## Scale-to-zero — cycle de vie des modules (ref #896)
`secubox-profiles` porte, en plus de l'inventaire/apply déjà décrits, une
politique de cycle de vie par module (`lifecycle` dans son manifeste
`/etc/secubox/modules.d/<id>.toml`) : `always-on` (jamais éteint — un
`protected` l'est toujours, quoi qu'il déclare), `eager` (démarre au boot,
peut se rendormir), `on-demand` (éteint par défaut, réveillé sur accès) et
`manual` (opérateur uniquement).
Deux services dédiés pilotent le sommeil/réveil :
- **`secubox-sleeper.service`** (root, tick 30s) — endort tout module
`eager`/`on-demand` observé idle (aucune connexion active depuis le seuil
configuré, aucun réveil en cours) via le même actionneur `apply_plan` que
`secubox-profilectl apply` (snapshot 4R, audit, rollback si échec). Ne dort
jamais sur un signal indéterminé.
- **`secubox-waker.service`** (`User=secubox`, non privilégié) — reçoit du
trafic **sbxwaf** (le WAF Go, `packages/secubox-toolbox-ng`/`secubox-waf-ng`) :
un vhost on-demand sans route active est proxifié vers `/_wake/<vhost>` au
lieu d'un 421. Le waker sert un splash 503 (`templates/waking.html`) et
déclenche le réveil via `sudo→systemd-run→secubox-wakectl` (webui→ctl,
jamais en direct — même patron que les autres `ctl` confinés).
Ce mécanisme **remplace** le déclencheur nginx `@waker` envisagé initialement
(PIVOT 2026-07-20) : `sbxwaf` est le point d'entrée du trafic public sur ce
dépôt, c'est donc lui qui détecte l'accès à un vhost éteint, pas nginx.
Détails complets (table lifecycle, format manifeste, procédure pilote) :
[`packages/secubox-profiles/README.md`](../packages/secubox-profiles/README.md#scale-to-zero--lifecycle-waker-sleeper-ref-896).
---
## Contraintes ANSSI CSPN
Le projet vise la certification ANSSI CSPN à horizon 2027. Contraintes respectées :