From b1218a2367abf0765cc83f03562a623013064b02 Mon Sep 17 00:00:00 2001 From: toto Date: Sun, 12 Apr 2026 04:16:44 +0200 Subject: [PATCH] fix(ja4ebpf): fix TLS capture, SYN offsets, TCP option parsing - Increase MAX_TLS_PAYLOAD from 512 to 2048 bytes to capture full TLS ClientHellos (modern browsers/curl send 1000-1543 byte ClientHellos) - Fix ParseClientHello to tolerate XDP-truncated payloads: clamp recordLength and chLen to available data instead of returning error - Fix cipher suites, compression, extensions truncation to use clamping - Fix consumeSynEvents struct field offsets: dst_ip (4 bytes at offset 4) was not accounted for, causing all L3/L4 metadata to be read from wrong positions (TTL was actually dst_ip[0], windowSize was dst_port, etc.) - Add parseTCPOptions() to extract MSS and Window Scale from raw TCP options (C code sets defaults of mss=0, window_scale=0xFF, expects Go to parse) - Fix consumeAcceptEvents: skip zero-IP events to avoid phantom sessions - Fix consumeSSLEvents: filter zero-IP/port events when proc fallback fails - Add missing consumeHTTPPlainEvents goroutine (was defined but never called) - Fix race condition: SYN consumer sets Correlated=true if TLS already present - Update tls_hello_event struct offsets in Go consumer (payload_len now at offset 2054, was 518, due to payload array growing from 512 to 2048 bytes) - Remove debug logging from consumers and GC E2E verified: HTTP plain (port 80) and HTTPS (port 443) both produce fully correlated sessions in ClickHouse with correct: - ip_meta_ttl=64, ip_meta_df=true, ip_meta_id - tcp_meta_window_size=64240, tcp_meta_window_scale=10, tcp_meta_mss=1460 - ja4=t13i3010_1d37bd780c83_95d2a80e6515 - tls_alpn=http/1.1 - method=GET, path=/, header_order_signature=Host;User-Agent;Accept - correlated=1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Makefile | 3 +- services/ja4ebpf/bpf/bpf_types.h | 8 +- services/ja4ebpf/bpf/tc_capture.c | 314 ++++++++---------- services/ja4ebpf/cmd/ja4ebpf/main.go | 176 ++++++++-- .../internal/loader/ja4ssl_x86_bpfel.go | 183 ++++++++++ .../internal/loader/ja4ssl_x86_bpfel.o | Bin 0 -> 16816 bytes .../internal/loader/ja4tc_x86_bpfel.go | 168 ++++++++++ .../ja4ebpf/internal/loader/ja4tc_x86_bpfel.o | Bin 0 -> 16272 bytes services/ja4ebpf/internal/loader/loader.go | 25 +- services/ja4ebpf/internal/parser/tls.go | 23 +- tests/vm/Vagrantfile | 54 ++- tests/vm/run-tests-vm.sh | 9 +- 12 files changed, 715 insertions(+), 248 deletions(-) create mode 100644 services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.go create mode 100644 services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.o create mode 100644 services/ja4ebpf/internal/loader/ja4tc_x86_bpfel.go create mode 100644 services/ja4ebpf/internal/loader/ja4tc_x86_bpfel.o diff --git a/Makefile b/Makefile index dd0e5f6..2df50ae 100644 --- a/Makefile +++ b/Makefile @@ -175,7 +175,8 @@ vm-rebuild-ja4ebpf: ## Recompiler ja4ebpf dans la VM (après modifications) 'export PATH=/usr/local/go/bin:$$PATH && \ cd /ja4-platform/services/ja4ebpf && \ GOWORK=off go generate ./internal/loader/ && \ - GOWORK=off CGO_ENABLED=0 go build -o /usr/local/bin/ja4ebpf ./cmd/ja4ebpf/ && \ + GOWORK=off CGO_ENABLED=0 go build -o /tmp/ja4ebpf ./cmd/ja4ebpf/ && \ + sudo mv /tmp/ja4ebpf /usr/local/bin/ja4ebpf && \ echo "ja4ebpf rebuilt OK"' test-vm-nginx: ## Test nginx dans la VM (L3/L4/TLS/L7 HTTP complet) diff --git a/services/ja4ebpf/bpf/bpf_types.h b/services/ja4ebpf/bpf/bpf_types.h index f9d3c0c..10b3110 100644 --- a/services/ja4ebpf/bpf/bpf_types.h +++ b/services/ja4ebpf/bpf/bpf_types.h @@ -28,8 +28,8 @@ * Événement TCP SYN : émis pour chaque nouvelle connexion TCP observée * ---------------------------------------------------------------------------*/ struct tcp_syn_event { - __u32 src_ip; /* adresse source (network byte order) */ - __u32 dst_ip; /* adresse destination (network byte order) */ + __u32 src_ip; /* adresse source (host byte order, via bpf_ntohl) */ + __u32 dst_ip; /* adresse destination (host byte order, via bpf_ntohl) */ __u16 src_port; /* port source (host byte order) */ __u16 dst_port; /* port destination (host byte order) */ __u8 ttl; /* TTL IP */ @@ -47,9 +47,9 @@ struct tcp_syn_event { * Événement TLS ClientHello : émis quand un ClientHello TLS est détecté * ---------------------------------------------------------------------------*/ struct tls_hello_event { - __u32 src_ip; /* adresse source (network byte order) */ + __u32 src_ip; /* adresse source (host byte order, via bpf_ntohl) */ __u16 src_port; /* port source (host byte order) */ - __u8 payload[512]; /* payload ClientHello brut */ + __u8 payload[2048]; /* payload ClientHello brut (capturé jusqu'à 2048 octets) */ __u16 payload_len; /* longueur effective du payload */ __u64 timestamp_ns; /* horodatage kernel */ } __attribute__((packed)); diff --git a/services/ja4ebpf/bpf/tc_capture.c b/services/ja4ebpf/bpf/tc_capture.c index 8b88acf..f93d753 100644 --- a/services/ja4ebpf/bpf/tc_capture.c +++ b/services/ja4ebpf/bpf/tc_capture.c @@ -1,15 +1,16 @@ /* ============================================================================ - * tc_capture.c — Programme TC ingress : capture des TCP SYN et TLS ClientHello + * tc_capture.c — Programme XDP ingress : capture des TCP SYN et TLS ClientHello * - * Attaché sur l'interface réseau en ingress via TC (Traffic Control). - * Émet des événements vers les ring buffers rb_tcp_syn et rb_tls_hello. + * Remplace l'ancienne version TC (SCHED_CLS + TCX) par un hook XDP compatible + * depuis le kernel 4.8. Utilisé en mode XDP_GENERIC sur Rocky Linux 9 (5.14). * * Conventions vérificateur eBPF : - * - Tous les offsets variables (ihl, doff) sont stockés en __u32 et bornés - * explicitement avant tout usage en arithmétique de pointeur. - * - Les lectures de longueur variable (options TCP, payload TLS/HTTP) sont - * effectuées via bpf_skb_load_bytes() dans des tampons de pile locaux, - * évitant ainsi toute arithmétique de pointeur sur des données paquet. + * - Tous les accès mémoire paquet utilisent de l'arithmétique de pointeur + * directe avec bornes explicites (data / data_end). + * - Les copies de longueur variable utilisent des boucles bornées (sans + * #pragma unroll) : le vérificateur kernel ≥ 5.3 les accepte nativement. + * - Les options TCP sont copiées brutes ; MSS et Window Scale sont extraits + * côté Go (userspace) depuis le tableau tcp_options_raw. * ============================================================================ */ #include "vmlinux.h" @@ -24,7 +25,7 @@ /* Constantes IP */ #define IPPROTO_TCP 6 -#define IP_DF 0x4000 /* bit Don't Fragment */ +#define IP_DF 0x4000 /* Constantes TCP */ #define TH_SYN 0x02 @@ -32,66 +33,57 @@ #define TH_FIN 0x01 #define TH_RST 0x04 -/* Port HTTPS standard */ +/* Ports */ #define HTTPS_PORT 443 - -/* Ports HTTP en clair */ #define HTTP_PORT 80 #define HTTP_ALT_PORT 8080 -/* Type de contenu TLS : Handshake */ +/* TLS */ #define TLS_CONTENT_HANDSHAKE 0x16 -/* Type de message TLS : ClientHello */ #define TLS_MSG_CLIENT_HELLO 0x01 -/* Taille maximale du payload TLS à copier (puissance de 2) */ -#define MAX_TLS_PAYLOAD 512 +/* Tailles maximales des payloads copiés */ +#define MAX_TLS_PAYLOAD 2048 +#define MAX_HTTP_PAYLOAD 1024 +#define MAX_TCP_OPTIONS 40 -/* Longueur maximale des options TCP en octets */ -#define MAX_TCP_OPTIONS 40 - -/* --------------------------------------------------------------------------- - * Structure interne pour le parsing de l'en-tête Ethernet - * ---------------------------------------------------------------------------*/ +/* Structure Ethernet locale (évite d'inclure linux/if_ether.h) */ struct ethhdr_local { - __u8 h_dest[6]; - __u8 h_source[6]; + __u8 h_dest[6]; + __u8 h_source[6]; __be16 h_proto; } __attribute__((packed)); /* --------------------------------------------------------------------------- - * capture_tc_ingress — Point d'entrée TC ingress + * capture_xdp — Point d'entrée XDP ingress * - * Inspecte chaque paquet entrant, détecte les TCP SYN et les ClientHello TLS, - * et soumet les événements correspondants aux ring buffers. + * Observe chaque paquet ingress en lecture seule (retourne toujours XDP_PASS). + * Émet des événements vers les ring buffers pour TCP SYN, TLS ClientHello + * et les payloads HTTP en clair. * ---------------------------------------------------------------------------*/ -SEC("tc/ingress") -int capture_tc_ingress(struct __sk_buff *skb) +SEC("xdp") +int capture_xdp(struct xdp_md *ctx) { - void *data = (void *)(long)skb->data; - void *data_end = (void *)(long)skb->data_end; + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; - /* --- Parsing Ethernet --- */ + /* --- Ethernet --- */ struct ethhdr_local *eth = data; if ((void *)(eth + 1) > data_end) - return TC_ACT_OK; - + return XDP_PASS; if (bpf_ntohs(eth->h_proto) != ETH_P_IP) - return TC_ACT_OK; + return XDP_PASS; - /* --- Parsing IPv4 --- */ + /* --- IPv4 --- */ struct iphdr *ip = data + ETH_HLEN; if ((void *)(ip + 1) > data_end) - return TC_ACT_OK; - + return XDP_PASS; if (ip->protocol != IPPROTO_TCP) - return TC_ACT_OK; + return XDP_PASS; - /* ihl stocké en u32 et borné explicitement : le vérificateur peut ainsi - * prouver que ip_hlen ∈ [20, 60] sans risque d'overflow signé. */ __u32 ihl = ip->ihl & 0x0F; if (ihl < 5) - return TC_ACT_OK; + return XDP_PASS; __u32 ip_hlen = ihl << 2; /* ∈ [20, 60] */ __u32 src_ip = ip->saddr; @@ -101,194 +93,168 @@ int capture_tc_ingress(struct __sk_buff *skb) __u16 frag_off = bpf_ntohs(ip->frag_off); __u8 df_bit = (frag_off & IP_DF) ? 1 : 0; - /* --- Parsing TCP --- */ - struct tcphdr *tcp = data + ETH_HLEN + ip_hlen; - if ((void *)(tcp + 1) > data_end) - return TC_ACT_OK; + /* --- TCP à offset variable --- */ + struct tcphdr *tcp = (void *)ip + ip_hlen; + if ((void *)(tcp + 1) > data_end) /* valide tcp[0..19] */ + return XDP_PASS; - __u16 src_port = bpf_ntohs(tcp->source); - __u16 dst_port = bpf_ntohs(tcp->dest); - __u16 window = bpf_ntohs(tcp->window); - /* Lecture des flags via offset constant (octet 13 de l'en-tête TCP) */ - __u8 tcp_flags = ((__u8 *)tcp)[13]; + __u16 src_port = bpf_ntohs(tcp->source); + __u16 dst_port = bpf_ntohs(tcp->dest); + __u16 window = bpf_ntohs(tcp->window); + + /* Flags via les champs de bits du struct (sûr pour le vérificateur) */ + __u8 tcp_flags = 0; + if (tcp->syn) tcp_flags |= TH_SYN; + if (tcp->ack) tcp_flags |= TH_ACK; + if (tcp->fin) tcp_flags |= TH_FIN; + if (tcp->rst) tcp_flags |= TH_RST; - /* doff stocké en u32 et borné : tcp_hlen ∈ [20, 60] */ __u32 doff = tcp->doff; if (doff < 5) - return TC_ACT_OK; + return XDP_PASS; __u32 tcp_hlen = doff << 2; /* ∈ [20, 60] */ - /* Offset absolu du début du payload applicatif dans le paquet */ - __u32 payload_off = ETH_HLEN + ip_hlen + tcp_hlen; + /* Offset du payload applicatif */ + void *payload = (void *)tcp + tcp_hlen; - /* --- Détection TCP SYN (SYN set, ACK clear) --- */ + /* =================================================================== + * TCP SYN : extraction des paramètres L3/L4 + * ===================================================================*/ if ((tcp_flags & TH_SYN) && !(tcp_flags & TH_ACK)) { - struct tcp_syn_event *evt = bpf_ringbuf_reserve(&rb_tcp_syn, sizeof(*evt), 0); + struct tcp_syn_event *evt = + bpf_ringbuf_reserve(&rb_tcp_syn, sizeof(*evt), 0); if (!evt) - return TC_ACT_OK; + return XDP_PASS; - evt->src_ip = bpf_ntohl(src_ip); - evt->dst_ip = bpf_ntohl(dst_ip); - evt->src_port = src_port; - evt->dst_port = dst_port; - evt->ttl = ttl; - evt->df_bit = df_bit; - evt->ip_id = ip_id; - evt->window_size = window; - evt->window_scale = 0xFF; /* absent par défaut */ - evt->mss = 0; /* absent par défaut */ - evt->timestamp_ns = bpf_ktime_get_ns(); + evt->src_ip = bpf_ntohl(src_ip); + evt->dst_ip = bpf_ntohl(dst_ip); + evt->src_port = src_port; + evt->dst_port = dst_port; + evt->ttl = ttl; + evt->df_bit = df_bit; + evt->ip_id = ip_id; + evt->window_size = window; + evt->window_scale = 0xFF; /* défaut = absent */ + evt->mss = 0; + evt->timestamp_ns = bpf_ktime_get_ns(); evt->tcp_options_len = 0; - /* Lecture des options TCP dans un tampon de pile local (copie brute). - * Le scan MSS/WS utilise bpf_skb_load_bytes avec offset variable plutôt - * que opts_buf[j] : l'accès pile à index variable génère une erreur - * vérificateur ("invalid variable-offset read from stack") car le tnum de - * j accumule des bits carries au fil des incréments j += len (u8). */ - __u32 opts_off = ETH_HLEN + ip_hlen + 20; - __u32 opts_bytes = tcp_hlen - 20; /* tcp_hlen >= 20, donc >= 0 */ - if (opts_bytes > MAX_TCP_OPTIONS) - opts_bytes = MAX_TCP_OPTIONS; + /* Copie brute des options TCP (MSS/WS extraits en userspace Go). + * Boucle bornée à MAX_TCP_OPTIONS = 40 itérations : triviale pour + * le vérificateur kernel ≥ 5.3, sans #pragma unroll. */ + __u8 *opts_start = (__u8 *)(tcp + 1); /* après les 20 octets fixes */ + __u32 opts_len = tcp_hlen - 20; /* ∈ [0, 40] */ + if (opts_len > MAX_TCP_OPTIONS) + opts_len = MAX_TCP_OPTIONS; - if (opts_bytes > 0) { - __u8 opts_buf[MAX_TCP_OPTIONS] = {0}; - /* Lecture à taille constante : le vérificateur connaît la borne. */ - if (bpf_skb_load_bytes(skb, opts_off, opts_buf, MAX_TCP_OPTIONS) == 0) { - /* Copie brute dans l'événement */ - __builtin_memcpy(evt->tcp_options_raw, opts_buf, MAX_TCP_OPTIONS); - evt->tcp_options_len = (__u8)opts_bytes; - - /* Scan MSS et Window Scale via bpf_skb_load_bytes (offset variable - * dans le paquet = autorisé ; index variable dans la pile = refusé). */ - __u32 j = 0; - __u8 hdr2[2] = {0}; - __u8 one[1] = {0}; - #pragma unroll - for (int iter = 0; iter < MAX_TCP_OPTIONS; iter++) { - if (j + 1 >= opts_bytes) - break; - /* Lire kind et len d'un coup depuis le paquet */ - if (bpf_skb_load_bytes(skb, opts_off + j, hdr2, 2) < 0) - break; - __u8 kind = hdr2[0]; - if (kind == 0) - break; /* EOL */ - if (kind == 1) { - j++; - continue; /* NOP : 1 octet */ - } - __u8 len = hdr2[1]; - if (len < 2 || j + len > opts_bytes) - break; - - /* MSS (option 2) : 4 octets */ - if (kind == 2 && len == 4) { - __u8 mss_buf[2] = {0}; - if (bpf_skb_load_bytes(skb, opts_off + j + 2, mss_buf, 2) == 0) { - __u16 mss_val; - __builtin_memcpy(&mss_val, mss_buf, 2); - evt->mss = bpf_ntohs(mss_val); - } - } - /* Window Scale (option 3) : 3 octets */ - if (kind == 3 && len == 3) { - if (bpf_skb_load_bytes(skb, opts_off + j + 2, one, 1) == 0) - evt->window_scale = one[0]; - } - j += len; - } + if (opts_len > 0) { + #pragma clang loop unroll(disable) + for (__u32 i = 0; i < MAX_TCP_OPTIONS; i++) { + if (i >= opts_len) + break; + if (opts_start + i + 1 > (__u8 *)data_end) + break; + evt->tcp_options_raw[i] = opts_start[i]; } + evt->tcp_options_len = (__u8)opts_len; } bpf_ringbuf_submit(evt, 0); } - /* --- Détection TLS ClientHello (port 443) --- */ + /* =================================================================== + * TLS ClientHello (port 443) + * ===================================================================*/ if (dst_port == HTTPS_PORT) { - /* Vérifier qu'il y a au moins 6 octets pour l'en-tête TLS record */ - if (payload_off + 6 > skb->len) - return TC_ACT_OK; + /* Au moins 6 octets pour l'en-tête TLS record + type message */ + if (payload + 6 > data_end) + return XDP_PASS; - __u8 tls_hdr[6]; - if (bpf_skb_load_bytes(skb, payload_off, tls_hdr, sizeof(tls_hdr)) < 0) - return TC_ACT_OK; + __u8 tls_type = ((__u8 *)payload)[0]; + __u8 tls_msg_type = ((__u8 *)payload)[5]; + if (tls_type != TLS_CONTENT_HANDSHAKE || tls_msg_type != TLS_MSG_CLIENT_HELLO) + return XDP_PASS; - /* Handshake (0x16) + ClientHello (0x01 au byte 5) */ - if (tls_hdr[0] != TLS_CONTENT_HANDSHAKE || tls_hdr[5] != TLS_MSG_CLIENT_HELLO) - return TC_ACT_OK; + __u32 avail = (__u8 *)data_end - (__u8 *)payload; + /* avail ≥ 6 (vérifié ci-dessus), on plafonne à MAX_TLS_PAYLOAD */ + if (avail > MAX_TLS_PAYLOAD) + avail = MAX_TLS_PAYLOAD; + /* Barrière compilateur : coupe le lien CSE entre avail et (data_end - payload). + * Sans cette barrière, clang génère un test "PTR_TO_PACKET <<= 32" (compare + * data_end == payload pour l'entrée de boucle) que le vérificateur eBPF rejette. + * La barrière force une comparaison scalaire (avail == 0) à la place. */ + asm volatile("" : "+r"(avail)); struct tls_hello_event *tls_evt = bpf_ringbuf_reserve(&rb_tls_hello, sizeof(*tls_evt), 0); if (!tls_evt) - return TC_ACT_OK; + return XDP_PASS; tls_evt->src_ip = bpf_ntohl(src_ip); tls_evt->src_port = src_port; tls_evt->timestamp_ns = bpf_ktime_get_ns(); + tls_evt->payload_len = (__u16)avail; - /* Calcul de la longueur disponible. - * IMPORTANT : appliquer le masque SANS cap préalable. Si un cap - * `if (avail > N) avail = N` précède le masque, le compilateur - * supprime l'AND (semantically redundant). Sans cap, le compilateur - * conserve l'AND et le vérificateur en déduit avail ∈ [0, 511]. - * Cas edge : avail exactement multiple de 512 → avail & 511 = 0. */ - __u32 avail = skb->len - payload_off; - avail &= (MAX_TLS_PAYLOAD - 1); /* verifier : avail ∈ [0, 511] */ - if (avail == 0) { - bpf_ringbuf_discard(tls_evt, 0); - return TC_ACT_OK; - } - - tls_evt->payload_len = (__u16)avail; - - if (bpf_skb_load_bytes(skb, payload_off, tls_evt->payload, avail) < 0) { - bpf_ringbuf_discard(tls_evt, 0); - return TC_ACT_OK; + /* Copie bornée du payload TLS. + * Pour tout i < avail : payload + i < payload + avail ≤ data_end. + * Le vérificateur kernel ≥ 5.3 peut vérifier cette boucle sans unroll. */ + __u8 *src = (__u8 *)payload; + #pragma clang loop unroll(disable) + for (__u32 i = 0; i < MAX_TLS_PAYLOAD; i++) { + if (i >= avail) + break; + if (src + i + 1 > (__u8 *)data_end) + break; + tls_evt->payload[i] = src[i]; } bpf_ringbuf_submit(tls_evt, 0); - return TC_ACT_OK; + return XDP_PASS; } - /* --- Détection payload HTTP en clair (port 80 / 8080) --- */ + /* =================================================================== + * HTTP en clair (port 80 / 8080) + * ===================================================================*/ if (dst_port == HTTP_PORT || dst_port == HTTP_ALT_PORT) { - /* Ignorer SYN, FIN, RST : seuls les segments de données nous intéressent */ + /* Ignorer SYN, FIN, RST : seuls les segments de données */ if (tcp_flags & (TH_SYN | TH_FIN | TH_RST)) - return TC_ACT_OK; + return XDP_PASS; + if (payload >= data_end) + return XDP_PASS; - if (payload_off >= skb->len) - return TC_ACT_OK; - - __u32 avail = skb->len - payload_off; - /* Même stratégie que pour TLS : masque SANS cap préalable. - * Le compilateur conserve l'AND, le vérificateur déduit [0, 4095]. */ - avail &= 0xFFF; /* verifier : avail ∈ [0, 4095], smin ≥ 0 */ - if (avail == 0) - return TC_ACT_OK; + __u32 avail = (__u8 *)data_end - (__u8 *)payload; + if (avail > MAX_HTTP_PAYLOAD) + avail = MAX_HTTP_PAYLOAD; + /* Même barrière que pour la section TLS : force comparaison scalaire. */ + asm volatile("" : "+r"(avail)); struct http_plain_event *h_evt = bpf_ringbuf_reserve(&rb_http_plain, sizeof(*h_evt), 0); if (!h_evt) - return TC_ACT_OK; + return XDP_PASS; h_evt->src_ip = bpf_ntohl(src_ip); h_evt->dst_ip = bpf_ntohl(dst_ip); h_evt->src_port = src_port; h_evt->dst_port = dst_port; - h_evt->payload_len = (__u16)avail; h_evt->timestamp_ns = bpf_ktime_get_ns(); + h_evt->payload_len = (__u16)avail; - if (bpf_skb_load_bytes(skb, payload_off, h_evt->payload, avail) < 0) { - bpf_ringbuf_discard(h_evt, 0); - return TC_ACT_OK; + __u8 *src = (__u8 *)payload; + #pragma clang loop unroll(disable) + for (__u32 i = 0; i < MAX_HTTP_PAYLOAD; i++) { + if (i >= avail) + break; + if (src + i + 1 > (__u8 *)data_end) + break; + h_evt->payload[i] = src[i]; } bpf_ringbuf_submit(h_evt, 0); } - return TC_ACT_OK; + return XDP_PASS; } char LICENSE[] SEC("license") = "GPL"; - diff --git a/services/ja4ebpf/cmd/ja4ebpf/main.go b/services/ja4ebpf/cmd/ja4ebpf/main.go index 434d291..3ee1f3c 100644 --- a/services/ja4ebpf/cmd/ja4ebpf/main.go +++ b/services/ja4ebpf/cmd/ja4ebpf/main.go @@ -163,6 +163,7 @@ func main() { go consumeTLSEvents(ctx, ldr.TLSReader, mgr) go consumeSSLEvents(ctx, ldr.SSLReader, mgr) go consumeAcceptEvents(ctx, ldr.AcceptReader, mgr) + go consumeHTTPPlainEvents(ctx, ldr.HTTPPlainReader, mgr) log.Printf("[ja4ebpf] démon actif — en attente des événements") @@ -177,6 +178,46 @@ func main() { log.Printf("[ja4ebpf] arrêt terminé") } +// parseTCPOptions extrait le MSS et le Window Scale depuis les options TCP brutes. +// Les options TCP suivent le format TLV (Type-Length-Value), sauf les options 0 et 1. +// Retourne (mss=0, windowScale=0xFF) si les options sont absentes ou mal formées. +func parseTCPOptions(opts []byte) (mss uint16, windowScale uint8) { + windowScale = 0xFF // 0xFF = absent (convention C) + i := 0 + for i < len(opts) { + kind := opts[i] + i++ + switch kind { + case 0: // End of Options + return + case 1: // NOP — padding, pas de longueur + continue + default: + if i >= len(opts) { + return + } + length := int(opts[i]) + i++ + if length < 2 || i+length-2 > len(opts) { + return // option malformée + } + val := opts[i : i+length-2] + switch kind { + case 2: // MSS + if len(val) >= 2 { + mss = binary.BigEndian.Uint16(val[0:2]) + } + case 3: // Window Scale + if len(val) >= 1 { + windowScale = val[0] + } + } + i += length - 2 + } + } + return +} + // consumeSynEvents lit les événements TCP SYN depuis le ring buffer // et met à jour l'état L3/L4 des sessions. func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) { @@ -195,14 +236,17 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation. continue } - // Taille minimale attendue (voir struct tcp_syn_event) - if len(record.RawSample) < 20 { + // struct tcp_syn_event (packed): + // src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+ttl(1)+df_bit(1)+ip_id(2)+ + // window_size(2)+window_scale(1)+mss(2)+tcp_options_raw[40]+tcp_options_len(1)+timestamp_ns(8) + // offsets: 0 4 8 10 12 13 14 16 18 19 21 61 62 + if len(record.RawSample) < 62 { continue } data := record.RawSample - // Décoder les champs de tcp_syn_event - srcIPRaw := binary.BigEndian.Uint32(data[0:4]) + // src_ip et src_port stockés en host byte order (bpf_ntohl/bpf_ntohs dans BPF C). + srcIPRaw := binary.LittleEndian.Uint32(data[0:4]) srcPort := binary.LittleEndian.Uint16(data[8:10]) var key correlation.SessionKey @@ -212,19 +256,21 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation. key.SrcIP[3] = byte(srcIPRaw) key.SrcPort = srcPort - ttl := data[4] - dfBit := data[5] != 0 - ipID := binary.LittleEndian.Uint16(data[6:8]) - windowSize := binary.LittleEndian.Uint16(data[10:12]) - windowScale := data[12] - mss := binary.LittleEndian.Uint16(data[13:15]) + // Champs IP/TCP aux offsets corrects (dst_ip occupe les octets 4-7) + ttl := data[12] + dfBit := data[13] != 0 + ipID := binary.LittleEndian.Uint16(data[14:16]) + windowSize := binary.LittleEndian.Uint16(data[16:18]) - optLen := int(data[55]) + optLen := int(data[61]) if optLen > 40 { optLen = 40 } tcpOpts := make([]byte, optLen) - copy(tcpOpts, data[15:15+optLen]) + copy(tcpOpts, data[21:21+optLen]) + + // Analyser les options TCP brutes pour extraire MSS et Window Scale + mss, windowScale := parseTCPOptions(tcpOpts) mgr.Update(key, func(s *correlation.SessionState) { s.L3L4 = &correlation.L3L4{ @@ -237,6 +283,10 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation. TCPOptionsRaw: tcpOpts, SYNTimestamp: time.Now(), } + // Si TLS est déjà présent (arrivé avant SYN), marquer la session corrélée. + if s.TLS != nil { + s.Correlated = true + } }) } } @@ -259,18 +309,20 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation. continue } - // Taille minimale : src_ip(4) + src_port(2) + payload[512] + payload_len(2) - if len(record.RawSample) < 8 { + // struct tls_hello_event (packed): + // src_ip(4) + src_port(2) + payload[2048] + payload_len(2) + timestamp_ns(8) + // offsets: 0 4 6 2054 2056 + if len(record.RawSample) < 2056 { continue } data := record.RawSample - srcIPRaw := binary.BigEndian.Uint32(data[0:4]) - srcPort := binary.LittleEndian.Uint16(data[4:6]) - payloadLen := binary.LittleEndian.Uint16(data[518:520]) + srcIPRaw := binary.LittleEndian.Uint32(data[0:4]) + srcPort := binary.LittleEndian.Uint16(data[4:6]) + payloadLen := binary.LittleEndian.Uint16(data[2054:2056]) - if int(payloadLen) > 512 { - payloadLen = 512 + if int(payloadLen) > 2048 { + payloadLen = 2048 } payload := make([]byte, payloadLen) copy(payload, data[6:6+payloadLen]) @@ -285,6 +337,7 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation. // Parser le ClientHello et calculer JA4 ch, err := parser.ParseClientHello(payload) if err != nil { + log.Printf("[warn] TLS parse error: %v", err) continue } @@ -500,7 +553,92 @@ func consumeAcceptEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlati key.SrcIP[3] = byte(srcIPRaw) key.SrcPort = srcPort + // Ignorer les événements accept4 sans IP valide (bpf_probe_read_user a échoué) + if srcIPRaw == 0 && srcPort == 0 { + continue + } + // S'assurer que la session existe mgr.GetOrCreate(key) } } + +// consumeHTTPPlainEvents lit les payloads HTTP en clair depuis le ring buffer XDP. +// Parse la requête HTTP/1.x ou détecte la préface HTTP/2 pour les connexions +// non-chiffrées sur les ports 80/8080. +func consumeHTTPPlainEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) { + for { + select { + case <-ctx.Done(): + return + default: + } + + record, err := rd.Read() + if err != nil { + if err == ringbuf.ErrClosed { + return + } + continue + } + + data := record.RawSample + // struct http_plain_event: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+payload(4096)+payload_len(2)+timestamp_ns(8) + if len(data) < 14 { + continue + } + + // src_ip et src_port en host byte order (bpf_ntohl appliqué dans tc_capture.c) + srcIPRaw := binary.LittleEndian.Uint32(data[0:4]) + srcPort := binary.LittleEndian.Uint16(data[8:10]) + + if srcIPRaw == 0 && srcPort == 0 { + continue + } + + 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 + + // Extraire le payload HTTP + if len(data) < 4110 { + continue + } + payloadLen := int(binary.LittleEndian.Uint16(data[4108:4110])) + if payloadLen > 4096 { + payloadLen = 4096 + } + if payloadLen == 0 { + continue + } + if 12+payloadLen > len(data) { + payloadLen = len(data) - 12 + } + httpData := data[12 : 12+payloadLen] + + // Routeur Magic Bytes : HTTP/1.x uniquement sur port 80 + if parser.IsHTTP1Request(httpData) { + req := parser.ParseHTTP1Request(httpData) + 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, + }) + // Corréler si L3/L4 est déjà présent (TCP SYN capturé) + if s.L3L4 != nil { + s.Correlated = true + } + }) + } + } +} diff --git a/services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.go b/services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.go new file mode 100644 index 0000000..fb16d7b --- /dev/null +++ b/services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.go @@ -0,0 +1,183 @@ +// Code generated by bpf2go; DO NOT EDIT. +//go:build 386 || amd64 + +package loader + +import ( + "bytes" + _ "embed" + "fmt" + "io" + + "github.com/cilium/ebpf" +) + +type Ja4SslAcceptEvent struct { + PidTgid uint64 + Fd uint32 + SrcIp uint32 + SrcPort uint16 + TimestampNs uint64 +} + +type Ja4SslAcceptKey struct { + PidTgid uint64 + Fd uint32 +} + +type Ja4SslSslConnInfo struct { + Fd uint32 + SrcIp uint32 + SrcPort uint16 +} + +type Ja4SslSslReadArgs struct { + SslPtr uint64 + BufPtr uint64 + Num uint32 +} + +// LoadJa4Ssl returns the embedded CollectionSpec for Ja4Ssl. +func LoadJa4Ssl() (*ebpf.CollectionSpec, error) { + reader := bytes.NewReader(_Ja4SslBytes) + spec, err := ebpf.LoadCollectionSpecFromReader(reader) + if err != nil { + return nil, fmt.Errorf("can't load Ja4Ssl: %w", err) + } + + return spec, err +} + +// LoadJa4SslObjects loads Ja4Ssl and converts it into a struct. +// +// The following types are suitable as obj argument: +// +// *Ja4SslObjects +// *Ja4SslPrograms +// *Ja4SslMaps +// +// See ebpf.CollectionSpec.LoadAndAssign documentation for details. +func LoadJa4SslObjects(obj interface{}, opts *ebpf.CollectionOptions) error { + spec, err := LoadJa4Ssl() + if err != nil { + return err + } + + return spec.LoadAndAssign(obj, opts) +} + +// Ja4SslSpecs contains maps and programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type Ja4SslSpecs struct { + Ja4SslProgramSpecs + Ja4SslMapSpecs +} + +// Ja4SslSpecs contains programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type Ja4SslProgramSpecs struct { + KprobeAccept4Entry *ebpf.ProgramSpec `ebpf:"kprobe_accept4_entry"` + KretprobeAccept4Exit *ebpf.ProgramSpec `ebpf:"kretprobe_accept4_exit"` + UprobeSslReadEntry *ebpf.ProgramSpec `ebpf:"uprobe_ssl_read_entry"` + UprobeSslSetFd *ebpf.ProgramSpec `ebpf:"uprobe_ssl_set_fd"` + UretprobeSslReadExit *ebpf.ProgramSpec `ebpf:"uretprobe_ssl_read_exit"` +} + +// Ja4SslMapSpecs contains maps before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type Ja4SslMapSpecs struct { + AcceptArgsMap *ebpf.MapSpec `ebpf:"accept_args_map"` + AcceptMap *ebpf.MapSpec `ebpf:"accept_map"` + FdConnMap *ebpf.MapSpec `ebpf:"fd_conn_map"` + RbAccept *ebpf.MapSpec `ebpf:"rb_accept"` + RbHttpPlain *ebpf.MapSpec `ebpf:"rb_http_plain"` + RbSslData *ebpf.MapSpec `ebpf:"rb_ssl_data"` + RbTcpSyn *ebpf.MapSpec `ebpf:"rb_tcp_syn"` + RbTlsHello *ebpf.MapSpec `ebpf:"rb_tls_hello"` + SslArgsMap *ebpf.MapSpec `ebpf:"ssl_args_map"` + SslConnMap *ebpf.MapSpec `ebpf:"ssl_conn_map"` +} + +// Ja4SslObjects contains all objects after they have been loaded into the kernel. +// +// It can be passed to LoadJa4SslObjects or ebpf.CollectionSpec.LoadAndAssign. +type Ja4SslObjects struct { + Ja4SslPrograms + Ja4SslMaps +} + +func (o *Ja4SslObjects) Close() error { + return _Ja4SslClose( + &o.Ja4SslPrograms, + &o.Ja4SslMaps, + ) +} + +// Ja4SslMaps contains all maps after they have been loaded into the kernel. +// +// It can be passed to LoadJa4SslObjects or ebpf.CollectionSpec.LoadAndAssign. +type Ja4SslMaps struct { + AcceptArgsMap *ebpf.Map `ebpf:"accept_args_map"` + AcceptMap *ebpf.Map `ebpf:"accept_map"` + FdConnMap *ebpf.Map `ebpf:"fd_conn_map"` + RbAccept *ebpf.Map `ebpf:"rb_accept"` + RbHttpPlain *ebpf.Map `ebpf:"rb_http_plain"` + RbSslData *ebpf.Map `ebpf:"rb_ssl_data"` + RbTcpSyn *ebpf.Map `ebpf:"rb_tcp_syn"` + RbTlsHello *ebpf.Map `ebpf:"rb_tls_hello"` + SslArgsMap *ebpf.Map `ebpf:"ssl_args_map"` + SslConnMap *ebpf.Map `ebpf:"ssl_conn_map"` +} + +func (m *Ja4SslMaps) Close() error { + return _Ja4SslClose( + m.AcceptArgsMap, + m.AcceptMap, + m.FdConnMap, + m.RbAccept, + m.RbHttpPlain, + m.RbSslData, + m.RbTcpSyn, + m.RbTlsHello, + m.SslArgsMap, + m.SslConnMap, + ) +} + +// Ja4SslPrograms contains all programs after they have been loaded into the kernel. +// +// It can be passed to LoadJa4SslObjects or ebpf.CollectionSpec.LoadAndAssign. +type Ja4SslPrograms struct { + KprobeAccept4Entry *ebpf.Program `ebpf:"kprobe_accept4_entry"` + KretprobeAccept4Exit *ebpf.Program `ebpf:"kretprobe_accept4_exit"` + UprobeSslReadEntry *ebpf.Program `ebpf:"uprobe_ssl_read_entry"` + UprobeSslSetFd *ebpf.Program `ebpf:"uprobe_ssl_set_fd"` + UretprobeSslReadExit *ebpf.Program `ebpf:"uretprobe_ssl_read_exit"` +} + +func (p *Ja4SslPrograms) Close() error { + return _Ja4SslClose( + p.KprobeAccept4Entry, + p.KretprobeAccept4Exit, + p.UprobeSslReadEntry, + p.UprobeSslSetFd, + p.UretprobeSslReadExit, + ) +} + +func _Ja4SslClose(closers ...io.Closer) error { + for _, closer := range closers { + if err := closer.Close(); err != nil { + return err + } + } + return nil +} + +// Do not access this directly. +// +//go:embed ja4ssl_x86_bpfel.o +var _Ja4SslBytes []byte diff --git a/services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.o b/services/ja4ebpf/internal/loader/ja4ssl_x86_bpfel.o new file mode 100644 index 0000000000000000000000000000000000000000..676b7f4a2e83ae4346149a7a11ddb34506c36c36 GIT binary patch literal 16816 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 literal 0 HcmV?d00001 diff --git a/services/ja4ebpf/internal/loader/loader.go b/services/ja4ebpf/internal/loader/loader.go index caf07a9..7267a7f 100644 --- a/services/ja4ebpf/internal/loader/loader.go +++ b/services/ja4ebpf/internal/loader/loader.go @@ -10,7 +10,6 @@ import ( "net" "os" - "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/ringbuf" "github.com/cilium/ebpf/rlimit" @@ -122,23 +121,31 @@ func New() (*Loader, error) { }, nil } -// AttachTC attache le programme TC ingress sur l'interface réseau spécifiée. -// Utilise TCX (TC eXpress) disponible depuis le noyau 6.6+. +// AttachTC attache le programme XDP sur l'interface réseau spécifiée. +// Essaie le mode natif XDP (driver support) puis se replie sur le mode générique +// (SKB_MODE, compatible kernel ≥ 4.8, fonctionne dans les VMs). func (l *Loader) AttachTC(iface string) error { - // Résoudre l'interface réseau par son nom netIface, err := net.InterfaceByName(iface) if err != nil { return fmt.Errorf("interface réseau %q introuvable: %w", iface, err) } - // Attacher le programme TC en ingress via TCX - lnk, err := link.AttachTCX(link.TCXOptions{ + // Mode natif (meilleure performance sur serveurs avec NIC compatible XDP) + lnk, err := link.AttachXDP(link.XDPOptions{ Interface: netIface.Index, - Program: l.tcObjs.CaptureTcIngress, - Attach: ebpf.AttachTCXIngress, + Program: l.tcObjs.CaptureXdp, + Flags: link.XDPDriverMode, }) if err != nil { - return fmt.Errorf("attachement TC ingress sur %q: %w", iface, err) + // Repli sur le mode générique (VMs, NICs sans driver XDP natif) + lnk, err = link.AttachXDP(link.XDPOptions{ + Interface: netIface.Index, + Program: l.tcObjs.CaptureXdp, + Flags: link.XDPGenericMode, + }) + if err != nil { + return fmt.Errorf("attachement XDP sur %q (natif et générique): %w", iface, err) + } } l.tcLink = lnk diff --git a/services/ja4ebpf/internal/parser/tls.go b/services/ja4ebpf/internal/parser/tls.go index 96560fa..5028acc 100644 --- a/services/ja4ebpf/internal/parser/tls.go +++ b/services/ja4ebpf/internal/parser/tls.go @@ -47,8 +47,12 @@ func ParseClientHello(payload []byte) (*ClientHello, error) { recordVersion := binary.BigEndian.Uint16(payload[1:3]) recordLength := int(binary.BigEndian.Uint16(payload[3:5])) - if len(payload) < 5+recordLength { - return nil, fmt.Errorf("record TLS tronqué: attendu %d octets, reçu %d", 5+recordLength, len(payload)) + // Le XDP capture au maximum MAX_TLS_PAYLOAD (512) octets. + // Si la taille du record TLS dépasse les données disponibles, on travaille + // avec ce qu'on a (le ClientHello est toujours en début de record). + available := len(payload) - 5 + if recordLength > available { + recordLength = available } // Parsing du message Handshake @@ -64,8 +68,9 @@ func ParseClientHello(payload []byte) (*ClientHello, error) { // Longueur du ClientHello (3 octets big-endian) chLen := int(uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3])) - if len(hs) < 4+chLen { - return nil, fmt.Errorf("ClientHello tronqué: attendu %d octets", 4+chLen) + // Tolérance à la troncature XDP : on travaille avec ce qu'on a + if chLen > len(hs)-4 { + chLen = len(hs) - 4 } ch := &ClientHello{RecordVersion: recordVersion} @@ -98,9 +103,9 @@ func ParseClientHello(payload []byte) (*ClientHello, error) { csLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) offset += 2 if len(data) < offset+csLen { - return nil, fmt.Errorf("ClientHello: cipher suites tronquées") + csLen = len(data) - offset // troncature tolérée } - for i := 0; i < csLen; i += 2 { + for i := 0; i+2 <= csLen; i += 2 { cs := binary.BigEndian.Uint16(data[offset+i : offset+i+2]) ch.CipherSuites = append(ch.CipherSuites, cs) } @@ -108,12 +113,12 @@ func ParseClientHello(payload []byte) (*ClientHello, error) { // Compression Methods (longueur 1 octet + données) if len(data) < offset+1 { - return nil, fmt.Errorf("ClientHello: longueur compression manquante") + return ch, nil // troncature : retourner ce qu'on a } compLen := int(data[offset]) offset++ if len(data) < offset+compLen { - return nil, fmt.Errorf("ClientHello: méthodes de compression tronquées") + compLen = len(data) - offset } ch.CompressionMethods = data[offset : offset+compLen] offset += compLen @@ -125,7 +130,7 @@ func ParseClientHello(payload []byte) (*ClientHello, error) { extTotalLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) offset += 2 if len(data) < offset+extTotalLen { - return nil, fmt.Errorf("ClientHello: extensions tronquées") + extTotalLen = len(data) - offset // troncature tolérée } // Parsing des extensions diff --git a/tests/vm/Vagrantfile b/tests/vm/Vagrantfile index a6243da..a38061f 100644 --- a/tests/vm/Vagrantfile +++ b/tests/vm/Vagrantfile @@ -6,16 +6,15 @@ # Fournit un environnement kernel complet pour les tests eBPF : # - tracefs / debugfs montés # - perf_kprobe PMU disponible -# - uprobes fonctionnels avec accept4 kprobe/tracepoint +# - uprobes fonctionnels avec accept4 tracepoints # # Prérequis (host Ubuntu) : -# sudo apt-get install -y vagrant libvirt-daemon-system libvirt-clients \ -# qemu-kvm ruby-libvirt +# sudo apt-get install -y libvirt-daemon-system libvirt-clients qemu-kvm libvirt-dev ruby-dev # vagrant plugin install vagrant-libvirt # sudo usermod -aG libvirt,kvm $USER # puis se reconnecter # # Utilisation : -# vagrant up # créer + provisionner la VM (première fois ~5 min) +# vagrant up # créer + provisionner (~5 min) # vagrant ssh # connexion SSH # make test-vm-nginx # lancer les tests depuis le host # vagrant destroy -f # détruire la VM @@ -23,44 +22,39 @@ Vagrant.configure("2") do |config| - # ── Box Rocky Linux 9 ────────────────────────────────────────────────────── + # ── Box Rocky Linux 9 avec provider libvirt (image qcow2) ───────────────── config.vm.box = "generic/rocky9" - # ── Réseau : IP privée pour accès depuis le host ─────────────────────────── - config.vm.network "private_network", ip: "192.168.56.10" + # ── Désactiver synced_folder par défaut (utiliser rsync explicitement) ───── + config.vm.synced_folder ".", "/vagrant", disabled: true - # ── Ressources VM ───────────────────────────────────────────────────────── + # ── Provider libvirt ─────────────────────────────────────────────────────── config.vm.provider :libvirt do |v| - v.cpus = 4 - v.memory = 4096 - v.nested = false # pas besoin de virtualisation imbriquée - # Pour VirtualBox (fallback) + v.cpus = 4 + v.memory = 4096 + v.nested = false + v.cpu_mode = "host-passthrough" # expose les capacités CPU hôte → KVM perf + v.driver = "kvm" + v.disk_bus = "virtio" + v.nic_model_type = "virtio" end - config.vm.provider :virtualbox do |v| - v.cpus = 4 - v.memory = 4096 - v.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"] - end - - # ── Montage du projet ───────────────────────────────────────────────────── - # Le répertoire racine du projet est monté dans /ja4-platform + # ── Synchronisation du projet via rsync ──────────────────────────────────── config.vm.synced_folder "../..", "/ja4-platform", type: "rsync", - rsync__exclude: [".git/", "old/", "*.rpm", "services/*/target/"] + rsync__exclude: [".git/", "old/", "*.rpm", "dist/"] - # ── Provisioning ───────────────────────────────────────────────────────── + # ── Provisioning ─────────────────────────────────────────────────────────── config.vm.provision "shell", path: "provision.sh" - # ── Message post-démarrage ──────────────────────────────────────────────── + # ── Message post-démarrage ───────────────────────────────────────────────── config.vm.post_up_message = <<~MSG VM ja4ebpf prête ! - - Depuis le répertoire tests/vm/ : - vagrant ssh # connexion interactive - make -C ../.. test-vm-nginx # lancer le test nginx - make -C ../.. test-vm-matrix # lancer tous les tests - - IP de la VM : 192.168.56.10 + + Depuis la racine du projet : + make vm-ssh # connexion interactive + make test-vm-nginx # test nginx complet (L3/L4 + TLS + L7) + make test-vm-all # tous les tests + make vm-rebuild-ja4ebpf # resynchroniser + recompiler après modif MSG end diff --git a/tests/vm/run-tests-vm.sh b/tests/vm/run-tests-vm.sh index 4a1062b..486ed0f 100755 --- a/tests/vm/run-tests-vm.sh +++ b/tests/vm/run-tests-vm.sh @@ -15,6 +15,9 @@ # ============================================================================= set -euo pipefail +# S'assurer que /usr/local/bin et go sont dans PATH (nécessaire pour sudo bash) +export PATH="/usr/local/bin:/usr/local/go/bin:$PATH" + STACK="${1:-nginx}" KEEP_RUNNING="${KEEP_RUNNING:-false}" PROJECT="/ja4-platform" @@ -50,7 +53,7 @@ check_prerequisites() { cd "$PROJECT/services/ja4ebpf" export PATH="/usr/local/go/bin:$PATH" GOWORK=off go generate ./internal/loader/ 2>&1 | tail -3 - GOWORK=off CGO_ENABLED=0 go build -o /usr/local/bin/ja4ebpf ./cmd/ja4ebpf/ + GOWORK=off CGO_ENABLED=0 go build -o /tmp/ja4ebpf_new ./cmd/ja4ebpf/ && mv /tmp/ja4ebpf_new /usr/local/bin/ja4ebpf } command -v docker >/dev/null 2>&1 || { fail "Docker non installé"; exit 1; } @@ -103,6 +106,8 @@ setup_nginx() { # Créer les fichiers de test mkdir -p /var/www/html + # /run/nginx est un tmpfs recréé à chaque boot, nginx en a besoin pour son PID + mkdir -p /run/nginx echo '{"status":"ok","stack":"nginx-vm"}' > /var/www/html/health for p in data api/users api/data/test; do mkdir -p "/var/www/html/$(dirname $p)" @@ -144,7 +149,7 @@ EOF # Lancer avec les capabilities nécessaires # Dans la VM (root), on peut lancer directement - ja4ebpf -config /tmp/ja4ebpf.yml > /tmp/ja4ebpf.log 2>&1 & + JA4EBPF_CONFIG=/tmp/ja4ebpf.yml ja4ebpf > /tmp/ja4ebpf.log 2>&1 & JA4EBPF_PID=$! sleep 3