Commit Graph

2797 Commits

Author SHA1 Message Date
9325376034 docs(plan): générateur WAF product-absent — 6 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
3 modules purs (extract, presence, inventory) + emit additif/atomique + CLI
dry-run + panel. Les 2 barrières (allowlist appliance ET union dpkg∪vhosts∪
modules) avec fail-safe présent. Sortie toujours detect (jamais armée), preuve
par mutation à chaque tâche. Ajoute python3-yaml. 28 tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 05:30:01 +02:00
ed9382b30b docs(spec): générateur de règles WAF "produit absent"
Fiche les scanners sondant des exploits de produits que la box ne fait PAS
tourner (F5/PAN-OS/Ivanti): 0 faux positif car le produit est absent. Principe:
honeypot=DPI, rendre le bruit pertinent. Vit dans cve-triage (a déjà l'inventaire
dpkg + KEV). Source = sous-ensemble Nuclei vendored (MIT, pas de git-pull
runtime). Sortie = catégorie product_absent_probes en mode DETECT, additive/
idempotente, jamais les 17 catégories existantes, jamais armée (promotion
escalate = humain).

Oracle de présence = DEUX barrières: (1) allowlist de familles d'appliances
qu'une box Debian n'est jamais; (2) absent de dpkg ∪ vhosts routés ∪ modules —
crucial car nextcloud/gitea sont en LXC, absents du dpkg hôte mais routés
(vérifié gk2). Règle de sûreté: dans le doute, PRÉSENT (rater une sonde est
sans conséquence; bannir un vrai user ne l'est pas). S'appuie sur detect
(PR#872) + escalate (PR#873) livrés.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 05:13:23 +02:00
192f059df6 Merge fix/podcaster-import-staging-ssd: stage YouTube imports on the SSD (#874)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 19:34:46 +02:00
a9e0e97ed3 fix(podcaster): stage YouTube imports on the SSD, not the eMMC root
run_import computed its staging workdir as Path(media_dir).parent /
youtube-import. media_dir is the symlink …/podcaster/media -> the SSD; taking
.parent BEFORE resolving lands staging back on the eMMC root. A large playlist
import then downloaded ~1.4G into eMMC staging, filled / to 99%, and stalled
before moving anything to the SSD store — a recurring failure a symlink alone
never fixed because the path is computed in code.

.resolve() follows the symlink so staging and store share the SSD, and it
derives from the real path so a package reinstall that re-lays the symlink
cannot revert it. Verified: with media -> SSD, the workdir resolves onto the SSD.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 19:30:35 +02:00
fd8fe87a4c Merge feat/sbxwaf-mode-escalate: escalate mode — observe then ban a persistent scanner (#873)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:36:19 +02:00
772590b0d1 fix(sbxwaf): log an escalate ban to journald, like the block path
The final review found escalate bans were visible in the dashboard JSON
(action=banned) but silent in journald — the block path emits a THREAT line on
ban, the escalate branch did not. An operator tailing journalctl -u
secubox-waf-ng for THREAT would miss the very scanners escalate exists to catch.
One line, mirroring the block path. No behaviour change; tests + vet clean.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:35:26 +02:00
6c85979070 feat(sbxwaf): escalate path — observe on a separate counter, then ban
A separate escalateBan (default 24h/3, --escalate-window/--escalate-threshold)
counts hits on escalate-mode categories. Below the threshold the request passes
and logs action=detect (like detect); at the threshold it bans exactly as the
block path does (crowdsec.Report + writeBan) — but gated on escalateBan, never
s.ban, so the slow-scanner signal never mixes with the block ban. A nil
escalateBan degrades to detect (observe, never ban), never to block.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:16:58 +02:00
af832de5a1 feat(sbxwaf): recognise the escalate mode value
escalate is a first-class known mode alongside block/detect. Absent/unknown
still fail closed to block; escalate itself must not be treated as unknown.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:09:54 +02:00
3549e2f743 docs(plan): sbxwaf mode escalate — 3 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
Task 1: reconnaître modeEscalate (fail-closed préservé). Task 2: champ
escalateBan (compteur SÉPARÉ, défauts 24h/3), branche self-contained qui observe
(action=detect, passe) sous le seuil et banne au seuil via crowdsec+writeBan —
gatée sur escalateBan, JAMAIS s.ban, sinon les signaux se mélangent. nil →
comme detect. Task 3: déploiement + preuve live (observation; le ban au seuil
est prouvé déterministe­ment par les tests unitaires).

Un seul site d'appel de Match. Mutation obligatoire par tâche.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:07:05 +02:00
6d0e18eab9 docs(spec): sbxwaf mode escalate — observer puis bannir un scanner récidiviste
Moitié "mécanisme" du besoin (l'autre moitié = générateur Nuclei, spec B).
escalate = detect jusqu'à N sondes, puis block+cscli. Réutilise entièrement
Ban.Record + CscliReporter + les chemins detect/block livrés (PR #872) — la
seule pièce neuve est une 2e instance Ban avec fenêtre LONGUE (défaut 24h/3),
car un scanner lent échappe à la fenêtre 5min du mode block.

Clarification (retour user): escalate n'affaiblit PAS l'autoban. block reste le
défaut et banne les agressions immédiatement; les 17 catégories restent en
block. escalate AJOUTE l'autoban de l'adversaire patient que le ban rapide rate.

Deux limites posées franchement: scanner distribué (1 IP/sonde) n'atteint jamais
N; le ban escalate = drop nft de tout le trafic → les règles générées (spec B)
naissent en detect pur et sont promues à la main après vérif 0 FP.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:02:34 +02:00
3de66fb05c docs(spec): CVE — distinguer les deux buts aux filtres opposés
Correction de l'utilisateur: un CVE sert à deux choses aux filtres INVERSES.
PROTÉGER (ce spec): KEV ∧ paquet installé ∧ exposé — suis-je vulnérable.
FICHER (générateur WAF): le motif vise un produit ABSENT ⟹ 0 faux positif —
qui me sonde. Personne n'a de raison légitime de demander /mgmt/tm/util/bash
sur une box sans F5; c'est un signal pur qu'on ne bloque pas (mode detect) mais
qu'on fiche. Ce spec ne traite QUE PROTÉGER; le filtre fingerprinting appartient
au générateur, qui s'appuie sur le mode detect livré (PR #872).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:37:03 +02:00
8f9cdeaaec Merge feat/sbxwaf-mode-detect: per-category detect mode + hot-reload + honest counters (#872)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:34:56 +02:00
81a50f6307 fix(waf): keep detect-mode matches out of the block counters
The final review found the producer/consumer halves disagreed: sbxwaf writes
action="detect" for a matched-but-passed request, but the dashboard's
_get_threat_stats counted every log line as a threat and set
blocked_24h = total_threats. The instant an operator armed a detect category,
every observed-but-passed request would inflate the "blocked" count and the
category/severity donuts — the exact conflation action="detect" exists to
prevent.

Detect records now feed a separate observed_threats/observed_today counter
(persisted like the rest, since the reader is incremental) and are excluded
from total_threats and the block breakdown. CrowdSec's own alert overlay is a
distinct source (real bans) and is untouched.

Two tests, mutation-verified (removing the exclusion makes both fail). They
live in a new file because the pre-existing test_stats_cache.py fails to
collect on master (stale `import StatsCache`, since renamed to a no-op shim) —
not touched here.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:30:46 +02:00
f3bb74e0a0 fix(sbxwaf): wire rules hot-reload into the request path
The design spec claimed a rules change needs no restart. It was false:
Rules.Maybe() was defined but never called — only s.routes.Maybe() was. So an
operator flipping a category to detect saw nothing until the WAF fronting every
vhost was restarted.

One line beside the existing routes reload check. Match takes an RLock, the
reload Apply takes the Lock — race-safe (go test -race clean), same one-stat
cost routes already pays. Server-level test proves a live block->detect edit
takes effect across two requests without a restart; mutation-verified (removing
the line makes it fail).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:30:46 +02:00
2f44ee7d55 feat(sbxwaf): honour detect mode — match, log, pass, never ban
Match now reports the category mode; main.go's single call site treats a detect
hit as observation: the threat record is written with action=detect and the
request continues to the upstream. No ban, no CrowdSec report, no nft decision —
a detect category is as harmless as enabled:false, minus the log line.

action=detect keeps stats honest: without it, 'blocked' and 'would have blocked'
would be indistinguishable.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:59:25 +02:00
2dc2654bfe feat(sbxwaf): parse a per-category mode (default block, fail closed)
Absent, empty or null mode means block: the 17 shipped categories carry no
mode and must keep blocking. An unknown value also means block, loudly — a
typo like 'monitor' must never silently drop a protection nor downgrade it to
observation.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:48:19 +02:00
a1b9add575 docs(plan): sbxwaf mode detect — 3 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
Task 1: parser `mode` par catégorie (défaut block, fail-closed sur valeur
inconnue). Task 2: Match remonte le mode; le chemin detect journalise
action=detect et laisse passer, sans jamais bannir. Task 3: déploiement gk2 +
preuve sur trafic réel (bascule cve_2024 en detect à chaud, puis restauration).

Un seul site d'appel de Match (main.go:376) — rayon de souffle contenu.
Chaque tâche exige une preuve par mutation: casser le comportement, constater
l'échec du test, restaurer.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:42:20 +02:00
572d6eece9 docs(spec): sbxwaf mode detect — essayer une règle avant de l'armer
Né de la question "peut-on dériver des règles WAF depuis les CVE ?", dont
l'exploration a montré que ce mode en est le prérequis bloquant. Mais il vaut
seul, et il est livré seul.

Aujourd'hui rules.go n'a que deux états: catégorie sautée, ou évaluée ET
bloquante. Toute nouvelle règle est donc un pari armé en production devant
Nextcloud/PeerTube/Gitea — et une régression WAF a déjà servi du JS vide en
silence (YaCy blanc).

Champ `mode: detect|block` par catégorie. Absent ⇒ block: les 17 catégories
existantes ne changent pas. Un défaut detect transformerait le WAF en
observateur muet — une panne de sécurité silencieuse.

Un match en detect est compté et journalisé (action: "detect") mais laisse
passer et ne déclenche AUCUN ban. Invariant: une catégorie en detect doit être
aussi inoffensive qu'un enabled:false, à la journalisation près.

Donne enfin l'instrument pour prouver le poids mort déjà diagnostiqué: 39/149
patterns voip/xmpp inutiles en HTTP, et une catégorie cve_2024 visant PAN-OS,
Ivanti et F5 — trois produits absents de cette box Debian.

Générateur CVE→règles explicitement hors périmètre (chantier suivant): source
Nuclei (MIT vérifié; les CVE ne contiennent aucun exploit — vérifié sur le KEV),
filtre KEV ∧ installé ∧ exposé, expiration à la correction.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:36:02 +02:00
a33e3c0092 docs(spec): alertes CVE récurrentes et réelles
Le module secubox-cve-triage existe déjà et fait plus que prévu (dpkg, NVD,
CVSS, EPSS réel, flux KEV CISA, triage, panel). Il lui manque une gâchette
(aucun .timer), une voix (zéro notify) et un filtre anti-bruit. Ce spec livre
l'appelant, pas un module.

Filtre: KEV ∧ installé ∧ (exposé public ∨ rançongiciel) — un ET, pas un OU.
L'exposition vient de l'inventaire secubox-profiles livré ce jour (16 modules
publics sur 187). Anti-bruit: alerte sur le delta, ré-alerte sur escalade,
acquittement, digest, plancher anti-tempête.

CrowdSec CTI écarté: clé obligatoire, 40 requêtes/mois en gratuit, dépendance
cloud pour une donnée que le KEV CISA fournit librement (1647 CVE, sans clé).

XMPP chiffré honnêtement: le module jabber ne sait pas envoyer: c'est un
gestionnaire Prosody. Le sink exige slixmpp (présent dans bookworm), un compte
bot, un secret et un client — une tâche isolée, pas un branchement.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:17:50 +02:00
ea25a23831 Merge feat/profiles-webui: profiles panel + own-socket API + 46s->0.03s status (#871)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:16:08 +02:00
b7bd56b23a perf(profiles): batch the probes and cache the status (46s -> 0.03s)
GET /status took 46 SECONDS on the board and the panel was unusable.

Cause: observe() ran 3 subprocesses per module (is-enabled, is-active, show)
plus lxc-info, and load_routes() re-read the routes file once per module. With
187 modules that is ~560 spawns at ~29ms each, on a box running 118 services at
load 5.4 on 4 cores.

observe_all() now issues ONE systemctl-show for every unit (measured: 187 units
in 0.28s), ONE LXC enumeration, and reads routes once. RSS comes from
/proc/<MainPID>/status - a file read, not a spawn. web.py serves /status from a
60s cache refreshed off the event loop (asyncio.to_thread), per the project's
own "double caching" pattern; the CLI still computes live, since a one-shot
command gains nothing from staleness. /status reports cached_at + age_s rather
than pretending to be live.

Two traps, both covered by tests:
- A bare template unit (name@.service) makes systemctl-show fail for the WHOLE
  batch - it returned 40 blocks of 188. Templates are excluded.
- A unit missing from the batch output must become enabled=None/active=None
  (unknown), never False. This branch has fixed five defects of that class; a
  batch is a fresh way to commit the same one.

Measured live on gk2 after deploy: 46s -> 2.57s cold, 0.025s warm; 187 modules;
sockets 108 before and after; panel 1.6ms and API 94ms through nginx.

The honest-unknown rule is visible in the real data: totals went from
off:66/unknown:0 to off:62/unknown:4 - the old code was asserting "off" for the
4 template units it could not actually resolve.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:10:17 +02:00
dfb01df1bb fix(profiles): ship the nginx route to the directory nginx actually includes
Two bugs that cancelled into a 404, both found by deploying rather than by tests:

1. The drop-in was installed to /etc/nginx/secubox.d/, which nothing includes.
   The admin vhost includes /etc/nginx/secubox-routes.d/*.conf. secubox.d holds
   138 files and is dead — a route dropped there is inert.

2. proxy_pass ended in ':/', which strips the prefix. The app registers the FULL
   path (@app.get('/api/v1/profiles/status')), so stripping handed it /status —
   a route it does not know. Prefix must be preserved for a dedicated socket
   whose app owns its full path.

Verified live through nginx and public HTTPS: panel and all three API routes 200.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:45:21 +02:00
cedc012051 fix(profiles): load_routes must not crash on an untraversable parent
Found by deploying to the real board, not by tests: GET /status returned 500.

/etc/secubox/waf is 0750 root:root while haproxy-routes.json inside it is 0644
— the file is world-readable, the directory is not traversable by the secubox
user the service runs as. Path.exists() raises PermissionError in that case, and
it sat OUTSIDE the try that already guarded read_text.

This is the fifth instance of the same class on this branch. The previous four
were 'unknown must not become a definite answer'; this one is its twin:
unknown must not become a crash either. An unreadable routes file now yields
None (undeterminable) as intended, so /status degrades to 'exposure unknown'
instead of 500ing the whole panel.

The directory permissions are the WAF module's business and are left alone —
this module's job is to survive them.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:40:21 +02:00
78f5e80747 feat(profiles): webui surface — own-socket API + hybrid-dark panel
Phase 1 stays read-only for actuation: no apply endpoint anywhere. Adds
api/web.py (FastAPI, JWT-gated) exposing status/profiles/pins/diff plus the
two writes Phase 1 allows — profile membership and pins — both atomic
(temp file + os.replace) and limited to /etc/secubox/profiles/*.toml.

A pin that would switch a protected module (auth/aggregator/core/nginx/
firewall/profiles) off is refused with HTTP 409 independently of
diff.plan_changes, before anything touches disk — proven load-bearing by
temporarily removing the guard and watching the test fail, then restoring
it and watching it pass.

Runs on its own service + socket (/run/secubox/profiles.sock), never via
the aggregator. Probe results that couldn't be determined surface as
"unknown", never silently downgraded to "off" (observe.is_on() collapses
None to False by design for diff's actionable decision, which would
misrepresent an indeterminate probe here).

www/profiles/index.html follows WEBUI-PANEL-GUIDELINES.md (hybrid-dark,
shared sidebar, data-* delegation, esc() on all injected values) with
stat grid, per-category/runtime/exposure aggregates, per-module pin/
membership controls, and a diff view explicitly labelled not-applied.

94/94 tests pass (73 pre-existing + 21 new, packages/secubox-profiles/tests).
2026-07-17 13:35:44 +02:00
2dff4868fe Merge feat/profiles-phase1: module inventory, profiler and diff (read-only)
Some checks are pending
License Headers / check (push) Waiting to run
Phase 1 of the profile system: flat-file manifests, a scan profiler that derives
them from reality and measures cost, pure state resolution, an ordered diff, and
the secubox-profilectl CLI. apply deliberately does not exist — it arrives in
Phase 3 with 4R snapshots, sequential application and audit; a test locks its
absence.

73 tests. Six review rounds caught five defects the tests could not: vacuous
negative tests short-circuited by an earlier guard, a failed probe fabricating a
definite False (three separate instances), a taxonomy guard defeated by a string
collision, a bypassable protected core, and an unbuildable package.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:19:01 +02:00
fc28ab7678 fix(profiles): unbuildable package, rc collision on scan, data-driven protection
C1: drop debian/compat (kept debhelper-compat (= 13) Build-Depends) — the
package was the only one in the repo carrying both, which debhelper refuses
to build.

C2: cli._run() now returns rc=None for "did not execute" (OSError/timeout),
mirroring observe._run_cmd's contract. _cmd_scan checks rc for both
systemctl list-unit-files and lxc-ls and aborts rather than silently
deriving LXC modules as runtime="native" from empty output; a non-root
invocation (rc=0, empty output) is refused explicitly since it is
indistinguishable from "no containers" by rc alone.

C3: PROTECTED_IDS moves from scan.py to manifest.py (a schema property, not
a profiler detail) and load_manifest() now forces protected=True for those
ids regardless of what a shipped manifest declares, closing the gap where a
sloppy module package could silently unprotect auth/aggregator/core/etc.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:14:02 +02:00
310f4713f5 Merge fix/users-secret-leak: stop leaking password hashes + TOTP secrets (#870)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:59:25 +02:00
6f8a04fcad fix(users): stop leaking password hashes and TOTP secrets to any client
GET /api/v1/users/users, /user/{username} and /export returned raw records
from users.json, which holds the argon2id password hash, the TOTP *secret*,
and the argon2id hashes of the TOTP backup codes.

All three routes are gated by `require_jwt` — ANY authenticated account, not
just an admin — so the lowest-privilege user could harvest every other
account's credentials. The TOTP secret alone lets an attacker mint valid
6-digit codes forever: a complete MFA bypass. This is a privilege-escalation
vector on a product whose own charter forbids secrets in clear.

/export was the worst: its comment said "Remove sensitive data" while dropping
only `provision_results`. A comment asserting a guarantee the code does not
provide is worse than no comment — it stops reviewers from looking.

Add redact_user() as the single place that decides what a client may see, and
route all three through it. `totp` is kept but reduced to its non-secret
metadata, and its PRESENCE is preserved: the webui shows the MFA badge on a
truthiness test (`u.totp_enabled || u.totp || u.mfa_enabled`), so dropping the
key would blank the badge and always emitting one would light it up for users
who never enrolled.

It lives in its own module rather than main.py because main.py reads the config
at import, so importing it requires read access to /etc/secubox/secubox.conf —
a security rule must stay testable without privileges.

Verified live on gk2 across all three routes: no argon2 hash, no TOTP secret,
no backup codes; totp_enabled still correct per account (admin true, gk2 and
operator false). Mutation-checked: restoring the passthrough fails 5 of the 9
tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:58:38 +02:00
041b2d63ed fix(profiles): stop scan from silently downgrading exposure on unreadable routes
_cmd_scan collapsed load_routes()'s None (routes file present but
unreadable/corrupt, indeterminate) into the same set() as a genuinely
absent routes file, so a corrupt WAF routes file silently derived
every routed module's exposure down from public to lan/internal with
no operator feedback — and since a written manifest is authoritative,
the degraded value would stick until a manual --force rescan. scan now
warns on stderr in the indeterminate case only, naming the consequence
and the remedy, and stays silent when routes are genuinely absent.
_cmd_status/_cmd_diff were already correct and are untouched. Also
drops the unused pins_file binding in _cmd_status.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:49:38 +02:00
536eac0977 feat(profiles): CLI secubox-profilectl (scan/status/diff) + paquet
Phase 1 en lecture seule : apply n'existe pas, un test le verrouille.
Vérifié sur gk2 : scan découvre les modules et status affiche taxonomie +
coût RSS, sans qu'aucun changement de service ne soit attribuable à ces
commandes (fluctuation ±1 confirmée comme bruit préexistant des timers
oneshot du board, pas causée par cet outil).

Fix additif : load_routes() peut renvoyer None (fichier de routes corrompu),
scan._cmd_scan retombe sur set() plutôt que de planter dans discover().

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:43:20 +02:00
775175f0dc fix(profiles): stop menu.d mesh from colliding with deployment mesh
_category() let menu.d's UI catch-all "mesh" (declared by media modules
like peertube/lyrion) pass through as deployment category "mesh" simply
because the token matched — corrupting per-category stats. Replace the
membership check with an explicit MENU_CATEGORY_MAP, defaulting unknown
tokens to the neutral "infra" instead of a confidently wrong category.

Also make _toml_str escape control characters (\n \t \r) per TOML basic-
string rules — an unescaped literal newline in a field value previously
emitted a file tomllib itself could not reparse. Add roundtrip tests for
quote/backslash and control-character values, and replace a vacuous
"category in the whole enum" assertion with the exact expected value.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:29:33 +02:00
ec589adfca feat(profiles): profiler scan — dérive les manifestes du réel
134 manifestes ne s'écrivent pas à la main : on les dérive des units, LXC,
routes WAF et menu.d, puis l'opérateur corrige — et scan n'écrase plus.
Émetteur TOML écrit à la main (tomllib est en lecture seule), validé par
aller-retour avec le loader.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:19:16 +02:00
cb691e3577 feat(profiles): diff ordonné désiré vs réel
Ordre = décision de sûreté : stops avant starts (la box a ~2 Go libres, un pic
d'allumage la tuerait), stops par priorité croissante, starts par décroissante.
Un pin 'off' sur un module protégé est refusé, pas averti.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:11:45 +02:00
aa430739e6 fix(profiles): stop fabricating False in observe() systemd/routes branches
_run_cmd returned rc=1 both when systemctl genuinely answered "disabled"
and when the command failed to execute (OSError/timeout) — the latter
then read as a fabricated `enabled=False`/`active=False`, which a later
phase would treat as "off" and wrongly start. _run_cmd now returns
rc=None as the not-executed sentinel, and observe() maps it to None
instead of False for both enabled and active.

Likewise load_routes() folded an unreadable/corrupt routes file into the
same "no routes" set() as a genuinely absent file, producing a fabricated
portal_routed=False. It now returns None when the file exists but can't
be read/parsed, and observe() propagates that as portal_routed=None.

Also restores the `m: Manifest` type annotation on observe() that had
been dropped.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:04:55 +02:00
e0106674be feat(profiles): sondes d'état réel en lecture seule
L'inconnu reste None, jamais False : lxc-info échoue depuis le contexte non
privilégié de l'API (lxc_state='absent' alors que le service répond), et un
False inventé produirait une fausse décision d'allumage.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:56:13 +02:00
ea2f622503 feat(profiles): résolution de l'état désiré (protected > pin > profil > off)
Profils exhaustifs + pins persistants. resolve() est pure : c'est la règle la
plus critique du système, elle doit être testable sans board.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:51:20 +02:00
06b896c26c fix(profiles): make manifest negative tests reach their validators; validate needs/protected types
Six negative tests wrote fixtures to bad.toml while the TOML body still
declared id="lyrion", so they all raised on the id-mismatch check before
ever reaching the enum, lxc-requirement, or priority-bounds validator they
claimed to exercise. Fixed by writing to lyrion.toml so the intended check
is the one actually reached. Also fixed the runtime enum test's replace()
which searched for one space but MINIMAL uses two (column alignment),
making the substitution a silent no-op.

Added strict type validation for needs (must be list[str], not a bare
string that tuple() would explode into single chars) and protected (must
be bool, not a string coerced by truthiness) — protected gates whether a
module may ever be shut down, so silent coercion here is a live risk.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:46:58 +02:00
e53b049b6c feat(profiles): schéma et chargement des manifestes module
Valide strictement : un manifeste mal typé deviendrait une décision
d'extinction erronée en Phase 3.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:39:28 +02:00
722b5c1b03 docs(plan): profils & modules — Phase 1 (lecture seule)
6 tâches TDD : manifestes, résolution d'état, sondes, diff ordonné, profiler
scan, CLI + paquet. 55 tests.

Phase 1 ne peut rien allumer ni éteindre — un test verrouille l'absence de la
commande apply, et observe.py n'exécute que des commandes de lecture.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:36:40 +02:00
26eea724e1 docs(spec): conception du système de profils & activation de modules
Bascule de contexte au quotidien (média / sécurité / démo) : profils exhaustifs
+ pins persistants, toggle individuel, taxonomie des modules, dépendances
minimisées, priorités présentées.

Architecture retenue : manifestes plats + moteur de réconciliation. Les .target
systemd sont écartées : LXC, routes WAF et menus ne sont pas des units, et une
target n'exprime pas 'éteint'.

Le design est contraint par le réel mesuré sur gk2 : 187 units (118 actives),
load 5.4 sur 4 cœurs, ~2 Go libres, 24 LXC, 134 menu.d, et 80 units en
Requires=secubox-core (un oneshot mkdir) — d'où application séquentielle,
extinction avant allumage, et conversion Requires -> Wants en prérequis.

Noyau protégé non négociable : un profil ne doit pas pouvoir éteindre ce qui
permet de le rallumer.

Métriques mesh explicitement hors périmètre (spec séparé).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:29:04 +02:00
c2c1d0e008 docs: APK rebuilt + republished (SW v11, peertube, upload, 401 re-login)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:12:06 +02:00
c9f2164c26 build(companion): commit the Capacitor Android scaffold + lockfile
The android/ project backing `npm run apk` was never tracked, so the APK build
was only reproducible on the machine that happened to have it.

The gradle wrapper is pinned to gradle-8.9-bin (the default 8.7-all is what
broke the build); the URL is the public distribution, and the tree carries no
machine-specific paths, so it builds anywhere.

.gitignore is the standard Capacitor/Android template — no APKs, build/,
.gradle/, local.properties, keystores, or the copied web assets under
app/src/main/assets/public (those are generated from www/ by cap sync, and
committing them would fork the app code).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:08:42 +02:00
7e46e323bf Merge feat/companion-peertube: PeerTube module in the Companion (#869)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:00:12 +02:00
06cea1ffb0 feat(companion): add PeerTube module
Video instance in the Companion: overview (state, version, counts, storage,
transcoding queue), video library, channels, and federation peers.

Every route was probed live and every shape read from
packages/secubox-peertube/api/main.py — none guessed. Verified against the real
instance: 42 videos, 3 channels, 17G storage.

Two box-side facts shape the view:
- `lxc_state` reads "absent" on this box while PeerTube serves fine (lxc-info
  cannot see an unprivileged container from the API's context). The box's own
  is_running() trusts http_reachable(), so the module does too — reporting
  "down" off lxc_state would be a lie the backend itself doesn't tell.
- The list routes answer {key:[...], total, error?} and SET `error` instead of
  failing when the instance is unreachable, so `error` is surfaced rather than
  rendered as a misleading "empty".

Each section degrades independently — one failing route cannot blank the
module. Storage rows with empty values are skipped (the live `videos` stat is
""). An idle transcoding queue says so explicitly rather than showing nothing.

Registered next to podcasteur (both MIND media modules). sw.js v10 -> v11.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:59:48 +02:00
28c4fbde84 Merge feat/sessions-companion-emoji: session auditing + Companion auth/system/metrics + billets emoji hashtags (#868)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:37:13 +02:00
1d0f578f1f docs: track session auditing + Companion auth/system/metrics + billets hashtags
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:36:29 +02:00
aef06b8b22 feat(companion): real auth/system modules, emoji metrics, 401 re-login
The auth and system modules were 34-line stubs guessing routes — auth called
/api/v1/auth/users, which 404s; identities actually live under /api/v1/users.
Both are rebuilt against routes read from the handler source, not guessed.

- core/metrics.js: emoji/severity gauges + human formatters. Thresholds are
  PER KIND because a disk at 94% and a cpu at 94% carry different risk and must
  not look alike — gk2's disk (94.5%) reads 🔴 while its cpu (10%) reads 🟢.
  Plain language over field names ("Memory 75% — 5.5 GB of 7.7 GB used").
- modules/auth: /api/v1/users/{users,sessions,roles,groups}; sessions rendered
  prominently (who / IP / device / age / current), which is the point of the
  session work landing alongside it.
- modules/system: /status, /metrics, /resources, /health_score, /network,
  /security — each section degrades independently so one failing route cannot
  blank the module.
- billets: tag chip bar + per-billet emoji chips, quick-view filtering, and
  short resumes instead of full bodies; list now uses the authoring endpoint,
  so Edit/Delete address real ids and drafts are visible.
- 401 re-login: box tokens live 24h, and the Companion seals one at pairing and
  reuses it — so a day later every authed call 401'd with no way out but
  unpairing ("Failed: unauthorized" in billets and podcaster). api.js now asks
  the app to re-authenticate and REPLAYS the request once; single-flight, so a
  screenful of concurrent 401s yields one prompt, and an in-flight write (a
  billet just typed) survives instead of being lost.
- sw.js v9→v10, metrics.js added to the offline shell.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:35:37 +02:00
f9302df075 feat(billets): emoji hashtags, quick views, and feed resumes
Authors already typed #hashtags in their bodies (37 of the 46 billets on gk2
carry one), so tags are EXTRACTED from the body rather than added via a picker:
tagging costs the author nothing and applies retroactively.

- migration 0004: tag(slug,emoji,label) + billet_tag, indexed on tag_slug.
  Extracted into rows rather than re-parsed per request, so filtering hits an
  index instead of LIKE-scanning every body. slug is accent-folded + lowercased
  (#Réseau == #reseau); emoji is STORED so a later curation change never
  silently restyles an already-published billet.
- services/tags.py: curated map + extraction. The lookbehind keeps a URL anchor
  (page#anchor) from becoming a tag; unknown tags fall back to a neutral badge
  rather than being rejected. Map covers this box's real content (podcast/media)
  as well as the security/box vocabulary; genuinely ambiguous topics are left on
  the default rather than editorialised.
- quick views: ?tag=slug on / and /feed.json, plus /tags.json and
  /admin/api/tags for the chip bar. Uses EXISTS, not a JOIN, so keyset paging
  cannot duplicate a row per matching tag; the pager carries the active tag.
- feed resumes: long billets show a short excerpt + "Lire la suite"; short ones
  render whole. `is_long` is measured on the markdown-collapsed text, so a short
  billet padded with link syntax is not "resumed" into a copy of itself.
- feed.json gains `summary` + `tags` (both JSON Feed 1.1 standard) with the
  emoji in the spec-legal `_secubox` extension; optional keys are omitted, not
  nulled.
- GET /admin/api/billets: the authoring list. The public feed cannot serve it —
  its item id is a permalink URL (so edit/delete could not address a row, which
  is why they 404'd) and it hides drafts, which an authoring client must see.
- manage.py backfill-tags: idempotent; also re-applies the curated map to
  existing tags as an explicit operator step.

Verified on gk2: backfill tagged 37/46 billets; ?tag=podcast→37, jfk→4, nope→0;
a draft's tag correctly stays out of the public chip bar; delete cascades.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:35:24 +02:00
b50387a06b fix(auth): record client IP + user-agent on the MFA login paths
/login/mfa and /totp/confirm both hardcoded "ip": "" and omitted user_agent
entirely, while only the password path captured them. admin is forced-TOTP,
so EVERY real admin login took the MFA path — every session row landed
unauditable (who logged in, from where, with what), and the users webui
sessions tab rendered blanks for data it was already asking for.

Factor the extraction into _client_meta(request) (X-Forwarded-For first: nginx
and HAProxy front every login, so request.client.host is only ever the proxy)
and use it on all three paths.

Verified by driving the real /login/mfa handler in-process with
SECUBOX_AUTH_SESSIONS pointed at a temp file: the session row now records
ip=192.168.1.77 (the first XFF hop, not the proxy) and the full user-agent.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 10:35:07 +02:00
bf97dc94c7 fix(companion): SW network-first bypasses HTTP cache (cache: reload)
Some checks are pending
License Headers / check (push) Waiting to run
A plain fetch(req) in the network-first handler still consults the browser
HTTP cache, which heuristically caches module JS (no Cache-Control on .js).
That let an old view.js survive a deploy — the recurring 'still broken after
hard refresh'. Refetch with cache:'reload' so every online load pulls fresh
app code; offline still falls back to the Cache API. Bump v7 -> v8.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 07:56:04 +02:00