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

@ -0,0 +1,247 @@
// Package procutil fournit des utilitaires pour résoudre les informations de
// connexion réseau depuis le système de fichiers /proc.
// Utilisé comme fallback quand la sonde accept4 n'est pas disponible (ex: Docker).
package procutil
import (
"bufio"
"encoding/binary"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
// cacheEntry est une entrée du cache de résolution fd→IP.
type cacheEntry struct {
IP net.IP
Port uint16
expiresAt time.Time
}
// FDCache résout un descripteur de fichier socket en adresse IP:port du client
// en interrogeant /proc. Les résultats sont mis en cache pour limiter les I/O.
type FDCache struct {
mu sync.Mutex
cache map[fdKey]*cacheEntry
ttl time.Duration
}
// fdKey est la clé du cache : TGID (PID du groupe de threads) + fd.
type fdKey struct {
tgid uint32
fd uint32
}
// NewFDCache crée un nouveau cache avec la durée de vie d'entrée spécifiée.
func NewFDCache(ttl time.Duration) *FDCache {
c := &FDCache{
cache: make(map[fdKey]*cacheEntry),
ttl: ttl,
}
// Purge périodique des entrées expirées
go c.purgeLoop()
return c
}
// Lookup retourne l'IP et le port du client pour un socket identifié par (tgid, fd).
// Consulte d'abord le cache, puis /proc si nécessaire.
func (c *FDCache) Lookup(tgid, fd uint32) (net.IP, uint16, error) {
key := fdKey{tgid: tgid, fd: fd}
c.mu.Lock()
if e, ok := c.cache[key]; ok && time.Now().Before(e.expiresAt) {
ip, port := e.IP, e.Port
c.mu.Unlock()
return ip, port, nil
}
c.mu.Unlock()
// Résoudre depuis /proc
ip, port, err := lookupFDPeer(tgid, fd)
if err != nil {
return nil, 0, err
}
c.mu.Lock()
c.cache[key] = &cacheEntry{
IP: ip,
Port: port,
expiresAt: time.Now().Add(c.ttl),
}
c.mu.Unlock()
return ip, port, nil
}
// lookupFDPeer résout l'adresse du pair (client) pour un fd donné via /proc.
func lookupFDPeer(tgid, fd uint32) (net.IP, uint16, error) {
// Lire le lien symbolique /proc/<tgid>/fd/<fd> → "socket:[inode]"
linkPath := fmt.Sprintf("/proc/%d/fd/%d", tgid, fd)
dest, err := os.Readlink(linkPath)
if err != nil {
return nil, 0, fmt.Errorf("readlink %s: %w", linkPath, err)
}
if !strings.HasPrefix(dest, "socket:[") || !strings.HasSuffix(dest, "]") {
return nil, 0, fmt.Errorf("fd %d n'est pas un socket: %s", fd, dest)
}
inodeStr := dest[8 : len(dest)-1]
inode, err := strconv.ParseUint(inodeStr, 10, 64)
if err != nil {
return nil, 0, fmt.Errorf("inode invalide '%s': %w", inodeStr, err)
}
// Chercher dans /proc/<tgid>/net/tcp (IPv4)
ip, port, err := searchTCPTable(fmt.Sprintf("/proc/%d/net/tcp", tgid), inode, false)
if err == nil {
return ip, port, nil
}
// Fallback sur /proc/<tgid>/net/tcp6 (IPv6 et IPv4-mappé)
ip, port, err = searchTCPTable(fmt.Sprintf("/proc/%d/net/tcp6", tgid), inode, true)
if err == nil {
return ip, port, nil
}
// Dernier recours : /proc/net/tcp (namespace réseau global)
ip, port, err = searchTCPTable("/proc/net/tcp", inode, false)
if err == nil {
return ip, port, nil
}
return nil, 0, fmt.Errorf("inode %d introuvable dans les tables TCP", inode)
}
// searchTCPTable recherche un inode dans /proc/.../net/tcp ou tcp6.
// Retourne l'adresse du pair (remote = client) et son port.
func searchTCPTable(path string, inode uint64, isIPv6 bool) (net.IP, uint16, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Scan() // sauter la ligne d'en-tête
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
// Le champ d'inode est en position 9
lineInode, err := strconv.ParseUint(fields[9], 10, 64)
if err != nil || lineInode != inode {
continue
}
// Le champ remote_address est en position 2 : "AABBCCDD:PPPP"
remAddr := fields[2]
colonIdx := strings.Index(remAddr, ":")
if colonIdx < 0 {
continue
}
hexIP := remAddr[:colonIdx]
hexPort := remAddr[colonIdx+1:]
var ip net.IP
if isIPv6 {
ip, err = parseHexIPv6(hexIP)
} else {
ip, err = parseHexIPv4(hexIP)
}
if err != nil {
continue
}
portVal, err := strconv.ParseUint(hexPort, 16, 16)
if err != nil {
continue
}
return ip, uint16(portVal), nil
}
return nil, 0, fmt.Errorf("inode %d non trouvé dans %s", inode, path)
}
// parseHexIPv4 décode une adresse IPv4 hex 8 caractères depuis /proc/net/tcp.
// Sur x86 little-endian, le noyau écrit l'adresse en ordre little-endian.
// Exemple : "0201010A" → 10.1.1.2
func parseHexIPv4(hexStr string) (net.IP, error) {
if len(hexStr) != 8 {
return nil, fmt.Errorf("adresse IPv4 hex invalide: %s", hexStr)
}
val, err := strconv.ParseUint(hexStr, 16, 32)
if err != nil {
return nil, err
}
ip := make(net.IP, 4)
// Le noyau stocke en little-endian sur x86 → PutUint32 en little-endian reconstitue les octets
binary.LittleEndian.PutUint32(ip, uint32(val))
return ip, nil
}
// parseHexIPv6 décode une adresse IPv6 hex 32 caractères depuis /proc/net/tcp6.
// Gère aussi les adresses IPv4-mappées (::ffff:x.x.x.x).
func parseHexIPv6(hexStr string) (net.IP, error) {
if len(hexStr) != 32 {
return nil, fmt.Errorf("adresse IPv6 hex invalide: %s", hexStr)
}
// Les 32 caractères hex représentent 4 groupes de 4 octets en little-endian
rawIP := make(net.IP, 16)
for i := 0; i < 4; i++ {
chunk := hexStr[i*8 : i*8+8]
val, err := strconv.ParseUint(chunk, 16, 32)
if err != nil {
return nil, err
}
binary.LittleEndian.PutUint32(rawIP[i*4:], uint32(val))
}
// Détecter IPv4-mappé ::ffff:x.x.x.x
if isIPv4MappedIPv6(rawIP) {
return rawIP[12:].To4(), nil
}
return rawIP, nil
}
// isIPv4MappedIPv6 retourne true si l'adresse est une IPv4-mappée dans IPv6.
func isIPv4MappedIPv6(ip net.IP) bool {
if len(ip) != 16 {
return false
}
// ::ffff:x.x.x.x : les 10 premiers octets sont 0, puis FF FF, puis 4 octets IPv4
for i := 0; i < 10; i++ {
if ip[i] != 0 {
return false
}
}
return ip[10] == 0xff && ip[11] == 0xff
}
// purgeLoop nettoie périodiquement le cache des entrées expirées.
func (c *FDCache) 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)
}
}
c.mu.Unlock()
}
}