feat(ja4ebpf): add multi-interface TC, LPM_TRIE ignore_src, unit tests, and fix bugs
- Add multi-interface TC attachment (default "any" = all UP interfaces) - Add BPF LPM_TRIE map ignored_src for kernel-side CIDR filtering - Add userspace ignore_src filtering for SSL/accept4 path via net.IPNet.Contains() - Add AcceptCache for fd→SessionKey correlation with TTL and Close() - Add 5 test files covering writer, procutil, dispatcher, accept_cache, and cmd - Fix formatTCPOptions infinite loop on EOL (case 0 break→return) - Fix pseudoOrderToShort panic on empty slice (negative cap) - Fix AcceptCache goroutine leak (add done channel + Close()) - Update config.yml.example with interfaces, listen_ports, ignore_src - Rewrite docs/services/ja4ebpf.md (was massively stale: XDP, RingBuffer, etc.) - Fix stale XDP/RingBuffer references in docs/architecture.md, thesis, tls.go Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -7,9 +7,11 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@ -32,13 +34,18 @@ var fdCache = procutil.NewFDCache(5 * time.Second)
|
||||
// Prioritaire sur fdCache car source de vérité (tracepoint kernel).
|
||||
var acceptCache = correlation.NewAcceptCache(10 * time.Second)
|
||||
|
||||
// ignoreNets contient les CIDR sources à ignorer (peuplé depuis cfg.IgnoreSrc).
|
||||
var ignoreNets []*net.IPNet
|
||||
|
||||
// Config décrit la configuration complète du démon ja4ebpf.
|
||||
// Chargée depuis un fichier YAML et enrichie par les variables d'environnement
|
||||
// avec le préfixe JA4EBPF_.
|
||||
type Config struct {
|
||||
Interface string `yaml:"interface"` // interface réseau à surveiller (ex: "eth0")
|
||||
SSLLibPath string `yaml:"ssl_lib_path"` // chemin vers libssl (ex: "/usr/lib64/libssl.so.3")
|
||||
Debug bool `yaml:"debug"` // mode debug : dump compteurs BPF, log verbeux, ClickHouse optionnel
|
||||
Interfaces []string `yaml:"interfaces"` // interfaces à surveiller (défaut: ["any"])
|
||||
SSLLibPath string `yaml:"ssl_lib_path"` // chemin vers libssl (ex: "/usr/lib64/libssl.so.3")
|
||||
ListenPorts []uint16 `yaml:"listen_ports"` // ports à surveiller (défaut: [80, 443])
|
||||
IgnoreSrc []string `yaml:"ignore_src"` // CIDR/IP sources à ignorer (ex: ["10.0.0.0/8"])
|
||||
Debug bool `yaml:"debug"` // mode debug : dump compteurs BPF, log verbeux, ClickHouse optionnel
|
||||
|
||||
ClickHouse struct {
|
||||
DSN string `yaml:"dsn"` // DSN ClickHouse natif
|
||||
@ -63,9 +70,10 @@ func loadConfig(path string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
// Valeurs par défaut
|
||||
cfg.Interface = "eth0"
|
||||
cfg.Interfaces = []string{"any"}
|
||||
cfg.SSLLibPath = "/usr/lib64/libssl.so.3"
|
||||
cfg.ClickHouse.DSN = "clickhouse://default:@localhost:9000/ja4_logs"
|
||||
cfg.ListenPorts = []uint16{80, 443}
|
||||
cfg.ClickHouse.DSN = "clickhouse://default:@localhost:9000/ja4_logs?async_insert=0"
|
||||
cfg.ClickHouse.BatchSize = 500
|
||||
cfg.ClickHouse.FlushSecs = 1
|
||||
cfg.Correlation.TimeoutMS = 5000
|
||||
@ -85,8 +93,12 @@ func loadConfig(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Surcharges via variables d'environnement
|
||||
if v := os.Getenv("JA4EBPF_INTERFACES"); v != "" {
|
||||
cfg.Interfaces = strings.Split(v, ",")
|
||||
}
|
||||
// Rétrocompatibilité : JA4EBPF_INTERFACE écrase la liste
|
||||
if v := os.Getenv("JA4EBPF_INTERFACE"); v != "" {
|
||||
cfg.Interface = v
|
||||
cfg.Interfaces = []string{v}
|
||||
}
|
||||
if v := os.Getenv("JA4EBPF_SSL_LIB_PATH"); v != "" {
|
||||
cfg.SSLLibPath = v
|
||||
@ -97,11 +109,83 @@ func loadConfig(path string) (*Config, error) {
|
||||
if v := os.Getenv("JA4EBPF_DEBUG"); v != "" {
|
||||
cfg.Debug = strings.EqualFold(v, "true") || v == "1" || v == "yes"
|
||||
}
|
||||
if v := os.Getenv("JA4EBPF_LISTEN_PORTS"); v != "" {
|
||||
cfg.ListenPorts = nil
|
||||
for _, s := range strings.Split(v, ",") {
|
||||
p, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16)
|
||||
if err != nil {
|
||||
log.Printf("[ja4ebpf] port invalide dans JA4EBPF_LISTEN_PORTS: %q", s)
|
||||
continue
|
||||
}
|
||||
cfg.ListenPorts = append(cfg.ListenPorts, uint16(p))
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("JA4EBPF_IGNORE_SRC"); v != "" {
|
||||
cfg.IgnoreSrc = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// main est le point d'entrée du programme.
|
||||
// parseCIDRs convertit une liste de CIDR/IP en clés LPM_TRIE (big-endian).
|
||||
func parseCIDRs(cidrs []string) ([]loader.LPMKey, error) {
|
||||
var keys []loader.LPMKey
|
||||
for _, cidr := range cidrs {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if !strings.Contains(cidr, "/") {
|
||||
cidr += "/32"
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CIDR invalide %q: %w", cidr, err)
|
||||
}
|
||||
ip4 := ipNet.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
prefixLen, _ := ipNet.Mask.Size()
|
||||
var data [4]byte
|
||||
copy(data[:], ip4)
|
||||
keys = append(keys, loader.LPMKey{
|
||||
Prefixlen: uint32(prefixLen),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// isIgnoredIP vérifie si une adresse IPv4 (4 octets) match un des CIDR ignore_src.
|
||||
func isIgnoredIP(ip [4]byte) bool {
|
||||
ip4 := net.IPv4(ip[0], ip[1], ip[2], ip[3])
|
||||
for _, cidr := range ignoreNets {
|
||||
if cidr.Contains(ip4) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseIgnoreNets convertit la liste de CIDR ignore_src en []*net.IPNet.
|
||||
func parseIgnoreNets(cidrs []string) []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range cidrs {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if !strings.Contains(cidr, "/") {
|
||||
cidr += "/32"
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
log.Printf("[ja4ebpf] CIDR ignore_src invalide %q: %v", cidr, err)
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.To4() != nil {
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Déterminer le chemin du fichier de configuration
|
||||
configPath := os.Getenv("JA4EBPF_CONFIG")
|
||||
@ -117,7 +201,10 @@ func main() {
|
||||
if cfg.Debug {
|
||||
log.Printf("[ja4ebpf] MODE DEBUG ACTIVÉ")
|
||||
}
|
||||
log.Printf("[ja4ebpf] démarrage — interface=%s ssl=%s debug=%v", cfg.Interface, cfg.SSLLibPath, cfg.Debug)
|
||||
log.Printf("[ja4ebpf] démarrage — interfaces=%v ssl=%s debug=%v", cfg.Interfaces, cfg.SSLLibPath, cfg.Debug)
|
||||
|
||||
// Peupler ignoreNets pour filtrage userspace (SSL_read/SSL_write/accept4)
|
||||
ignoreNets = parseIgnoreNets(cfg.IgnoreSrc)
|
||||
|
||||
// Contexte principal avec annulation sur signal système
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@ -134,12 +221,44 @@ func main() {
|
||||
}
|
||||
defer ldr.Close()
|
||||
|
||||
// --- 2. Attachement TC ingress ---
|
||||
log.Printf("[ja4ebpf] attachement TC ingress sur %s...", cfg.Interface)
|
||||
if err := ldr.AttachTC(cfg.Interface); err != nil {
|
||||
log.Fatalf("erreur attachement TC sur %s: %v", cfg.Interface, err)
|
||||
// --- 1b. Peuplement de la map allowed_ports ---
|
||||
if err := ldr.PopulatePorts(cfg.ListenPorts); err != nil {
|
||||
log.Fatalf("[ja4ebpf] erreur peuplement allowed_ports: %v", err)
|
||||
}
|
||||
for _, p := range cfg.ListenPorts {
|
||||
log.Printf("[ja4ebpf] port %d surveillé", p)
|
||||
}
|
||||
|
||||
// --- 1c. Peuplement de la map ignored_src (LPM_TRIE) ---
|
||||
if len(cfg.IgnoreSrc) > 0 {
|
||||
lpmKeys, err := parseCIDRs(cfg.IgnoreSrc)
|
||||
if err != nil {
|
||||
log.Fatalf("[ja4ebpf] erreur parsing ignore_src: %v", err)
|
||||
}
|
||||
if err := ldr.PopulateIgnoredSrc(lpmKeys); err != nil {
|
||||
log.Fatalf("[ja4ebpf] erreur peuplement ignored_src: %v", err)
|
||||
}
|
||||
for _, c := range cfg.IgnoreSrc {
|
||||
log.Printf("[ja4ebpf] ignore src: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Attachement TC ingress ---
|
||||
if len(cfg.Interfaces) == 1 && cfg.Interfaces[0] == "any" {
|
||||
ifaces, err := ldr.AttachTCAll()
|
||||
if err != nil {
|
||||
log.Fatalf("[ja4ebpf] erreur attachement TC: %v", err)
|
||||
}
|
||||
log.Printf("[ja4ebpf] TC ingress attaché sur: %v", ifaces)
|
||||
} else {
|
||||
for _, iface := range cfg.Interfaces {
|
||||
log.Printf("[ja4ebpf] attachement TC ingress sur %s...", iface)
|
||||
if err := ldr.AttachTC(iface); err != nil {
|
||||
log.Fatalf("[ja4ebpf] erreur attachement TC %s: %v", iface, err)
|
||||
}
|
||||
log.Printf("[ja4ebpf] TC ingress attaché sur %s", iface)
|
||||
}
|
||||
}
|
||||
log.Printf("[ja4ebpf] TC ingress attaché sur %s", cfg.Interface)
|
||||
|
||||
// --- 3. Attachement uprobes SSL ---
|
||||
if err := ldr.AttachUprobes(cfg.SSLLibPath); err != nil {
|
||||
@ -349,6 +468,11 @@ func consumeSynEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man
|
||||
tcpOpts := make([]byte, optLen)
|
||||
copy(tcpOpts, data[23:23+optLen])
|
||||
|
||||
// Filtrer les IPs sources ignorées (ignore_src)
|
||||
if isIgnoredIP(key.SrcIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Analyser les options TCP brutes pour extraire MSS et Window Scale
|
||||
mss, windowScale := parseTCPOptions(tcpOpts)
|
||||
|
||||
@ -426,6 +550,11 @@ func consumeTLSEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man
|
||||
tlsDstIP[2] = byte(dstIPRaw >> 8)
|
||||
tlsDstIP[3] = byte(dstIPRaw)
|
||||
|
||||
// Filtrer les IPs sources ignorées (ignore_src)
|
||||
if isIgnoredIP(key.SrcIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parser le ClientHello et calculer JA4
|
||||
ch, err := parser.ParseClientHello(payload)
|
||||
if err != nil {
|
||||
@ -567,6 +696,12 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man
|
||||
continue
|
||||
}
|
||||
|
||||
// Filtrer les IPs sources ignorées (ignore_src)
|
||||
if key.SrcIP != [4]byte{} && isIgnoredIP(key.SrcIP) {
|
||||
log.Printf("[debug-ssl] FILTERED srcIP=%d.%d.%d.%d", key.SrcIP[0], key.SrcIP[1], key.SrcIP[2], key.SrcIP[3])
|
||||
continue
|
||||
}
|
||||
|
||||
counter.Add(1)
|
||||
|
||||
// === Routeur par direction ===
|
||||
@ -592,137 +727,125 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP/2 server HEADERS frame (contient :status)
|
||||
if parser.IsH2FrameHeader(sslData) {
|
||||
h2kv := parser.ExtractH2HeaderKV(sslData)
|
||||
if statusCode, ok := h2kv[":status"]; ok {
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
if len(s.Requests) > 0 {
|
||||
last := &s.Requests[len(s.Requests)-1]
|
||||
if last.StatusCode == 0 {
|
||||
// Conversion du code de statut H2 (ex: "200" → 200)
|
||||
code := 0
|
||||
for _, c := range statusCode {
|
||||
if c >= '0' && c <= '9' {
|
||||
code = code*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
if code >= 100 && code <= 599 {
|
||||
last.StatusCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// HTTP/2 : traiter via H2ConnState si la connexion est H2
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
if s.H2Conn == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.H2Conn.ProcessFrames(sslData, 1)
|
||||
if err != nil || result == nil {
|
||||
return
|
||||
}
|
||||
// Extraire le code de statut des réponses serveur
|
||||
if result.StatusCode > 0 && len(s.Requests) > 0 {
|
||||
last := &s.Requests[len(s.Requests)-1]
|
||||
if last.StatusCode == 0 {
|
||||
last.StatusCode = result.StatusCode
|
||||
}
|
||||
}
|
||||
// Mettre à jour les paramètres SETTINGS serveur
|
||||
if result.ServerSettings != nil {
|
||||
s.H2Conn.ServerSettings = result.ServerSettings
|
||||
}
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// === Client → Serveur : requêtes HTTP (direction=0) ===
|
||||
|
||||
if parser.DetectH2Preface(sslData) {
|
||||
// HTTP/2 : extraire les paramètres SETTINGS et en-têtes depuis la préface
|
||||
// HTTP/2 : préface détectée, créer H2ConnState et traiter les frames
|
||||
afterPreface := sslData
|
||||
if len(afterPreface) > parser.H2MagicPrefaceLen() {
|
||||
afterPreface = sslData[parser.H2MagicPrefaceLen():]
|
||||
}
|
||||
h2settings, err := parser.ParseH2ClientPreface(afterPreface)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
req := correlation.HTTPRequest{
|
||||
Timestamp: time.Now(),
|
||||
// Créer le H2ConnState s'il n'existe pas
|
||||
if s.H2Conn == nil {
|
||||
s.H2Conn = parser.NewH2ConnState()
|
||||
}
|
||||
if h2settings != nil {
|
||||
req.HTTP2Settings = &correlation.HTTP2Settings{
|
||||
HeaderTableSize: h2settings.HeaderTableSize,
|
||||
EnablePush: h2settings.EnablePush,
|
||||
MaxConcurrentStreams: h2settings.MaxConcurrentStreams,
|
||||
InitialWindowSize: h2settings.InitialWindowSize,
|
||||
MaxFrameSize: h2settings.MaxFrameSize,
|
||||
MaxHeaderListSize: h2settings.MaxHeaderListSize,
|
||||
UnknownSettings: h2settings.UnknownSettings,
|
||||
EnableConnectProtocol: h2settings.EnableConnectProtocol,
|
||||
WindowUpdateIncrement: h2settings.WindowUpdateIncrement,
|
||||
PseudoHeaderOrder: h2settings.PseudoHeaderOrder,
|
||||
}
|
||||
// Extraire les en-têtes H2 (User-Agent, Accept, etc.)
|
||||
if len(h2settings.HeaderKV) > 0 {
|
||||
req.HeaderKV = h2settings.HeaderKV
|
||||
req.HeaderOrder = h2settings.HeaderOrder
|
||||
req.HeaderOrderSig = strings.Join(h2settings.HeaderOrder, ";")
|
||||
if h2settings.HeaderKV[":method"] != "" {
|
||||
req.Method = h2settings.HeaderKV[":method"]
|
||||
}
|
||||
if h2settings.HeaderKV[":path"] != "" {
|
||||
p := h2settings.HeaderKV[":path"]
|
||||
if idx := strings.Index(p, "?"); idx >= 0 {
|
||||
req.Path = p[:idx]
|
||||
req.QueryString = p[idx+1:]
|
||||
} else {
|
||||
req.Path = p
|
||||
}
|
||||
}
|
||||
if h2settings.HeaderKV[":authority"] != "" {
|
||||
req.Host = h2settings.HeaderKV[":authority"]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.Requests) == 0 {
|
||||
req.HTTPVersion = "HTTP/2"
|
||||
s.Requests = append(s.Requests, req)
|
||||
}
|
||||
// Si la session n'a pas de L3L4 (pas de SYN capturé),
|
||||
// peupler dst_ip/dst_port depuis le cache accept4
|
||||
if s.L3L4 == nil && (dstIPFromAccept != [4]byte{} || dstPortFromAccept != 0) {
|
||||
s.L3L4 = &correlation.L3L4{
|
||||
DstIP: dstIPFromAccept,
|
||||
DstPort: dstPortFromAccept,
|
||||
}
|
||||
}
|
||||
_ = s.TLS // corrélation implicite
|
||||
|
||||
result, _ := s.H2Conn.ProcessFrames(afterPreface, 0)
|
||||
applyH2Result(s, result, dstIPFromAccept, dstPortFromAccept)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// HTTP/2 frames seules (sans préface — SSL_read ultérieurs)
|
||||
if parser.IsH2FrameHeader(sslData) {
|
||||
h2kv := parser.ExtractH2HeaderKV(sslData)
|
||||
if len(h2kv) > 0 {
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
if len(s.Requests) > 0 {
|
||||
last := &s.Requests[len(s.Requests)-1]
|
||||
if last.HeaderKV == nil {
|
||||
last.HeaderKV = make(map[string]string)
|
||||
}
|
||||
for k, v := range h2kv {
|
||||
if _, exists := last.HeaderKV[k]; !exists {
|
||||
last.HeaderKV[k] = v
|
||||
// Utiliser H2ConnState si disponible
|
||||
var h2connExists bool
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
h2connExists = s.H2Conn != nil
|
||||
})
|
||||
|
||||
if h2connExists {
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
result, _ := s.H2Conn.ProcessFrames(sslData, 0)
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// En-têtes décodés
|
||||
if len(result.Headers) > 0 && len(s.Requests) > 0 {
|
||||
last := &s.Requests[len(s.Requests)-1]
|
||||
if last.HeaderKV == nil {
|
||||
last.HeaderKV = make(map[string]string)
|
||||
}
|
||||
for _, h := range result.Headers {
|
||||
nameLower := strings.ToLower(h.Name)
|
||||
if parser.HpackCapturedHeaders[nameLower] && h.Value != "" {
|
||||
if _, exists := last.HeaderKV[nameLower]; !exists {
|
||||
last.HeaderKV[nameLower] = h.Value
|
||||
last.HeaderOrder = append(last.HeaderOrder, nameLower)
|
||||
}
|
||||
}
|
||||
// Mettre à jour method/path/host si pas encore remplis
|
||||
if last.Method == "" && h2kv[":method"] != "" {
|
||||
last.Method = h2kv[":method"]
|
||||
}
|
||||
if last.Path == "" && h2kv[":path"] != "" {
|
||||
p := h2kv[":path"]
|
||||
if idx := strings.Index(p, "?"); idx >= 0 {
|
||||
last.Path = p[:idx]
|
||||
last.QueryString = p[idx+1:]
|
||||
} else {
|
||||
last.Path = p
|
||||
switch nameLower {
|
||||
case ":method":
|
||||
if last.Method == "" {
|
||||
last.Method = h.Value
|
||||
}
|
||||
case ":path":
|
||||
if last.Path == "" {
|
||||
p := h.Value
|
||||
if idx := strings.Index(p, "?"); idx >= 0 {
|
||||
last.Path = p[:idx]
|
||||
last.QueryString = p[idx+1:]
|
||||
} else {
|
||||
last.Path = p
|
||||
}
|
||||
}
|
||||
case ":authority":
|
||||
if last.Host == "" {
|
||||
last.Host = h.Value
|
||||
}
|
||||
}
|
||||
if last.Host == "" && h2kv[":authority"] != "" {
|
||||
last.Host = h2kv[":authority"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(last.HeaderOrder) > 0 && last.HeaderOrderSig == "" {
|
||||
last.HeaderOrderSig = strings.Join(last.HeaderOrder, ";")
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour SETTINGS client si présents
|
||||
if result.ClientSettings != nil && len(s.Requests) > 0 {
|
||||
last := &s.Requests[len(s.Requests)-1]
|
||||
updateH2Settings(last, result.ClientSettings)
|
||||
}
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Première frame H2 sans préface — créer H2ConnState
|
||||
if parser.IsH2FrameHeader(sslData) {
|
||||
mgr.Update(key, func(s *correlation.SessionState) {
|
||||
s.H2Conn = parser.NewH2ConnState()
|
||||
result, _ := s.H2Conn.ProcessFrames(sslData, 0)
|
||||
applyH2Result(s, result, dstIPFromAccept, dstPortFromAccept)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if parser.IsHTTP1Request(sslData) {
|
||||
// HTTP/1.x : parser la requête
|
||||
req := parser.ParseHTTP1Request(sslData)
|
||||
@ -799,6 +922,11 @@ func consumeAcceptEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.
|
||||
continue
|
||||
}
|
||||
|
||||
// Filtrer les IPs sources ignorées (ignore_src)
|
||||
if isIgnoredIP(key.SrcIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Peupler le cache accept4 pour corrélation SSL
|
||||
tgid := uint32(pidTgid >> 32)
|
||||
acceptCache.Store(tgid, fd, key, [4]byte{}, 0)
|
||||
@ -857,6 +985,11 @@ func consumeHTTPPlainEvents(ctx context.Context, rd *perf.Reader, mgr *correlati
|
||||
httpDstIP[2] = byte(dstIPRaw >> 8)
|
||||
httpDstIP[3] = byte(dstIPRaw)
|
||||
|
||||
// Filtrer les IPs sources ignorées (ignore_src)
|
||||
if isIgnoredIP(key.SrcIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extraire le payload HTTP
|
||||
if len(data) < 4110 {
|
||||
continue
|
||||
@ -903,3 +1036,122 @@ func consumeHTTPPlainEvents(ctx context.Context, rd *perf.Reader, mgr *correlati
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyH2Result applique le résultat du parsing H2 à la session.
|
||||
// Crée ou met à jour la requête HTTP avec les paramètres SETTINGS et en-têtes.
|
||||
func applyH2Result(s *correlation.SessionState, result *parser.H2FrameResult, dstIPFromAccept [4]byte, dstPortFromAccept uint16) {
|
||||
if result == nil {
|
||||
if len(s.Requests) == 0 {
|
||||
s.Requests = append(s.Requests, correlation.HTTPRequest{
|
||||
Timestamp: time.Now(),
|
||||
HTTPVersion: "HTTP/2",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
req := correlation.HTTPRequest{
|
||||
Timestamp: time.Now(),
|
||||
HTTPVersion: "HTTP/2",
|
||||
}
|
||||
|
||||
// Paramètres SETTINGS client
|
||||
if result.ClientSettings != nil {
|
||||
req.HTTP2Settings = &correlation.HTTP2Settings{
|
||||
HeaderTableSize: result.ClientSettings.HeaderTableSize,
|
||||
EnablePush: result.ClientSettings.EnablePush,
|
||||
MaxConcurrentStreams: result.ClientSettings.MaxConcurrentStreams,
|
||||
InitialWindowSize: result.ClientSettings.InitialWindowSize,
|
||||
MaxFrameSize: result.ClientSettings.MaxFrameSize,
|
||||
MaxHeaderListSize: result.ClientSettings.MaxHeaderListSize,
|
||||
UnknownSettings: result.ClientSettings.UnknownSettings,
|
||||
EnableConnectProtocol: result.ClientSettings.EnableConnectProtocol,
|
||||
WindowUpdateIncrement: result.ClientSettings.WindowUpdateIncrement,
|
||||
PseudoHeaderOrder: result.ClientSettings.PseudoHeaderOrder,
|
||||
}
|
||||
}
|
||||
|
||||
// En-têtes décodés
|
||||
if len(result.Headers) > 0 {
|
||||
req.HeaderKV = make(map[string]string)
|
||||
for _, h := range result.Headers {
|
||||
nameLower := strings.ToLower(h.Name)
|
||||
if parser.HpackCapturedHeaders[nameLower] && h.Value != "" {
|
||||
req.HeaderKV[nameLower] = h.Value
|
||||
req.HeaderOrder = append(req.HeaderOrder, nameLower)
|
||||
}
|
||||
switch nameLower {
|
||||
case ":method":
|
||||
req.Method = h.Value
|
||||
case ":path":
|
||||
if idx := strings.Index(h.Value, "?"); idx >= 0 {
|
||||
req.Path = h.Value[:idx]
|
||||
req.QueryString = h.Value[idx+1:]
|
||||
} else {
|
||||
req.Path = h.Value
|
||||
}
|
||||
case ":authority":
|
||||
req.Host = h.Value
|
||||
}
|
||||
}
|
||||
if len(req.HeaderOrder) > 0 {
|
||||
req.HeaderOrderSig = strings.Join(req.HeaderOrder, ";")
|
||||
}
|
||||
}
|
||||
|
||||
// Pseudo-headers order (toujours disponible via result, même sans ClientSettings)
|
||||
if len(result.PseudoHeaderOrder) > 0 {
|
||||
if req.HTTP2Settings == nil {
|
||||
req.HTTP2Settings = &correlation.HTTP2Settings{}
|
||||
}
|
||||
req.HTTP2Settings.PseudoHeaderOrder = result.PseudoHeaderOrder
|
||||
}
|
||||
|
||||
if len(s.Requests) == 0 {
|
||||
s.Requests = append(s.Requests, req)
|
||||
}
|
||||
if s.L3L4 == nil && (dstIPFromAccept != [4]byte{} || dstPortFromAccept != 0) {
|
||||
s.L3L4 = &correlation.L3L4{
|
||||
DstIP: dstIPFromAccept,
|
||||
DstPort: dstPortFromAccept,
|
||||
}
|
||||
}
|
||||
_ = s.TLS
|
||||
}
|
||||
|
||||
// updateH2Settings met à jour les paramètres HTTP/2 d'une requête existante.
|
||||
func updateH2Settings(last *correlation.HTTPRequest, settings *parser.HTTP2Settings) {
|
||||
if last.HTTP2Settings == nil {
|
||||
last.HTTP2Settings = &correlation.HTTP2Settings{
|
||||
WindowUpdateIncrement: settings.WindowUpdateIncrement,
|
||||
PseudoHeaderOrder: settings.PseudoHeaderOrder,
|
||||
}
|
||||
}
|
||||
if settings.HeaderTableSize >= 0 {
|
||||
last.HTTP2Settings.HeaderTableSize = settings.HeaderTableSize
|
||||
}
|
||||
if settings.EnablePush >= 0 {
|
||||
last.HTTP2Settings.EnablePush = settings.EnablePush
|
||||
}
|
||||
if settings.MaxConcurrentStreams >= 0 {
|
||||
last.HTTP2Settings.MaxConcurrentStreams = settings.MaxConcurrentStreams
|
||||
}
|
||||
if settings.InitialWindowSize >= 0 {
|
||||
last.HTTP2Settings.InitialWindowSize = settings.InitialWindowSize
|
||||
}
|
||||
if settings.MaxFrameSize >= 0 {
|
||||
last.HTTP2Settings.MaxFrameSize = settings.MaxFrameSize
|
||||
}
|
||||
if settings.MaxHeaderListSize >= 0 {
|
||||
last.HTTP2Settings.MaxHeaderListSize = settings.MaxHeaderListSize
|
||||
}
|
||||
if settings.UnknownSettings >= 0 {
|
||||
last.HTTP2Settings.UnknownSettings = settings.UnknownSettings
|
||||
}
|
||||
if settings.EnableConnectProtocol >= 0 {
|
||||
last.HTTP2Settings.EnableConnectProtocol = settings.EnableConnectProtocol
|
||||
}
|
||||
if len(settings.PseudoHeaderOrder) > 0 {
|
||||
last.HTTP2Settings.PseudoHeaderOrder = settings.PseudoHeaderOrder
|
||||
}
|
||||
}
|
||||
|
||||
271
services/ja4ebpf/cmd/ja4ebpf/main_test.go
Normal file
271
services/ja4ebpf/cmd/ja4ebpf/main_test.go
Normal file
@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/antitbone/ja4/ja4ebpf/internal/loader"
|
||||
)
|
||||
|
||||
func TestParseCIDRs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
check func(keys []loader.LPMKey) bool
|
||||
}{
|
||||
{
|
||||
"single IP",
|
||||
[]string{"192.168.1.1"},
|
||||
1, false,
|
||||
func(keys []loader.LPMKey) bool {
|
||||
return keys[0].Prefixlen == 32 && keys[0].Data == [4]byte{192, 168, 1, 1}
|
||||
},
|
||||
},
|
||||
{
|
||||
"single CIDR /24",
|
||||
[]string{"10.0.0.0/24"},
|
||||
1, false,
|
||||
func(keys []loader.LPMKey) bool {
|
||||
return keys[0].Prefixlen == 24 && keys[0].Data == [4]byte{10, 0, 0, 0}
|
||||
},
|
||||
},
|
||||
{
|
||||
"multiple CIDRs",
|
||||
[]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"},
|
||||
3, false,
|
||||
func(keys []loader.LPMKey) bool {
|
||||
return keys[0].Prefixlen == 8 && keys[1].Prefixlen == 12 && keys[2].Prefixlen == 16
|
||||
},
|
||||
},
|
||||
{
|
||||
"IP without slash gets /32",
|
||||
[]string{"127.0.0.1"},
|
||||
1, false,
|
||||
func(keys []loader.LPMKey) bool {
|
||||
return keys[0].Prefixlen == 32
|
||||
},
|
||||
},
|
||||
{
|
||||
"whitespace trimmed",
|
||||
[]string{" 10.0.0.0/8 "},
|
||||
1, false,
|
||||
func(keys []loader.LPMKey) bool {
|
||||
return keys[0].Prefixlen == 8
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid CIDR",
|
||||
[]string{"not-a-cidr/8"},
|
||||
0, true, nil,
|
||||
},
|
||||
{
|
||||
"IPv6 ignored",
|
||||
[]string{"::1/128"},
|
||||
0, false, nil,
|
||||
},
|
||||
{
|
||||
"empty list",
|
||||
[]string{},
|
||||
0, false, nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
keys, err := parseCIDRs(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(keys) != tt.wantLen {
|
||||
t.Errorf("got %d keys, want %d", len(keys), tt.wantLen)
|
||||
return
|
||||
}
|
||||
if tt.check != nil && !tt.check(keys) {
|
||||
t.Errorf("check failed for keys=%v", keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCIDRs_ByteOrder(t *testing.T) {
|
||||
// Verify that LPMKey.Data stores IP in network byte order (big-endian)
|
||||
keys, err := parseCIDRs([]string{"10.1.2.3/32"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected 1 key, got %d", len(keys))
|
||||
}
|
||||
|
||||
// 10.1.2.3 → data should be [10, 1, 2, 3] (network byte order)
|
||||
want := [4]byte{10, 1, 2, 3}
|
||||
if keys[0].Data != want {
|
||||
t.Errorf("LPMKey.Data = %v, want %v (network byte order)", keys[0].Data, want)
|
||||
}
|
||||
|
||||
// Verify prefixlen
|
||||
if keys[0].Prefixlen != 32 {
|
||||
t.Errorf("Prefixlen = %d, want 32", keys[0].Prefixlen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCIDRs_RFC1918(t *testing.T) {
|
||||
cidrs := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.1"}
|
||||
keys, err := parseCIDRs(cidrs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(keys) != 4 {
|
||||
t.Fatalf("expected 4 keys, got %d", len(keys))
|
||||
}
|
||||
|
||||
expected := []struct {
|
||||
prefix uint32
|
||||
data [4]byte
|
||||
}{
|
||||
{8, [4]byte{10, 0, 0, 0}},
|
||||
{12, [4]byte{172, 16, 0, 0}},
|
||||
{16, [4]byte{192, 168, 0, 0}},
|
||||
{32, [4]byte{127, 0, 0, 1}},
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if keys[i].Prefixlen != exp.prefix {
|
||||
t.Errorf("keys[%d].Prefixlen = %d, want %d", i, keys[i].Prefixlen, exp.prefix)
|
||||
}
|
||||
if keys[i].Data != exp.data {
|
||||
t.Errorf("keys[%d].Data = %v, want %v", i, keys[i].Data, exp.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIgnoreNets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want int
|
||||
}{
|
||||
{"empty", nil, 0},
|
||||
{"single", []string{"10.0.0.0/8"}, 1},
|
||||
{"multiple", []string{"10.0.0.0/8", "192.168.0.0/16"}, 2},
|
||||
{"IP auto /32", []string{"127.0.0.1"}, 1},
|
||||
{"invalid logged", []string{"not-valid/8", "10.0.0.0/8"}, 1},
|
||||
{"IPv6 skipped", []string{"::1/128"}, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nets := parseIgnoreNets(tt.input)
|
||||
if len(nets) != tt.want {
|
||||
t.Errorf("parseIgnoreNets() = %d nets, want %d", len(nets), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIgnoreNets_MaskCorrect(t *testing.T) {
|
||||
nets := parseIgnoreNets([]string{"10.0.0.0/8"})
|
||||
if len(nets) != 1 {
|
||||
t.Fatalf("expected 1 net, got %d", len(nets))
|
||||
}
|
||||
ones, bits := nets[0].Mask.Size()
|
||||
if ones != 8 || bits != 32 {
|
||||
t.Errorf("mask = /%d of %d, want /8 of 32", ones, bits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIgnoredIP(t *testing.T) {
|
||||
// Set up global ignoreNets for testing
|
||||
origNets := ignoreNets
|
||||
defer func() { ignoreNets = origNets }()
|
||||
|
||||
ignoreNets = parseIgnoreNets([]string{"10.0.0.0/8", "192.168.0.0/16", "127.0.0.1"})
|
||||
|
||||
tests := []struct {
|
||||
ip [4]byte
|
||||
want bool
|
||||
}{
|
||||
{[4]byte{10, 0, 0, 1}, true}, // 10.x → ignored
|
||||
{[4]byte{10, 255, 255, 255}, true}, // 10.x → ignored
|
||||
{[4]byte{192, 168, 1, 1}, true}, // 192.168.x → ignored
|
||||
{[4]byte{127, 0, 0, 1}, true}, // 127.0.0.1 → ignored
|
||||
{[4]byte{8, 8, 8, 8}, false}, // public → not ignored
|
||||
{[4]byte{172, 16, 0, 1}, false}, // 172.16 not in our list → not ignored
|
||||
{[4]byte{1, 2, 3, 4}, false}, // public → not ignored
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isIgnoredIP(tt.ip)
|
||||
if got != tt.want {
|
||||
ip := net.IPv4(tt.ip[0], tt.ip[1], tt.ip[2], tt.ip[3])
|
||||
t.Errorf("isIgnoredIP(%s) = %v, want %v", ip, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIgnoredIP_EmptyNets(t *testing.T) {
|
||||
origNets := ignoreNets
|
||||
defer func() { ignoreNets = origNets }()
|
||||
|
||||
ignoreNets = nil
|
||||
if isIgnoredIP([4]byte{10, 0, 0, 1}) {
|
||||
t.Error("isIgnoredIP should return false with empty ignoreNets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTCPOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []byte
|
||||
wantMSS uint16
|
||||
wantWS uint8
|
||||
}{
|
||||
{"empty", nil, 0, 0xFF},
|
||||
{"MSS only", []byte{2, 4, 0x05, 0xB4}, 1460, 0xFF},
|
||||
{"WS only", []byte{3, 3, 6}, 0, 6},
|
||||
{"MSS+WS", []byte{2, 4, 0x05, 0xB4, 3, 3, 6}, 1460, 6},
|
||||
{"NOP+MSS+WS+SACK+TS", []byte{
|
||||
1, // NOP
|
||||
2, 4, 0x05, 0xB4, // MSS=1460
|
||||
3, 3, 7, // WS=7
|
||||
4, 2, // SACK
|
||||
1, // NOP
|
||||
8, 10, 0, 0, 0, 1, 0, 0, 0, 0, // TS
|
||||
}, 1460, 7},
|
||||
{"EOL", []byte{0}, 0, 0xFF},
|
||||
{"MSS only first byte", []byte{2}, 0, 0xFF}, // malformed: length byte missing
|
||||
{"truncated MSS value", []byte{2, 4, 0x05}, 0, 0xFF}, // length says 4 but only 1 byte of value
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mss, ws := parseTCPOptions(tt.opts)
|
||||
if mss != tt.wantMSS {
|
||||
t.Errorf("mss = %d, want %d", mss, tt.wantMSS)
|
||||
}
|
||||
if ws != tt.wantWS {
|
||||
t.Errorf("windowScale = %d, want %d", ws, tt.wantWS)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTCPOptions_MSSByteOrder(t *testing.T) {
|
||||
// MSS value 1460 = 0x05B4, big-endian in TCP options
|
||||
opts := []byte{2, 4, 0x05, 0xB4}
|
||||
mss, _ := parseTCPOptions(opts)
|
||||
if mss != 1460 {
|
||||
t.Errorf("MSS = %d, want 1460 (big-endian 0x05B4)", mss)
|
||||
}
|
||||
|
||||
// Verify it matches binary.BigEndian.Uint16
|
||||
expected := binary.BigEndian.Uint16([]byte{0x05, 0xB4})
|
||||
if mss != expected {
|
||||
t.Errorf("MSS = %d, expected from BigEndian = %d", mss, expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user