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:
@ -27,6 +27,7 @@ type AcceptCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[acceptCacheKey]*acceptCacheEntry
|
||||
ttl time.Duration
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewAcceptCache crée un cache avec la durée de vie spécifiée.
|
||||
@ -34,11 +35,17 @@ func NewAcceptCache(ttl time.Duration) *AcceptCache {
|
||||
c := &AcceptCache{
|
||||
cache: make(map[acceptCacheKey]*acceptCacheEntry),
|
||||
ttl: ttl,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.purgeLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
// Close arrête la goroutine de purge.
|
||||
func (c *AcceptCache) Close() {
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
// Store enregistre l'association {tgid, fd} → SessionKey.
|
||||
func (c *AcceptCache) Store(tgid, fd uint32, key SessionKey, dstIP [4]byte, dstPort uint16) {
|
||||
c.mu.Lock()
|
||||
@ -65,14 +72,19 @@ func (c *AcceptCache) Lookup(tgid, fd uint32) (SessionKey, [4]byte, uint16, bool
|
||||
func (c *AcceptCache) purgeLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, e := range c.cache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(c.cache, k)
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, e := range c.cache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(c.cache, k)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
117
services/ja4ebpf/internal/correlation/accept_cache_test.go
Normal file
117
services/ja4ebpf/internal/correlation/accept_cache_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package correlation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAcceptCache_StoreAndLookup(t *testing.T) {
|
||||
cache := NewAcceptCache(10 * time.Second)
|
||||
defer cache.Close()
|
||||
|
||||
key := SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 12345}
|
||||
dstIP := [4]byte{192, 168, 1, 1}
|
||||
dstPort := uint16(443)
|
||||
|
||||
cache.Store(100, 5, key, dstIP, dstPort)
|
||||
|
||||
gotKey, gotDstIP, gotDstPort, ok := cache.Lookup(100, 5)
|
||||
if !ok {
|
||||
t.Fatal("AcceptCache.Lookup() not found")
|
||||
}
|
||||
if gotKey != key {
|
||||
t.Errorf("Lookup key = %v, want %v", gotKey, key)
|
||||
}
|
||||
if gotDstIP != dstIP {
|
||||
t.Errorf("Lookup dstIP = %v, want %v", gotDstIP, dstIP)
|
||||
}
|
||||
if gotDstPort != dstPort {
|
||||
t.Errorf("Lookup dstPort = %d, want %d", gotDstPort, dstPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptCache_NotFound(t *testing.T) {
|
||||
cache := NewAcceptCache(10 * time.Second)
|
||||
defer cache.Close()
|
||||
|
||||
_, _, _, ok := cache.Lookup(999, 999)
|
||||
if ok {
|
||||
t.Error("AcceptCache.Lookup() should return false for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptCache_Overwrite(t *testing.T) {
|
||||
cache := NewAcceptCache(10 * time.Second)
|
||||
defer cache.Close()
|
||||
|
||||
key1 := SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 12345}
|
||||
key2 := SessionKey{SrcIP: [4]byte{10, 0, 0, 2}, SrcPort: 54321}
|
||||
|
||||
cache.Store(100, 5, key1, [4]byte{}, 0)
|
||||
cache.Store(100, 5, key2, [4]byte{1, 2, 3, 4}, 8080)
|
||||
|
||||
gotKey, gotDstIP, gotDstPort, ok := cache.Lookup(100, 5)
|
||||
if !ok {
|
||||
t.Fatal("AcceptCache.Lookup() not found after overwrite")
|
||||
}
|
||||
if gotKey != key2 {
|
||||
t.Errorf("Lookup key = %v, want %v (overwritten)", gotKey, key2)
|
||||
}
|
||||
if gotDstIP != [4]byte{1, 2, 3, 4} {
|
||||
t.Errorf("Lookup dstIP = %v, want [1 2 3 4]", gotDstIP)
|
||||
}
|
||||
if gotDstPort != 8080 {
|
||||
t.Errorf("Lookup dstPort = %d, want 8080", gotDstPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptCache_Expiration(t *testing.T) {
|
||||
// Very short TTL to test expiration
|
||||
cache := NewAcceptCache(50 * time.Millisecond)
|
||||
defer cache.Close()
|
||||
|
||||
key := SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 12345}
|
||||
cache.Store(100, 5, key, [4]byte{}, 0)
|
||||
|
||||
// Should exist immediately
|
||||
_, _, _, ok := cache.Lookup(100, 5)
|
||||
if !ok {
|
||||
t.Fatal("AcceptCache.Lookup() should find entry before TTL")
|
||||
}
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
// Should be expired now
|
||||
_, _, _, ok = cache.Lookup(100, 5)
|
||||
if ok {
|
||||
t.Error("AcceptCache.Lookup() should return false after TTL expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptCache_DifferentKeys(t *testing.T) {
|
||||
cache := NewAcceptCache(10 * time.Second)
|
||||
defer cache.Close()
|
||||
|
||||
key1 := SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 12345}
|
||||
key2 := SessionKey{SrcIP: [4]byte{10, 0, 0, 2}, SrcPort: 54321}
|
||||
|
||||
cache.Store(100, 5, key1, [4]byte{}, 0)
|
||||
cache.Store(200, 10, key2, [4]byte{1, 1, 1, 1}, 443)
|
||||
|
||||
gotKey1, _, _, ok1 := cache.Lookup(100, 5)
|
||||
if !ok1 || gotKey1 != key1 {
|
||||
t.Errorf("Lookup(100,5) = %v, %v; want %v, true", gotKey1, ok1, key1)
|
||||
}
|
||||
|
||||
gotKey2, _, _, ok2 := cache.Lookup(200, 10)
|
||||
if !ok2 || gotKey2 != key2 {
|
||||
t.Errorf("Lookup(200,10) = %v, %v; want %v, true", gotKey2, ok2, key2)
|
||||
}
|
||||
|
||||
// Cross lookup should not find
|
||||
_, _, _, ok3 := cache.Lookup(100, 10)
|
||||
if ok3 {
|
||||
t.Error("Lookup(100,10) should not find entry")
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@ package correlation
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||
)
|
||||
|
||||
// SessionKey identifie une connexion TCP de façon unique.
|
||||
@ -81,6 +83,7 @@ type SessionState struct {
|
||||
TLS *TLSInfo // données TLS (peut être nil si HTTP plain)
|
||||
Requests []HTTPRequest // requêtes HTTP observées
|
||||
MaxKeepAlives int // nombre maximum de requêtes keep-alive
|
||||
H2Conn *parser.H2ConnState // état HTTP/2 par-connexion (nil pour HTTP/1.x)
|
||||
|
||||
FirstSeen time.Time // horodatage de création de la session
|
||||
LastActivity time.Time // horodatage de la dernière activité
|
||||
|
||||
Reference in New Issue
Block a user