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>
The billets module called `/feed` (404) — billets serves a JSON Feed at
/feed.json — and read `published_at` where JSON Feed uses `date_published`;
status_endpoint was /health (billets serves /healthz). Fixed all three, so the
#/m/billets list loads (30 items) instead of {"detail":"Not Found"}. Writes
(new billet, comment moderation) remain unavailable — billets exposes only GET
routes (its admin is the session-based web UI); those tabs already degrade
gracefully. A JWT write API for billets is a separate feature.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The API runs unprivileged (secubox) but probed the container via lxc-info/occ
WITHOUT sudo and with lxc_path=/srv/lxc (container is /data/lxc) → status always
'Stopped', cloud.local domain, empty version/users/disk (and a 500 once the path
was corrected, from a PermissionError on /data/lxc). Now: privilege-free TCP port
probe for liveness, all container ops via 'sudo nextcloudctl' (the sole sudoers
surface, incl. its occ passthrough), a daemon-thread 60s cache for the slow occ
fields (version/users/storage) so /status stays fast, real data_path, and a
public web_url from the domain. Webui restyled to the /certs/ cyan hybrid-skin
(361 lines, emoji cards, live pulse, 30s refresh); every endpoint preserved.
Needs (board drift): NoNewPrivileges=false drop-in (sudo was blocked) and
[nextcloud] domain=nc.gk2.secubox.in in secubox.conf.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The CrowdSec bridge was silently OFF: the unit passes --crowdsec-url but no
--crowdsec-jwt-file, so the wiring hit the 'disabled' branch and srv.crowdsec
stayed nil → Report() was never called. 198K threats detected, ~0 auto-bans
enforced (the 36k-hit offender was never nft-dropped). The LAPI /v1/alerts path
was doubly broken anyway: JWTs expire hourly and our alert schema 500s on this
build.
Add CscliReporter — the proven path the dashboard's manual ban uses
('cscli decisions add', which creates a real bouncer-enforced nft drop). It
engages when --crowdsec-url is set without a JWT file (new --crowdsec-cscli
flag, default 'cscli'). Per-IP 5-min dedup collapses the storm from the
graduated ban firing Report on every banned request (a rapid attacker otherwise
spawned dozens of concurrent cscli procs that contended until killed); on cscli
failure the IP is un-recorded so the next hit retries. Verified live: 5 honeypot
hits from a test IP → exactly 1 decision, no storm.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The endpoint shelled to 'mailctl user passwd', which writes a host-side copy the
LXC never reads, so admin password resets silently didn't take (login kept
failing). Now generates the SHA512-CRYPT hash via the container's doveadm and
updates DATA_PATH/config/users directly (the bind-mounted file dovecot reads),
normalising any legacy 2-field line to the full 8-field record.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
crt.sh is frequently down (502/404 HTML) and its raw error leaked into scan
results. Now queries certSpotter first (reliable JSON), falls back to crt.sh,
then a clean message. whois now resolves the registrable domain for sub-domains.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
/exfil read the idle live-window state.json (empty when the wg-toolbox tunnel is
idle) so the dashboard showed no devices; now serves the cumulative rollup (real
devices) + overlays live active_flows + an engine-liveness block. Service control
repointed from dormant netifyd to the real Go collector (secubox-dpi-flowcap) via
a scoped sudoers grant (postinst). Webui regenerated in the cyan hybrid-skin look,
framed on the R3 engine, netifyd/mirred/block-rules cruft removed; the XSS-safe
media-buffer rendering is preserved verbatim.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Status/components/ports now use direct TCP probes instead of lxc-info (which
read the wrong lxc_path and reported everything Stopped); storage reads vmail.
Webui rebuilt in the /certs/ hybrid-skin look: emoji stat-cards, 15s live
pulse, External-mailboxes tab, scanline engine dropped; all endpoints preserved.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
A present-but-stale localStorage Bearer was used exclusively, ignoring a valid
secubox_session cookie, so every webui 401'd into a /login.html loop. Now both
sources are tried (Bearer first, then cookie); 401 only if neither validates.
Strictly more permissive — no regression for working Bearer clients.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The stale-while-revalidate SW served cached core/*.js first, so every fix needed
2+ reloads (and left the old registry.js resolving module imports to /core/…).
Switch to network-first (fresh when online, cache only offline). SW→v5.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Root cause of the module 401s: the companion proxied /api/v1/auth to the
AGGREGATOR, whose minted token's jti is never written to sessions.json, so
require_jwt rejected it. Route /api/v1/auth to auth.sock (canonical, session-
recorded). Implement the real two-step login like login.html: password →
access_token, or mfa_required → prompt TOTP code → /auth/login/mfa → access_token.
Capture the token as Bearer (+ the SSO cookie login sets). Ship the nginx vhost
in deploy/. SW→v4.
Proven end-to-end (temp viewer user): login returns access_token + sets
secubox_session; podcaster/wireguard/system → 200 via both Bearer and cookie.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Dynamic import() resolves relative to registry.js (/core/), so import('./modules/x/view.js')
hit /core/modules/… → 404 → index.html fallback (HTML) → browser rejected the module
('not a valid JavaScript MIME'). Make m._path an absolute URL via new URL(path, document.baseURI).
SW→v3 to ship past the cached shell.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
secubox-auth login is cookie-based (#400 SSO-lite: require_jwt accepts the
parent-domain secubox_session cookie OR a Bearer token). Switch the client to
credentials:'include' (send the cookie) and stop treating 'no token in body' as
an error; keep an optional bearer if a build returns one. Pre-fill the box URL
with the same-origin (the companion vhost proxies /api/v1 to the box, so no
CORS). Bump SW cache to v2 to ship the fix past the cached shell.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Proves the drop-a-folder flow (core untouched) and gives the dashboard one card
per canonical group. Reads use known box routes; a few write/detail routes are
marked TODO(api).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The /certificates handler shelled out to openssl once per PEM (84 blocking
subprocesses) inside an async handler on the shared aggregator loop → froze the
board when the certs tab was viewed. Now: parse in-process via cryptography
(84 certs in 0.1s vs 12s), run via asyncio.to_thread, and cache 60s. Reads the
real HAProxy PEM store (/data/haproxy/certs), matching the certs module.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The cert list + cert_count read the legacy acme.sh /etc/acme layout (empty on
this platform) → 0 certs shown. Point them at the combined HAProxy PEM store
(/data/haproxy/certs/*.pem) — the same source the certs module uses. Wildcard
filenames (_wildcard_.x.pem) map back to *.x.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Productises the one-off importer into the module:
- api/importer.py: yt-dlp URL importer (playlist or single video, or any yt-dlp
site). Per item: download video<=720p + extract audio, optional PeerTube upload
(unlisted, creds from root-only /etc/secubox/secrets/peertube-import.json),
podcaster episode (local audio, done), optional published billet (description +
source + both stream links). Off-loop via asyncio.to_thread; single job with
live JOB progress. Optional yt-cookies.txt to defeat YouTube's bot throttle;
yt-dlp errors surfaced in the progress log.
- endpoints: POST /import/url (guarded), GET /import/status.
- webui: '➕ Add from URL' button + modal (URL + PeerTube/billets toggles) +
live progress (current, done/total, bar, errors, log tail).
- Recommends: yt-dlp, ffmpeg.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Detect podcasts.apple.com / itunes.apple.com links, extract the numeric id, and
resolve the underlying feedUrl via the iTunes Lookup API before ingesting as a
normal RSS feed. Best-effort: falls back to the original URL on any error.
Declare python3-mutagen + python3-pil (used by the #855 cover extractor).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
- backend: _extract_cover pulls ID3 APIC / MP4 covr / FLAC picture from the first
audiobook track via mutagen, downscales with Pillow, stores <feed>/cover.jpg,
sets feed image to /feeds/<fid>/cover; new public cover endpoint. Best-effort
(never breaks an upload).
- webui: cover thumbnails on episode cards/rows with graceful emoji fallback on
missing/404; portal gets a 'Now Playing' cover bar driven by a capture-phase
play listener reading data-* (does NOT rebuild the list → playback guard intact).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>