feat(profiles): panel lifecycle/wake_class/sleep-state + manual sleep/wake (ref #896)

Status payload now surfaces effective lifecycle, wake_class and a derived
sleep_state (up/asleep/n-a) + wake budget per module. Adds POST /wake and
POST /sleep, webui->ctl (JWT + _apply_lock + fixed sudo argv), refusing
unknown/non-sleepable modules locally before any sudo call. Two new bounded
sudoers grants: synchronous wakectl wake (distinct from the waker's
fire-and-forget grant) and profilectl apply --only. Panel gains a
sleep-state pill + manual Sleep/Wake buttons per module row.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 16:31:17 +02:00
parent 51b5c26140
commit 8e9ca2df12
5 changed files with 502 additions and 83 deletions

View File

@ -1,84 +1,192 @@
# Task 10 — Packaging report
# Task 10 Report: webui — lifecycle/wake_class/sleep-state + manual sleep/wake (secubox-profiles, ref #896)
## Status: DONE
**Status:** Done.
The task brief assumed a `debian/*.install` file; the package actually ships
files via `override_dh_auto_install` in `debian/rules`, so that recipe was
extended instead (per the corrected instructions), plus `debian/postinst`
and `debian/changelog`.
(Note: this file previously held an unrelated stale Task-10 report for a
different plan — a `secubox-metablogizer` packaging task — overwritten
here since it did not belong to this plan.)
## Part 1 — debian/rules
## What was implemented
Added, right after the existing `sbin/*` install line inside
`override_dh_auto_install`:
- `packages/secubox-profiles/api/web.py`:
- `_build_status_payload(manifests, actuals)` — each module row now also
carries `lifecycle` (the **effective** value from
`lifecycle.effective_lifecycle(m)`, so a protected module reads
`"always-on"` regardless of its declared manifest value — consistent
with how `protected` is already surfaced), `wake_class` (raw
`m.wake_class`), `sleep_state`, and `wake_budget_s`.
`sleep_state ∈ {"up","asleep","n/a"}`: `"n/a"` when
`effective_lifecycle(m)` is `always-on`/`manual`; otherwise `"up"` if
`observe.is_on(a)` else `"asleep"`. **`"waking"` is deliberately NOT
modeled** — the only candidate signal (`api/waker.py`'s
`waker-active.json`) is a best-effort anti-storm lock, not a reliable
"in-progress" probe; inventing a third state off it would be exactly
the fragile probe the task brief warned against. Documented inline.
`wake_budget_s` is `lifecycle.wake_budget(m)` for sleepable modules,
`None` for `n/a` ones.
- Refactored `_run_ctl_json(verb)` into a shared `_run_ctl_json_argv(argv)`
(same parse-report-first-then-rc logic, now argv-agnostic) plus three
thin builders: `_run_ctl_json(verb)` (apply/rollback, unchanged
behavior), `_run_wake_ctl(module)`, `_run_sleep_ctl(module)`.
- New `POST /api/v1/profiles/wake` and `POST /api/v1/profiles/sleep`
(`ModuleAction{module: str}` body): both JWT-gated
(`Depends(require_jwt)`), both refuse **locally** (structural refusal,
before any sudo call — same posture as the pin protected-off check)
via a shared `_sleepable_module_or_error(mod_dir, module)`: unknown
module → 404, non-sleepable (`always-on`/`manual`, incl.
protected-forced) → 409. Both then run under `_apply_lock` (same
process-local serialization as apply/rollback) and delegate to the
ctl, returning its JSON report as-is (refused/rolled_back statuses
surface in the 200 body, exactly like apply's `rolled_back` already
does — the caller/panel classifies the status, not the HTTP code).
```make
install -d debian/secubox-metablogizer/etc/sudoers.d
[ -f debian/secubox-publish-wizard.sudoers ] && install -m 0440 debian/secubox-publish-wizard.sudoers debian/secubox-metablogizer/etc/sudoers.d/secubox-publish-wizard || true
```
- `packages/secubox-profiles/sudoers.d/secubox-profiles` — **two new
grants** (visudo -c passes):
```
secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-wakectl wake *
secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-profilectl apply --only * --yes --json
```
Both were **required**, not deferrable:
- The existing `secubox-wakectl wake *` grant (from Task 6/waker) is
**fire-and-forget** (no `--wait --pipe`) — used by `api/waker.py`'s
`_fire_wake`, which never blocks a public request on the outcome. The
panel's manual Wake button needs the *synchronous* report to show the
operator what happened, which is a **different, additional** command
line (`--wait --pipe` inserted) that does not match the existing rule.
- `/sleep` reuses the existing `apply` actuator (no new privileged code
path) scoped to one module via `--only <id>`, but the only sudoers rule
for `apply` was the exact-string `apply --yes --json` (no `--only`) —
a genuinely new, bounded wildcard grant was needed.
Both wildcards are safe for the same reason already documented for the
pre-existing wake grant (quoted/extended in the file): sudo matches argv
via `execve`, never a shell, so glob metacharacters in a module id are
inert; and the downstream CLI (`secubox-wakectl`/`secubox-profilectl`)
only ever acts on ids it recognizes from `modules.d` — an unrecognized id
either gets `status=refused` (wake) or silently filters the plan to
nothing (sleep's `--only`), never arbitrary execution.
Tab-indented, matching the rest of the recipe. This installs the
Task-1-created `debian/secubox-publish-wizard.sudoers` to
`/etc/sudoers.d/secubox-publish-wizard` at mode 0440.
- `packages/secubox-profiles/www/profiles/index.html` — each module row
gained one more grid column: a `sleep_state` pill (🟢 up / 🌙 asleep /
the raw lifecycle label for n/a) plus, when sleepable, the one
applicable manual action button (💤 Sleep when up, ⚡ Wake when asleep,
titled with the `wake_budget_s` estimate; no button for n/a). Wired via
a `data-act="wake"|"sleep"` click handler mirroring the existing
pin-button pattern: POST to the new routes, refresh() on completion,
toast/errorToast based on the returned report's `status`/`failed`.
Sleep asks `confirm()` first (it stops a running module); Wake does not
(idempotent no-op if already up). New column hidden on the existing
mobile media query alongside meta/prio/rss/member (unchanged narrow
layout otherwise). Verified with `node --check` on the extracted
`<script>` body (no syntax errors).
`sbin/secubox-publishctl` itself was already covered by the pre-existing
`install -m 755 sbin/*` line — no duplication added.
## TDD: RED → GREEN
## Part 2 — debian/postinst
- Added tests to `tests/test_web.py` (status payload extension, wake/sleep
delegation-argv, 404, 409, JWT-gating) — ran first to confirm RED:
`python -m pytest tests/test_web.py -q -k "lifecycle_wake_class or wake_route or sleep_route or wake_and_sleep"`
**6 failed** (routes/fields didn't exist yet; the JWT test failed
because the routes 404'd before ever reaching `require_jwt`, i.e. it
wasn't a false-positive pass).
- Implemented the payload extension, the two routes, and the sudoers
grants.
- GREEN: same command → **8 passed**.
Inserted before the final `exit 0` (after the existing
daemon-reload/enable/start block):
```bash
# Publisher wizard: the sbxwaf/haproxy/cert steps run through the
# secubox-publishctl root helper, authorized by this sudoers drop-in.
chmod 0755 /usr/sbin/secubox-publishctl 2>/dev/null || true
if [ -f /etc/sudoers.d/secubox-publish-wizard ]; then
chmod 0440 /etc/sudoers.d/secubox-publish-wizard
visudo -cf /etc/sudoers.d/secubox-publish-wizard >/dev/null 2>&1 || rm -f /etc/sudoers.d/secubox-publish-wizard
fi
```
This re-asserts perms post-dpkg-unpack and self-heals (removes) a malformed
sudoers drop-in rather than leaving a broken file that would break sudo
host-wide. `postinst` is a plain `set -e` script (no `case "$1" in
configure)` guard) — the block runs unconditionally on install/upgrade,
consistent with the rest of the file.
## Part 3 — debian/changelog
Prepended entry `secubox-metablogizer (1.3.0-1~bookworm1) bookworm;
urgency=medium`, dated `Sat, 11 Jul 2026 13:00:00 +0200`, author
`Gerald KERMA <devel@cybermind.fr>`, bullet text as specified in the task
brief (publisher wizard flow, secubox-publishctl + sudoers, cert handling,
.sbxsite backup, retiring the mitmproxy-LXC route sync, new
`/publish/{wizard,route,export,import}` endpoints). Formatting (2-space
indent before `*`, blank line before ` -- ` sign-off with two spaces before
the name) matches the existing top entry.
## Verify output
## Test results
```
== bash -n postinst ==
OK
== changelog version ==
1.3.0-1~bookworm1
== tab-indented sudoers lines in rules ==
install -d debian/secubox-metablogizer/etc/sudoers.d
[ -f debian/secubox-publish-wizard.sudoers ] && install -m 0440 debian/secubox-publish-wizard.sudoers debian/secubox-metablogizer/etc/sudoers.d/secubox-publish-wizard || true
cd packages/secubox-profiles && python -m pytest tests/test_web.py -q
```
**44 passed** (8 new, 36 pre-existing untouched).
All three checks pass as specified.
Full suite:
```
cd packages/secubox-profiles && python -m pytest tests/ -q
```
**236 passed**, 3 warnings (pre-existing FastAPI `on_event` deprecation
warnings, unrelated).
## mypy vs. baseline
Non-strict (`mypy api/`): **3 errors** before and after — identical
(`api/scan.py:80`, `api/snapshot.py:55`, `api/web.py:49` import-not-found
for `secubox_core.auth`, all pre-existing, none touched).
`--strict` (matches Task 7's comparison method): baseline **93 errors in
12 files** (checked via `git stash` of this task's 4 changed files) →
after this task **102 errors in 12 files** (+9). All 9 new errors are
instances of the **same two categories already pervasive throughout
`web.py`** before this task (every existing nested route handler in the
file is already untyped the same way): `no-untyped-def` (missing
return/param annotation — the 3 new nested route-scoped helpers
`_sleepable_module_or_error`, `wake_module`, `sleep_module` follow the
exact same unannotated-`_claims=Depends(...)` idiom as every pre-existing
handler like `set_pin`/`apply_active`) and `type-arg` (bare `dict` return
annotation — the 3 new top-level ctl helpers `_run_ctl_json_argv`,
`_run_wake_ctl`, `_run_sleep_ctl` return bare `dict`, matching ~15
pre-existing bare-`dict` returns elsewhere in the same file). No new
error *category* was introduced.
## Delegation argv (webui→ctl)
- `POST /wake {module}`
`sudo -n /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-wakectl wake <module> --json`
- `POST /sleep {module}`
`sudo -n /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-profilectl apply --only <module> --yes --json`
Both under `_apply_lock` (process-local asyncio.Lock, same one apply/
rollback/active already share) and `Depends(require_jwt)`.
## What T12 must wire
- Nothing new beyond what earlier tasks already deferred to T12 (nginx
`@waker` include wiring, WAF route file, etc.) — **no new sudoers grant
is deferred here**; both grants this task needed were shipped and
`visudo -c`'d in this task, since the brief called them out as a real
requirement, not deferrable.
- The panel's new column is additive CSS/markup only; no server-side
wiring is needed beyond the two routes added here.
## Self-review
- Confirmed `test_web_module_has_no_actuation_helper` (greps `web.py` for
`systemctl start/stop/enable/disable`, `lxc-start/lxc-stop`) still
passes — the new code only ever shells out through `_ctl_run` to
`sudo`/`secubox-wakectl`/`secubox-profilectl`, never touches
systemd/LXC directly.
- Confirmed `/wake` and `/sleep` reject unknown/non-sleepable modules
**before** calling `_ctl_run` at all (asserted via a `called=[]` list in
the 404/409 tests), matching the "never actuate for a refusal" posture
already used for the pin protected-off check.
- Confirmed the two new sudoers lines with `visudo -cf
sudoers.d/secubox-profiles` (parses clean) — chose to ship them now
rather than defer, per the brief's explicit instruction that a
genuinely-needed grant is not deferrable.
- Extracted the panel's `<script>` body and ran `node --check` on it to
catch any JS syntax errors from the new markup-building code before
committing.
- Verified the `sleep_state`/`wake_budget_s` semantics against a 3rd,
on-demand/urgent fixture module (`podcast`, deliberately left
unobserved by the test's `_observe_all` stub) to prove the
`is_on()`-driven None→False→"asleep" coalescing, not just the two
pre-existing fixture modules (`lyrion` eager/observed-on, `auth`
protected/always-on).
## Concerns
- Did not run an actual `dpkg-buildpackage` in this pass (out of scope per
the task instructions, which only ask for the three listed verify
commands); the `.install`-vs-`rules` distinction was the main risk and is
now resolved by following the actual `rules` mechanism.
- Pre-existing unstaged changes to `.superpowers/sdd/task-2-report.md`,
`task-4-report.md`, `task-8-report.md` were present in the worktree before
this task started (not touched, not committed as part of this task).
- `visudo` must be present on the target system for the postinst self-heal
check to run; it ships in the base `sudo` package, which is a safe
assumption on Debian bookworm SecuBox images.
- `/sleep`'s reuse of `apply --only <module>` is correct **only** because
`plan_changes` already computes a STOP for any currently-up sleepable
module that is not desired ON (absent from the active profile / not
pinned on) — this is the common case for genuinely on-demand modules,
which typically aren't listed as profile members. If an operator adds a
sleepable module to the active profile's `on` list (making it
desired-ON), a manual Sleep click becomes a **safe no-op**
(`changed: []`, no error) rather than a forced stop, because `apply`
will always want to re-converge it back on. This is intentional
(avoids fighting the declared desired state) but may surprise an
operator who expects Sleep to always work — flagging in case a
dedicated "force sleep" actuator (bypassing desired-state) is wanted
later.
- `sleep_state`'s `"waking"` value from the brief's enum is not emitted by
this implementation (only `"up"`/`"asleep"`/`"n/a"`) — deliberately, per
the brief's own permission to omit it absent a reliable signal. Noted
in code and here in case a future task adds a proper in-progress probe.

View File

@ -53,8 +53,9 @@ except ImportError: # pragma: no cover - dev sans secubox_core installé
from . import cli as _cli
from .diff import ProtectedViolation, plan_changes
from .lifecycle import effective_lifecycle, is_sleepable, wake_budget
from .manifest import ManifestError, load_all
from .observe import Actual, load_routes
from .observe import Actual, is_on, load_routes
from .scan import _toml_list, _toml_str
from .state import OFF, ON, Profile, StateError, load_pins, load_profile
@ -140,10 +141,30 @@ def _build_status_payload(manifests: dict, actuals: dict) -> dict:
for mid, m in sorted(manifests.items()):
a = actuals.get(mid, Actual())
st = _tri_state(a)
# sleep_state ("up"/"asleep"/"n/a") is a SEPARATE axis from the
# observation tri-state `st` above: `st` can be "unknown" (probe
# failed) while sleep_state still has an actionable answer, because
# it reuses is_on()'s None -> False coalescing (same posture as
# observe.is_on's own docstring: correct for a DECISION, wrong for an
# inventory view — which is exactly what this field is for, a
# manual-action affordance, not a status display of doubt).
# "waking" (mid-wake-up) is deliberately NOT modeled here: the only
# candidate signal (waker-active.json, api/waker.py) is a best-effort
# anti-storm lock, not a reliable "in progress" probe — surfacing it
# as a third state would be a fragile guess. Deferred.
eff = effective_lifecycle(m)
if eff in ("always-on", "manual"):
sleep_state = "n/a"
budget = None
else:
sleep_state = "up" if is_on(a) else "asleep"
budget = wake_budget(m)
modules.append({
"id": mid, "category": m.category, "runtime": m.runtime,
"exposure": m.exposure, "priority": m.priority,
"protected": m.protected, "on": st, "rss_kb": a.rss_kb,
"lifecycle": eff, "wake_class": m.wake_class,
"sleep_state": sleep_state, "wake_budget_s": budget,
})
totals["count"] += 1
totals[st] += 1
@ -289,6 +310,10 @@ class ApplyRequest(BaseModel):
profile: str
class ModuleAction(BaseModel):
module: str
# Sérialise TOUTE écriture du fichier `active` partagé + toute actuation
# (apply/rollback) en un seul verrou async, dans ce process. Corrige un TOCTOU
# structurel : entre la préview d'un opérateur (GET /diff) et sa confirmation
@ -309,9 +334,10 @@ def _ctl_run(argv, **kw):
return subprocess.run(argv, capture_output=True, text=True, timeout=1800, **kw)
async def _run_ctl_json(verb: str):
"""Délègue au helper root (webui→ctl), FIXE + exact-command (voir
/etc/sudoers.d/secubox-profiles). Bloquant to_thread.
async def _run_ctl_json_argv(argv: list[str]) -> dict:
"""Corps partagé par tout appel webui→ctl synchrone (--wait --pipe) :
exécute l'argv FIXE (voir /etc/sudoers.d/secubox-profiles) via
`_ctl_run`, hors-loop (to_thread), et parse le rapport JSON s'il existe.
IMPORTANT on lance le CLI via `systemd-run`, PAS un simple `sudo ctl` :
ce service tourne en ProtectSystem=strict avec un ReadWritePaths réduit, et
@ -321,17 +347,18 @@ async def _run_ctl_json(verb: str):
unité transitoire `systemd-run` s'exécute dans le contexte de PID 1, HORS
du sandbox, avec tous les accès nécessaires pour piloter systemd/LXC et
écrire ces chemins. `--wait` = synchrone, `--pipe` = on récupère le JSON du
CLI sur stdout, `--collect --quiet` = nettoyage + pas de bruit systemd-run."""
CLI sur stdout, `--collect --quiet` = nettoyage + pas de bruit systemd-run.
Le rapport est parsé EN PREMIER, avant tout regard sur le rc : apply/
rollback/wake émettent tous un --json report même quand ils se replient
(apply rolled_back, wake refused) le surfacer est bien plus utile à
l'opérateur/panel qu'une chaîne d'erreur tronquée ; c'est l'appelant HTTP
qui classe applied/refused/rolled_back, pas cette fonction. Ce n'est
qu'à défaut de JSON parseable qu'on retombe sur un échec HTTP (409 pour
le rc=3 conventionnel de refus protégé, 500 sinon)."""
import asyncio as _a
import json as _json
argv = ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet",
"/usr/sbin/secubox-profilectl", verb, "--yes", "--json"]
proc = await _a.to_thread(_ctl_run, argv)
# Parse the report FIRST: apply/rollback emit a --json report even when they
# end in `rolled_back` (CLI rc=2). Surfacing that report (which modules
# changed / were rolled back) is far more useful to the operator than a raw
# truncated error string — the panel classifies applied-vs-failed itself.
try:
report = _json.loads(proc.stdout)
except ValueError:
@ -343,7 +370,43 @@ async def _run_ctl_json(verb: str):
if proc.returncode == 3:
raise HTTPException(status_code=409, detail=(proc.stderr or proc.stdout).strip()[:300])
raise HTTPException(status_code=500,
detail=f"{verb} échoué: {(proc.stderr or proc.stdout).strip()[:300]}")
detail=f"ctl échoué: {(proc.stderr or proc.stdout).strip()[:300]}")
async def _run_ctl_json(verb: str) -> dict:
argv = ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet",
"/usr/sbin/secubox-profilectl", verb, "--yes", "--json"]
return await _run_ctl_json_argv(argv)
async def _run_wake_ctl(module: str) -> dict:
"""Réveil manuel (panel) — synchrone (--wait --pipe), DIFFÉRENT du réveil
fire-and-forget du waker (api/waker.py::_fire_wake, pas de --wait/--pipe) :
le panel attend et affiche le report, le waker rend la main tout de suite
pour ne jamais bloquer une requête HTTP publique sur l'issue du réveil.
Deux argv distincts => deux grants sudoers distincts (voir
sudoers.d/secubox-profiles)."""
argv = ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet",
"/usr/sbin/secubox-wakectl", "wake", module, "--json"]
return await _run_ctl_json_argv(argv)
async def _run_sleep_ctl(module: str) -> dict:
"""Sommeil manuel (panel) — réutilise l'actionneur `apply` du profilectl
déjà grant-é, restreint à CE module via `--only` : plan_changes calcule
déjà un STOP pour tout module sleepable actuellement up et non désiré ON
(absent du profil actif / non épinglé on), donc `--only <module>` isole
correctement l'effet à ce seul module sans réimplémenter d'actionneur
dédié. Si le module EST désiré ON (listé dans le profil actif / épinglé
on), le plan filtré est vide et l'appel est un no-op sûr — le panel/
l'opérateur voit alors `changed: []` plutôt qu'un module qui se
rendormirait aussitôt reconverge."""
argv = ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet",
"/usr/sbin/secubox-profilectl", "apply", "--only", module, "--yes", "--json"]
return await _run_ctl_json_argv(argv)
def create_app() -> FastAPI:
@ -522,6 +585,38 @@ def create_app() -> FastAPI:
active_path.unlink(missing_ok=True)
return report
def _sleepable_module_or_error(mod_dir, module: str):
"""Refus STRUCTUREL, comme la refus-avant-écriture des pins (voir
set_pin) : un module inconnu ou non-endormable (always-on/manual, y
compris protected forcé always-on) ne doit JAMAIS déclencher un appel
sudo il est rejeté ICI, avant que /wake ou /sleep ne shell out."""
manifests = _load_manifests_or_500(mod_dir)
m = manifests.get(module)
if m is None:
raise HTTPException(status_code=404, detail=f"module inconnu: {module}")
if not is_sleepable(m):
raise HTTPException(
status_code=409,
detail=f"{module} n'est pas endormable (lifecycle={effective_lifecycle(m)})",
)
return m
@app.post("/api/v1/profiles/wake")
async def wake_module(body: ModuleAction, _claims=Depends(require_jwt)):
root = _root()
mod_dir, _prof_dir, _pins_file, _active = _cli._paths(root)
_sleepable_module_or_error(mod_dir, body.module)
async with _apply_lock:
return await _run_wake_ctl(body.module)
@app.post("/api/v1/profiles/sleep")
async def sleep_module(body: ModuleAction, _claims=Depends(require_jwt)):
root = _root()
mod_dir, _prof_dir, _pins_file, _active = _cli._paths(root)
_sleepable_module_or_error(mod_dir, body.module)
async with _apply_lock:
return await _run_sleep_ctl(body.module)
return app

