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>
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>
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>
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éterministement par les tests unitaires).
Un seul site d'appel de Match. Mutation obligatoire par tâche.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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).
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>
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>
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>
_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>
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>
_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>
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>
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>
_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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
Add packages/secubox-billets/api/routes/jwt_admin.py — a JWT-authed JSON
admin surface (secubox_core.auth.require_jwt: Bearer OR secubox_session
cookie) that reuses the same repo/media layer as the session HTML admin.
Endpoints: POST/PUT/DELETE /admin/api/billets, POST
/admin/api/billets/{id}/media (multipart, EXIF-strip via services.media),
GET /admin/api/comments, approve/delete comment moderation. No-op if
secubox_core is absent (isolated unit tests, session admin unaffected).
Wire it into api/main.py (register_jwt_admin after register_admin).
postinst: drop a sorted-last zz-secubox-system.pth into the venv so the
isolated billets venv can import the .deb-managed secubox_core without
shadowing its own fastapi/pydantic wheels.
Companion billets module: point EP map at /feed.json + /admin/api/*, add
an image file input to the editor and upload it after the billet is
created (POST /admin/api/billets/{id}/media, multipart). Fixes the "New
billet missing upload" gap. Bump service worker v6 -> v7.
Verified live on gk2: create -> feed, valid PNG upload -> {url,thumb},
delete — all 200.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>