mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 16:31:31 +00:00
Compare commits
No commits in common. "dde96f212ebbc399a899a0f8651a966fe2fb918c" and "9eb2d68b9232986e9ad53410ac333a497b37a195" have entirely different histories.
dde96f212e
...
9eb2d68b92
4
packages/secubox-dpi/.gitignore
vendored
4
packages/secubox-dpi/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
# debian/rules build artifacts (Go collector + module caches)
|
|
||||||
collector/secubox-dpi-collector
|
|
||||||
_gocache/
|
|
||||||
_gopath/
|
|
||||||
|
|
@ -64,23 +64,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module github.com/cybermind/secubox-deb/packages/secubox-dpi/collector
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
@ -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 "."
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
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), golang-go (>= 2:1.22~)
|
Build-Depends: debhelper-compat (= 13)
|
||||||
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: arm64
|
Architecture: all
|
||||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2, libndpi-bin
|
Depends: ${misc:Depends}, secubox-core (>= 1.0), iproute2
|
||||||
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,10 +7,6 @@ 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,34 +1,8 @@
|
||||||
#!/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
|
||||||
|
|
@ -38,15 +12,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -346,19 +346,6 @@
|
||||||
</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>
|
||||||
|
|
@ -511,99 +498,6 @@
|
||||||
`).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(); }
|
||||||
|
|
@ -624,7 +518,6 @@
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
loadStatus();
|
loadStatus();
|
||||||
loadExfil();
|
|
||||||
loadTopApps();
|
loadTopApps();
|
||||||
loadTopProtocols();
|
loadTopProtocols();
|
||||||
loadBandwidthDevices();
|
loadBandwidthDevices();
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,8 @@ 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},
|
||||||
// #689 — forged leaves must outlive the (non-evicting) cert cache, else a
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
// long-running worker keeps serving an expired leaf and every client
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
// 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