feat(ebpf): add nginx HTTP capture infrastructure via kretprobe recvfrom

Add supporting infrastructure for nginx HTTP capture using kretprobe
on __x64_sys_recvfrom to replace the blocked tracepoint sys_exit_recvfrom.

Changes:
- bpf/bpf_types.h: Add nginx_pid_map for filtering recvfrom by PID
- cmd/ja4ebpf/main.go: Add Uprobes configuration section
- Makefile: Add test targets for recvfrom validation
- internal/loader: Generate nginx HTTP event structures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-20 13:30:41 +02:00
parent bb2160efbc
commit 382683710a
5 changed files with 336 additions and 56 deletions

View File

@ -62,6 +62,13 @@ type Config struct {
Level string `yaml:"level"` // niveau de log (debug, info, warn, error)
Format string `yaml:"format"` // format de log ("json" ou "text")
} `yaml:"log"`
Uprobes struct {
Enabled bool `yaml:"enabled"` // activer l'attachement automatique des uprobes
NginxBinPath string `yaml:"nginx_bin_path"` // chemin vers le binaire nginx
MaxRetries int `yaml:"max_retries"` // nombre de tentatives d'attachement (défaut: 30)
RetryIntervalSec int `yaml:"retry_interval_sec"` // intervalle entre tentatives (défaut: 2)
} `yaml:"uprobes"`
}
// loadConfig charge la configuration depuis le fichier YAML spécifié,
@ -80,6 +87,10 @@ func loadConfig(path string) (*Config, error) {
cfg.Correlation.SlowlorisMS = 10000
cfg.Log.Level = "info"
cfg.Log.Format = "json"
cfg.Uprobes.Enabled = false
cfg.Uprobes.NginxBinPath = "/usr/sbin/nginx"
cfg.Uprobes.MaxRetries = 30
cfg.Uprobes.RetryIntervalSec = 2
// Charger depuis le fichier YAML si spécifié
if path != "" {
@ -123,6 +134,14 @@ func loadConfig(path string) (*Config, error) {
if v := os.Getenv("JA4EBPF_IGNORE_SRC"); v != "" {
cfg.IgnoreSrc = strings.Split(v, ",")
}
// Uprobes configuration via environment variables
if v := os.Getenv("JA4EBPF_UPROBES_ENABLED"); v != "" {
cfg.Uprobes.Enabled = strings.EqualFold(v, "true") || v == "1" || v == "yes"
}
if v := os.Getenv("JA4EBPF_NGINX_BIN_PATH"); v != "" {
cfg.Uprobes.NginxBinPath = v
}
return cfg, nil
}
@ -271,6 +290,12 @@ func main() {
log.Printf("[ja4ebpf] avertissement tracepoint accept4: %v", err)
}
// --- 4b. Attachement uprobes nginx (avec retry automatique) ---
if err := attachNginxUprobesWithRetry(ctx, ldr, cfg); err != nil {
log.Printf("[ja4ebpf] erreur attachement uprobes nginx: %v", err)
}
// --- 5. Gestionnaire de sessions ---
sessionTimeout := time.Duration(cfg.Correlation.TimeoutMS) * time.Millisecond
mgr := correlation.NewManager(sessionTimeout)
@ -314,6 +339,8 @@ func main() {
// --- 8. Compteurs d'événements consommés (mode debug) ---
consumed := &eventCounters{}
go consumeNginxHTTPEvents(ctx, ldr.NginxHTTPReader, mgr, &consumed.nginx)
// --- 9. Goroutines de consommation des ring buffers ---
go consumeSynEvents(ctx, ldr.SynReader, mgr, &consumed.syn)
go consumeTLSEvents(ctx, ldr.TLSReader, mgr, &consumed.tls)
@ -354,6 +381,7 @@ type eventCounters struct {
ssl atomic.Uint64
accept atomic.Uint64
httpPlain atomic.Uint64
nginx atomic.Uint64
}
// debugStatsDumper affiche les compteurs BPF et les événements consommés toutes les 5 secondes.
@ -1176,3 +1204,174 @@ func updateH2Settings(last *correlation.HTTPRequest, settings *parser.HTTP2Setti
last.HTTP2Settings.PseudoHeaderOrder = settings.PseudoHeaderOrder
}
}
// attachNginxUprobesWithRetry attache les uprobes nginx avec retry automatique.
// Retente jusqu'à maxRetries fois toutes les retryInterval secondes.
// Utile pour attendre que nginx démarre après ja4ebpf.
func attachNginxUprobesWithRetry(ctx context.Context, l *loader.Loader, cfg *Config) error {
if !cfg.Uprobes.Enabled {
log.Printf("[uprobes] nginx uprobes désactivés (uprobes.enabled=false)")
return nil
}
binPath := cfg.Uprobes.NginxBinPath
maxRetries := cfg.Uprobes.MaxRetries
retryInterval := time.Duration(cfg.Uprobes.RetryIntervalSec) * time.Second
log.Printf("[uprobes] tentative d'attachement nginx uprobes (bin=%s, max_retries=%d, interval=%v)",
binPath, maxRetries, retryInterval)
for attempt := 1; attempt <= maxRetries; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Vérifier que le binaire existe
if _, err := os.Stat(binPath); err != nil {
log.Printf("[uprobes] tentative %d/%d: binaire nginx non trouvé (%s), retry dans %v",
attempt, maxRetries, binPath, retryInterval)
time.Sleep(retryInterval)
continue
}
// Tenter d'attacher les uprobes
err := l.AttachUprobesNginx(binPath)
if err == nil {
log.Printf("[uprobes] nginx uprobes attachés avec succès (tentative %d/%d)", attempt, maxRetries)
return nil
}
log.Printf("[uprobes] tentative %d/%d: échec attachement: %v, retry dans %v",
attempt, maxRetries, err, retryInterval)
// Dernière tentative : ne pas sleep
if attempt < maxRetries {
time.Sleep(retryInterval)
}
}
return fmt.Errorf("attachement nginx uprobes échoué après %d tentatives", maxRetries)
}
// consumeNginxHTTPEvents lit et traite les événements HTTP depuis nginx via uprobes.
// Ces événements contiennent les requêtes HTTP complètes (méthode, URI, headers) capturées
// par nginx avant tout traitement, garantissant des headers complets.
func consumeNginxHTTPEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
// Structure nginx_http_event BPF (version tracepoint recvfrom):
// Offset: 0:pid_tgid(8), 8:fd(4), 12:src_ip(4), 16:src_port(2), 18:timestamp_ns(8),
// 26:http_method[16], 42:uri[256], 298:query[128], 426:data[3640],
// 4066:method_len(4), 4070:uri_len(4), 4074:query_len(4), 4078:body_len(4), 4082:data_len(4)
// NOTE: Avec tracepoint recvfrom, http_method/uri/query sont vides, les données HTTP sont dans data[]
const minEventSize = 426
for {
select {
case <-ctx.Done():
return
default:
}
record, err := rd.Read()
if err != nil {
if err == os.ErrClosed {
return
}
continue
}
data := record.RawSample
if len(data) < 426 {
// Les événements doivent avoir au moins l'offset du champ data
continue
}
// Parser les metadata (structure tracepoint recvfrom)
pidTgid := binary.LittleEndian.Uint64(data[0:8])
fd := binary.LittleEndian.Uint32(data[8:12])
srcIPRaw := binary.LittleEndian.Uint32(data[12:16])
srcPort := binary.LittleEndian.Uint16(data[16:18])
dataLen := binary.LittleEndian.Uint32(data[4082:4086])
// Extraire les données HTTP brutes (offset 426)
rawHTTP := string(data[426 : 426+dataLen])
if len(rawHTTP) < 4 {
continue
}
// Parser la ligne de requête HTTP: "METHOD /path?query HTTP/1.1\r\n"
requestLineEnd := strings.Index(rawHTTP, "\r\n")
if requestLineEnd < 0 {
continue
}
requestLine := rawHTTP[:requestLineEnd]
parts := strings.Fields(requestLine)
if len(parts) < 2 {
continue
}
httpMethod := parts[0]
fullPath := parts[1]
// Séparer path et query string
queryStart := strings.Index(fullPath, "?")
var uri, query string
if queryStart >= 0 {
uri = fullPath[:queryStart]
query = fullPath[queryStart+1:]
} else {
uri = fullPath
}
// Parser les headers HTTP
headersData := rawHTTP[requestLineEnd+2:]
var req correlation.HTTPRequest
req.Method = httpMethod
req.Path = uri
req.QueryString = query
req.Timestamp = time.Now()
req.HeaderKV = make(map[string]string)
lines := strings.Split(headersData, "\r\n")
for _, line := range lines {
if line == "" {
break
}
colon := strings.Index(line, ":")
if colon > 0 {
name := strings.TrimSpace(line[:colon])
value := ""
if colon+1 < len(line) {
value = strings.TrimSpace(line[colon+1:])
}
nameLower := strings.ToLower(name)
req.HeaderOrder = append(req.HeaderOrder, nameLower)
req.HeaderKV[nameLower] = value
}
}
req.HeaderOrderSig = strings.Join(req.HeaderOrder, ";")
// Créer la clé de session
var key correlation.SessionKey
key.SrcIP[0] = byte(srcIPRaw >> 24)
key.SrcIP[1] = byte(srcIPRaw >> 16)
key.SrcIP[2] = byte(srcIPRaw >> 8)
key.SrcIP[3] = byte(srcIPRaw)
key.SrcPort = srcPort
// Filtrer les IPs sources ignorées
if isIgnoredIP(key.SrcIP) {
continue
}
// Mettre à jour la session
mgr.Update(key, func(s *correlation.SessionState) {
s.Requests = append(s.Requests, req)
})
counter.Add(1)
log.Printf("[nginx] HTTP: pid=%d fd=%d %s %s (headers=%d)",
pidTgid>>32, fd, httpMethod, uri, len(req.HeaderOrder))
}
}