mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
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:
parent
51b5c26140
commit
8e9ca2df12
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user