Compare commits

..

No commits in common. "dde96f212ebbc399a899a0f8651a966fe2fb918c" and "9eb2d68b9232986e9ad53410ac333a497b37a195" have entirely different histories.

12 changed files with 5 additions and 702 deletions

View File

@ -1,4 +0,0 @@
# debian/rules build artifacts (Go collector + module caches)
collector/secubox-dpi-collector
_gocache/
_gopath/

View File

@ -64,23 +64,6 @@ async def health_check():
"""Public health check endpoint for sidebar status."""
return {"status": "ok", "module": "deb"}
@app.get("/exfil")
async def exfil_state():
"""#687 Phase 2 — per-device cloud-exfiltration state produced by the Go
collector (secubox-dpi-flowcap secubox-dpi-collector). Fail-empty so the
dashboard never errors before the first capture window completes."""
import json as _json
from pathlib import Path as _P
p = _P("/var/lib/secubox/dpi/state.json")
try:
if p.exists():
return _json.loads(p.read_text())
except Exception as e: # pragma: no cover
return {"generated_at": 0, "devices": [], "alerts": [], "error": str(e)}
return {"generated_at": 0, "devices": [], "alerts": [], "alert_count": 0,
"note": "no capture window completed yet (or wg-toolbox idle)"}
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
log = get_logger("dpi")

View File

@ -1,3 +0,0 @@
module github.com/cybermind/secubox-deb/packages/secubox-dpi/collector
go 1.22

View File