View File

@ -36,3 +36,32 @@ secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --pipe --collect --quie
# So the wildcard only ever resolves to "a manifest id known to this system,
# plus --json" — never to arbitrary code execution or an arbitrary argv.
secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --collect --quiet /usr/sbin/secubox-wakectl wake *
# Task 10 (#896) — manual sleep/wake buttons on the /profiles/ panel itself
# (api/web.py POST /wake, POST /sleep). Two MORE grants, distinct from both
# sections above:
#
# * /wake — same secubox-wakectl binary as the waker's fire-and-forget
# grant just above, but SYNCHRONOUS (--wait --pipe): the panel waits for
# and displays the wake report to the operator, unlike the waker (which
# never blocks a public, unauthenticated HTTP request on the wake's
# outcome). The extra --wait --pipe flags make this a DIFFERENT command
# line that does not match the fire-and-forget grant above, hence its
# own line here. Same wildcard-safety argument as above applies
# verbatim: execve (no shell) + secubox-wakectl re-validates the module
# id itself (refuses unknown/manual/always-on before touching
# systemd/LXC).
#
# * /sleep — reuses the EXISTING `apply` actuator (no new binary, no new
# privileged code path), restricted to one module via `--only <id>`:
# plan_changes already computes a STOP for any sleepable module that is
# currently up and not desired ON (absent from the active profile /
# not pinned on), so `--only` simply narrows apply's effect to that one
# module — if the module IS desired ON, the filtered plan is empty
# (safe no-op). The wildcard is bounded the same way as the two grants
# above: sudo matches argv directly (execve, no shell), and
# secubox-profilectl's own plan_changes only ever acts on a module id it
# recognizes from modules.d — an unknown id just filters the plan down
# to nothing, never arbitrary execution.
secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-wakectl wake *
secubox ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --pipe --collect --quiet /usr/sbin/secubox-profilectl apply --only * --yes --json

