secubox-deb/common/secubox_core/media_buffer.py
CyberMind 418126c541
feat: SecuBox Media Buffer Phase 1 — dashcam capture + replay + metatag lifecycle (#814)
* docs(media-buffer): design spec — dashcam capture + replay + metatag lifecycle (ref #812)

* docs(media-buffer): phase-1 implementation plan (capture+janitor+API+DPI tab) (ref #812)

* feat(sbxmitm): media buffer store + metatag writer (ref #812)

* fix(sbxmitm): media-buffer review — 4096 atomic-append cap + *string buffer_ref (ref #812)

Task 1 review (Important): mediaBufferLineMax 8192→4096 to match mediacatch.go's
PIPE_BUF atomic-append guarantee across 4 workers; BufferRef string→*string so the
janitor (Task 3) can flip it to JSON null on eviction.

* feat(sbxmitm): tee media up/download bodies into the buffer (ref #812)

Wire the R4 media buffer tees into mitmPipeline: the download hook wraps
resp.Body and the upload path wraps req.Body via teeReadCloser, capturing
whole-file media into the rolling /data buffer. Add the mbuf field + flags
(--media-buffer default OFF, --media-buffer-root, --media-buffer-per-object).

Design upgrade over the plan sketch: ObjectWriter.Write is now NON-BLOCKING.
Instead of io.TeeReader driving a synchronous os.File.Write on the client read
path (which would add latency / could stall large media on ARM eMMC), Write
copies each chunk into a bounded channel and returns len(p),nil immediately; a
background goroutine started at Capture drains the channel to disk. A full
channel drops the chunk and flags truncated — never blocks, never errors the
proxied flow (design 4.2/7). Close signals the drainer, waits for it to flush
queued chunks + close the file, then appends the metatag; it is idempotent,
deadlock-free (sole closer of ch) and safe if Write is never called or races
Close.

* fix(sbxmitm): media download tee — defer closure so w.Close runs (ref #812)

defer resp.Body.Close() bound the ORIGINAL upstream body at the defer
statement, before the media-buffer tee reassigned resp.Body — so the
tee's Close() (and w.Close, which flushes the sink/metatag/drain
goroutine) never ran on any download. Wrap the close in a closure so
it reads resp.Body at return time instead.

Adds TestTeeDeferOrderingMatchesMainGo, which replicates main.go's
exact defer-then-reassign ordering and fails against the old plain
defer form (verified manually, then restored the fix).

Also: drop the dead write-only ObjectWriter.dropped field (truncated
already covers it), document Capture's reserved contentLen param, and
note that ObjectWriter.Close is allowed to block (post-stream
finalisation), unlike Write.

* feat(sbxmitm): media buffer janitor — time + LRU eviction, metatag survives (ref #812)

SweepOnce evicts a session's bytes (RemoveAll, idempotent) once
first_ts+retentionSecs elapses, then falls back to oldest-first LRU
eviction if the buffer root (media bytes only, log excluded) still
exceeds sizeCeilBytes. Eviction never rewrites the append-only
metatag log in place — it appends a corrected line with the same id
(expired:true, buffer_ref:null); readers dedup by id, last line wins,
so the flip is durable and safe across the 4 concurrent sbxmitm
worker processes each running their own RunJanitor (idempotent
RemoveAll + harmless duplicate flip lines).

RunJanitor ticks SweepOnce every 30s (test-overridable period) until
its context is cancelled; wired at sbxmitm startup as a
process-lifetime goroutine when --media-buffer is enabled.

NewMediaBuffer's signature is unchanged; retentionSecs (1200),
sizeCeilBytes (24 GiB) and nowFn now default inside the constructor.

* feat(core): media-buffer metatag reader (dedup by id, fail-empty) (ref #812)

* test(core): media-buffer reader — tail-read truncation + malformed-field regressions (ref #812)

* chore(#812): keep SDD scratch reports out of the feature diff (restore to master)

* feat(dpi): media-buffer list/replay/thumb API (admin/owner, audited, 410-on-evict, traversal-safe) (ref #812)

* harden(dpi): media API — \Z-anchored id regex + role short-circuit security note (ref #812)

* feat(sbxmitm): media-buffer retention/size-ceil flags + /data tmpfiles + opt-in worker env toggle (ref #812)

- main.go: --media-buffer-retention / --media-buffer-size-ceil flags, applied
  onto the already-constructed mbuf's unexported retentionSecs/sizeCeilBytes
  fields (NewMediaBuffer signature unchanged).
- tmpfiles/zz-secubox-media-buffer.conf: pre-creates /data/secubox (0755) and
  /data/secubox/media-buffer (0750 secubox:secubox), installed via the
  package's existing manual tmpfiles.d mechanism in debian/rules.
- secubox-toolbox-ng-worker@.service: EnvironmentFile=-/etc/secubox/toolbox-ng.env
  + trailing $SBX_MEDIA_BUFFER_ARGS on ExecStart so an operator can opt in
  (SBX_MEDIA_BUFFER_ARGS=--media-buffer) without editing the shipped unit;
  default stays OFF. Added ReadWritePaths=/data/secubox/media-buffer.

* fix(sbxmitm): media-buffer dir owned by writer (secubox-toolbox) + setgid group secubox; changelog 0.1.27 (ref #812)

Task 6 review Critical: buffer dir was 0750 secubox:secubox but sbxmitm workers run
as secubox-toolbox (not in group secubox) → EACCES on every capture, silently no-op'd
(errors swallowed by design). Fix: 2750 secubox-toolbox:secubox — writer owns, dpi
reader (secubox) is group, setgid propagates group secubox to created objects so the
reader can read them. Important: add debian/changelog 0.1.27 stanza (version bump).

* feat(dpi): Media tab — live capture gallery with replay/download, escaped fields (ref #812)

Adds a Media Buffer card to the DPI dashboard (packages/secubox-dpi/www/dpi/index.html)
that lists sbxmitm's live media-buffer captures via GET /api/v1/dpi/media/buffer.

- Auth: dedicated apiAuthed() reads the canonical `sbx_token` localStorage key
  and sends it as a Bearer token (media/buffer requires JWT, unlike the rest
  of this page's endpoints which currently ride the SSO-lite session cookie);
  401 redirects to /login.html, matching sibling dashboards (e.g. crowdsec).
- Security: host/url/mac_hash are attacker-influenceable (captured live
  traffic). Every card is built via createElement + textContent (or the
  `.title` IDL property, never HTML-parsed) — no innerHTML string
  concatenation of these fields — so a captured value like
  `<img onerror=...>` renders as inert text. Replay/download URLs are built
  with encodeURIComponent(id) even though the id is hex-validated server-side.
- UI: kind emoji (video/audio/manifest/file), short device id (shortDev,
  reused from the existing #785 helpers), ⬆/⬇ direction, human size
  (formatBytes) and age (agoStr) per card; expired captures grey out and show
  "métatag seul (expiré)" with no action links.
- Refresh: wired into the existing refreshAll() plus its own ~15s interval.

Verified with a Playwright harness stubbing fetch (incl. an XSS payload in
host/url) against a local copy of the page: no console/page errors, XSS did
not fire, no <img>/<script> element materialized from the captured strings,
expired-card gating confirmed.

* fix(sbxmitm): media buffer — upload tee needs a real body, download tee 200-only (whole-branch review); drop dead item.ts (ref #812)

* docs(media-buffer): Phase 2 plan — HLS reassembly via read-time URL join (ref #812)

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-07-04 18:15:58 +02:00

124 lines
4.5 KiB
Python

# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: media-buffer metatag reader
Shared reader for the sbxmitm media-buffer metatag log
(/data/secubox/media-buffer/media-buffer.jsonl). Each line is one metatag
record appended by the toolbox-ng MITM workers (Task 1) or by the janitor
(Task 3) when it evicts the backing bytes:
{"id","session_id","first_ts","last_ts","mac_hash","host","url",
"direction","kind","ctype","bytes","segments","truncated",
"buffer_ref","expired"}
The log is append-only, so a given `id` can appear on multiple lines — the
janitor "flips" a record on eviction by appending a fresh line with
`expired:true, buffer_ref:null` rather than rewriting history. Readers MUST
dedup by `id`, last line wins, so the eviction flip is always honored.
Pure stdlib only — no FastAPI, no I/O beyond reading the file. Fail-empty: a
missing file, empty file, or corrupt/partial lines never raise.
`max_bytes` bounds how much of the file is actually read off disk (a bounded
tail read), while `max_lines` bounds how many records are kept/processed
after that read — the two are independent caps.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
MEDIA_BUFFER_PATH = "/data/secubox/media-buffer/media-buffer.jsonl"
def _tail_lines(path: Path, max_lines: int,
max_bytes: int = 16 * 1024 * 1024) -> list[str]:
"""Return up to the last `max_lines` decoded lines, best-effort.
Only the tail `max_bytes` of the file are ever read off disk — on a busy
board the log can grow large, so this bounds memory/IO regardless of
`max_lines`. If the seek lands mid-line, the (possibly partial) first
line of the read is dropped.
"""
try:
with path.open("rb") as f:
size = f.seek(0, os.SEEK_END)
if size > max_bytes:
f.seek(size - max_bytes)
raw = f.read()
parts = raw.splitlines()
# Drop the first (possibly partial) line from the mid-file seek.
parts = parts[1:]
else:
f.seek(0)
raw = f.read()
parts = raw.splitlines()
except OSError:
return []
if len(parts) > max_lines:
parts = parts[-max_lines:]
out: list[str] = []
for b in parts:
try:
out.append(b.decode("utf-8"))
except UnicodeDecodeError:
continue
return out
def _deduped_records(path: str, max_lines: int,
max_bytes: int = 16 * 1024 * 1024) -> dict:
"""Parse the tail of the JSONL log into {id: record}, last line wins.
Records missing an `id` are skipped (can't be deduped/looked up safely).
Fail-empty: any file/parse error yields an empty dict, never raises.
"""
p = Path(path)
by_id: dict = {}
for line in _tail_lines(p, max_lines, max_bytes=max_bytes):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except (ValueError, TypeError):
continue
if not isinstance(rec, dict):
continue
rec_id = rec.get("id")
if not rec_id:
continue
by_id[rec_id] = rec # later lines overwrite — last-writer-wins
return by_id
def read_records(path: str = MEDIA_BUFFER_PATH, mac_hash: str | None = None,
max_lines: int = 2000,
max_bytes: int = 16 * 1024 * 1024) -> list[dict]:
"""Return deduped metatag records, newest-first. Fail-empty."""
by_id = _deduped_records(path, max_lines, max_bytes=max_bytes)
records = list(by_id.values())
if mac_hash:
records = [r for r in records if r.get("mac_hash") == mac_hash]
try:
records.sort(key=lambda r: r.get("first_ts") or 0, reverse=True)
except TypeError:
# Defensive: a malformed first_ts type shouldn't blow up the caller.
pass
return records
def record_by_id(rec_id: str, path: str = MEDIA_BUFFER_PATH,
max_lines: int = 2000,
max_bytes: int = 16 * 1024 * 1024) -> dict | None:
"""Return the deduped record for `rec_id` (last line wins) or None."""
if not rec_id:
return None
by_id = _deduped_records(path, max_lines, max_bytes=max_bytes)
return by_id.get(rec_id)