feat: pipeline L7 HTTP complet + infrastructure tests VM

Correctifs pipeline L7 (uprobe SSL_read) :
- uprobe_ssl.c : ssl_set_fd ne retourne plus tôt quand fd_conn_map est
  vide (accept4 non disponible en Docker). Sauvegarde ssl_ptr→{fd,0,0}
  pour permettre le fallback /proc côté Go.
- main.go : consumeSSLEvents reécrit avec routeur magic-bytes complet :
  * HTTP/2 preface → extraction SETTINGS + conversion correlation.HTTP2Settings
  * HTTP/1.x requête → method, path, query, headers, header_order_sig
  * HTTP/1.x réponse → status_code
  * Fallback /proc/<tgid>/fd/<fd> quand src_ip=0 (accept4 absent)
- writer/clickhouse.go : export header_order_signature ajouté

Nouveaux packages :
- internal/parser/http1.go : parseur HTTP/1.x (IsHTTP1Request,
  ParseHTTP1Request, IsHTTP1Response, ParseHTTP1Response)
- internal/parser/http1_test.go : 11 tests unitaires (28 total passent)
- internal/procutil/proc_lookup.go : résolution fd→IP via /proc avec cache
  TTL 5s (FDCache). Supporte /proc/PID/net/tcp et tcp6, IPv4-mappé IPv6.

Infrastructure tests VM (tests/vm/) :
- Vagrantfile : VM Rocky Linux 9 KVM, 4 CPU / 4 GB RAM
- provision.sh : installation toolchain eBPF + Go + Docker + nginx
- run-tests-vm.sh : suite de test complète dans la VM (L3/L4+TLS+L7)
- README.md : guide d'installation et d'utilisation
- Makefile : cibles vm-up, vm-down, vm-ssh, test-vm-nginx, test-vm-all,
  vm-rebuild-ja4ebpf

Corrections stack Docker :
- Dockerfiles nginx/apache/nginx-varnish/hitch-varnish : suppression des
  références à shared/go/ja4common/ (répertoire supprimé)
- clickhouse-init.sh : restauré depuis git, seed anubis_ua_rules obsolète
  supprimé (table REGEXP_TREE supprimée du schéma)
- traffic-gen : ajout HTTP/1.0 (http.client) et HTTP/2 (httpx)
- verify_db.py : script de vérification 35 checks (L3/L4/TLS/L7/corrélation)
- run-stack-tests.sh : phase 6 verify_db ajoutée

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-12 02:37:00 +02:00
parent 9734e21fe3
commit f85a10b012
21 changed files with 1868 additions and 74 deletions

View File

