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:
Jacquin Antoine
2026-04-16 01:49:26 +02:00
parent fd84aebc44
commit f0c8fe81c6
20 changed files with 3053 additions and 1261 deletions

View File

@ -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()
}
}

View 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")
}
}

View File

@ -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é