View File

@ -276,6 +276,129 @@ def test_set_pin_invalid_value_400(client):
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# lifecycle / wake_class / sleep_state (Task 10) — status payload extension.
# `auth` is protected (PROTECTED_IDS) => effective_lifecycle forces
# "always-on" regardless of its declared value => sleep_state "n/a". `lyrion`
# declares no lifecycle/wake_class => defaults "eager"/"normal", observed on
# (client fixture) => sleep_state "up". A third module declared "on-demand"/
# "urgent" and left UNOBSERVED by the client fixture's _observe_all stub
# proves the None -> False -> "asleep" coalescing (same philosophy as
# observe.is_on, see its docstring).
# ---------------------------------------------------------------------------
def test_status_includes_lifecycle_wake_class_and_sleep_state(client, root):
(root / "modules.d" / "podcast.toml").write_text(
'id = "podcast"\n'
'category = "media"\n'
'runtime = "native"\n'
'exposure = "lan"\n'
'units = ["secubox-podcast.service"]\n'
'priority = 20\n'
'lifecycle = "on-demand"\n'
'wake_class = "urgent"\n')
resp = client.get("/api/v1/profiles/status")
assert resp.status_code == 200
data = {m["id"]: m for m in resp.json()["modules"]}
assert data["lyrion"]["lifecycle"] == "eager"
assert data["lyrion"]["wake_class"] == "normal"
assert data["lyrion"]["sleep_state"] == "up"
assert data["lyrion"]["wake_budget_s"] == pytest.approx(45.0)
assert data["auth"]["lifecycle"] == "always-on" # protected overrides declared value
assert data["auth"]["wake_class"] == "normal"
assert data["auth"]["sleep_state"] == "n/a"
assert data["auth"]["wake_budget_s"] is None
assert data["podcast"]["lifecycle"] == "on-demand"
assert data["podcast"]["wake_class"] == "urgent"
assert data["podcast"]["sleep_state"] == "asleep" # unobserved -> is_on() False
assert data["podcast"]["wake_budget_s"] == pytest.approx(15.0)
# ---------------------------------------------------------------------------
# manual wake/sleep (Task 10) — webui->ctl: both routes only ever shell out to
# a FIXED sudo argv (see sudoers.d/secubox-profiles), gated by JWT and
# _apply_lock, exactly like apply/rollback. An unknown module is 404 and a
# non-sleepable one (always-on/manual, incl. protected-forced) is 409 —
# BOTH refused locally, before any sudo call is attempted (structural
# refusal, same posture as the pin protected-off check).
# ---------------------------------------------------------------------------
def test_wake_route_delegates_to_ctl_and_returns_report(client, monkeypatch):
def fake_run(argv, **kw):
assert argv == ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet", "/usr/sbin/secubox-wakectl",
"wake", "lyrion", "--json"]
class P:
returncode = 0
stdout = '{"status":"woken","module":"lyrion"}'
stderr = ""
return P()
monkeypatch.setattr(web, "_ctl_run", fake_run)
r = client.post("/api/v1/profiles/wake", json={"module": "lyrion"})
assert r.status_code == 200
assert r.json()["status"] == "woken"
def test_wake_route_unknown_module_404_and_ctl_not_called(client, monkeypatch):
called = []
monkeypatch.setattr(web, "_ctl_run", lambda argv, **kw: called.append(argv))
r = client.post("/api/v1/profiles/wake", json={"module": "fantome"})
assert r.status_code == 404
assert called == []
def test_wake_route_not_sleepable_409_and_ctl_not_called(client, monkeypatch):
called = []
monkeypatch.setattr(web, "_ctl_run", lambda argv, **kw: called.append(argv))
r = client.post("/api/v1/profiles/wake", json={"module": "auth"}) # protected -> always-on
assert r.status_code == 409
assert called == []
def test_sleep_route_delegates_to_ctl_and_returns_report(client, monkeypatch):
def fake_run(argv, **kw):
assert argv == ["sudo", "-n", "/usr/bin/systemd-run", "--wait", "--pipe",
"--collect", "--quiet", "/usr/sbin/secubox-profilectl",
"apply", "--only", "lyrion", "--yes", "--json"]
class P:
returncode = 0
stdout = '{"status":"applied","changed":["lyrion"],"failed":[],"rolled_back":[]}'
stderr = ""
return P()
monkeypatch.setattr(web, "_ctl_run", fake_run)
r = client.post("/api/v1/profiles/sleep", json={"module": "lyrion"})
assert r.status_code == 200
assert r.json()["status"] == "applied"
assert r.json()["changed"] == ["lyrion"]
def test_sleep_route_unknown_module_404_and_ctl_not_called(client, monkeypatch):
called = []
monkeypatch.setattr(web, "_ctl_run", lambda argv, **kw: called.append(argv))
r = client.post("/api/v1/profiles/sleep", json={"module": "fantome"})
assert r.status_code == 404
assert called == []
def test_sleep_route_not_sleepable_409_and_ctl_not_called(client, monkeypatch):
called = []
monkeypatch.setattr(web, "_ctl_run", lambda argv, **kw: called.append(argv))
r = client.post("/api/v1/profiles/sleep", json={"module": "auth"}) # protected -> always-on
assert r.status_code == 409
assert called == []
def test_wake_and_sleep_require_jwt_when_not_overridden(root):
c = TestClient(web.app)
assert c.post("/api/v1/profiles/wake", json={"module": "lyrion"}).status_code == 401
assert c.post("/api/v1/profiles/sleep", json={"module": "lyrion"}).status_code == 401
def test_diff_after_pin_reflects_refusal_not_partial_state(client, root):
# Belt-and-braces: even if the write-path refusal were somehow bypassed,
# plan_changes() itself raises ProtectedViolation independently — but the

