mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 12:34:38 +00:00
* 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>
124 lines
4.5 KiB
Python
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)
|