GET /api/v1/meshtastic/channel-url returns the device's sharable Meshtastic
channel URL (localNode.getURL — encodes name + PSK + LoRa config) plus an
offline inline-SVG QR (segno, Recommends). Open/scan it on a phone or second
node to JOIN the SecuBox mesh. The URL discloses the channel key → JWT-gated,
and the daemon logs each disclosure to journald. _SerialRadio.channel_url()
reads it from the live iface; MockRadio mirrors it. Panel: a 'Générer le lien
de jonction' button on the Channels tab renders the URL + QR (inline SVG,
CSP-safe, no data: URI).
+3 web tests (67 total). Live-verified on gk2: /channel-url returns the URL +
a 2.4KB QR SVG, panel loads 200.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The panel was empty even with the radio present: mesh state was only populated
from received packets, never from the device's own node DB, and our own sent
texts were never recorded. Now:
- Engine.seed_from_radio() pulls the device node DB (self + known peers) at
connect; radio.on('node') feeds live NODEINFO updates.
- send_cb records the outbound message (record_sent) so /messages shows it.
- Node gains is_self; apply_nodeinfo folds in position/battery/snr from the DB.
_SerialRadio exposes my_num()/node_db(); MockRadio mirrors them. +3 tests (64
total). Live-verified on gk2: /nodes shows the RAK (is_self, !0b8e4573), and a
POST /send appears in /messages (outbound) and TXs on the radio.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The RAK4631 (Meshtastic fw 2.7.15, node 193873267) opens and handshakes fine —
proven manually and under the daemon's full systemd sandbox. The panel showed
'radio: absent' because the leaf dirs /var/cache/secubox/meshtastic and
/var/log/secubox/meshtastic had drifted to owner 'secubox' (not the daemon's
'secubox-meshtastic' user), so the daemon could not write state.json. The cache
refresh loop swallowed the PermissionError, freezing the panel on a 2-day-stale
cached snapshot.
- postinst: ENFORCE leaf-dir ownership on every configure (install -d only sets
it on create; a pre-existing/mass-reinstalled dir keeps the wrong owner). Also
chgrp+g+r /etc/secubox/meshtastic.toml every configure (it could be left
root:root → ConfigError → crash-loop). Leaf dirs only, never shared parents.
- cache.py: refresh loop no longer swallows write errors silently — logs to
stderr/journald so a frozen cache is diagnosable.
- radio.py: bounded open retry (gc between attempts frees the orphaned pyserial
FD on a restart-race) + stderr open-lifecycle logs (the logging module is
unusable — meshtastic reconfigures the root logger on import, which had made
the real failure invisible).
Deploy-tested on gk2 from a deliberately-drifted state (dirs->secubox,
config->root:root): install repaired ownership, radio -> present, state.json
fresh, service NRestarts=0. 61 tests pass.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
haproxyctl: honour an opt-in `waf_bypass = true` field per vhost so the
generator emits `use_backend <backend>` (nginx_vhosts) instead of forcing
mitmproxy_inspector under waf_enabled. Makes the WAF-bypass of media/large-
upload services (nc/photoprism/matrix/torrent) declarative, auditable and
regen-durable instead of fragile hand-edits of the generated haproxy.cfg.
Normal vhosts are unaffected (verified in isolation).
secubox-torrent postinst: register torrent.<host>.<suffix> in haproxy.toml
(backend=nginx_vhosts, waf_bypass=true, ssl=true) idempotently and run a
drift-guarded `haproxyctl generate` — which refuses safely on a board that
still has un-migrated hand-edit vhosts (live cfg untouched) and succeeds on a
fresh board. Torrent bypasses sbxwaf by design, so no sbxwaf route is needed.
CLAUDE.md: WAF-bypass section updated — default stays 'never bypass'; the ONLY
sanctioned exception is the declarative waf_bypass=true in haproxy.toml, never
a hand-edit of the generated cfg.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Backports the live gk2 WAN link guard into source as a proper package
(mirrors the secubox-frigate-diskguard skeleton):
- sbin/secubox-wan-link-guard: mvpp2 eth2 pause-off (ethtool -A) + renegotiate
(ethtool -r) + lan0 parasite route/addr cleanup + force default via eth2.
NEVER ip link down/up (wedges the comphy). Tunable via /etc/default.
- secubox-wan-link-guard.service + boot/30s .timer
- 10-secubox-eth2-noflowctl.link: flow-control off at eth2 appearance (boot)
- secubox-eth2-flowctl-off.service backstop (installed, left disabled; .link
supersedes it, matching live)
- postinst migrates the pre-dpkg hand-placed copies out of /etc/systemd +
/usr/local/sbin so the packaged units win, then enables the timer.
netplan (board/mochabin): add static fallback 192.168.1.200/24 on eth2 at
route-metric 200 (higher than the DHCP route's 100) so the box is always
reachable even without a lease — closes#913 task 1. Source-only; not applied
live (avoids a remote WAN blip on the already-marginal link).
Verified on gk2: install migrated the old units, timer enabled+active, guard
ran clean (Result=success), WAN gateway stayed reachable throughout.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
WebTorrent 2.x made client.get() async (returns a Promise). engine.get() used it
as if synchronous, so every caller operated on a Promise instead of the Torrent:
remove → promise.destroy() (500), /files → promise.files (500), stats →
promise.progress (garbage), and /stream never resolved its file. Match on the
synchronous client.torrents array instead. FakeWebTorrent.get() was sync and
masked this — made it async to model reality, added regression tests asserting
engine.get() returns a Torrent (not a Promise) and remove() drops it cleanly.
Verified on gk2: add/files/remove all 200 (files+remove were 500), service
restarts:0.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
uint8-util 2.3.0 rewrote arr2hex() to require a TypedArray (reads data.buffer/
byteOffset/byteLength). webtorrent 2.8.5's _onTorrentId passes it parse-torrent's
infoHash, which is a hex STRING (bytes live in infoHashBuffer) — so arr2hex threw
'Received undefined', crashing the add in a microtask before the torrent resolved
its infoHash. The request then blocked past the proxy read timeout and surfaced as
a board-wide 502 on /add. webtorrent declares uint8-util ^2.2.5, so the committed
lockfile had silently drifted up to the breaking 2.3.x. Pin to 2.2.6 (last pre-2.3,
string-tolerant arr2hex) via npm overrides; regenerate lockfile.
Verified on gk2: add returns 200 in ~0.1s (was a >15s hang), torrent lands in the
library, service stays up with no unhandledRejection.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
WebTorrent throws SYNCHRONOUSLY (arr2hex(undefined) in _onTorrentId) on some
malformed magnets — not an 'error' event, so engine listeners can't catch it,
crashing the process (user sees 502). Add process.on('uncaughtException') in
start() (kept alive), and store a canonical infohash-derived magnet when
magnetURI is absent so resumeLibrary never re-adds a bad value.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
library.js add() used INSERT OR REPLACE, so re-adding an already-kept
magnet reset kept=0 and added_at — the ephemeral purge sweep would then
delete it 6h later as if it had never been kept. Switch to
INSERT ... ON CONFLICT DO UPDATE SET last_played_at only, preserving kept/
added_at/name/magnet/path across a re-add.
server.js never resynced the WebTorrent engine with the library DB at
startup: kept titles 404'd on /stream after any restart (engine.get()
returns null for a torrent the engine never re-learned about), and
runPurge's engine.remove() became a silent no-op for ephemeral rows,
leaking their downloaded bytes on disk while their DB rows vanished. Add
resumeLibrary(engine, library, { rmrf }), wired into start(): kept rows are
re-added to the engine (try/catch + promise .catch so one bad magnet can't
abort the loop); ephemeral rows are dropped from the library and their
download dir is reclaimed via an injectable rmrf.
api.js recorded a fictitious tmp/<infohash> (add) and library/<infohash>
(keep) path that no code ever moves files into — files download flat to
downloadDir. Record path.join(downloadDir, infohash) in both places so the
path resumeLibrary's rmrf acts on matches where files actually live.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
postinst invoked the bash provisioner through /bin/sh (dash), which rejects
`set -o pipefail` on line 1 and silently aborted LXC provisioning behind the
`|| echo … deferred …` guard — apt install exited 0 with no LXC ever
created. Also add secubox-hub to Depends (owns the $lan_client nginx map
torrent.conf's auth gate references) and python3/build-essential to the
in-LXC apt install so npm can compile better-sqlite3/@roamhq/wrtc from
source on bookworm's Node 18 when an arm64 prebuild is missing.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Pivots the secubox-torrent Debian package to the WebTorrent/LXC-native
design (Tasks 1-7): control/rules/postinst/postrm now ship the Node app
to /usr/lib/secubox/torrent/app, run install-lxc.sh at configure, load
the nft egress scope from /etc/nftables.d/ (not the brief's unused
/etc/secubox/nft.d/), and symlink the standalone vhost into
sites-available/sites-enabled instead of the location-snippet secubox.d/.
Deletes the old host-side Transmission FastAPI (api/main.py) and its
systemd unit; Depends drops python3-uvicorn/transmission in favour of
lxc, debootstrap, nftables. Structural packaging test added
(tests/test_packaging.py). Builds cleanly to
secubox-torrent_2.0.0-1~bookworm1_all.deb.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Extends the Task 1 install-lxc.sh skeleton with the full app deploy
(tar-copy app/+www/ into the LXC, npm ci, torrent.env, in-LXC systemd
unit enable+start), plus the standalone torrent.gk2.secubox.in nginx
vhost, an nft egress visibility tap on the LXC veth, conf/torrent.toml,
and the menu.d entry (icon/category/description).
Picks 10.100.0.160 for the LXC (10.100.0.130 was already taken), pins
a predictable veth (veth-torrent0) so the nft rule can target it, and
bind-mounts /data/torrent from the host so downloaded data and the
disk-floor statfs() check in server.js see real storage instead of
LXC-overlay space.
The nginx vhost is a full standalone server{} block (own server_name),
so it targets sites-available/ (not secubox.d/, which is merged into
the shared hub server block and can't hold a nested server{}) -- Task 8
must update debian/rules accordingly. Its auth gate mirrors the current
LAN-only $lan_client stub (Authelia was decommissioned; there is no
aggregator JWT-verify endpoint to proxy to yet), not the brief's
aggregator/api/v1/auth/verify snippet.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Adds server.js (env-driven Engine/Library/buildApi wiring, ephemeral
torrent purge sweep on a timer, static webui serving) and the runPurge
unit tests (TTL + kept-flag + disk-floor sweep, in-memory Library only).
webtorrent and @fastify/static are imported dynamically inside start()
so runPurge stays testable without the native wrtc build.
Replaces the old Transmission-container panel with a single-page
WebTorrent player webui (magnet add, list with keep/remove, in-browser
playback via /stream, BitTorrent-only status badge) per the cyan
hybrid-dark self-contained module vhost guidelines.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Adds SQLite-backed torrent library (add/list/get/touch/keep/remove) and
the expiredEphemeral() query the purge job uses to find stale
kept=0 torrents (last_played_at < now-ttl). Includes package-lock.json
and a lxc/app .gitignore for node_modules.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Critical: Change file.stream({start,end}) → file.createReadStream({start,end})
to match real webtorrent ^2.5.1 API (WHATWG ReadableStream vs Node Readable).
Important #1: RFC 7233 compliance — clamp end >= total to total-1 instead
of rejecting with 416. Only reject when start >= total or start > end.
Important #2: Attach stream error handlers to prevent unhandled crashes
on peer disconnect or missing pieces.
Minor: Add tests for range clamping (bytes=50-999999 on 100-byte file)
and out-of-range fileIdx. All 6 tests passing (green).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Implement handleStream(engine, req, res) for WebTorrent streaming with
HTTP Range support. Handles 200 full, 206 partial, 416 unsatisfiable,
404 not found responses. MIME type detection from file extension.
Tests via node --test with fake engine/request/response (all green).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
FakeTorrent now stores client reference and splices itself from torrents[]
on destroy(), matching real webtorrent semantics. New test verifies
remove→add within maxActive limit succeeds (capacity freed).
Also remove unused 'import path' from engine.js.
4/4 tests passing.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
TDD complete: 3/3 tests passing. Engine constructor stores webrtc flag for
API /status route to report transport availability. Fakes enable unit tests
without real WebTorrent network or WebRTC dependencies.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Node app skeleton (package.json, wrtc-probe.js) and idempotent
install-lxc.sh skeleton for the secubox-torrent v2.0 WebTorrent
streaming pivot.
Validated @roamhq/wrtc on arm64 via the existing peertube LXC on gk2
(Node v22.22.2, Debian 12 aarch64) since the dev workstation is x86-64
and the dedicated torrent LXC is not provisioned yet: npm install
resolved a linux-arm64 prebuild cleanly, and the shipped wrtc-probe.js
printed WEBRTC_AVAILABLE=true with a working RTCPeerConnection. Result
recorded in SPIKE-RESULT.md; @roamhq/wrtc stays in package.json.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
At ~150 execs/s a dpkg -S subprocess per event can't keep up; the scanner
fell permanently behind and never reached recent execs. DpkgIndex loads all
/var/lib/dpkg/info/*.list paths into a set once at startup. +2 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
On bookworm merged-usr, audit reports /usr/bin/grep but dpkg owns /bin/grep,
so dpkg -S failed and legit system binaries were flagged non-dpkg -> false
positive flood in the scanner. Try both aliased forms. +2 regression tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
-i renders uid=root and decodes exe; parse_ausearch expects raw (uid=0,
hex/quoted exe) and matched ZERO events on interpreted output. Regression
guard test added. This was the last blocker: scanner now flags non-dpkg execs.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
-ts windowing was unworkable (epoch rejected, recent=30k events/poll); use
ausearch's native --checkpoint incremental mode + seed the checkpoint on first
start so pre-daemon history isn't replayed. Verified on gk2.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
On-hardware validation (gk2, aarch64) revealed the v1 exec-detection didn't
fire and had an unsafe blast radius. Fixes:
- audit: replace targeted `-w <dir> -p x` watches (which watch the dir inode,
NOT execs of files within it — 0 records on real execs) with a broad
`-a always,exit -F arch=b64 -S execve -k sbx_exec` rule. `-F dir=` does not
filter execve by target path either (both verified on the raw audit log).
~150 exec/s on a populated board, lost=0.
- api/policy.py (new): enforce flag (default FALSE = alert-only 'process
scanner') + jail_dirs scope. Broad detection would otherwise auto-jail
legitimate non-dpkg SecuBox binaries and cut their egress. Alert-only logs
+ alerts every flagged exec but never jails; enforce=true jails only flagged
execs under a jail_dir. Fail-safe: corrupt/missing config => alert-only.
- daemon: ExecLog now persists only FLAGGED (non-dpkg) execs, not the ~150/s
legitimate ones (would balloon to millions of rows/day, zero forensic value).
jail_fn receives the whole ExecEvent so policy can gate on the exe path.
- unit: SupplementaryGroups=adm so the daemon can actually read
/var/log/audit/audit.log (0640 root:adm) — without it the scanner is blind.
- ctl nft-load: mkdir the slice cgroup before `nft -f` (nft resolves the
cgroupv2 path at load time; missing cgroup => load fails).
- packaging: nginx route -> /etc/nginx/secubox-routes.d (the dir the hub's
webui.conf includes; secubox.d is inert on the admin vhost); postinst
reloads nginx so the panel goes live.
89 tests (was 80): +test_policy.py, +make_jail_fn alert-only/enforce tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>