Commit Graph

2902 Commits

Author SHA1 Message Date
c1970decc9 feat(profiles): set the awake level (lifecycle + wake_class) from the panel (ref #896)
The scale-to-zero policy (lifecycle always-on/eager/on-demand/manual +
wake_class normal/urgent) was manifest-only. Add a per-module selector to the
/profiles/ panel that writes the manifest and resyncs the derived files, via the
webui->ctl pattern:

- api/manifest_edit.py: targeted line-edit of a manifest's lifecycle/wake_class,
  preserving everything else (comments, inline portal table, …); refuses a
  sectioned manifest; atomic write.
- api/cli.py: `secubox-profilectl set-lifecycle <mod> --lifecycle X --wake-class Y`
  (root) — edits the manifest then re-runs waf-sync + health-sync so the change
  takes effect; resync paths derive from --root (test-isolated). argparse choices=
  hard-gate the enums; refuses an unknown module.
- api/web.py: POST /api/v1/profiles/lifecycle — validates enum (422) + known
  module (404) BEFORE any sudo, then delegates to the root ctl via systemd-run.
- sudoers.d: one scoped grant (three bounded wildcards, panel pre-validates +
  ctl re-validates — execve, no shell).
- www/profiles/index.html: two compact <select>s per module (protected → 🔒),
  POSTing /lifecycle and refreshing.

16 new tests (manifest_edit, cli set-lifecycle, web route); 275 pass, no
live-state leakage, sudoers + JS validated.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 19:01:04 +02:00
5f86544125 docs(profiles): note remember_path as a hardcoded test-isolation path (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:39:29 +02:00
9edf69a158 feat(profiles): durable portal-route memory so wake restores the WAF route (ref #896)
The scale-to-zero pilot proved the wake-trigger chain but found the round-trip
broken: a woken portal module started (container up, backend serving) yet stayed
unreachable via its vhost because its sbxwaf route was never re-added. wake.py
passed routes={} to apply_plan, and by wake time the route is long gone from the
live haproxy-routes.json (removed at sleep) and out of the rotated 4R snapshot.

Fix — a durable per-domain route memory (portal_routes.remember/recall,
/var/lib/secubox/profiles/portal-routes.json): the actuator persists the live
route value just before removing it on STOP; wake recalls it and passes it as the
routes map, so the existing snapshot->START->_portal_add path restores it. No
change to snapshot/apply/_portal_add semantics. Best-effort memory (a failed
write never breaks a STOP). remember_path_for(root) mirrors snap_root_for for
test isolation.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:34:33 +02:00
356f8293a8 fix(profiles): default lifecycle is always-on, not eager (ref #896)
On a 184-module fleet, DEFAULT_LIFECYCLE="eager" made every module with
no manifest opinion idle-sleep-eligible, including core services (admin,
gitea, nextcloud) that never opted into scale-to-zero. Flip the default
to always-on: sleep is now a strict opt-in via lifecycle="eager" or
"on-demand" declared explicitly in the manifest. scan()-derived
manifests already relied on this same default (no code change needed
there), so they inherit the safer behavior too.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:11:24 +02:00
ed45608e0c fix(profiles,waf-ng): final review fixes for scale-to-zero pilot (ref #896)
Three findings from the pre-pilot review:

- waf-sync/health-sync _write_atomic left the produced file 0600 root-owned
  (tempfile.mkstemp default) with no chmod before the rename. sbxwaf (user
  secubox-waf) and secubox-hub (user secubox) could never read their own
  public on-demand-vhosts.json / sleepable-modules.json — the wake trigger
  and health-distinction were silently inert on install. Chmod 0644 before
  os.replace in both helpers.

- The two sbxwaf workers (@1, @2) each hold independent in-memory
  Begin/End vhost state but flushed to the SAME --vhost-signals path,
  last-writer-wins clobbering each other every ~5s — a service kept fresh
  by one worker could be read as stale by the sleeper and wrongly STOPped.
  Unit now passes a per-instance path (vhost-signals.@%i.json, no Go
  change needed); sleeper_daemon._signal_reader globs and merges per-vhost
  (max last_request_ts, sum active_conns) across workers, falling back to
  the legacy plain path when no per-worker files exist yet.

- manifest.load_manifest stored portal_domain verbatim; the rest of the
  front pipeline (waker exact-match, sleeper keys, wafsync output) is
  lowercase, so a hand-edited mixed-case domain would silently never wake
  nor sleep. Lowercase at load time.

Full profiles suite: 251 passed. mypy --strict api/: unchanged 102
pre-existing errors (diffed against HEAD, only reordering, zero new).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 17:33:58 +02:00
dee65ed6c8 fix(profiles): sleeper unit not ProtectSystem=strict (drives LXC in-process) (ref #896)
secubox-sleeper.service actuates in-process as root (no sudo/systemd-run,
unlike the panel/waker which escape their own sandbox before touching
systemd/LXC). ProtectSystem=strict made everything under /run read-only
outside the listed ReadWritePaths, but lxc-start/lxc-stop write
LXC-internal runtime/lock/cgroup paths (/run/lxc/, /run/lock/lxc/, mount-
namespace setup) that are not part of this codebase's stable contract to
enumerate. api/actuate.py::_issue only hard-fails on rc is None, so the
resulting EROFS would have been silent: auto-sleep for every LXC-backed
on-demand module would quietly stop working.

Drop ProtectSystem=strict and ReadWritePaths=; keep ProtectHome=true,
PrivateTmp=true, NoNewPrivileges=true, and RuntimeDirectory=secubox. A
root daemon driving LXC in-process without ProtectSystem is at this
codebase's normal floor, not a regression.

debian/postinst: keep pre-creating /var/log/secubox and
/var/lib/secubox/profiles/rollback (still needed by the actuator
regardless of sandboxing), reword the comment to drop the
ProtectSystem=strict framing.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 17:14:53 +02:00
d67e2c03b7 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>
2026-07-20 17:06:47 +02:00
dc08a8c139 feat(profiles): boot reconciliation + watchdog exclusion for on-demand (ref #896)
boot_should_start(m) is the pure boot policy (always-on/eager start
immediately, on-demand/manual wait for a real wake) and
watchdog_should_manage(m) documents/tests that no sleepable module is ever
force-revived by secubox-watchdog.

Investigation found: HEAD's secubox-watchdog has no auto-revive logic at
all today (monitor_loop only logs up/down, restarts are manual-only); the
unmerged feat/watchdog-auto-revive branch adds one gated purely on
lxc.start.auto, which actuate.py::runtime_stop already clears before
lxc-stop specifically to avoid this race — the same flag streamlit sleepers
rely on for exclusion today. No separate exclusion file exists to reuse, so
none was invented; once that branch merges it is already #896-safe with
zero further wiring.

The real, closeable gap was secubox-hub's own sidebar health-batch, which
reported a sleeping on-demand module's inactive systemd unit as "warn".
Added a profiles-side export (secubox-wakectl health-sync ->
sleepable-modules.json, mirrors waf-sync/nginx-sync) and wired it into
secubox-hub so a sleepable module shows "Asleep (on-demand)" instead of an
alarm; a failed unit still alarms regardless. The full admin /health/ page
reads module_prober.py/prober.py, which aren't sourced in this repo yet
(TODO #393) -- documented as a precise cross-package follow-up.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:51:56 +02:00
8e9ca2df12 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>
2026-07-20 16:31:17 +02:00
51b5c26140 fix(waf-ng): vhost signal excludes WAF-blocked/banned traffic (real activity only) (ref #896)
The Begin/End hook was placed before the WAF-inspection block, so a
request the WAF blocks (403 warning/ban) still refreshed last_request_ts —
scanner/bot traffic against public on-demand vhosts (near-constant
internet-wide scanning) kept the signal "fresh" forever, so
should_sleep()'s idle-age check never passed. Defeated auto-sleep for the
feature's primary deployment (public on-demand vhosts).

Move the Begin/defer End bracket to right after the WAF-inspection block's
403/warning/ban early-returns, immediately before the media-cache-hit
check. One placement still covers three real-response exit paths via the
single defer: the media-cache hit (counts — genuine content served),
media-cache-miss proxy, and the plain proxy. Blocked, banned, waker, and
421 requests never reach this line.

Adds TestVhostSignalsExcludedForWAFBlock (verified RED against the old
placement, GREEN after the move).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:17:59 +02:00
733037052e feat(profiles): wire sleeper's real vhost signal reader + wall-clock now (ref #896)
_signal_reader was a documented stub returning {} (auto-sleep never fired).
It now reads sbxwaf's vhost-signals.json (best-effort: missing/unreadable/
corrupt => {}, same contract as sleeper._read_wake_locked, never raises).

Critical fix: sbxwaf writes last_request_ts as unix wall-clock seconds
(time.Now().Unix()), and front_signals.vhost_signals(reader, now) computes
age = now() - last_request_ts, so `now` must be wall-clock too. main_async
was wiring time.monotonic (arbitrary epoch, unrelated to unix time) into
that slot; flipped to time.time. serve()'s `now` param has exactly one
consumer (vhost_signals) so this is an isolated fix — the separate `stamp`
param (audit timestamp string) is untouched.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:05:20 +02:00
d492db1086 feat(waf-ng): per-vhost last-request/active-conns signal emitter (ref #896)
sbxwaf tracked cumulative visit counts but nothing usable to decide vhost
idleness. Add VhostSignals (mirrors visitstats.go's lock+flusher+atomic-
rename shape): Begin/End bracket every request actually proxied to a real
backend for on-demand vhosts only, a 5s-ticker flusher writes
{"<vhost>": {"last_request_ts", "active_conns"}} atomically to
--vhost-signals (default /var/cache/secubox/waf/vhost-signals.json). The
waker-splash branch is never bracketed — a sleeping vhost must not look
like it just received a real hit. Wired into the worker unit's ExecStart.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:04:58 +02:00
b4e89696aa feat(profiles): waker/sleeper systemd units + sudoers + wake-active state (ref #896)
Adds the systemd/sudoers scaffolding for scale-to-zero's two new services:
secubox-waker.service (User=secubox, ProtectSystem=strict, delegates the
privileged wake to root via sudo->systemd-run->secubox-wakectl, same EROFS
lesson as secubox-profilectl) and secubox-sleeper.service (root, long-running
idle daemon that drives apply.apply_plan directly, no sandbox needed since
it is itself the actuator).

_fire_wake now wraps its sudo call in systemd-run (fire-and-forget) so the
wake escapes the waker's read-only sandbox; the matching sudoers grant is
added to sudoers.d/secubox-profiles. The waker also persists its in-memory
_last_wake set to /run/secubox/waker-active.json (TDD'd), the only channel
the sleeper's wake-lock reads to avoid racing a module it just woke.

api/sleeper_daemon.py wires api.sleeper.serve()'s production dependencies;
the front-signal reader is a documented safe stub ({}) pending a real
sbxwaf-stats source.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 13:38:36 +02:00
0770b52f79 fix(waf-ng): normalize wake host casing so mixed-case Host reaches the waker (ref #896)
The waker Director rebuilt the wake path from the raw req.Host without
lowercasing, while OnDemand.Contains matches case-insensitively. A
mixed-case Host (hand-typed URL, script caller) would pass the
on-demand gate but the waker's exact-match lookup against the
lowercase-stored portal_domain would miss, leaving the service
permanently unwoken behind the splash. Normalize the same way
(lowercase + trim) in the Director before building /_wake/<host>.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 13:25:16 +02:00
942ecbb3a6 feat(waf-ng): sbxwaf routes on-demand vhosts to the waker instead of 421 (ref #896)
Add OnDemand (hot-reloadable set loaded from --on-demand-vhosts /
/etc/secubox/waf/on-demand-vhosts.json) and a cached unix-socket
reverse proxy to the waker. When a request hits a vhost with no live
route but present in the on-demand set (the sleeper stopped it), sbxwaf
now reverse-proxies to /run/secubox/waker.sock (path rewritten to
/_wake/<host>) instead of answering 421 — the vhost is real, just
asleep. Vhosts outside the on-demand set are unaffected. The waker
splash is excluded from the visit-stats legitimate-traffic tally, same
as the 403/421 it stands in for.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 13:20:11 +02:00
2008b9c29d feat(profiles): waf-sync — on-demand-vhost list for sbxwaf wake trigger (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 13:09:46 +02:00
9d4da5bf67 docs(scale-to-zero): pivot to sbxwaf-side wake trigger (supersede nginx @waker); +waf-sync +sbxwaf Go tasks (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 13:05:45 +02:00
c605ad3bd8 feat(profiles): sleeper serve loop + /idle hint + wake-lock coordination (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:54:10 +02:00
8bf5152899 feat(profiles): nginx @waker snippet generator + nginx-sync verb (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:38:46 +02:00
39fa581e66 fix(profiles): waker reaps wake subprocess (no zombies) + cache splash template (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:32:21 +02:00
ec02088607 feat(profiles): secubox-waker activator — splash + one-wake lock + rate cap (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:23:05 +02:00
a88dd2beae feat(profiles): sleeper run_once — stop idle sleepable modules via the actuator (ref #896)
Adds run_once alongside Task 4's should_sleep: one daemon pass over the
sleepable modules, STOP-planning and apply_plan-ing each one that's up,
idle, and not wake-locked. Factors the snap_root/audit_path production-vs-
test-confinement logic (already established by wake.py in Task 2) into a
shared api/actuate_paths.py so wake and sleep never diverge on which 4R
chain/audit log they write to.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:16:56 +02:00
dee5670a14 feat(profiles): sleeper decision logic (idle + 0-conns + hint, never on uncertainty) (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:09:05 +02:00
988001bcb0 feat(profiles): front signals (last-request age + active conns per vhost) (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:04:59 +02:00
a0ae5b80e5 fix(profiles): wakectl main() tests + --root scoping note + planned status (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 12:01:48 +02:00
4a4382fa2c feat(profiles): secubox-wakectl wake verb (start one module via the actuator) (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 11:55:27 +02:00
02c79eb706 feat(profiles): lifecycle + wake_class manifest policy (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 11:46:39 +02:00
afc549ba14 docs(profiles): implementation plan — scale-to-zero public services (12 tasks, ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 11:43:27 +02:00
a2998c1a23 docs(profiles): spec — scale-to-zero for public services (sleep/wake + lifecycle policy)
Design brainstormed 2026-07-20: sleep=STOP(free RAM)+splash-on-wake; per-module
lifecycle (always-on|eager|on-demand|manual) + wake_class; secubox-waker
activator (nginx->waker->wakectl+splash+lock); secubox-sleeper hybrid idle
(front last-request+conns + optional module /idle hint); reuses the 0.7.0
observed-state actuator. Single spec covering wake-on-access + auto-sleep.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 11:43:27 +02:00
b5503a12d6 fix(profiles): standalone rollback clears the active pointer (state no longer matches a named profile) (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 11:06:24 +02:00
3edc32cd4b test(profiles): cover /apply active-unlink on rollback with no prior active (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 09:23:17 +02:00
ec4acb8e99 docs(profiles): changelog 0.7.0 + README actuation note (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 09:15:27 +02:00
e805387f28 fix(profiles): /apply reverts active pointer when the ctl rolls back (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:59:21 +02:00
813e543aa8 fix(profiles): rollback_to matches apply_plan's 300s derived-timeout cap (ref #893)
rollback_to's own default wait_timeout=30.0 re-capped the standalone/panel
rollback path (cli.py's rollback --yes calls rollback_to without passing
wait_timeout), defeating the derived-timeout wiring on that path. Also adds
an integration test that only passes because the derived timeout (105s)
exceeds a flat 30s, using apply_plan's injected clock/sleep — the previous
happy-path test converged on the first probe and would have passed even
without the wiring.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:53:59 +02:00
8407a322f3 fix(profiles): apply waits the per-module derived timeout, observed-state arbiter (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:46:09 +02:00
ff1c51222e fix(profiles): non-blocking systemctl + best-effort lxc, state-arbitrated (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:39:30 +02:00
3b00e53a4b fix(profiles): wait_state observes container state for LXC modules (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:31:47 +02:00
c7ec47ab0e fix(profiles): derive wait_state timeout per-module from systemd (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:26:54 +02:00
c8e23843ed fix(profiles): _run distinguishes TimeoutExpired from could-not-run (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 08:18:03 +02:00
649a544669 docs(profiles): implementation plan — actuation robustness, 7 tasks (ref #893)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 03:59:49 +02:00
be901fa7b3 docs(profiles): spec — actuation robustness, observed-state arbiter (ref #893)
The lite mass-apply test spuriously aborted because the actuator judged a
STOP/START by the command return code, not the observed state (metrics'
90s TimeoutStopSec > _run's 15s timeout -> rc=None -> spurious rollback).

Design: non-blocking commands + wait_state as sole arbiter (LXC via
lxc-info as root), wait_timeout derived per-module from systemd, _run
distinguishes TimeoutExpired from OSError, /apply reverts active on
rolled_back.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 03:47:58 +02:00
ffff1b70f5 fix(profiles): a condition-gated START is not an apply failure (no spurious rollback)
Some checks failed
License Headers / check (push) Has been cancelled
A unit whose systemd start-condition fails (e.g. hexo) can never go active, so
enable --now succeeds but wait_state timed out → apply of any profile listing such
a module rolled back. condition_failed() detects ConditionResult=no and _do_change/
_rollback treat that START as reached (the module is deliberately off here). Found
applying the full profile from the panel.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 10:49:46 +02:00
e2819f3d31 docs: webui→ctl — system-driving ops from a ProtectSystem=strict service need systemd-run (not plain sudo)
A sudo child inherits the service's read-only mount namespace (EROFS); systemd-run
runs the ctl in PID 1's context, outside the sandbox. Found in secubox-profiles 0.6.1.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 10:30:10 +02:00
b756c35eb0 fix(profiles): run apply/rollback via systemd-run (escape ProtectSystem=strict sandbox)
The panel's sudo child inherited the service's read-only mount namespace →
EROFS writing the 4R snapshot (and it couldn't drive systemd/LXC). Wrap the CLI in
systemd-run --wait --pipe so it runs in PID 1's context, outside the sandbox. Sudoers
grants updated to the systemd-run-wrapped exact commands. Found on the first real
panel apply.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 10:26:40 +02:00
CyberMind
9ed71c890b
Merge pull request #892 from CyberMind-FR/feat/profiles-apply-webui-ctl
feat(profiles): API/panel apply+rollback via webui→ctl (0.6.0)
2026-07-19 10:16:08 +02:00
76e3f81e83 fix(profiles): surface the apply/rollback report even on rolled_back (rc=2) + single-worker lock note
Final-review finding 1: _run_ctl_json parsed the --json report only after mapping
rc!=0 to 500, so a rolled_back apply showed a raw truncated string instead of the
report banner. Now parse first: a valid {status,...} report returns 200 and the panel
classifies applied-vs-failed. rc=3 (protected refusal, no report) still 409.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 10:14:46 +02:00
c27d6a6b03 fix(profiles): apply actuates the REQUESTED profile under a lock (TOCTOU) + panel preview isolation + honest toast
Task-4 review: /apply now takes {profile}, rewrites active + actuates under a shared
async lock (with /active + /rollback) so a concurrent tab can't make it apply the wrong
profile. Panel POSTs the previewed profile, previews into its own #basculeDiffList (poll
can't clobber it) + re-fetches the plan before confirm, and uses errorToast on any
non-success report. 157 tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 10:08:35 +02:00
58e30f5883 feat(profiles): panel Bascule — select/preview/apply/rollback (webui→ctl)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 09:56:00 +02:00
aa37a434e6 feat(profiles): NNP=false + scoped sudoers so the panel can sudo→profilectl
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 09:49:06 +02:00
723acccdc4 feat(profiles): API active/apply/rollback — delegate to root profilectl via sudo
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 09:32:56 +02:00