mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 11:08:33 +00:00
Compare commits
7 Commits
9eb2d68b92
...
dde96f212e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde96f212e | ||
|
|
8fae0dab54 | ||
| 997fa0501d | |||
| 1567f94184 | |||
| 7b379a03d6 | |||
| 01b35e7b95 | |||
| 76acf259c2 |
4
packages/secubox-dpi/.gitignore
vendored
Normal file
4
packages/secubox-dpi/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# debian/rules build artifacts (Go collector + module caches)
|
||||||
|
collector/secubox-dpi-collector
|
||||||
|
_gocache/
|
||||||
|
_gopath/
|
||||||
|
|
@ -64,6 +64,23 @@ async def health_check():
|
||||||
"""Public health check endpoint for sidebar status."""
|
"""Public health check endpoint for sidebar status."""
|
||||||
return {"status": "ok", "module": "deb"}
|
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")
|
app.include_router(auth_router, prefix="/auth")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = get_logger("dpi")
|
log = get_logger("dpi")
|
||||||
|
|
|
||||||
3
packages/secubox-dpi/collector/go.mod
Normal file
3
packages/secubox-dpi/collector/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/cybermind/secubox-deb/packages/secubox-dpi/collector
|
||||||
|
|
||||||
|
go 1.22
|
||||||
445
packages/secubox-dpi/collector/main.go
Normal file
445
packages/secubox-dpi/collector/main.go
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
// 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 "."
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,19 @@
|
||||||
|
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
|
secubox-dpi (1.0.5-1~bookworm1) bookworm; urgency=low
|
||||||
|
|
||||||
* Clarify Description: this is the netifyd-backed analytics layer
|
* Clarify Description: this is the netifyd-backed analytics layer
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ Source: secubox-dpi
|
||||||
Section: net
|
Section: net
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||||
Build-Depends: debhelper-compat (= 13)
|
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
|
||||||
Standards-Version: 4.6.2
|
Standards-Version: 4.6.2
|
||||||
Homepage: https://cybermind.fr/secubox
|
Homepage: https://cybermind.fr/secubox
|
||||||
Rules-Requires-Root: no
|
Rules-Requires-Root: no
|
||||||
|
|
||||||
Package: secubox-dpi
|
Package: secubox-dpi
|
||||||
Architecture: all
|
Architecture: arm64
|
||||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2
|
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2, libndpi-bin
|
||||||
Recommends: netifyd, secubox-netifyd
|
Recommends: netifyd, secubox-netifyd
|
||||||
Description: SecuBox DPI Analytics — netifyd-backed app/protocol classification
|
Description: SecuBox DPI Analytics — netifyd-backed app/protocol classification
|
||||||
Analytics layer on top of netifyd: top applications, top protocols,
|
Analytics layer on top of netifyd: top applications, top protocols,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ case "$1" in
|
||||||
--home /var/lib/secubox --shell /usr/sbin/nologin secubox
|
--home /var/lib/secubox --shell /usr/sbin/nologin secubox
|
||||||
install -d -o root -g root -m 1777 /run/secubox
|
install -d -o root -g root -m 1777 /run/secubox
|
||||||
install -d -o secubox -g secubox -m 755 /var/lib/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 daemon-reload
|
||||||
systemctl enable secubox-dpi.service
|
systemctl enable secubox-dpi.service
|
||||||
systemctl start secubox-dpi.service || true
|
systemctl start secubox-dpi.service || true
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,34 @@
|
||||||
#!/usr/bin/make -f
|
#!/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 $@
|
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:
|
override_dh_auto_install:
|
||||||
|
# Python API + dashboard (arch-independent payload, shipped in the arm64 deb)
|
||||||
install -d debian/secubox-dpi/usr/lib/secubox/dpi/
|
install -d debian/secubox-dpi/usr/lib/secubox/dpi/
|
||||||
cp -r api 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
|
install -d debian/secubox-dpi/usr/share/secubox/www
|
||||||
|
|
@ -12,3 +38,15 @@ override_dh_auto_install:
|
||||||
# Modular nginx config
|
# Modular nginx config
|
||||||
install -d debian/secubox-dpi/etc/nginx/secubox.d
|
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
|
[ -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
|
||||||
|
|
|
||||||
36
packages/secubox-dpi/sbin/secubox-dpi-flowcap
Normal file
36
packages/secubox-dpi/sbin/secubox-dpi-flowcap
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/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
|
||||||
23
packages/secubox-dpi/systemd/secubox-dpi-flowcap.service
Normal file
23
packages/secubox-dpi/systemd/secubox-dpi-flowcap.service
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# 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
|
||||||
|
|
@ -346,6 +346,19 @@
|
||||||
</div>
|
</div>
|
||||||
</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="grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2>📊 Top Applications</h2></div>
|
<div class="card-header"><h2>📊 Top Applications</h2></div>
|
||||||
|
|
@ -498,6 +511,99 @@
|
||||||
`).join('') : '<p style="color: var(--text-dim);">No rules</p>';
|
`).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 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 removeMirred() { await api('/remove_mirred', 'POST'); alert('Mirred removed'); loadStatus(); }
|
||||||
async function restartNetifyd() { await api('/restart', 'POST'); alert('netifyd restarted'); loadStatus(); }
|
async function restartNetifyd() { await api('/restart', 'POST'); alert('netifyd restarted'); loadStatus(); }
|
||||||
|
|
@ -518,6 +624,7 @@
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
loadStatus();
|
loadStatus();
|
||||||
|
loadExfil();
|
||||||
loadTopApps();
|
loadTopApps();
|
||||||
loadTopProtocols();
|
loadTopProtocols();
|
||||||
loadBandwidthDevices();
|
loadBandwidthDevices();
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,12 @@ func (c *CA) forge(host string) (*tls.Certificate, error) {
|
||||||
tmpl := &x509.Certificate{
|
tmpl := &x509.Certificate{
|
||||||
SerialNumber: serial,
|
SerialNumber: serial,
|
||||||
Subject: pkix.Name{CommonName: host},
|
Subject: pkix.Name{CommonName: host},
|
||||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
// #689 — forged leaves must outlive the (non-evicting) cert cache, else a
|
||||||
NotAfter: time.Now().Add(24 * time.Hour),
|
// 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),
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
DNSNames: []string{host},
|
DNSNames: []string{host},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user