Commit Graph

71 Commits

Author SHA1 Message Date
b6ab518e2a release: bump minor on the 87 modules converted to Wants=secubox-core (Phase 2)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 09:08:22 +02:00
2ec7cef1b2 feat(profiles): Phase 2 — Requires=secubox-core → Wants= on all units (remove hard cascade)
secubox-core.service is a Type=oneshot (mkdir+chown) that RemainAfterExit=yes. A hard
Requires= on ~108 units cascade-stops them all if core is restarted/fails (e.g. a
secubox-core package upgrade) — a thundering-herd outage. After= keeps the ordering;
Wants= keeps the soft dependency without the cascade. Prereq for mass native apply
(Phase 3). Scaffolds (new-module.sh/new-package.sh) updated so future units use Wants=.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-19 09:06:54 +02:00
8396f76123 fix(metablogizer): WAF route gates publish (vhost advisory); portable safe tar-extract
Live gk2 findings: haproxyctl vhost-add is blocked by its drift-guard and is
redundant with default_backend mitmproxy_inspector, so route_ok now reflects the
WAF route (the operative mechanism), vhost stays advisory. And the board runs
Python 3.11.2 (no tarfile filter= kwarg) — replaced filter=data with a portable
manual member check (rejects traversal/absolute/links).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:49:38 +02:00
ca166762fb fix(metablogizer): publishctl uses real haproxyctl signature (vhost add <domain> <backend>; remove)
Live verification on gk2 showed 'haproxyctl vhost add <domain>' errors (needs
<backend>); route the host to mitmproxy_inspector (=sbxwaf). vhost del -> remove.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:45:57 +02:00
983c6dd1de build(metablogizer): ship publishctl sudoers; changelog 1.3.0
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:35:47 +02:00
c838b83d17 fix(publish): delegate vhost/WAF/cert to metablogizer /publish/route; drop illegal root-config writes
secubox-publish's infra provisioner ran as unprivileged `secubox` and tried
to write /etc/nginx/sites-available/<domain>.conf, rewrite
/etc/haproxy/haproxy.cfg, write /etc/secubox/waf/haproxy-routes.json, and
reload nginx/haproxy/secubox-waf directly. All of these silently failed
(permission denied), which was the root cause of published sites answering
421/never routing. Metablogizer already owns routing through the audited
secubox-publishctl root helper (apply_route + provision_cert), so add a
POST /publish/route endpoint there and have the hub call it instead of
touching root config itself.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:28:32 +02:00
b755a90eec feat(metablogizer): publisher wizard UI (5-step stepper)
Adds the Task-7 5-step Publish wizard (Content -> Version -> Route ->
Cert -> Backup) to www/metablogizer/index.html, wired to the Task-6
endpoints (POST /publish/wizard multipart, GET /publish/export/{name}).
Reuses the page's existing token()/API helpers and the
uploadSiteContent() multipart-fetch/401-retry pattern for consistency;
JSON result is rendered via textContent only.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:23:53 +02:00
5b907bdf3a fix(metablogizer): wizard reads sites_root from config; clean tempdirs; corrupt-zip -> 400
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:19:04 +02:00
cc06d72b57 feat(metablogizer): publisher wizard endpoints (wizard/export/import)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:14:40 +02:00
4305908ad5 fix(metablogizer): harden .sbxsite import against path traversal (name + tar filter)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:11:25 +02:00
b91b42e313 feat(metablogizer): portable .sbxsite backup (git bundle + manifest) with restore
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:07:31 +02:00
b2e3131991 feat(metablogizer): cert provisioning (wildcard for gk2, certbot for custom)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:04:39 +02:00
5b8feb2272 feat(metablogizer): route publishes into the live sbxwaf host file via publishctl
Retires sync_mitmproxy_routes (wrote the retired mitmproxy-LXC file — the zem 421).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 13:02:02 +02:00
389b2f380b feat(metablogizer): safe zip/html upload extraction (zip-slip guarded)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 12:56:39 +02:00
1338971818 fix(metablogizer): publishctl — valid JSON on all paths + real vhost failure reporting
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 12:54:06 +02:00
22c3260eac feat(metablogizer): secubox-publishctl root helper for privileged publish steps
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-11 12:48:39 +02:00
238637e09a release(webui): bump 127 arch:all packages for hybrid-dark reskin bake-in 2026-07-10 09:07:42 +02:00
3197f866e4 feat(webui): fleet reskin — apply hybrid-dark cyan overlay to all 116 crt-light panels
Add /shared/hybrid-dark.css: a shared overlay that remaps the design tokens
(consumed by crt-light.css, sidebar-light.css and every module's inline styles)
to the cyan hybrid-dark palette from /certs/ + /wireguard/. body.hybrid-dark
(specificity 0,1,1) beats :root and inline :root, so one token remap flips the
whole tree — no per-panel rewrite. Each panel gains the overlay <link> + the
hybrid-dark body class (2-line additive swap; crt-light.css kept as base).
See .claude/WEBUI-PANEL-GUIDELINES.md.

Panels with fully bespoke styles (certs/wireguard/users/nac already hybrid;
~14 non-crt-light custom panels) are untouched.
2026-07-10 08:40:10 +02:00
9eb89fcd9b feat(metablogizer): version uploads into their Gitea repo (auto commit+push per upload)
- git_commit_push(): stage-all + commit + push origin HEAD, best-effort
  (no repo / no changes / push-rejected never raise; commit kept local)
- /site/{name}/upload wires it under the per-site lock, off the event loop
- postinst rewrites the push URL to Gitea's internal LXC endpoint for the
  service user (public host NAT-hairpins → pack transfer stalls); seeds
  known_hosts. Verified live on gk2: upload → commit → push = ok
2026-07-10 08:22:22 +02:00
8adac26e55 fix(metablogizer): upload writes to the served root, not always public/
Some checks are pending
License Headers / check (push) Waiting to run
The upload endpoint unconditionally created and wrote into <site>/public/, but
a site's served root follows the convention 'public/ if it exists, else the
site dir root' (same as regenerate_nginx_config/publish). For a site published
from its dir root (e.g. 'status', root .../status), writing to public/ meant
nginx never served the uploaded content — the target site appeared unchanged.

Now target = public/ only when it already exists, else the site dir root, and
we no longer force-create public/ (which would have silently moved the served
root). Response includes the resolved target for clarity.
2026-07-02 08:05:29 +02:00
53465e3140 feat(metablogizer): per-site quick-access icons — update-from-git + upgrade-from-upload
Adds two per-row actions in the sites list:
- 🔄 deploySite → POST /site/{name}/deploy : manual git pull + redeploy, the same
  operation as the Gitea webhook (shared per-site lock, records a deploy entry).
- ⬆ uploadSiteContent → POST /site/{name}/upload : upgrade content from an
  uploaded archive; upload now also accepts a bare .html (written as index.html)
  in addition to .zip / .tar.gz.

Both reuse the existing auth (require_jwt) with the Bearer→cookie retry pattern.
Deployed + served on gk2.
2026-07-02 07:58:07 +02:00
4926347056 fix(metablogizer): non-destructive 401 retry — stop nuking shared tokens
Some checks are pending
License Headers / check (push) Waiting to run
Previous fix removeItem'd sbx_token/secubox_token on any 401, which destroyed
the auth the shared sidebar + sibling components rely on, cascading the whole
page to /login even for a transient/single 401. Now retries cookie-only
(credentials:same-origin) WITHOUT deleting the token, then redirects only if
that also 401s.
2026-07-01 16:38:22 +02:00
bb92387e80 fix(metablogizer): read both token keys (sbx_token||secubox_token) like every module + race-free cookie fallback
Root cause of the login bounce that other modules didn't have: metablogizer's
token()/headers() read ONLY sbx_token, but the fleet (hub, portal) reads
sbx_token||secubox_token — a valid token stored under secubox_token was invisible
to metablogizer, so it sent no Bearer and 401'd. Now reads both keys. Also: the
401 cookie-retry no longer gates on token() still being set (parallel dashboard
calls raced — first cleared the token, others bounced), and clears both keys.
2026-07-01 16:24:11 +02:00
28593b5a97 fix(metablogizer): stale localStorage token shadowed SSO cookie → 401 loop
require_jwt prefers the Bearer token when present (else the secubox_session
cookie). A stale sbx_token in localStorage was sent as Bearer, shadowing the
user's VALID session cookie → 401 → bounce to login → (with the return-redirect)
an endless login↔metablogizer loop, even though the user was authenticated.
Proven live: session-cookie-only=200, but stale-bearer+valid-cookie=401.
Fix: on 401 with a token, drop the stale token and retry cookie-only (SSO
fallback) before ever redirecting to login. Self-healing; no loop.
2026-07-01 16:01:33 +02:00
216bd3ae3d fix(login,metablogizer): auth redirect returns to the originating dashboard
login.html hardcoded window.location='/' after auth, ignoring where the user
came from — so any module that 401-bounced to login landed on the hub root
instead of coming back. Add _sbxRedirect(): honor a same-origin ?redirect=
path (open-redirect-safe, falls back to '/'). metablogizer now sends
?redirect=<pathname> on 401 (index.html + site.html), so /metablogizer/ ->
login -> back to the /metablogizer/ dashboard. Fixes the 'ko' auth/dashboard
switch; benefits every module that already sends ?redirect=.
2026-07-01 15:57:36 +02:00
CyberMind
82b0522c5b
Aggregator résilience: sweep async (243 handlers) + threadpool 80 (#738) (#739)
* perf(aggregator): async-sweep — 243 blocking route handlers async def→def (#738)

Mounted in the aggregator's single event loop, an 'async def' route handler
that runs blocking code (subprocess/journalctl/openssl/argon2) freezes the
WHOLE loop -> aggregator.sock Connection refused -> 502 board-wide.

Deterministic AST codemod (scripts/async-sweep.py) converts route handlers
that (a) are decorated with an HTTP verb, (b) contain a known blocking call,
(c) have NO await/async-with/async-for/yield, (d) are never used as a
coroutine elsewhere -> plain 'def'. Starlette then runs them in the AnyIO
threadpool, so the blocking call no longer stalls the gateway. await/stream/
websocket handlers are left untouched. Every file py_compile-checked.

243 handlers across 56 modules (system 17, qos 13, netdiag/hexo 12, hub 11...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(aggregator): raise AnyIO threadpool to 80 tokens (#738)

The async-sweep moves ~243 blocking handlers to the threadpool. With ~110
modules in one process, the default 40-token pool can queue head-of-line under
concurrent blocking load. Raise to 80 on startup (best-effort, never breaks
boot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(hub): double-buffer status cache + emojized health page (#738)

Navbar status (menu + health-batch) is now a strict double-buffer cache:
- request handlers NEVER compute on the request path — they return the current
  snapshot instantly (or a 'warming' placeholder), so the sidebar's polling can
  no longer serialize behind a ~3s systemctl walk and starve the loop;
- the background refresher is kicked from the request path (_ensure_bg) because
  mounted sub-apps receive neither startup nor @app.middleware events under the
  aggregator — the previous lazy-start middleware never fired there;
- snapshots are built complete then swapped atomically, so the dashboard never
  shows partial/bad counts.

Served by the dedicated secubox-hub process (:8001, isolated loop) the navbar
stays <50ms and holds 200 under 25+ concurrent polls where the aggregator-
mounted copy wedged (000). health.js: 🟢🟡🔴 emoji status indicators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(hub): render health status emoji cleanly (neutralize .led dot) (#738)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(sidebar): kill per-module health storm, use batch endpoint (#738)

The navbar refreshed LEDs by firing ONE /api/v1/<module>/health request per
module — ~119 requests every 30s, in batches of 8 — straight at the aggregator's
single shared event loop. Combined with the in-process module mount this is a
prime driver of the recurring board-wide 502 wedge (user-identified).

checkAllHealth + refreshStaleHealth now call /api/v1/hub/public/health-batch
ONCE (served by the dedicated, double-buffered hub process) and populate every
module's LED from that single response. 119 reqs/cycle -> 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:06:15 +02:00
c01f10c474 docs(wiki): refonte 126 modules — snapshots WebUI déterministes + READMEs 4 langues (ref #742)
Capture des 126 dashboards authentifiés (JWT minté serveur, injection localStorage)
avec attente de COMPLÉTION d'affichage déterministe (sentinelles de chargement
purgées + réseau calmé, plafond --delay) au lieu d'un sleep fixe. 126/126 OK.

- capture-screenshots.py: _wait_content_ready déterministe, mode --token (bypass
  login), goto domcontentloaded (plus de stall networkidle sur dashboards live)
- generate-docs.py: 23 modules jusqu'ici non documentés ajoutés (descriptions
  réelles depuis debian/control, EN/FR/DE/ZH), licence MIT→LicenseRef-CMSD-1.0,
  images wiki en URL raw.githubusercontent absolue (relatif = 404 sur GitHub wiki)
- 126 snapshots + thumbnails régénérés
- 126 READMEs paquet succincts + pages wiki MODULES/CATEGORIES ×4 langues

Gap modules documentés vs découverts: 23 → 0.
2026-06-26 10:14:14 +02:00
1001d24180 fix(#494): stop ALL units chowning the shared /run/secubox parent (systemic)
/run/secubox parent to a module user, clobbering the canonical tmpfiles rule
(1777 root root) and 502'ing cross-user socket traversal:

- secubox-hub.service ExecStartPre (the active cascading culprit — fired on
  every (re)start, right after secubox-runtime re-applied 1777 root root)
- secubox-eye-remote / metrics / metablogizer postinsts (chown parent)
- secubox-eye-square postinst (chowned BOTH /run/secubox AND /var/log/secubox
  to secubox-eye-square — worst, #511 class)
- secubox-p2p postinst (chown parent + /var/log/secubox)

All now keep only mkdir as a fallback; the 1777 sticky parent lets each daemon
create its own socket, and module logs go to own subdirs (eye-square/p2p) instead
of owning the shared /var/log/secubox. Parent lifecycle is owned solely by
tmpfiles.d + secubox-runtime.service. Bumped: hub 1.4.4, eye-remote 1.0.1,
eye-square 1.0.4, metablogizer 1.2.2, metrics 1.0.4, p2p 1.7.1 (core 1.1.7
in prior commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 68d98bf1eb)
2026-06-24 09:59:11 +02:00
CyberMind
e83fc3ba5b
fix(secubox-metablogizer): v1.2.1 — uvicorn --log-level info, surface webhook deploy success (#329)
debian/secubox-metablogizer.service: lower uvicorn --log-level from
warning to info so the webhook deploy success line
`logger.info("deploy site=…")` reaches journald. Previously only
error paths were visible, making successful auto-deploys silent.

The 8 info-level log sites in api/main.py are all event-driven
(startup + webhook + sync) — no flood concern.

Closes #119.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 07:07:17 +02:00
CyberMind
5e93c73600
feat(navbar): consolidate to 6 charter categories + fix dropped menu entries (closes #306) (#307)
The hub's `_compute_menu_sync()` drops menu entries lacking an `id`
field. 6+ packages (lyrion, yacy, zigbee, rustdesk, grafana, authelia)
shipped a different schema (`title/url/section/module`) so they NEVER
appeared in the navbar. Plus the existing CATEGORY_META declared 12
sections (dashboard/security/network/system/core/users/services/
privacy/monitoring/publishing/apps/admin), not the 6 SecuBox charter
modules.

Changes:
* All 121 packages/secubox-*/menu.d/*.json normalised to the canonical
  schema (id, name, path, category, icon, order, description). Legacy
  schema aliases (title/url/module/section) preserved as fallbacks then
  dropped from the file.
* Each menu entry's category remapped to one of the 6 charter modules:
  AUTH (auth/users/identity/zkp/nac/openclaw),
  WALL (crowdsec/waf/mitmproxy/hardening/threat-* /cve-triage/...),
  BOOT (kernel-build/eye-remote/master-link/droplet/cloner/backup/...),
  MIND (ai-gateway/mcp-server/grafana/ndpid/netifyd/glances/...),
  ROOT (system/hub/portal/console/admin/vault/vm/rtty/...),
  MESH (wireguard/dns/tor/matrix/gitea/nextcloud/mail/lyrion/yacy/
        zigbee/dns-provider/rustdesk/... — all network + comms apps).
* secubox-hub v1.4.0 — CATEGORY_META rewritten with the 6 charter
  modules carrying their official color from DESIGN-CHARTER.md and
  the complementary-pair order. DEFAULT_MENU fallback remapped too.
* secubox-zigbee v2.5.3 — www/zigbee/index.html rewritten with the
  canonical SecuBox scaffold (body display:flex, sidebar 220px fixed,
  .main reserving 48px for the global-menu-bar). MESH palette.

Browser side: operators need to clear localStorage sbx_menu_cache (or
hard-refresh after the 1h TTL) to see the new sections after deploy.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 10:03:05 +02:00
CyberMind
e0de750361
feat(metablogizer,droplet): orchestrate haproxy + mitmproxy on publish (closes #200) (#209)
metablogizerctl 1.2.0:

  site publish now accepts --public-domain <fqdn>. When given:
    * Inject the FQDN into the nginx vhost server_name (so the
      same nginx instance answers Host: <fqdn> coming back through
      HAProxy → mitmproxy).
    * Persist public_domain in site.json (consumed by unpublish).
    * Run `haproxyctl vhost add <fqdn> mitmproxy_inspector true`.
    * Trigger sync via `systemctl start sync-mitmproxy-routes.service`
      (falls back to `sync-mitmproxy-routes.sh` in PATH; warns if
      neither is available).
    * Without the flag the behaviour is unchanged from 1.1.x.

  site unpublish reads public_domain from site.json and auto-reverses
  the HAProxy add + re-triggers the route sync. No-op if the site
  was never published with --public-domain.

  --public-domain is validated against ^[a-zA-Z0-9.-]+$ as defence-
  in-depth against shell/TOML injection from API callers.

dropletctl 1.1.1:

  cmd_publish and cmd_rename now pass --public-domain "$vhost" to
  metablogizerctl so the full HAProxy → mitmproxy chain is wired
  automatically. Closes the end-to-end gap reported by the gk2
  smoke test of 1.1.0 (curl https://smoke.gk2.secubox.in/ → 502
  because metablogizerctl publish only configured nginx).

  Bump Depends: secubox-metablogizer (>= 1.2) — required for the
  --public-domain flag.

  Tests tightened to assert the new flag is passed on both publish
  and rename. 17/17 bats green.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:42:38 +02:00
CyberMind
7c273e2132
feat(metablogizerctl): forge tor noun verbs — Emancipate (closes #184) (#186)
Per CLAUDE.md the Punk Exposure Engine has three verbs (Peek/Poke/
Emancipate) and Tor is one of the three exposure channels. metablogizerctl
already handled site create/delete/publish/unpublish/list (the Poke verb
at the publishing layer); this adds the Emancipate verb:

  tor expose <site>   Publish site via Tor hidden service
  tor revoke <site>   Stop publishing via Tor
  tor list            List Tor-exposed sites + onion addresses
  tor status <site>   Stanza + onion + tor service state

When secubox-exposure is installed, the verbs delegate to it for
consistency with other exposure channels (DNS+SSL, Mesh) — one
orchestrator across all three channels. When unavailable, falls back
to direct /etc/tor/secubox-metablogizer.d/<site>.conf stanza writes
and `systemctl reload tor`, reading the resulting .onion hostname from
/var/lib/tor/secubox-metablogizer/<site>/hostname.

The onion address is persisted back into the site's site.json under
exposure.tor so the Peek verb (later, separate ticket) can surface it
without re-running tor status.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:32:24 +02:00
875e90e073 feat(metablogizer): simplify UI to single Sites tab with rich tooltip
Remove Access and Actions tabs (duplicate / obsolete) along with the
migrate-from-OpenWrt modal and dead JS handlers. Make each row's Domain
column a clickable link to the live site and attach an emoji-rich title
tooltip (name, status, version, title, description, category, tags,
Streamlit app, port, size, last updated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:19:27 +02:00
6b0c3fa7b5 fix(metablog-webhook): pass safe.directory to git on every call (ref #117)
Site dirs at /srv/metablogizer/sites/* are owned by root (from sub-B
ingest), but the FastAPI service runs as secubox. Git 2.35+ refuses
to operate with `fatal: detected dubious ownership in repository`,
turning every webhook-triggered deploy into a 500.

Pass `-c safe.directory=<site_dir>` per-invocation. Scoped to each
git call — no global git config change.

Existing git_pull test updated for the new arg layout
([git, -c, safe.directory=…, -C, <site>, <op>, ...]) and asserts the
override is on every call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:43:18 +02:00
283f682175 docs(metablog-webhook): tracking + README deploy section (ref #113)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:32:36 +02:00
6335ece3de feat(metablog-webhook): mount POST /webhook + GET /deploys (ref #113)
- Wires webhook.py into the FastAPI app: HMAC verify → classify →
  per-site lock → git_pull → cache invalidate → conditional nginx
  reload if site.json:domain changed → record + return.
- Git ops dispatched via loop.run_in_executor so the single uvicorn
  worker stays responsive during concurrent deploys.
- GET /deploys (JWT-gated) exposes the last 50 deploys for ops review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:31:20 +02:00
6a51130d13 feat(metablog-webhook): payload classifier + per-site asyncio lock (ref #113)
- classify_payload(): pure dispatcher returning accept/skip/malformed
  with a structured info dict. Covers non-metablog repos, non-default
  refs (incl. tags + feature branches), and missing fields.
- site_lock(name): per-site asyncio.Lock pool with a master lock around
  dict access so two concurrent first-creates don't race.

8 new pytest cases. 21 total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:31:20 +02:00
da0b4e537d feat(metablog-webhook): git_pull helper + deploy ring buffer (ref #113)
- git_pull(site_dir, branch) -> (old_sha, new_sha) via 4 git ops
  (rev-parse, fetch, reset, rev-parse) with timeouts (60s fetch,
  10s local ops).
- Ring buffer holds the last 50 deploy records; oldest evicted
  on overflow. list_deploys() returns newest-first.

5 new pytest cases cover the buffer and git ops (with subprocess
stubbed via monkeypatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:31:20 +02:00
f1946fb178 feat(metablog-webhook): HMAC verify + secret loader (ref #113)
- verify_signature(): constant-time HMAC-SHA256 comparison
- load_secret(): reads /etc/secubox/metablogizer-webhook.secret,
  cached after first read, empty/missing raises a clear error

8 pytest cases cover happy path + wrong secret, truncated sig,
empty sig, missing file, empty file, and the cache behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:31:20 +02:00
3ebf79647e fix(metablog-api): cache load_sites() + fix /sites double-call (ref #111)
After deploying sub-C runtime (#110), /status and /sites became
extremely slow (3-30 s per request) because load_sites() walks
all 166 sites and runs enrich+validate+du -sh per entry. The new
60s polling on the dashboard amplified the load via concurrent
queued requests → browser-side NetworkError.

Two fixes here:

1. In-memory cache of load_sites() with a 30 s TTL. Hot reads
   now return in microseconds. Invalidated explicitly from every
   write path:
   - POST /site (create)
   - DELETE /site/<name>
   - POST /site/<name>/publish
   - POST /site/<name>/unpublish
   - POST /republish-all

2. /sites no longer calls load_sites() twice. Was:
       return {"sites": load_sites(), "count": len(load_sites())}
   Now:
       sites = load_sites()
       return {"sites": sites, "count": len(sites)}

Tests in api/tests/test_sites_cache.py (5 cases) mirror the
contract in isolation — TTL-based caching, explicit invalidation,
identity stable for same-tick reads. All 15 metablogizer api tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:06:46 +02:00
3bd8f3e488 fix(metablog-api): self-bootstrap api/ onto sys.path (ref #109)
`uvicorn api.main:app` runs with WorkingDirectory=/usr/lib/secubox/metablogizer,
which puts the parent dir on sys.path but not api/ itself. Sub-C's
`from site_schema import …` (PR #102) and #106's `from rmtree import …`
both fail in production with ModuleNotFoundError, sending the service
into a restart loop and nginx into 502.

Insert `sys.path.insert(0, str(Path(__file__).resolve().parent))` at the
top of main.py so api/ is discoverable regardless of invocation style.
The pytest path (PYTHONPATH=api) is unaffected: the insert is a no-op
when api/ is already on sys.path.

All 10 metablogizer api tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:51:25 +02:00
692c77ec27 fix(metablog-api): DELETE /site/<name> survives locked .git subtrees (ref #106)
Sites cloned from Gitea (sub-B of #49 — 166 sites) ship a .git tree
whose pack files are 0444 and whose directories are 0500.
`shutil.rmtree` then fails on both `os.open(dir, O_RDONLY, ...)` (for
restricted directories) and `os.unlink(file)` (for files inside a
non-writable parent).

Extract _rmtree_force into api/rmtree.py with an onerror that chmods
both the parent dir AND the entry to 0700 before retrying the failing
op. The new helper handles both classes of EACCES that rmtree raises.

Tests in api/tests/test_rmtree_force.py:
  - locked Gitea-style .git subtree (0500 dirs + 0444 packs) is cleared
  - plain directory still cleared (no regression on non-locked sites)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:45:19 +02:00
ad9d9288a6 fix(metablog-ui): defensive api() helper (ref #105)
The `return res.json()` pattern in both index.html and site.html
returned the parse promise unawaited, so a JSON.parse rejection
escaped the surrounding try/catch and surfaced as
"Uncaught (in promise) SyntaxError" in the browser console.

Changes per file (index.html and site.html):
- `return await res.json()` so rejections enter the catch block
- Check `res.ok` and the `content-type` header before parsing
- `console.warn(path, status, content-type)` on any non-OK or
  non-JSON response, so future failures are diagnosable in the
  browser console without server-side digging
- Symmetric catch: log the thrown error with path context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:39:28 +02:00
bc52a60be9 docs(metablog-ui): Session 165 tracking + README dashboard URLs (ref #103)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:27:28 +02:00
9d9eb2304a fix(metablog-ui): site.html uses canonical sbx_token key (ref #103)
Match the rest of the SecuBox Hub codebase (index.html, login.html,
shared/api-utils.js, every other module) which reads localStorage
under 'sbx_token'. The plan's heredoc had 'jwt' which would have
caused an infinite login redirect on every drill-in page load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:44 +02:00
6b11bcda9a feat(metablog-ui): drill-in page /metablogizer/site.html?name=<X> (ref #103)
Surfaces every site.json field (domain, version, last_updated, title,
description, category, tags) plus three external links: live site,
Gitea repo, Streamlit app (hidden when streamlit_app is null).

Same CRT P31 phosphor theme as index.html. Single fetch to
/api/v1/metablogizer/site/<name>; renders or shows a clear error
('site not found' / 'missing name').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:15:28 +02:00
5c8148e818 feat(metablog-ui): version/streamlit/updated columns + filter + sort + 60s poll (ref #103)
- 3 new columns: Version (links to Gitea releases), Streamlit (icon link
  when site has a streamlit_app), Updated (relative time, full ISO tooltip)
- Filter box above the table: live substring match on name + domain
- Sortable headers (Name, Domain, Version, Updated) with ▲/▼ indicator
- 60-second auto-refresh, paused when tab is hidden (Page Visibility API)
- Row name now links to site.html?name=<X> (drill-in, Task 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:51:11 +02:00
2f4774aad2 docs(metablog): Session 164 tracking + README + .gitignore (ref #101)
README: Version metadata section (schema, fields, backfill CLI)
WIP/HISTORY: Session 164 entry covering all 8 plan tasks + the
3 discoveries fixed mid-execution
.gitignore: metablog-backfill-report.json, log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:44:34 +02:00
7ca8b46864 feat(metablog): Depend on python3-jsonschema for schema validation (ref #101)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:21:04 +02:00
58a80b8f24 feat(metablog-api): load_sites() emits schema-enriched site dicts (ref #101)
- _load_site_json() reads site.json, enriches from git, runs the
  validator in warn-only mode (log violations, never reject)
- load_sites() overlays the enriched fields (version, last_updated,
  streamlit_app, etc.) onto the per-site response

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:20:33 +02:00