@ -1,445 +0,0 @@
// 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 :: secubox-dpi :: flow collector (#687, Phase 2)
//
// Per-device cloud-exfiltration detection from nDPI flow records. Reads
// ndpiReader CSV (the flow producer on wg-toolbox), maps each flow's source
// 10.99.1.x to its R3 device identity (sha256(wg_pubkey)[:16] from
// wg-peers.json), detects external clouds (by SNI / dst), runs the exfil
// scenarios, and writes JSON state for the secubox-dpi dashboard.
//
// Pure Go stdlib — no external deps — so it cross-compiles to arm64 with zero
// vendoring. The CSV producer is swappable for an nDPId JSON socket later.
package main
import (
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"time"
)
const (
upExfilBytes = 5 << 20 // >=5 MB outbound to a cloud → volume alert
beaconMinFlows = 6 // >=6 flows same dst → candidate beacon
beaconCVMax = 0.25 // iat coefficient-of-variation <= 0.25 → periodic
topN = 12
)
var (
wgPeersPath = env("SECUBOX_WG_PEERS", "/var/lib/secubox/toolbox/wg-peers.json")
statePath = env("SECUBOX_DPI_STATE", "/var/lib/secubox/dpi/state.json")
seenPath = env("SECUBOX_DPI_SEEN", "/var/lib/secubox/dpi/seen.json")
)
// svc = (category, human service name) for a known SNI suffix. Categories are
// nDPI-style buckets so the dashboard shows *what* each device talks to, not
// just whether it's a cloud. Exfil scenarios only fire on categories where an
// uncontrolled upload is a real data-leak concern (see exfilCat()).
type svc struct{ cat, name string }
// SNI suffix → service. Longest matching suffix wins (deterministic), so add
// specific suffixes (s3.amazonaws.com) alongside broad ones (amazonaws.com).
var serviceSuffix = map[string]svc{
// ── cloud storage / compute (exfil-relevant) ──
"amazonaws.com": {"cloud", "AWS"}, "s3.amazonaws.com": {"cloud", "AWS S3"}, "cloudfront.net": {"cloud", "AWS CloudFront"},
"googleapis.com": {"cloud", "Google APIs"}, "googleusercontent.com": {"cloud", "Google"}, "storage.googleapis.com": {"cloud", "Google Storage"},
"blob.core.windows.net": {"cloud", "Azure Blob"}, "core.windows.net": {"cloud", "Azure"}, "azureedge.net": {"cloud", "Azure CDN"},
"digitaloceanspaces.com": {"cloud", "DigitalOcean"}, "backblazeb2.com": {"cloud", "Backblaze B2"}, "wasabisys.com": {"cloud", "Wasabi"},
"oraclecloud.com": {"cloud", "Oracle Cloud"}, "ovh.net": {"cloud", "OVH"}, "scw.cloud": {"cloud", "Scaleway"}, "hetzner.com": {"cloud", "Hetzner"},
"firebaseio.com": {"cloud", "Firebase"}, "supabase.co": {"cloud", "Supabase"},
// ── file-host / paste / drop (classic exfil channels) ──
"dropboxusercontent.com": {"filehost", "Dropbox"}, "dropbox.com": {"filehost", "Dropbox"}, "box.com": {"filehost", "Box"},
"mega.nz": {"filehost", "MEGA"}, "transfer.sh": {"filehost", "transfer.sh"}, "pastebin.com": {"filehost", "Pastebin"},
"wetransfer.com": {"filehost", "WeTransfer"}, "anonfiles.com": {"filehost", "AnonFiles"}, "gofile.io": {"filehost", "Gofile"},
// ── messaging (can tunnel data out) ──
"telegram.org": {"messaging", "Telegram"}, "t.me": {"messaging", "Telegram"}, "discord.com": {"messaging", "Discord"},
"discordapp.com": {"messaging", "Discord"}, "whatsapp.net": {"messaging", "WhatsApp"}, "signal.org": {"messaging", "Signal"},
// ── AI services (uploads = data egress to 3rd-party models) ──
"openai.com": {"ai", "OpenAI"}, "oaistatic.com": {"ai", "OpenAI"}, "anthropic.com": {"ai", "Anthropic"},
"claude.ai": {"ai", "Claude"}, "perplexity.ai": {"ai", "Perplexity"}, "x.ai": {"ai", "xAI"}, "mistral.ai": {"ai", "Mistral"},
// ── media / streaming ──
"nflxvideo.net": {"media", "Netflix"}, "netflix.com": {"media", "Netflix"}, "googlevideo.com": {"media", "YouTube"},
"youtube.com": {"media", "YouTube"}, "ytimg.com": {"media", "YouTube"}, "ttvnw.net": {"media", "Twitch"}, "twitch.tv": {"media", "Twitch"},
"spotify.com": {"media", "Spotify"}, "scdn.co": {"media", "Spotify"}, "dssott.com": {"media", "Disney+"}, "disneyplus.com": {"media", "Disney+"},
"primevideo.com": {"media", "Prime Video"}, "aiv-cdn.net": {"media", "Prime Video"}, "deezer.com": {"media", "Deezer"},
"dailymotion.com": {"media", "Dailymotion"}, "vimeo.com": {"media", "Vimeo"}, "molotov.tv": {"media", "Molotov"},
// ── gaming ──
"steampowered.com": {"game", "Steam"}, "steamcontent.com": {"game", "Steam"}, "steamserver.net": {"game", "Steam"},
"epicgames.com": {"game", "Epic"}, "playstation.net": {"game", "PlayStation"}, "playstation.com": {"game", "PlayStation"},
"xboxlive.com": {"game", "Xbox"}, "riotgames.com": {"game", "Riot"}, "battle.net": {"game", "Battle.net"},
"roblox.com": {"game", "Roblox"}, "nintendo.net": {"game", "Nintendo"}, "ea.com": {"game", "EA"}, "ubisoft.com": {"game", "Ubisoft"},
// ── social ──
"facebook.com": {"social", "Facebook"}, "fbcdn.net": {"social", "Facebook"}, "instagram.com": {"social", "Instagram"},
"tiktokcdn.com": {"social", "TikTok"}, "tiktokv.com": {"social", "TikTok"}, "tiktok.com": {"social", "TikTok"},
"twitter.com": {"social", "X"}, "x.com": {"social", "X"}, "twimg.com": {"social", "X"}, "snapchat.com": {"social", "Snapchat"},
"reddit.com": {"social", "Reddit"}, "redditmedia.com": {"social", "Reddit"}, "pinterest.com": {"social", "Pinterest"},
// ── adult ──
"pornhub.com": {"adult", "Pornhub"}, "phncdn.com": {"adult", "Pornhub"}, "xvideos.com": {"adult", "XVideos"},
"xnxx.com": {"adult", "XNXX"}, "xnxx-cdn.com": {"adult", "XNXX"}, "redtube.com": {"adult", "RedTube"},
"youporn.com": {"adult", "YouPorn"}, "xhamster.com": {"adult", "xHamster"}, "onlyfans.com": {"adult", "OnlyFans"},
"chaturbate.com": {"adult", "Chaturbate"}, "stripchat.com": {"adult", "Stripchat"},
}
// exfilCat → does an uncontrolled upload to this category constitute data egress?
func exfilCat(cat string) bool {
switch cat {
case "cloud", "filehost", "messaging", "ai":
return true
}
return false
}
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func registrable(host string) string {
host = strings.ToLower(strings.TrimSuffix(strings.Split(host, ":")[0], "."))
p := strings.Split(host, ".")
if len(p) <= 2 {
return host
}
return strings.Join(p[len(p)-2:], ".")
}
// classify → (category, service) for a flow's SNI. Longest matching suffix
// wins so specific entries (s3.amazonaws.com) beat broad ones (amazonaws.com).
// Returns ("","") when the SNI is unknown.
func classify(sni, dstIP string) (string, string) {
s := strings.ToLower(strings.TrimSpace(sni))
if s == "" {
return "", ""
}
bestLen := -1
var bestCat, bestName string
for suf, v := range serviceSuffix {
if s == suf || strings.HasSuffix(s, "."+suf) {
if len(suf) > bestLen {
bestLen, bestCat, bestName = len(suf), v.cat, v.name
}
}
}
return bestCat, bestName
}
func isPrivate(ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
return p.IsPrivate() || p.IsLoopback() || p.IsLinkLocalUnicast()
}
// ── wg-peers.json : { "peers": { "<pubkey>": { "ip": "10.99.1.X", ... } } } ──
func loadDeviceMap() map[string]string {
m := map[string]string{}
b, err := os.ReadFile(wgPeersPath)
if err != nil {
return m
}
var doc struct {
Peers map[string]struct {
IP string `json:"ip"`
} `json:"peers"`
}
if json.Unmarshal(b, &doc) != nil {
return m
}
for pk, meta := range doc.Peers {
if meta.IP != "" {
sum := sha256.Sum256([]byte(pk))
m[meta.IP] = hex.EncodeToString(sum[:])[:16]
}
}
return m
}
type agg struct {
Device string `json:"device"`
Dst string `json:"dst"`
Category string `json:"category,omitempty"` // cloud|filehost|messaging|ai|media|game|social|adult
Service string `json:"service,omitempty"` // human service name (Netflix, AWS S3, …)
Cloud string `json:"cloud,omitempty"` // back-compat: = Service when exfilCat(Category)
Proto string `json:"proto"`
Up int64 `json:"up_bytes"`
Down int64 `json:"down_bytes"`
Flows int `json:"flows"`
iatAvg float64 // accumulators
iatStd float64
external bool
}
type alert struct {
Device string `json:"device"`
Kind string `json:"kind"`
Dst string `json:"dst"`
Category string `json:"category,omitempty"`
Service string `json:"service,omitempty"`
Cloud string `json:"cloud,omitempty"` // back-compat
Up int64 `json:"up_bytes"`
Down int64 `json:"down_bytes"`
Detail string `json:"detail"`
TS int64 `json:"ts"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: secubox-dpi-collector <flows.csv> [now_epoch]")
os.Exit(2)
}
now := time.Now().Unix()
if len(os.Args) >= 3 {
if v, err := strconv.ParseInt(os.Args[2], 10, 64); err == nil {
now = v
}
}
devmap := loadDeviceMap()
seen := loadSeen()
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "collector: open %s: %v\n", os.Args[1], err)
os.Exit(1)
}
defer f.Close()
r := csv.NewReader(f)
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
if err != nil || len(rows) < 2 {
// no flows this batch — still refresh state timestamp, exit clean.
writeState(map[string]*agg{}, nil, now)
return
}
col := indexCols(rows[0])
get := func(rec []string, name string) string {
if i, ok := col[name]; ok && i < len(rec) {
return rec[i]
}
return ""
}
atoi := func(s string) int64 { v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64); return v }
atof := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
aggs := map[string]*agg{}
for _, rec := range rows[1:] {
src := get(rec, "src_ip")
dev, ok := devmap[src]
if !ok || !strings.HasPrefix(src, "10.99.1.") {
continue // only attributed R3 devices
}
dstIP := get(rec, "dst_ip")
sni := get(rec, "server_name_sni")
proto := get(rec, "ndpi_proto")
cat, service := classify(sni, dstIP)
cloud := ""
if exfilCat(cat) {
cloud = service // back-compat field, only set for exfil-relevant dests
}
dst := sni
if dst == "" {
dst = dstIP
}
key := dev + "|" + dst
a := aggs[key]
if a == nil {
a = &agg{Device: dev, Dst: dst, Category: cat, Service: service, Cloud: cloud,
Proto: proto, external: !isPrivate(dstIP)}
aggs[key] = a
}
a.Up += atoi(get(rec, "c_to_s_bytes"))
a.Down += atoi(get(rec, "s_to_c_bytes"))
a.Flows++
a.iatAvg += atof(get(rec, "iat_flow_avg"))
a.iatStd += atof(get(rec, "iat_flow_stddev"))
}
var alerts []alert
newseen := map[string]bool{}
for _, a := range aggs {
base := func(kind, detail string) alert {
return alert{Device: a.Device, Kind: kind, Dst: a.Dst, Category: a.Category,
Service: a.Service, Cloud: a.Cloud, Up: a.Up, Down: a.Down, Detail: detail, TS: now}
}
exfilDest := exfilCat(a.Category)
label := a.Service
if label == "" {
label = a.Dst
}
// 1) volume exfil: lots OUT to a cloud/filehost/ai/messaging, more out than in
if exfilDest && a.Up >= upExfilBytes && a.Up > a.Down {
alerts = append(alerts, base("exfil_volume",
fmt.Sprintf("%s envoyé vers %s", human(a.Up), label)))
}
// 2) new exfil-relevant destination for this device
if exfilDest {
sk := a.Device + "|" + a.Service
newseen[sk] = true
if !seen[sk] {
alerts = append(alerts, base("new_cloud", "première sortie vers "+label))
}
}
// 3) beaconing: many flows, low inter-arrival variance
if a.Flows >= beaconMinFlows {
avg := a.iatAvg / float64(a.Flows)
std := a.iatStd / float64(a.Flows)
if avg > 0 && std/avg <= beaconCVMax {
alerts = append(alerts, base("beaconing",
fmt.Sprintf("%d flux périodiques (~%.0f ms)", a.Flows, avg)))
}
}
// 4) unclassified flow to an external host with notable upload
if a.external && a.Category == "" && a.Up >= upExfilBytes &&
(a.Proto == "" || strings.Contains(strings.ToLower(a.Proto), "unknown")) {
alerts = append(alerts, base("unclassified_external", human(a.Up)+" sortie non classifiée"))
}
}
// merge seen (persist union so new_cloud only fires once)
for k := range seen {
newseen[k] = true
}
saveSeen(newseen)
writeState(aggs, alerts, now)
fmt.Printf("collector: %d flows-agg, %d alerts @ %d\n", len(aggs), len(alerts), now)
}
func indexCols(header []string) map[string]int {
m := map[string]int{}
for i, h := range header {
m[strings.TrimPrefix(strings.TrimSpace(h), "#")] = i
}
return m
}
func human(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f Go", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.1f Mo", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f Ko", float64(b)/(1<<10))
}
return fmt.Sprintf("%d o", b)
}
func loadSeen() map[string]bool {
m := map[string]bool{}
b, err := os.ReadFile(seenPath)
if err != nil {
return m
}
var keys []string
if json.Unmarshal(b, &keys) == nil {
for _, k := range keys {
m[k] = true
}
}
return m
}
func saveSeen(m map[string]bool) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
writeJSON(seenPath, keys)
}
func writeState(aggs map[string]*agg, alerts []alert, now int64) {
// per-device rollup
type devstat struct {
Device string `json:"device"`
Flows int `json:"flows"`
UpBytes int64 `json:"up_bytes"`
Services []*agg `json:"services"` // all classified egress (any category)
Clouds []*agg `json:"clouds"` // back-compat: exfil-relevant subset
ByCat map[string]int `json:"by_category"` // category → flow count
Alerts []alert `json:"alerts"`
}
devs := map[string]*devstat{}
for _, a := range aggs {
d := devs[a.Device]
if d == nil {
d = &devstat{Device: a.Device, ByCat: map[string]int{}}
devs[a.Device] = d
}
d.Flows += a.Flows
d.UpBytes += a.Up
if a.Category != "" {
d.Services = append(d.Services, a)
d.ByCat[a.Category] += a.Flows
}
if a.Cloud != "" {
d.Clouds = append(d.Clouds, a)
}
}
for _, al := range alerts {
if d := devs[al.Device]; d != nil {
d.Alerts = append(d.Alerts, al)
}
}
list := make([]*devstat, 0, len(devs))
for _, d := range devs {
sort.Slice(d.Services, func(i, j int) bool { return d.Services[i].Up > d.Services[j].Up })
if len(d.Services) > topN {
d.Services = d.Services[:topN]
}
sort.Slice(d.Clouds, func(i, j int) bool { return d.Clouds[i].Up > d.Clouds[j].Up })
if len(d.Clouds) > topN {
d.Clouds = d.Clouds[:topN]
}
list = append(list, d)
}
sort.Slice(list, func(i, j int) bool { return list[i].UpBytes > list[j].UpBytes })
out := map[string]any{
"generated_at": now,
"devices": list,
"alerts": alerts,
"alert_count": len(alerts),
}
writeJSON(statePath, out)
}
func writeJSON(path string, v any) {
if err := os.MkdirAll(dir(path), 0o755); err != nil {
fmt.Fprintf(os.Stderr, "collector: mkdir %s: %v\n", dir(path), err)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return
}
tmp := path + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
os.Rename(tmp, path)
}
}
func dir(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[:i]
}
return "."
}

View File

@ -1,19 +1,3 @@
secubox-dpi (1.1.0-1~bookworm1) bookworm; urgency=low
* #687 Phase 2/3: ship the per-device R3 cloud-exfiltration pipeline as a
proper package — no more manual scp deploys.
- Build the pure-stdlib Go collector (secubox-dpi-collector) offline for
arm64 in debian/rules (GOTOOLCHAIN=local, GOPROXY=off).
- Ship sbin/secubox-dpi-flowcap (ndpiReader capture loop) +
secubox-dpi-flowcap.service (auto-enabled), Nice 15 / MemoryMax 256M.
- GET /api/v1/dpi/exfil serves the collector state; dashboard gains the
"Cloud Exfiltration Watch" panel with per-device service categorization
(cloud/filehost/messaging/ai/media/game/social/adult).
* Architecture: all -> arm64 (now ships a compiled collector).
* Depends: libndpi-bin (provides ndpiReader); Build-Depends: golang-go.
-- Gerald KERMA <devel@cybermind.fr> Mon, 22 Jun 2026 09:30:00 +0000
secubox-dpi (1.0.5-1~bookworm1) bookworm; urgency=low
* Clarify Description: this is the netifyd-backed analytics layer

View File

@ -2,14 +2,14 @@ Source: secubox-dpi
Section: net
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Rules-Requires-Root: no
Package: secubox-dpi
Architecture: arm64
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2, libndpi-bin
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2
Recommends: netifyd, secubox-netifyd
Description: SecuBox DPI Analytics — netifyd-backed app/protocol classification
Analytics layer on top of netifyd: top applications, top protocols,

View File

@ -7,10 +7,6 @@ case "$1" in
--home /var/lib/secubox --shell /usr/sbin/nologin secubox
install -d -o root -g root -m 1777 /run/secubox
install -d -o secubox -g secubox -m 755 /var/lib/secubox
# #687 exfil collector state dir — collector (root) writes state.json 0644,
# dpi API (secubox) reads it; keep 0755 so secubox can traverse.
install -d -o root -g root -m 0755 /var/lib/secubox/dpi
install -d -o root -g root -m 0755 /run/secubox/dpi
systemctl daemon-reload
systemctl enable secubox-dpi.service
systemctl start secubox-dpi.service || true

View File

@ -1,34 +1,8 @@
#!/usr/bin/make -f
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-dpi — DPI dashboard (Python) + R3 exfil collector (Go)
#
# The per-device cloud-exfiltration collector (#687) is a pure-stdlib Go binary
# cross-built for arm64, fully offline (no module download): the collector has
# no external deps so no vendor tree is needed. GOTOOLCHAIN=local pins the build
# to the distro Go; GOPROXY=off forbids any network. CI cross-builds the same.
export DH_VERBOSE = 1
export GOOS = linux
export GOARCH = arm64
export CGO_ENABLED = 0
export GOPROXY = off
export GOTOOLCHAIN = local
# Keep the Go build/module cache inside the build tree (sandbox-friendly).
export GOCACHE = $(CURDIR)/_gocache
export GOPATH = $(CURDIR)/_gopath
%:
dh $@
override_dh_auto_build:
cd collector && go build -trimpath -ldflags=-s -o secubox-dpi-collector .
# The arm64 cross-binary cannot run its tests on the build host; CI runs Go
# unit tests on the host arch instead.
override_dh_auto_test:
override_dh_auto_install:
# Python API + dashboard (arch-independent payload, shipped in the arm64 deb)
install -d debian/secubox-dpi/usr/lib/secubox/dpi/
cp -r api debian/secubox-dpi/usr/lib/secubox/dpi/
install -d debian/secubox-dpi/usr/share/secubox/www
@ -38,15 +12,3 @@ override_dh_auto_install:
# Modular nginx config
install -d debian/secubox-dpi/etc/nginx/secubox.d
[ -f nginx/dpi.conf ] && cp nginx/dpi.conf debian/secubox-dpi/etc/nginx/secubox.d/ || true
# #687 R3 exfil pipeline: Go collector + capture loop
install -d debian/secubox-dpi/usr/sbin
install -m 0755 collector/secubox-dpi-collector debian/secubox-dpi/usr/sbin/secubox-dpi-collector
install -m 0755 sbin/secubox-dpi-flowcap debian/secubox-dpi/usr/sbin/secubox-dpi-flowcap
# flowcap unit — installed into the tree so dh_installsystemd auto-enables it
install -d debian/secubox-dpi/usr/lib/systemd/system
install -m 0644 systemd/secubox-dpi-flowcap.service \
debian/secubox-dpi/usr/lib/systemd/system/secubox-dpi-flowcap.service
override_dh_auto_clean:
rm -f collector/secubox-dpi-collector
rm -rf _gocache _gopath

View File

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# 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 :: secubox-dpi :: flow capture loop (#687, Phase 2)
#
# Captures nDPI flow records off the R3 tap (wg-toolbox) in fixed windows via
# ndpiReader (-C CSV), then hands each batch to the Go collector for device
# attribution + cloud-exfiltration scenarios. ndpiReader = the lean C nDPI
# engine (PoC producer; an nDPId JSON-socket daemon is the production upgrade).
set -euo pipefail
readonly MODULE="secubox-dpi-flowcap"
IFACE="${SECUBOX_DPI_IFACE:-wg-toolbox}"
WINDOW="${SECUBOX_DPI_WINDOW:-60}"
CSV="${SECUBOX_DPI_CSV:-/run/secubox/dpi/flows.csv}"
COLLECTOR="${SECUBOX_DPI_COLLECTOR:-/usr/sbin/secubox-dpi-collector}"
mkdir -p "$(dirname "$CSV")" /var/lib/secubox/dpi
if ! command -v ndpiReader >/dev/null 2>&1; then
echo "[$MODULE] ndpiReader missing (apt install libndpi-bin) — idle" >&2
sleep 3600; exit 0
fi
echo "[$MODULE] capturing $IFACE in ${WINDOW}s windows → $COLLECTOR"
while true; do
# one capture window → CSV (niced; nDPI is ~1% CPU but the board is busy)
nice -n 15 ndpiReader -i "$IFACE" -s "$WINDOW" -C "$CSV" >/dev/null 2>&1 || true
if [ -s "$CSV" ]; then
nice -n 15 "$COLLECTOR" "$CSV" >/dev/null 2>&1 || \
echo "[$MODULE] collector run failed" >&2
fi
done

View File

@ -1,23 +0,0 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Per-device flow-DPI on the R3 tap (#687): ndpiReader → Go collector →
# cloud-exfiltration scenarios → /var/lib/secubox/dpi/state.json (served by the
# secubox-dpi dashboard at /api/v1/dpi/exfil).
[Unit]
Description=SecuBox-Deb DPI flow capture + exfil collector (#687)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/sbin/secubox-dpi-flowcap
Restart=always
RestartSec=10
# ndpiReader needs raw packet capture on wg-toolbox; nothing else.
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
# Light on a saturated board (~1% CPU observed); bound memory + low priority.
Nice=15
CPUWeight=20
MemoryMax=256M
[Install]
WantedBy=multi-user.target

View File

@ -346,19 +346,6 @@
</div>
</div>
<!-- #687 — per-device cloud exfiltration watch (R3 DPI) -->
<div class="card" id="exfil-card" style="border-color: var(--p31-decay);">
<div class="card-header">
<h2>🛰️ Cloud Exfiltration Watch <span style="color: var(--text-dim); text-transform:none; letter-spacing:0; font-size:0.7rem;">— per-device egress on R3 (wg-toolbox)</span></h2>
<span class="badge" id="exfil-badge">-</span>
</div>
<div id="exfil-alerts" style="margin-bottom: 1rem;"></div>
<div id="exfil-devices">
<p class="empty">Loading…</p>
</div>
<p id="exfil-foot" style="color: var(--text-dim); font-size: 0.7rem; margin-top: 0.75rem;"></p>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header"><h2>📊 Top Applications</h2></div>
@ -511,99 +498,6 @@
`).join('') : '<p style="color: var(--text-dim);">No rules</p>';
}
const EXFIL_KINDS = {
exfil_volume: { label: 'BULK UPLOAD', cls: 'badge-red', icon: '⬆️' },
new_cloud: { label: 'NEW DEST', cls: 'badge-yellow', icon: '☁️' },
beaconing: { label: 'BEACONING', cls: 'badge-purple', icon: '📡' },
unclassified_external: { label: 'UNCLASSIFIED', cls: 'badge-amber', icon: '❔' },
};
// service category → icon + badge class (exfil-relevant cats are warm/red)
const CATEGORY = {
cloud: { icon: '☁️', cls: 'badge-red', exfil: true },
filehost: { icon: '📦', cls: 'badge-red', exfil: true },
messaging: { icon: '💬', cls: 'badge-amber', exfil: true },
ai: { icon: '🤖', cls: 'badge-purple', exfil: true },
media: { icon: '🎬', cls: 'badge-cyan', exfil: false },
game: { icon: '🎮', cls: 'badge-green', exfil: false },
social: { icon: '👥', cls: 'badge-blue', exfil: false },
adult: { icon: '🔞', cls: 'badge-purple', exfil: false },
};
function catMeta(c) { return CATEGORY[c] || { icon: '🌐', cls: 'badge-cyan', exfil: false }; }
function shortDev(d) { return d ? d.slice(0, 8) + '…' : 'unknown'; }
function agoStr(ts) {
if (!ts) return '';
const s = Math.max(0, Math.floor(Date.now()/1000 - ts));
if (s < 90) return s + 's ago';
if (s < 5400) return Math.round(s/60) + 'm ago';
return Math.round(s/3600) + 'h ago';
}
async function loadExfil() {
const data = await api('/exfil');
const alertsBox = document.getElementById('exfil-alerts');
const devBox = document.getElementById('exfil-devices');
const badge = document.getElementById('exfil-badge');
const foot = document.getElementById('exfil-foot');
const card = document.getElementById('exfil-card');
const alerts = Array.isArray(data.alerts) ? data.alerts : [];
const devices = Array.isArray(data.devices) ? data.devices : [];
const count = data.alert_count || alerts.length;
badge.textContent = count > 0 ? (count + ' ALERT' + (count > 1 ? 'S' : '')) : 'CLEAR';
badge.className = 'badge ' + (count > 0 ? 'badge-red' : 'badge-green');
card.style.borderColor = count > 0 ? '#ff4466' : 'var(--p31-decay)';
// alert feed (severity-first)
alertsBox.innerHTML = alerts.length ? alerts.map(a => {
const k = EXFIL_KINDS[a.kind] || { label: (a.kind || '?').toUpperCase(), cls: 'badge-amber', icon: '⚠️' };
const svc = a.service || a.cloud;
const cm = catMeta(a.category);
const dest = svc ? `${cm.icon} ${svc} (${a.dst})` : a.dst;
return `<div class="app-item" style="border-left:3px solid #ff4466; padding-left:0.6rem;">
<span><span class="badge ${k.cls}" style="margin-right:0.5rem;">${k.icon} ${k.label}</span>
<span style="font-family:monospace;">${shortDev(a.device)}</span>
→ ${dest}
<span style="color: var(--text-dim);"> · ${a.detail || ''}</span></span>
<span class="bytes">↑${formatBytes(a.up_bytes)} ${agoStr(a.ts)}</span>
</div>`;
}).join('') : '<p class="empty" style="color: var(--green);">✓ No exfiltration detected</p>';
// per-device egress rollup — grouped by service category
devBox.innerHTML = devices.length ? devices.map(d => {
const svcs = d.services || d.clouds || [];
const rows = svcs.map(c => {
const m = catMeta(c.category);
const name = c.service || c.cloud || c.dst;
return `<div class="app-item" style="padding:0.35rem 0.6rem; font-size:0.85rem;${m.exfil ? 'border-left:2px solid #ff4466; padding-left:0.5rem;' : ''}">
<span><span title="${c.category || 'other'}">${m.icon}</span> ${name}
<span style="color:var(--text-dim);">${c.proto || ''}</span></span>
<span class="bytes">↑${formatBytes(c.up_bytes)} ↓${formatBytes(c.down_bytes)}</span>
</div>`;
}).join('') || '<p class="empty" style="padding:0.35rem 0.6rem;">no classified egress</p>';
// category summary chips
const cats = d.by_category || {};
const chips = Object.keys(cats).sort((a,b)=>cats[b]-cats[a]).map(c => {
const m = catMeta(c);
return `<span class="badge ${m.cls}" style="margin-left:0.3rem; font-size:0.65rem;">${m.icon} ${c} ${cats[c]}</span>`;
}).join('');
const hot = (d.alerts && d.alerts.length) ? `<span class="badge badge-red" style="margin-left:0.5rem;">${d.alerts.length}⚠️</span>` : '';
return `<div style="margin-bottom:0.75rem; border:1px solid var(--tube-soft); border-radius:6px;">
<div class="app-item" style="background:rgba(255,179,71,0.06); flex-wrap:wrap;">
<span style="font-family:monospace; color:var(--p31-decay);">📟 ${shortDev(d.device)}${hot} ${chips}</span>
<span class="bytes">${d.flows} flows · ↑${formatBytes(d.up_bytes)}</span>
</div>
${rows}
</div>`;
}).join('') : '<p class="empty">No devices observed on R3 yet (tunnel idle, or first capture window pending).</p>';
foot.textContent = data.generated_at
? `Last capture: ${agoStr(data.generated_at)} · ${devices.length} device(s) · ${alerts.length} alert(s)`
: (data.note || 'no capture window completed yet');
}
async function setupMirred() { const r = await api('/setup_mirred', 'POST'); alert(r.error ? 'Error: ' + r.error : 'Mirred configured'); loadStatus(); }
async function removeMirred() { await api('/remove_mirred', 'POST'); alert('Mirred removed'); loadStatus(); }
async function restartNetifyd() { await api('/restart', 'POST'); alert('netifyd restarted'); loadStatus(); }
@ -624,7 +518,6 @@
function refreshAll() {
loadStatus();
loadExfil();
loadTopApps();
loadTopProtocols();
loadBandwidthDevices();

View File

@ -129,12 +129,8 @@ func (c *CA) forge(host string) (*tls.Certificate, error) {
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
// #689 — forged leaves must outlive the (non-evicting) cert cache, else a
// long-running worker keeps serving an expired leaf and every client
// reports "certificat expiré". 365d forward + 48h back-skew = 367d span,
// safely under Apple's 398-day max-validity rule for server certs.
NotBefore: time.Now().Add(-48 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{host},