View File

@ -78,7 +78,7 @@
.filter-bar input { flex: 1; min-width: 150px; padding: 0.4rem; background: var(--bg-row); border: 1px solid var(--border); border-radius: 4px; font-family: 'Courier Prime', monospace; font-size: 0.8rem; color: var(--text); }
.mod-list { display: flex; flex-direction: column; gap: 0.28rem; max-height: 560px; overflow-y: auto; }
.mod-row { display: grid; grid-template-columns: 1.4em 1fr auto auto auto auto auto auto; align-items: center; gap: 0.55rem; padding: 0.45rem 0.6rem; background: var(--bg-row); border-radius: 4px; font-size: 0.73rem; border-left: 3px solid var(--border); transition: background 0.15s; }
.mod-row { display: grid; grid-template-columns: 1.4em 1fr auto auto auto auto auto auto auto; align-items: center; gap: 0.55rem; padding: 0.45rem 0.6rem; background: var(--bg-row); border-radius: 4px; font-size: 0.73rem; border-left: 3px solid var(--border); transition: background 0.15s; }
.mod-row:hover { background: rgba(0,212,255,0.06); }
.mod-row.on { border-left-color: var(--green); }
.mod-row.off { border-left-color: var(--p31-dim); }
@ -98,6 +98,14 @@
.exposure-badge.lan { color: var(--blue); border-color: var(--blue); }
.exposure-badge.internal { color: var(--p31-dim); }
.mod-row .life-state { display: flex; align-items: center; gap: 0.3rem; white-space: nowrap; }
.life-pill { font-size: 0.58rem; padding: 0.1rem 0.4rem; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; border: 1px solid var(--border); }
.life-pill.up { color: var(--green); border-color: var(--green); }
.life-pill.asleep { color: var(--p31-dim); border-color: var(--p31-dim); }
.life-pill.na { color: var(--purple); border-color: var(--purple); }
.btn.sm.wake { color: var(--yellow); border-color: var(--yellow); }
.btn.sm.sleep { color: var(--blue); border-color: var(--blue); }
.diff-banner { background: rgba(255,153,68,0.08); border: 1px dashed var(--orange); border-radius: 6px; padding: 0.6rem 0.8rem; font-size: 0.7rem; color: var(--orange); margin-bottom: 0.75rem; }
.diff-list { display: flex; flex-direction: column; gap: 0.25rem; max-height: 340px; overflow-y: auto; }
.diff-row { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 0.6rem; padding: 0.4rem 0.55rem; background: var(--bg-row); border-radius: 4px; font-size: 0.72rem; border-left: 3px solid var(--border); }
@ -128,7 +136,7 @@
@media (max-width: 768px) {
.main { margin-left: 0; }
.mod-row { grid-template-columns: 1.2em 1fr auto; }
.mod-row .meta, .mod-row .prio, .mod-row .rss, .mod-row .member { display: none; }
.mod-row .meta, .mod-row .prio, .mod-row .rss, .mod-row .member, .mod-row .life-state { display: none; }
.diff-row { grid-template-columns: auto 1fr; }
.diff-row .reason, .diff-row .prio { display: none; }
}
@ -355,6 +363,26 @@
return { cls: 'follow', label: '⟳ follows profile' };
}
// sleep_state ('up'/'asleep'/'n/a') + lifecycle/wake_class/wake_budget_s
// come straight from GET /status (Task 10, #896) — this renders the
// pill + the one manual action (Sleep when up, Wake when asleep, no
// button at all for n/a always-on/manual modules).
function lifeStateCell(m) {
const st = m.sleep_state;
if (st === 'n/a') {
return '<span class="life-state"><span class="life-pill na" title="lifecycle: ' +
esc(m.lifecycle) + '">' + esc(m.lifecycle) + '</span></span>';
}
const pill = st === 'up'
? '<span class="life-pill up" title="lifecycle: ' + esc(m.lifecycle) + '">🟢 up</span>'
: '<span class="life-pill asleep" title="lifecycle: ' + esc(m.lifecycle) + '">🌙 asleep</span>';
const budget = m.wake_budget_s != null ? Math.round(m.wake_budget_s) + 's' : '?';
const btn = st === 'up'
? '<button class="btn sm sleep" data-act="sleep" data-id="' + esc(m.id) + '" title="put ' + esc(m.id) + ' to sleep now">💤 Sleep</button>'
: '<button class="btn sm wake" data-act="wake" data-id="' + esc(m.id) + '" title="wake ' + esc(m.id) + ' now (~' + budget + ')">⚡ Wake</button>';
return '<span class="life-state">' + pill + btn + '</span>';
}
function renderModules() {
const el = document.getElementById('modList');
let mods = (statusData && statusData.modules) || [];
@ -397,6 +425,7 @@
'<button class="btn sm' + (pin.cls === 'on' ? ' active' : '') + '" data-act="pin" data-id="' + esc(m.id) + '" data-pin="on" title="pin on">📌ON</button>' +
'<button class="btn sm' + (pin.cls === 'off' ? ' active' : '') + '" data-act="pin" data-id="' + esc(m.id) + '" data-pin="off"' + (pinOffDisabled ? ' disabled' : '') + ' title="' + (pinOffDisabled ? 'protected — cannot pin off' : 'pin off') + '">📌OFF</button>' +
'</span>' +
lifeStateCell(m) +
'</div>';
}).join('');
}
@ -617,7 +646,42 @@
}
}
// Manual sleep/wake (Task 10, #896) — same webui->ctl posture as
// Bascule's apply/rollback: POST, await the ctl's JSON report, then
// refresh() so the row's sleep_state/pill reflects reality rather
// than an optimistic guess. Sleep asks for confirmation (it stops a
// running module); wake does not (idempotent no-op if already up).
async function wakeOrSleep(id, action) {
try {
const report = await api('/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module: id }),
});
const st = report && report.status;
if (st === 'refused') {
errorToast('⚠️ ' + id + ': refused (' + (report.reason || '?') + ')');
} else if ((report && report.failed && report.failed.length)) {
errorToast('⚠️ ' + id + ': ' + st);
} else {
toast((action === 'wake' ? '⚡ ' : '💤 ') + id + ': ' + st);
}
await refresh();
} catch (e) {
if (e.message !== '401') errorToast('❌ ' + id + ' ' + action + ' échoué : ' + e.message);
}
}
document.getElementById('modList').addEventListener('click', e => {
const wakeSleep = e.target.closest('[data-act="wake"], [data-act="sleep"]');
if (wakeSleep) {
const id = wakeSleep.getAttribute('data-id');
const action = wakeSleep.getAttribute('data-act');
if (action === 'sleep' && !confirm('Endormir ' + id + ' maintenant ?')) return;
wakeSleep.disabled = true;
wakeOrSleep(id, action).finally(() => { wakeSleep.disabled = false; });
return;
}
const t = e.target.closest('[data-act="pin"]');
if (!t || t.disabled) return;
const id = t.getAttribute('data-id');