@ -16,11 +16,16 @@ import (
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
"github.com/antitbone/ja4/ja4ebpf/internal/loader"
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
"github.com/antitbone/ja4/ja4ebpf/internal/procutil"
"github.com/antitbone/ja4/ja4ebpf/internal/writer"
"github.com/cilium/ebpf/ringbuf"
"gopkg.in/yaml.v3"
)
// fdCache résout les associations fd → IP:port via /proc quand accept4 n'est pas disponible.
// Durée de vie d'une entrée : 5 secondes (suffisant pour une requête HTTP).
var fdCache = procutil.NewFDCache(5 * time.Second)
// 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_.
@ -127,9 +132,9 @@ func main() {
// Continuer sans uprobes SSL (capture L3/L4 toujours active)
}
// --- 4. Attachement kprobes accept4 ---
// --- 4. Attachement tracepoints accept4 (sys_enter/exit_accept4) ---
if err := ldr.AttachAcceptProbe(); err != nil {
log.Printf("[ja4ebpf] avertissement kprobe accept4: %v", err)
log.Printf("[ja4ebpf] avertissement tracepoint accept4: %v", err)
}
// --- 5. Gestionnaire de sessions ---
@ -312,7 +317,8 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
}
// consumeSSLEvents lit les données SSL déchiffrées depuis le ring buffer.
// Détecte le préambule HTTP/2 et extrait les paramètres SETTINGS.
// Parse les requêtes HTTP/1.x et détecte le préambule HTTP/2.
// Quand src_ip=0 (accept4 non disponible), tente un lookup /proc pour retrouver l'IP du client.
func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
for {
select {
@ -335,10 +341,12 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
continue
}
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])
// data_len à l'offset 4112 (8+4+4+2 + data[4096] = offset 18, data_len à 18+4096)
// data[4096] commence à offset 18, data_len à offset 4114
if len(data) < 4118 {
continue
}
@ -346,8 +354,31 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
if dataLen > 4096 {
dataLen = 4096
}
if dataLen == 0 {
continue
}
sslData := data[18 : 18+dataLen]
// --- Fallback /proc quand accept4 n'a pas fourni l'IP ---
if srcIPRaw == 0 && fd != 0 {
tgid := uint32(pidTgid >> 32)
if tgid == 0 {
tgid = uint32(pidTgid) // fallback: utiliser le TID si TGID=0
}
if ip, port, lookupErr := fdCache.Lookup(tgid, fd); lookupErr == nil {
ipv4 := ip.To4()
if ipv4 != nil {
srcIPRaw = uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3])
srcPort = port
}
}
}
// Ignorer les événements sans IP identifiable (ex: connexions locales non HTTP)
if srcIPRaw == 0 && srcPort == 0 {
continue
}
var key correlation.SessionKey
key.SrcIP[0] = byte(srcIPRaw >> 24)
key.SrcIP[1] = byte(srcIPRaw >> 16)
@ -355,25 +386,82 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
key.SrcIP[3] = byte(srcIPRaw)
key.SrcPort = srcPort
// Détecter le préambule HTTP/2
// === Routeur Magic Bytes ===
if parser.DetectH2Preface(sslData) {
// HTTP/2 : extraire les paramètres SETTINGS depuis la préface
afterPreface := sslData
if len(afterPreface) > parser.H2MagicPrefaceLen() {
afterPreface = sslData[parser.H2MagicPrefaceLen():]
}
_, err := parser.ParseH2ClientPreface(afterPreface)
if err == nil {
mgr.Update(key, func(s *correlation.SessionState) {
if len(s.Requests) == 0 {
s.Requests = append(s.Requests, correlation.HTTPRequest{
Timestamp: time.Now(),
})
}
if s.TLS != nil {
s.Correlated = true
}
})
h2settings, err := parser.ParseH2ClientPreface(afterPreface)
if err != nil {
continue
}
mgr.Update(key, func(s *correlation.SessionState) {
req := correlation.HTTPRequest{
Timestamp: time.Now(),
}
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,
WindowUpdateIncrement: h2settings.WindowUpdateIncrement,
PseudoHeaderOrder: h2settings.PseudoHeaderOrder,
}
}
if len(s.Requests) == 0 {
s.Requests = append(s.Requests, req)
}
if s.TLS != nil {
s.Correlated = true
}
})
continue
}
if parser.IsHTTP1Request(sslData) {
// HTTP/1.x : parser la requête
req := parser.ParseHTTP1Request(sslData)
if req == nil {
continue
}
mgr.Update(key, func(s *correlation.SessionState) {
s.Requests = append(s.Requests, correlation.HTTPRequest{
Timestamp: time.Now(),
Method: req.Method,
Path: req.Path,
QueryString: req.Query,
HeaderOrder: req.Headers,
HeaderOrderSig: req.HeaderSig,
})
if s.TLS != nil {
s.Correlated = true
}
})
continue
}
if parser.IsHTTP1Response(sslData) {
// Réponse HTTP/1.x : extraire le code de statut
resp := parser.ParseHTTP1Response(sslData)
if resp == nil {
continue
}
mgr.Update(key, func(s *correlation.SessionState) {
// Mettre à jour le code de statut de la dernière requête
if len(s.Requests) > 0 {
last := &s.Requests[len(s.Requests)-1]
if last.StatusCode == 0 {
last.StatusCode = resp.StatusCode
}
}
})
}
}
}