feat: multi-distro VM tests, ja4ebpf eBPF improvements, bot-detector scoring

ja4ebpf:
- Refactor BPF TC capture with improved SYN offset handling and TCP option parsing
- Enhance TLS uprobe SSL hooking for better key extraction
- Add ClickHouse writer improvements for HTTP log materialized views
- Update RPM spec for Rocky Linux 8/9/10, fix systemd service
- Simplify loader with cleaner bpf2go integration

bot-detector:
- Add H2 SETTINGS per-parameter comparison in browser_matcher
- Enhance browser signatures and scoring pipeline
- Improve preprocessing and cycle detection

infra:
- Multi-distro Vagrantfile (centos8, rocky9, rocky10) with per-distro provisioning
- New Makefile targets: vm-up-all, test-vm-matrix, test-vm-centos8/rocky10
- Add debug helpers and run-test-from-host.sh for host-driven VM testing
- Update run-tests-vm.sh for cross-distro compatibility
- Remove accidental binary blob (\004)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-13 01:09:33 +02:00
parent d81463a589
commit d75825278e
32 changed files with 2148 additions and 890 deletions

View File

@ -20,13 +20,17 @@
# =============================================================================
ARG BUILD_VERSION=dev
ARG GO_VERSION=1.24
ARG GO_VERSION=1.24.3
# ── Stage 1 : compilation Go ──────────────────────────────────────────────
FROM rockylinux:9 AS go-builder
ARG BUILD_VERSION
ARG GO_VERSION
RUN dnf install -y epel-release dnf-plugins-core && \
dnf config-manager --enable crb && \
dnf install -y --allowerasing \
clang llvm libbpf-devel bpftool \
curl tar gzip && \
dnf clean all

View File

@ -45,13 +45,17 @@ struct tcp_syn_event {
/* ---------------------------------------------------------------------------
* Événement TLS ClientHello : émis quand un ClientHello TLS est détecté
*
* IMPORTANT : le payload est à l'offset 0 pour que bpf_skb_load_bytes()
* puisse écrire directement au début du map value (compatible kernel 4.18).
* Les métadonnées sont placées APRÈS le payload.
* ---------------------------------------------------------------------------*/
struct tls_hello_event {
__u32 src_ip; /* adresse source (host byte order, via bpf_ntohl) */
__u16 src_port; /* port source (host byte order) */
__u8 payload[2048]; /* payload ClientHello brut (capturé jusqu'à 2048 octets) */
__u16 payload_len; /* longueur effective du payload */
__u64 timestamp_ns; /* horodatage kernel */
__u8 payload[2048]; /* payload ClientHello brut (offset 0) */
__u32 src_ip; /* adresse source (host byte order) */
__u16 src_port; /* port source (host byte order) */
__u16 payload_len; /* longueur effective du payload */
__u64 timestamp_ns; /* horodatage kernel */
} __attribute__((packed));
/* ---------------------------------------------------------------------------
@ -80,16 +84,14 @@ struct accept_event {
} __attribute__((packed));
/* ---------------------------------------------------------------------------
* Événement HTTP en clair : émis pour chaque segment TCP porteur d'un
* payload HTTP (port 80 ou 8080). Un seul segment par requête est capturé
* (le premier, qui contient la request-line et les en-têtes).
* Événement HTTP en clair : payload à l'offset 0 pour compat kernel 4.18.
* ---------------------------------------------------------------------------*/
struct http_plain_event {
__u8 payload[4096]; /* payload TCP brut (offset 0) */
__u32 src_ip; /* adresse source (host byte order) */
__u32 dst_ip; /* adresse destination (host byte order) */
__u16 src_port; /* port source (host byte order) */
__u16 dst_port; /* port destination 80 ou 8080 */
__u8 payload[4096]; /* payload TCP brut (request-line + headers) */
__u16 payload_len; /* longueur effective du payload copié */
__u64 timestamp_ns; /* horodatage kernel */
} __attribute__((packed));
@ -124,35 +126,65 @@ struct accept_key {
* Déclarations des maps eBPF avec annotations BTF
* ===========================================================================*/
/* Ring buffer : événements TCP SYN (16 MB) */
/* Perf event array : événements TCP SYN (kernel 4.4+) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} rb_tcp_syn SEC(".maps");
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} pb_tcp_syn SEC(".maps");
/* Ring buffer : événements TLS ClientHello (16 MB) */
/* Perf event array : événements TLS ClientHello (kernel 4.4+) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} rb_tls_hello SEC(".maps");
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} pb_tls_hello SEC(".maps");
/* Ring buffer : données SSL déchiffrées (64 MB, plus volumineux) */
/* Perf event array : données SSL déchiffrées (kernel 4.4+) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 26);
} rb_ssl_data SEC(".maps");
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} pb_ssl_data SEC(".maps");
/* Ring buffer : événements accept4 (4 MB) */
/* Perf event array : événements accept4 (kernel 4.4+) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 22);
} rb_accept SEC(".maps");
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} pb_accept SEC(".maps");
/* Ring buffer : payload HTTP en clair port 80/8080 (32 MB) */
/* Perf event array : payload HTTP en clair port 80/8080 (kernel 4.4+) */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 25);
} rb_http_plain SEC(".maps");
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} pb_http_plain SEC(".maps");
/* ── PERCPU_ARRAY temporaires pour les structs > 512o (stack eBPF) ──── */
/* TLS hello event : 2064 octets, ne tient pas sur la stack */
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct tls_hello_event);
} __tls_buf SEC(".maps");
/* HTTP plain event : 4118 octets */
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct http_plain_event);
} __http_buf SEC(".maps");
/* SSL data event : 4131 octets */
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct ssl_data_event);
} __ssl_buf SEC(".maps");
/* Hash map : pid_tgid → ssl_read_args (arguments SSL_read entry) */
struct {

View File

@ -1,16 +1,18 @@
/* ============================================================================
* tc_capture.c — Programme XDP ingress : capture des TCP SYN et TLS ClientHello
* tc_capture.c — Programme TC ingress : capture des TCP SYN, TLS ClientHello
* et HTTP en clair
*
* 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).
* Hook TC ingress (clsact qdisc) compatible kernel 4.1+.
* Émet via bpf_perf_event_output() (kernel 4.4+) pour compatibilité maximale.
*
* Conventions vérificateur eBPF :
* - 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.
* IMPORTANT : Ce programme n'utilise AUCUN accès direct au paquet (data/data_end).
* Toutes les lectures se font via bpf_skb_load_bytes() (kernel 4.5+) avec des
* tailles constantes, pour compatibilité avec le vérificateur kernel 4.18 qui
* rejette "math between pkt pointer and register with unbounded min value".
*
* Les copies de payload utilisent bpf_skb_load_bytes() avec &= (2^n - 1)
* pour borner la taille per le vérificateur.
* Les structs > 512o utilisent un PERCPU_ARRAY temporaire (stack limit eBPF).
* ============================================================================ */
#include "vmlinux.h"
@ -19,219 +21,248 @@
#include <bpf/bpf_core_read.h>
#include "bpf_types.h"
/* Constantes Ethernet */
/* Constantes */
#define ETH_P_IP 0x0800
#define ETH_HLEN 14
/* Constantes IP */
#define IPPROTO_TCP 6
#define IP_DF 0x4000
/* Constantes TCP */
#define TH_SYN 0x02
#define TH_ACK 0x10
#define TH_FIN 0x01
#define TH_RST 0x04
/* Ports */
#define HTTPS_PORT 443
#define HTTP_PORT 80
#define HTTP_ALT_PORT 8080
/* TLS */
#define TLS_CONTENT_HANDSHAKE 0x16
#define TLS_MSG_CLIENT_HELLO 0x01
/* Tailles maximales des payloads copiés */
#define MAX_TLS_PAYLOAD 2048
#define MAX_HTTP_PAYLOAD 1024
#define MAX_TCP_OPTIONS 40
/* Structure Ethernet locale (évite d'inclure linux/if_ether.h) */
struct ethhdr_local {
__u8 h_dest[6];
__u8 h_source[6];
__be16 h_proto;
} __attribute__((packed));
/* Counter map for debug */
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 7);
__type(key, __u32);
__type(value, __u64);
} tc_stats SEC(".maps");
#define STAT_TOTAL 0
#define STAT_IPV4 1
#define STAT_TCP 2
#define STAT_SYN 3
#define STAT_SYN_SUBMIT 4
#define STAT_TLS_SUBMIT 5
#define STAT_HTTP_SUBMIT 6
/* ---------------------------------------------------------------------------
* capture_xdp — Point d'entrée XDP ingress
* capture_tc — Point d'entrée TC ingress (clsact)
*
* 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.
* AUCUN accès direct au paquet. Tout via bpf_skb_load_bytes() + tailles constantes.
* Compatible vérificateur kernel 4.18.
* ---------------------------------------------------------------------------*/
SEC("xdp")
int capture_xdp(struct xdp_md *ctx)
SEC("tc")
int capture_tc(struct __sk_buff *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
__u32 key;
__u64 *cnt;
__u32 pkt_len = ctx->len;
/* --- Ethernet --- */
struct ethhdr_local *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
return XDP_PASS;
key = STAT_TOTAL;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
/* --- IPv4 --- */
struct iphdr *ip = data + ETH_HLEN;
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
if (ip->protocol != IPPROTO_TCP)
return XDP_PASS;
/* --- Ethernet : vérifier type IPv4 --- */
if (pkt_len < ETH_HLEN + 20 + 20)
return TC_ACT_OK;
__u32 ihl = ip->ihl & 0x0F;
if (ihl < 5)
return XDP_PASS;
__u32 ip_hlen = ihl << 2; /* ∈ [20, 60] */
__be16 h_proto;
bpf_skb_load_bytes(ctx, 12, &h_proto, 2);
if (h_proto != bpf_htons(ETH_P_IP))
return TC_ACT_OK;
__u32 src_ip = ip->saddr;
__u32 dst_ip = ip->daddr;
__u8 ttl = ip->ttl;
__u16 ip_id = bpf_ntohs(ip->id);
__u16 frag_off = bpf_ntohs(ip->frag_off);
/* --- IPv4 : lire le header (20 octets min) --- */
key = STAT_IPV4;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
struct iphdr iph;
bpf_skb_load_bytes(ctx, ETH_HLEN, &iph, sizeof(iph));
if (iph.protocol != IPPROTO_TCP)
return TC_ACT_OK;
__u32 ihl = iph.ihl & 0x0F;
if (ihl < 5 || ihl > 15)
return TC_ACT_OK;
__u32 ip_hlen = ihl << 2;
if (ip_hlen < 20 || ip_hlen > 60)
return TC_ACT_OK;
__u32 src_ip = iph.saddr;
__u32 dst_ip = iph.daddr;
__u8 ttl = iph.ttl;
__u16 ip_id = bpf_ntohs(iph.id);
__u16 frag_off = bpf_ntohs(iph.frag_off);
__u8 df_bit = (frag_off & IP_DF) ? 1 : 0;
/* --- TCP à offset variable --- */
struct tcphdr *tcp = (void *)ip + ip_hlen;
if ((void *)(tcp + 1) > data_end) /* valide tcp[0..19] */
return XDP_PASS;
/* --- TCP : lire le header (20 octets) --- */
__u32 tcp_off = ETH_HLEN + ip_hlen;
if (pkt_len < tcp_off + 20)
return TC_ACT_OK;
__u16 src_port = bpf_ntohs(tcp->source);
__u16 dst_port = bpf_ntohs(tcp->dest);
__u16 window = bpf_ntohs(tcp->window);
key = STAT_TCP;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
struct tcphdr tcph;
bpf_skb_load_bytes(ctx, tcp_off, &tcph, sizeof(tcph));
__u16 src_port = bpf_ntohs(tcph.source);
__u16 dst_port = bpf_ntohs(tcph.dest);
__u16 window = bpf_ntohs(tcph.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;
if (tcph.syn) tcp_flags |= TH_SYN;
if (tcph.ack) tcp_flags |= TH_ACK;
if (tcph.fin) tcp_flags |= TH_FIN;
if (tcph.rst) tcp_flags |= TH_RST;
__u32 doff = tcp->doff;
if (doff < 5)
return XDP_PASS;
__u32 tcp_hlen = doff << 2; /* ∈ [20, 60] */
__u32 doff = tcph.doff;
if (doff < 5 || doff > 15)
return TC_ACT_OK;
__u32 tcp_hlen = doff << 2;
if (tcp_hlen < 20 || tcp_hlen > 60)
return TC_ACT_OK;
/* Offset du payload applicatif */
void *payload = (void *)tcp + tcp_hlen;
__u32 payload_off = ETH_HLEN + ip_hlen + tcp_hlen;
/* ===================================================================
* TCP SYN : extraction des paramètres L3/L4
* TCP SYN
* ===================================================================*/
if ((tcp_flags & TH_SYN) && !(tcp_flags & TH_ACK)) {
struct tcp_syn_event *evt =
bpf_ringbuf_reserve(&rb_tcp_syn, sizeof(*evt), 0);
if (!evt)
return XDP_PASS;
key = STAT_SYN;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
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;
struct tcp_syn_event evt = {};
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;
evt.mss = 0;
evt.timestamp_ns = bpf_ktime_get_ns();
evt.tcp_options_len = 0;
/* 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_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;
/* Copie des options TCP via bpf_skb_load_bytes avec taille constante.
* On lit MAX_TCP_OPTIONS=40 octets depuis le début des options.
* Si le paquet est trop court, l'appel échoue → options absentes. */
__u32 opts_off = tcp_off + 20;
__u32 opts_len = tcp_hlen - 20;
if (opts_len > 0 && opts_len <= MAX_TCP_OPTIONS &&
opts_off + MAX_TCP_OPTIONS <= pkt_len) {
bpf_skb_load_bytes(ctx, opts_off, evt.tcp_options_raw, MAX_TCP_OPTIONS);
evt.tcp_options_len = (__u8)opts_len;
}
bpf_ringbuf_submit(evt, 0);
bpf_perf_event_output(ctx, &pb_tcp_syn, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
key = STAT_SYN_SUBMIT;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
}
/* ===================================================================
* TLS ClientHello (port 443)
* ===================================================================*/
if (dst_port == HTTPS_PORT) {
/* Au moins 6 octets pour l'en-tête TLS record + type message */
if (payload + 6 > data_end)
return XDP_PASS;
/* Lire les 6 premiers octets du payload pour vérifier le type TLS */
if (payload_off + 6 > pkt_len)
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;
__u8 tls_hdr[6];
bpf_skb_load_bytes(ctx, payload_off, tls_hdr, 6);
__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));
if (tls_hdr[0] != TLS_CONTENT_HANDSHAKE || tls_hdr[5] != TLS_MSG_CLIENT_HELLO)
return TC_ACT_OK;
struct tls_hello_event *tls_evt =
bpf_ringbuf_reserve(&rb_tls_hello, sizeof(*tls_evt), 0);
/* Avail via pkt_len (scalaire pur) */
__u32 avail = 0;
if (pkt_len > payload_off) {
avail = pkt_len - payload_off;
if (avail > MAX_TLS_PAYLOAD)
avail = MAX_TLS_PAYLOAD;
}
if (avail == 0)
return TC_ACT_OK;
__u32 zero = 0;
struct tls_hello_event *tls_evt = bpf_map_lookup_elem(&__tls_buf, &zero);
if (!tls_evt)
return XDP_PASS;
return TC_ACT_OK;
tls_evt->src_ip = 0;
tls_evt->src_port = 0;
tls_evt->payload_len = 0;
tls_evt->timestamp_ns = 0;
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;
/* 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];
}
/* Copie via bpf_skb_load_bytes avec taille constante 256.
* Kernel 4.18 ne supporte pas les tailles variables vers map values.
* 256 octets capture le ClientHello dans la majorité des cas. */
if (bpf_skb_load_bytes(ctx, payload_off, tls_evt, 256))
return TC_ACT_OK;
bpf_ringbuf_submit(tls_evt, 0);
return XDP_PASS;
bpf_perf_event_output(ctx, &pb_tls_hello, BPF_F_CURRENT_CPU,
tls_evt, sizeof(*tls_evt));
key = STAT_TLS_SUBMIT;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
return TC_ACT_OK;
}
/* ===================================================================
* 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 */
if (tcp_flags & (TH_SYN | TH_FIN | TH_RST))
return XDP_PASS;
if (payload >= data_end)
return XDP_PASS;
return TC_ACT_OK;
if (payload_off >= pkt_len)
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));
/* Avail via pkt_len (scalaire pur) */
__u32 avail = 0;
if (pkt_len > payload_off) {
avail = pkt_len - payload_off;
if (avail > MAX_HTTP_PAYLOAD)
avail = MAX_HTTP_PAYLOAD;
}
if (avail == 0)
return TC_ACT_OK;
struct http_plain_event *h_evt =
bpf_ringbuf_reserve(&rb_http_plain, sizeof(*h_evt), 0);
__u32 zero = 0;
struct http_plain_event *h_evt = bpf_map_lookup_elem(&__http_buf, &zero);
if (!h_evt)
return XDP_PASS;
return TC_ACT_OK;
h_evt->src_ip = 0;
h_evt->dst_ip = 0;
h_evt->src_port = 0;
h_evt->dst_port = 0;
h_evt->payload_len = 0;
h_evt->timestamp_ns = 0;
h_evt->src_ip = bpf_ntohl(src_ip);
h_evt->dst_ip = bpf_ntohl(dst_ip);
@ -240,21 +271,19 @@ int capture_xdp(struct xdp_md *ctx)
h_evt->timestamp_ns = bpf_ktime_get_ns();
h_evt->payload_len = (__u16)avail;
__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];
}
/* Taille constante 256 pour compatibilité vérificateur kernel 4.18 */
if (bpf_skb_load_bytes(ctx, payload_off, h_evt, 256))
return TC_ACT_OK;
bpf_ringbuf_submit(h_evt, 0);
bpf_perf_event_output(ctx, &pb_http_plain, BPF_F_CURRENT_CPU,
h_evt, sizeof(*h_evt));
key = STAT_HTTP_SUBMIT;
cnt = bpf_map_lookup_elem(&tc_stats, &key);
if (cnt) (*cnt)++;
}
return XDP_PASS;
return TC_ACT_OK;
}
char LICENSE[] SEC("license") = "GPL";

View File

@ -4,6 +4,9 @@
* et corrige l'association socket ↔ SSL* via les tracepoints syscalls/accept4.
* Les tracepoints sont plus stables que les kprobes car ils ne dépendent pas
* du nom manglé __x64_sys_accept4 (variable selon la version du kernel).
*
* Utilise bpf_perf_event_output() (kernel 4.4+) pour compatibilité maximale.
* Les structs > 512o utilisent un PERCPU_ARRAY temporaire (__ssl_buf).
* ============================================================================ */
#include "vmlinux.h"
@ -105,7 +108,8 @@ int uprobe_ssl_read_entry(struct pt_regs *ctx)
/* ===========================================================================
* uretprobe_ssl_read_exit — Retour de SSL_read
*
* Lit le buffer déchiffré et l'émet dans rb_ssl_data.
* Lit le buffer déchiffré et l'émet via perf_event_output.
* Struct ssl_data_event = 4131 octets → PERCPU_ARRAY temporaire (__ssl_buf).
* ===========================================================================*/
SEC("uretprobe/SSL_read")
int uretprobe_ssl_read_exit(struct pt_regs *ctx)
@ -124,12 +128,21 @@ int uretprobe_ssl_read_exit(struct pt_regs *ctx)
return 0;
}
/* Allouer un slot dans le ring buffer */
struct ssl_data_event *evt = bpf_ringbuf_reserve(&rb_ssl_data, sizeof(*evt), 0);
/* Utiliser le buffer PERCPU (struct trop grande pour la stack) */
__u32 zero = 0;
struct ssl_data_event *evt = bpf_map_lookup_elem(&__ssl_buf, &zero);
if (!evt) {
bpf_map_delete_elem(&ssl_args_map, &pid_tgid);
return 0;
}
/* Initialiser les champs fixes (data sera écrasé par probe_read_user) */
evt->pid_tgid = 0;
evt->fd = 0;
evt->src_ip = 0;
evt->src_port = 0;
evt->data_len = 0;
evt->timestamp_ns = 0;
evt->direction = 0;
evt->pid_tgid = pid_tgid;
evt->direction = 0; /* lecture = client vers serveur */
@ -154,7 +167,8 @@ int uretprobe_ssl_read_exit(struct pt_regs *ctx)
evt->src_port = 0;
}
bpf_ringbuf_submit(evt, 0);
bpf_perf_event_output(ctx, &pb_ssl_data, BPF_F_CURRENT_CPU,
evt, sizeof(*evt));
bpf_map_delete_elem(&ssl_args_map, &pid_tgid);
return 0;
@ -181,7 +195,8 @@ int kprobe_accept4_entry(struct sys_enter_accept4_ctx *ctx)
* kretprobe_accept4_exit — Retour de accept4 via tracepoint syscalls
*
* Lit la sockaddr_in pour extraire src_ip:src_port du client,
* peuple accept_map et fd_conn_map, et émet dans rb_accept.
* peuple accept_map et fd_conn_map, et émet via perf_event_output.
* Struct accept_event = 26 octets → tient sur la stack (< 512o).
* ===========================================================================*/
SEC("tracepoint/syscalls/sys_exit_accept4")
int kretprobe_accept4_exit(struct sys_exit_accept4_ctx *ctx)
@ -238,21 +253,11 @@ int kretprobe_accept4_exit(struct sys_exit_accept4_ctx *ctx)
};
bpf_map_update_elem(&fd_conn_map, &fd, &conn_info, BPF_ANY);
/* Émettre dans rb_accept */
struct accept_event *out = bpf_ringbuf_reserve(&rb_accept, sizeof(*out), 0);
if (!out)
return 0;
out->pid_tgid = pid_tgid;
out->fd = fd;
out->src_ip = src_ip;
out->src_port = src_port;
out->timestamp_ns = aevt.timestamp_ns;
bpf_ringbuf_submit(out, 0);
/* Émettre via perf_event_output (struct 26o → sur la stack) */
bpf_perf_event_output(ctx, &pb_accept, BPF_F_CURRENT_CPU,
&aevt, sizeof(aevt));
return 0;
}
char LICENSE[] SEC("license") = "GPL";

View File

@ -10,6 +10,8 @@ import (
"log"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
@ -18,7 +20,7 @@ import (
"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"
"github.com/cilium/ebpf/perf"
"gopkg.in/yaml.v3"
)
@ -32,6 +34,7 @@ var fdCache = procutil.NewFDCache(5 * time.Second)
type Config struct {
Interface string `yaml:"interface"` // interface réseau à surveiller (ex: "eth0")
SSLLibPath string `yaml:"ssl_lib_path"` // chemin vers libssl (ex: "/usr/lib64/libssl.so.3")
Debug bool `yaml:"debug"` // mode debug : dump compteurs BPF, log verbeux, ClickHouse optionnel
ClickHouse struct {
DSN string `yaml:"dsn"` // DSN ClickHouse natif
@ -87,6 +90,9 @@ func loadConfig(path string) (*Config, error) {
if v := os.Getenv("JA4EBPF_CLICKHOUSE_DSN"); v != "" {
cfg.ClickHouse.DSN = v
}
if v := os.Getenv("JA4EBPF_DEBUG"); v != "" {
cfg.Debug = strings.EqualFold(v, "true") || v == "1" || v == "yes"
}
return cfg, nil
}
@ -104,7 +110,10 @@ func main() {
log.Fatalf("erreur chargement configuration: %v", err)
}
log.Printf("[ja4ebpf] démarrage — interface=%s ssl=%s", cfg.Interface, cfg.SSLLibPath)
if cfg.Debug {
log.Printf("[ja4ebpf] MODE DEBUG ACTIVÉ")
}
log.Printf("[ja4ebpf] démarrage — interface=%s ssl=%s debug=%v", cfg.Interface, cfg.SSLLibPath, cfg.Debug)
// Contexte principal avec annulation sur signal système
ctx, cancel := context.WithCancel(context.Background())
@ -122,9 +131,11 @@ func main() {
defer ldr.Close()
// --- 2. Attachement TC ingress ---
log.Printf("[ja4ebpf] attachement TC ingress sur %s...", cfg.Interface)
if err := ldr.AttachTC(cfg.Interface); err != nil {
log.Fatalf("erreur attachement TC sur %s: %v", cfg.Interface, err)
}
log.Printf("[ja4ebpf] TC ingress attaché sur %s", cfg.Interface)
// --- 3. Attachement uprobes SSL ---
if err := ldr.AttachUprobes(cfg.SSLLibPath); err != nil {
@ -144,26 +155,46 @@ func main() {
defer mgr.Close()
// --- 6. Writer ClickHouse ---
var w *writer.ClickHouseWriter
flushInterval := time.Duration(cfg.ClickHouse.FlushSecs) * time.Second
w, err := writer.NewClickHouseWriter(cfg.ClickHouse.DSN, cfg.ClickHouse.BatchSize, flushInterval)
w, err = writer.NewClickHouseWriter(cfg.ClickHouse.DSN, cfg.ClickHouse.BatchSize, flushInterval)
if err != nil {
log.Fatalf("erreur initialisation writer ClickHouse: %v", err)
if cfg.Debug {
log.Printf("[ja4ebpf] DEBUG: writer ClickHouse non disponible: %v (continue sans CH)", err)
} else {
log.Fatalf("erreur initialisation writer ClickHouse: %v", err)
}
}
if w != nil {
w.Start(ctx)
}
w.Start(ctx)
// --- 7. Goroutine : écriture des sessions prêtes ---
go func() {
for s := range mgr.ReadyCh {
w.Write(s)
if w != nil {
w.Write(s)
} else if cfg.Debug {
log.Printf("[ja4ebpf] DEBUG: session prête (sans CH): has_l3l4=%v has_tls=%v",
s.L3L4 != nil, s.TLS != nil)
}
}
}()
// --- 8. Goroutines de consommation des ring buffers ---
go consumeSynEvents(ctx, ldr.SynReader, mgr)
go consumeTLSEvents(ctx, ldr.TLSReader, mgr)
go consumeSSLEvents(ctx, ldr.SSLReader, mgr)
go consumeAcceptEvents(ctx, ldr.AcceptReader, mgr)
go consumeHTTPPlainEvents(ctx, ldr.HTTPPlainReader, mgr)
// --- 8. Compteurs d'événements consommés (mode debug) ---
consumed := &eventCounters{}
// --- 9. Goroutines de consommation des ring buffers ---
go consumeSynEvents(ctx, ldr.SynReader, mgr, &consumed.syn)
go consumeTLSEvents(ctx, ldr.TLSReader, mgr, &consumed.tls)
go consumeSSLEvents(ctx, ldr.SSLReader, mgr, &consumed.ssl)
go consumeAcceptEvents(ctx, ldr.AcceptReader, mgr, &consumed.accept)
go consumeHTTPPlainEvents(ctx, ldr.HTTPPlainReader, mgr, &consumed.httpPlain)
// --- 10. Stats dumper (mode debug) ---
if cfg.Debug {
go debugStatsDumper(ctx, ldr, consumed)
}
log.Printf("[ja4ebpf] démon actif — en attente des événements")
@ -178,6 +209,43 @@ func main() {
log.Printf("[ja4ebpf] arrêt terminé")
}
// eventCounters contient les compteurs atomiques pour chaque type d'événement consommé.
type eventCounters struct {
syn atomic.Uint64
tls atomic.Uint64
ssl atomic.Uint64
accept atomic.Uint64
httpPlain atomic.Uint64
}
// debugStatsDumper affiche les compteurs BPF et les événements consommés toutes les 5 secondes.
func debugStatsDumper(ctx context.Context, ldr *loader.Loader, consumed *eventCounters) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
// Compteurs BPF kernel
stats, err := ldr.ReadStats()
if err != nil {
log.Printf("[debug] erreur lecture tc_stats: %v", err)
continue
}
log.Printf("[debug] BPF: TOTAL=%d IPV4=%d TCP=%d SYN=%d SYN_SUB=%d TLS_SUB=%d HTTP_SUB=%d",
stats[0], stats[1], stats[2], stats[3], stats[4], stats[5], stats[6])
// Compteurs userspace
log.Printf("[debug] GO: syn=%d tls=%d ssl=%d accept=%d http=%d",
consumed.syn.Load(), consumed.tls.Load(), consumed.ssl.Load(),
consumed.accept.Load(), consumed.httpPlain.Load())
}
}
// 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.
@ -220,7 +288,7 @@ func parseTCPOptions(opts []byte) (mss uint16, windowScale uint8) {
// 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) {
func consumeSynEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
for {
select {
case <-ctx.Done():
@ -230,7 +298,7 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
if err == os.ErrClosed {
return
}
continue
@ -240,7 +308,7 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
// 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 {
if len(record.RawSample) < 70 {
continue
}
data := record.RawSample
@ -288,12 +356,13 @@ func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
_ = s.TLS // corrélation implicite par présence des deux champs
}
})
counter.Add(1)
}
}
// consumeTLSEvents lit les événements TLS ClientHello depuis le ring buffer
// et calcule l'empreinte JA4 pour chaque session.
func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
func consumeTLSEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
for {
select {
case <-ctx.Done():
@ -303,7 +372,7 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
if err == os.ErrClosed {
return
}
continue
@ -312,20 +381,20 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
// 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 {
if len(record.RawSample) < 2064 {
continue
}
data := record.RawSample
srcIPRaw := binary.LittleEndian.Uint32(data[0:4])
srcPort := binary.LittleEndian.Uint16(data[4:6])
srcIPRaw := binary.LittleEndian.Uint32(data[2048:2052])
srcPort := binary.LittleEndian.Uint16(data[2052:2054])
payloadLen := binary.LittleEndian.Uint16(data[2054:2056])
if int(payloadLen) > 2048 {
payloadLen = 2048
}
payload := make([]byte, payloadLen)
copy(payload, data[6:6+payloadLen])
copy(payload, data[0:payloadLen])
var key correlation.SessionKey
key.SrcIP[0] = byte(srcIPRaw >> 24)
@ -366,13 +435,14 @@ func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
_ = s.L3L4 // corrélation implicite par présence des deux champs
}
})
counter.Add(1)
}
}
// consumeSSLEvents lit les données SSL déchiffrées depuis le ring buffer.
// 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) {
func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
for {
select {
case <-ctx.Done():
@ -382,7 +452,7 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
if err == os.ErrClosed {
return
}
continue
@ -439,6 +509,7 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
key.SrcIP[3] = byte(srcIPRaw)
key.SrcPort = srcPort
counter.Add(1)
// === Routeur Magic Bytes ===
if parser.DetectH2Preface(sslData) {
@ -517,7 +588,7 @@ func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.
// consumeAcceptEvents lit les événements accept4 depuis le ring buffer.
// Met à jour les sessions avec les informations de connexion client.
func consumeAcceptEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
func consumeAcceptEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
for {
select {
case <-ctx.Done():
@ -527,7 +598,7 @@ func consumeAcceptEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlati
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
if err == os.ErrClosed {
return
}
continue
@ -556,13 +627,14 @@ func consumeAcceptEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlati
// S'assurer que la session existe
mgr.GetOrCreate(key)
counter.Add(1)
}
}
// consumeHTTPPlainEvents lit les payloads HTTP en clair depuis le ring buffer XDP.
// consumeHTTPPlainEvents lit les payloads HTTP en clair depuis le perf buffer TC.
// 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) {
func consumeHTTPPlainEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Manager, counter *atomic.Uint64) {
for {
select {
case <-ctx.Done():
@ -572,21 +644,21 @@ func consumeHTTPPlainEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correl
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
if err == os.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)
// struct http_plain_event: payload(4096)+src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+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])
srcIPRaw := binary.LittleEndian.Uint32(data[4096:4100])
srcPort := binary.LittleEndian.Uint16(data[4104:4106])
if srcIPRaw == 0 && srcPort == 0 {
continue
@ -610,10 +682,10 @@ func consumeHTTPPlainEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correl
if payloadLen == 0 {
continue
}
if 12+payloadLen > len(data) {
payloadLen = len(data) - 12
if 4096+payloadLen > len(data) {
payloadLen = len(data) - 4096
}
httpData := data[12 : 12+payloadLen]
httpData := data[0:payloadLen]
// Routeur Magic Bytes : HTTP/1.x uniquement sur port 80
if parser.IsHTTP1Request(httpData) {
@ -633,6 +705,7 @@ func consumeHTTPPlainEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correl
// Corréler si L3/L4 est déjà présent (TCP SYN capturé)
_ = s.L3L4 // corrélation implicite
})
counter.Add(1)
}
}
}

View File

@ -1,33 +1,26 @@
# Configuration de l'agent ja4ebpf
# Copiez ce fichier en config.yml et adaptez les valeurs.
# Interface réseau à surveiller (hook TC ingress)
# Interface réseau à surveiller (XDP ingress)
interface: eth0
# Processus à instrumenter via uprobes SSL
ssl_probes:
- executable: /usr/sbin/httpd
symbol: SSL_read
- executable: /usr/lib64/libssl.so.3
symbol: SSL_read
# Chemin vers libssl pour les uprobes SSL_read/SSL_write
ssl_lib_path: "/usr/lib64/libssl.so.3"
# Mode debug : dump compteurs BPF + événements consommés toutes les 5s
# ClickHouse optionnel en mode debug
debug: false
# Paramètres de connexion ClickHouse
clickhouse:
addr: "127.0.0.1:9000"
database: "ja4_logs"
table: "http_logs_raw"
username: "default"
password: ""
tls: false
dsn: "clickhouse://default:@127.0.0.1:9000/ja4_logs"
batch_size: 500
flush_every: "2s"
flush_secs: 1
# Délais de corrélation et de détection
timeouts:
# Durée sans activité avant expiration d'une session TCP
session_expiry: "500ms"
# Délai maximum pour une requête L7 sans réponse (détection Slowloris)
slowloris: "10s"
correlation:
timeout_ms: 500 # expiration session TCP (ms)
slowloris_ms: 10000 # seuil Slowloris (ms)
# Journalisation
log:

View File

@ -20,9 +20,10 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect
golang.org/x/sys v0.20.0 // indirect
)

View File

@ -65,6 +65,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
@ -103,6 +107,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@ -1,48 +1,90 @@
// Package loader initialise les programmes eBPF via cilium/ebpf,
// attache les hooks TC ingress et les uprobes SSL, et expose
// les readers RingBuffer aux consommateurs Go.
// attache le hook TC ingress et les uprobes SSL, et expose
// les readers PerfEvent aux consommateurs Go.
package loader
import (
"context"
"encoding/binary"
"fmt"
"net"
"os"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -D__TARGET_ARCH_x86 -Wno-pass-failed" Ja4Tc ../../bpf/tc_capture.c -- -I../../bpf/headers
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -D__TARGET_ARCH_x86 -Wno-pass-failed" Ja4Ssl ../../bpf/uprobe_ssl.c -- -I../../bpf/headers
// perCPUBufferSize est la taille du buffer perf per-CPU en octets (256 KB).
const perCPUBufferSize = 256 * 1024
// Loader encapsule les objets eBPF compilés, les liens vers les hooks,
// et les readers RingBuffer exposés au pipeline de traitement.
// et les readers PerfEvent exposés au pipeline de traitement.
type Loader struct {
tcObjs *Ja4TcObjects // généré par bpf2go (tc_capture.c)
sslObjs *Ja4SslObjects // généré par bpf2go (uprobe_ssl.c)
tcLink link.Link
tcNlLink netlink.Link // interface netlink pour cleanup TC
uprobeLinks []link.Link
statsMap *ebpf.Map // map tc_stats pour lecture des compteurs BPF (mode debug)
// SynReader lit les événements TCP SYN depuis rb_tcp_syn.
SynReader *ringbuf.Reader
// TLSReader lit les événements TLS ClientHello depuis rb_tls_hello.
TLSReader *ringbuf.Reader
// SSLReader lit les données SSL déchiffrées depuis rb_ssl_data.
SSLReader *ringbuf.Reader
// AcceptReader lit les événements accept4 depuis rb_accept.
AcceptReader *ringbuf.Reader
// HTTPPlainReader lit les payloads HTTP en clair depuis rb_http_plain.
HTTPPlainReader *ringbuf.Reader
// SynReader lit les événements TCP SYN depuis pb_tcp_syn.
SynReader *perf.Reader
// TLSReader lit les événements TLS ClientHello depuis pb_tls_hello.
TLSReader *perf.Reader
// SSLReader lit les données SSL déchiffrées depuis pb_ssl_data.
SSLReader *perf.Reader
// AcceptReader lit les événements accept4 depuis pb_accept.
AcceptReader *perf.Reader
// HTTPPlainReader lit les payloads HTTP en clair depuis pb_http_plain.
HTTPPlainReader *perf.Reader
}
// StatNames associe chaque index de compteur BPF à un nom lisible.
var StatNames = map[uint32]string{
0: "TOTAL",
1: "IPV4",
2: "TCP",
3: "SYN",
4: "SYN_SUBMIT",
5: "TLS_SUBMIT",
6: "HTTP_SUBMIT",
}
// ReadStats lit les compteurs de la map tc_stats (PERCPU_ARRAY).
// Retourne une map[index] → somme de toutes les valeurs CPU.
// Si la map n'est pas disponible, retourne une map vide.
func (l *Loader) ReadStats() (map[uint32]uint64, error) {
result := make(map[uint32]uint64)
if l.statsMap == nil {
return result, nil
}
for key := uint32(0); key < 7; key++ {
var values []uint64
if err := l.statsMap.Lookup(key, &values); err != nil {
continue
}
var sum uint64
for _, v := range values {
sum += v
}
result[key] = sum
}
return result, nil
}
// New charge le bytecode eBPF embarqué, supprime la limite mémoire
// RLIMIT_MEMLOCK (requise pour les ring buffers et les maps eBPF),
// RLIMIT_MEMLOCK (requise pour les maps eBPF),
// et retourne un Loader prêt à être attaché aux hooks.
//
// Cible : CentOS 8 / RHEL 8 et supérieur (kernel 4.18 avec BTF backporté).
// Cible : kernel 4.18+ avec BTF. Les perf event arrays sont supportés depuis
// kernel 4.4, bpf_skb_load_bytes depuis kernel 4.5, assurant une compatibilité
// maximale via le hook TC ingress.
// Le BTF natif est détecté automatiquement par cilium/ebpf via
// /sys/kernel/btf/vmlinux — aucun fallback manuel n'est requis.
func New() (*Loader, error) {
@ -57,6 +99,28 @@ func New() (*Loader, error) {
return nil, fmt.Errorf("chargement objets TC eBPF: %w", err)
}
// Trouver la map tc_stats par iteration des maps kernel
var statsMap *ebpf.Map
var mapID ebpf.MapID = 0
for {
nextID, err := ebpf.MapGetNextID(mapID)
if err != nil {
break
}
m, err := ebpf.NewMapFromID(nextID)
if err != nil {
mapID = nextID
continue
}
info, err := m.Info()
if err == nil && info.Name == "tc_stats" {
statsMap = m
break
}
m.Close()
mapID = nextID
}
// Charger les objets SSL/uprobe (uprobe_ssl.c)
sslObjs := &Ja4SslObjects{}
if err := LoadJa4SslObjects(sslObjs, nil); err != nil {
@ -64,42 +128,42 @@ func New() (*Loader, error) {
return nil, fmt.Errorf("chargement objets SSL eBPF: %w", err)
}
// Initialiser les readers pour chaque ring buffer
synReader, err := ringbuf.NewReader(tcObjs.RbTcpSyn)
// Initialiser les readers pour chaque perf event array
synReader, err := perf.NewReader(tcObjs.PbTcpSyn, perCPUBufferSize)
if err != nil {
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("création reader rb_tcp_syn: %w", err)
return nil, fmt.Errorf("création reader pb_tcp_syn: %w", err)
}
tlsReader, err := ringbuf.NewReader(tcObjs.RbTlsHello)
tlsReader, err := perf.NewReader(tcObjs.PbTlsHello, perCPUBufferSize)
if err != nil {
synReader.Close()
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("création reader rb_tls_hello: %w", err)
return nil, fmt.Errorf("création reader pb_tls_hello: %w", err)
}
httpPlainReader, err := ringbuf.NewReader(tcObjs.RbHttpPlain)
httpPlainReader, err := perf.NewReader(tcObjs.PbHttpPlain, perCPUBufferSize)
if err != nil {
tlsReader.Close()
synReader.Close()
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("création reader rb_http_plain: %w", err)
return nil, fmt.Errorf("création reader pb_http_plain: %w", err)
}
sslReader, err := ringbuf.NewReader(sslObjs.RbSslData)
sslReader, err := perf.NewReader(sslObjs.PbSslData, perCPUBufferSize)
if err != nil {
httpPlainReader.Close()
tlsReader.Close()
synReader.Close()
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("création reader rb_ssl_data: %w", err)
return nil, fmt.Errorf("création reader pb_ssl_data: %w", err)
}
acceptReader, err := ringbuf.NewReader(sslObjs.RbAccept)
acceptReader, err := perf.NewReader(sslObjs.PbAccept, perCPUBufferSize)
if err != nil {
sslReader.Close()
httpPlainReader.Close()
@ -107,12 +171,13 @@ func New() (*Loader, error) {
synReader.Close()
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("création reader rb_accept: %w", err)
return nil, fmt.Errorf("création reader pb_accept: %w", err)
}
return &Loader{
tcObjs: tcObjs,
sslObjs: sslObjs,
statsMap: statsMap,
SynReader: synReader,
TLSReader: tlsReader,
SSLReader: sslReader,
@ -121,66 +186,79 @@ func New() (*Loader, error) {
}, nil
}
// 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).
// AttachTC attache le programme TC ingress (clsact qdisc) sur l'interface
// réseau spécifiée. Crée le qdisc clsact (idempotent) et attache le filtre BPF
// en mode direct-action. Compatible kernel 4.1+.
func (l *Loader) AttachTC(iface string) error {
// Trouver l'interface par nom (standard Go net package)
netIface, err := net.InterfaceByName(iface)
if err != nil {
return fmt.Errorf("interface réseau %q introuvable: %w", iface, err)
}
// Mode natif (meilleure performance sur serveurs avec NIC compatible XDP)
lnk, err := link.AttachXDP(link.XDPOptions{
Interface: netIface.Index,
Program: l.tcObjs.CaptureXdp,
Flags: link.XDPDriverMode,
})
// Obtenir le link netlink par index (plus fiable que par nom)
nlLink, err := netlink.LinkByIndex(netIface.Index)
if err != nil {
// 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)
}
return fmt.Errorf("netlink link index %d introuvable: %w", netIface.Index, err)
}
l.tcLink = lnk
// Créer le qdisc clsact (idempotent via QdiscReplace)
qdisc := &netlink.Clsact{
QdiscAttrs: netlink.QdiscAttrs{
LinkIndex: nlLink.Attrs().Index,
Handle: netlink.MakeHandle(0xffff, 0),
Parent: netlink.HANDLE_CLSACT,
},
}
if err := netlink.QdiscReplace(qdisc); err != nil {
return fmt.Errorf("clsact qdisc sur %q: %w", iface, err)
}
// Attacher le programme BPF comme filtre ingress
filter := &netlink.BpfFilter{
FilterAttrs: netlink.FilterAttrs{
LinkIndex: nlLink.Attrs().Index,
Parent: netlink.HANDLE_MIN_INGRESS,
Handle: 1,
Protocol: unix.ETH_P_ALL,
Priority: 1,
},
ClassId: netlink.MakeHandle(1, 1),
Fd: l.tcObjs.CaptureTc.FD(),
DirectAction: true,
}
if err := netlink.FilterReplace(filter); err != nil {
return fmt.Errorf("TC filter ingress sur %q: %w", iface, err)
}
l.tcNlLink = nlLink
return nil
}
// AttachUprobes attache les uprobes SSL_read et SSL_set_fd
// sur le binaire libssl spécifié (ex: "/usr/lib64/libssl.so.3").
func (l *Loader) AttachUprobes(sslLibPath string) error {
// Vérifier que le fichier existe
if _, err := os.Stat(sslLibPath); err != nil {
return fmt.Errorf("bibliothèque SSL %q: %w", sslLibPath, err)
}
// Ouvrir le binaire exécutable pour les uprobes
ex, err := link.OpenExecutable(sslLibPath)
if err != nil {
return fmt.Errorf("ouverture exécutable %q pour uprobe: %w", sslLibPath, err)
}
// Uprobe sur SSL_set_fd (entry)
setFdLink, err := ex.Uprobe("SSL_set_fd", l.sslObjs.UprobeSslSetFd, nil)
if err != nil {
return fmt.Errorf("attachement uprobe SSL_set_fd: %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, setFdLink)
// Uprobe sur SSL_read (entry)
readEntryLink, err := ex.Uprobe("SSL_read", l.sslObjs.UprobeSslReadEntry, nil)
if err != nil {
return fmt.Errorf("attachement uprobe SSL_read (entry): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, readEntryLink)
// Uretprobe sur SSL_read (exit)
readExitLink, err := ex.Uretprobe("SSL_read", l.sslObjs.UretprobeSslReadExit, nil)
if err != nil {
return fmt.Errorf("attachement uretprobe SSL_read (exit): %w", err)
@ -191,10 +269,7 @@ func (l *Loader) AttachUprobes(sslLibPath string) error {
}
// AttachAcceptProbe attache les tracepoints syscalls/sys_{enter,exit}_accept4.
// Les tracepoints sont préférés aux kprobes car ils ne dépendent pas du nom
// manglé __x64_sys_accept4 qui varie entre les versions du kernel (5.1+).
func (l *Loader) AttachAcceptProbe() error {
// Tracepoint à l'entrée de accept4
kpEntry, err := link.Tracepoint("syscalls", "sys_enter_accept4",
l.sslObjs.KprobeAccept4Entry, nil)
if err != nil {
@ -202,7 +277,6 @@ func (l *Loader) AttachAcceptProbe() error {
}
l.uprobeLinks = append(l.uprobeLinks, kpEntry)
// Tracepoint à la sortie de accept4
kpExit, err := link.Tracepoint("syscalls", "sys_exit_accept4",
l.sslObjs.KretprobeAccept4Exit, nil)
if err != nil {
@ -215,7 +289,6 @@ func (l *Loader) AttachAcceptProbe() error {
// Close détache tous les hooks eBPF et libère toutes les ressources associées.
func (l *Loader) Close() error {
// Fermer les readers RingBuffer
if l.HTTPPlainReader != nil {
l.HTTPPlainReader.Close()
}
@ -232,19 +305,26 @@ func (l *Loader) Close() error {
l.SynReader.Close()
}
// Détacher les uprobes et kprobes
// Détacher le filtre TC ingress
if l.tcNlLink != nil {
filter := &netlink.BpfFilter{
FilterAttrs: netlink.FilterAttrs{
LinkIndex: l.tcNlLink.Attrs().Index,
Parent: netlink.HANDLE_MIN_INGRESS,
Handle: 1,
Priority: 1,
},
}
// Ignorer l'erreur — le filtre peut déjà être supprimé
netlink.FilterDel(filter)
}
for _, lnk := range l.uprobeLinks {
if lnk != nil {
lnk.Close()
}
}
// Détacher le hook TC
if l.tcLink != nil {
l.tcLink.Close()
}
// Libérer les objets eBPF (maps, programmes)
if l.sslObjs != nil {
l.sslObjs.Close()
}
@ -255,259 +335,10 @@ func (l *Loader) Close() error {
return nil
}
// =============================================================================
// Types d'événements : représentations Go des structures C eBPF
// =============================================================================
// TCPSynEvent représente un événement TCP SYN capturé par TC ingress.
type TCPSynEvent struct {
SrcIP uint32
DstIP uint32
SrcPort uint16
DstPort uint16
TTL uint8
DFBit uint8
IPID uint16
WindowSize uint16
WindowScale uint8
MSS uint16
TCPOptions [40]byte
TCPOptionsLen uint8
Timestamp uint64
}
// TLSHelloEvent représente un événement TLS ClientHello.
type TLSHelloEvent struct {
SrcIP uint32
SrcPort uint16
Payload []byte
PayloadLen uint16
Timestamp uint64
}
// SSLDataEvent représente un bloc de données SSL déchiffré par uprobe.
type SSLDataEvent struct {
PID uint32
TGID uint32
FD uint32
SrcIP uint32
SrcPort uint16
Data []byte
DataLen uint32
Timestamp uint64
Direction uint8
EOF bool
}
// HTTPPlainEvent représente un payload TCP HTTP en clair capturé par TC ingress.
type HTTPPlainEvent struct {
SrcIP uint32
DstIP uint32
SrcPort uint16
DstPort uint16
Payload []byte
PayloadLen uint16
Timestamp uint64
}
// AcceptEvent représente une acceptation de connexion TCP (accept4).
type AcceptEvent struct {
PID uint32
TGID uint32
FD uint32
SrcIP uint32
SrcPort uint16
Timestamp uint64
}
// =============================================================================
// Méthodes de lecture des RingBuffers
// =============================================================================
// ReadTCPSynEvent lit un événement TCP SYN depuis le RingBuffer.
// Bloque jusqu'à ce qu'un événement soit disponible ou que ctx soit annulé.
func (l *Loader) ReadTCPSynEvent(ctx context.Context) (*TCPSynEvent, error) {
rec, err := readRecord(ctx, l.SynReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct tcp_syn_event packed: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
// ttl(1)+df(1)+ip_id(2)+window(2)+wscale(1)+mss(2)+opts(40)+opts_len(1)+_pad(1)+ts(8) = 71
if len(data) < 64 {
return nil, fmt.Errorf("tcp_syn_event trop court: %d octets", len(data))
}
ev := &TCPSynEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
DstIP: binary.LittleEndian.Uint32(data[4:8]),
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
DstPort: binary.LittleEndian.Uint16(data[10:12]),
TTL: data[12],
DFBit: data[13],
IPID: binary.LittleEndian.Uint16(data[14:16]),
WindowSize: binary.LittleEndian.Uint16(data[16:18]),
WindowScale: data[18],
MSS: binary.LittleEndian.Uint16(data[19:21]),
}
copy(ev.TCPOptions[:], data[21:61])
ev.TCPOptionsLen = data[61]
if len(data) >= 70 {
ev.Timestamp = binary.LittleEndian.Uint64(data[62:70])
}
return ev, nil
}
// ReadTLSHelloEvent lit un événement TLS ClientHello depuis le RingBuffer.
func (l *Loader) ReadTLSHelloEvent(ctx context.Context) (*TLSHelloEvent, error) {
rec, err := readRecord(ctx, l.TLSReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct tls_hello_event: src_ip(4)+src_port(2)+payload(512)+payload_len(2)+ts(8) = 528
if len(data) < 8 {
return nil, fmt.Errorf("tls_hello_event trop court: %d octets", len(data))
}
plen := uint16(0)
if len(data) >= 520 {
plen = binary.LittleEndian.Uint16(data[518:520])
}
payload := make([]byte, plen)
if int(plen) <= 512 && len(data) >= 6+int(plen) {
copy(payload, data[6:6+plen])
}
ts := uint64(0)
if len(data) >= 528 {
ts = binary.LittleEndian.Uint64(data[520:528])
}
return &TLSHelloEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
SrcPort: binary.LittleEndian.Uint16(data[4:6]),
Payload: payload,
PayloadLen: plen,
Timestamp: ts,
}, nil
}
// ReadSSLDataEvent lit un bloc de données SSL déchiffrées depuis le RingBuffer.
func (l *Loader) ReadSSLDataEvent(ctx context.Context) (*SSLDataEvent, error) {
rec, err := readRecord(ctx, l.SSLReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct ssl_data_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+data(4096)+data_len(4)+ts(8)+direction(1)
if len(data) < 27 {
return nil, fmt.Errorf("ssl_data_event trop court: %d octets", len(data))
}
pidTGID := binary.LittleEndian.Uint64(data[0:8])
dlen := uint32(0)
if len(data) >= 4118 {
dlen = binary.LittleEndian.Uint32(data[4114:4118])
}
payload := make([]byte, dlen)
if int(dlen) <= 4096 && len(data) >= 18+int(dlen) {
copy(payload, data[18:18+dlen])
}
ts := uint64(0)
if len(data) >= 4126 {
ts = binary.LittleEndian.Uint64(data[4118:4126])
}
dir := uint8(0)
if len(data) >= 4127 {
dir = data[4126]
}
return &SSLDataEvent{
PID: uint32(pidTGID & 0xFFFFFFFF),
TGID: uint32(pidTGID >> 32),
FD: binary.LittleEndian.Uint32(data[8:12]),
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
Data: payload,
DataLen: dlen,
Timestamp: ts,
Direction: dir,
}, nil
}
// ReadHTTPPlainEvent lit un événement HTTP en clair depuis le RingBuffer TC.
// struct http_plain_event: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
//
// payload(4096)+payload_len(2)+ts(8) = 4118
func (l *Loader) ReadHTTPPlainEvent(ctx context.Context) (*HTTPPlainEvent, error) {
rec, err := readRecord(ctx, l.HTTPPlainReader)
if err != nil {
return nil, err
}
data := rec.RawSample
if len(data) < 12 {
return nil, fmt.Errorf("http_plain_event trop court: %d octets", len(data))
}
plen := uint16(0)
if len(data) >= 4110 {
plen = binary.LittleEndian.Uint16(data[4108:4110])
}
payload := make([]byte, plen)
if int(plen) <= 4096 && len(data) >= 12+int(plen) {
copy(payload, data[12:12+plen])
}
ts := uint64(0)
if len(data) >= 4118 {
ts = binary.LittleEndian.Uint64(data[4110:4118])
}
return &HTTPPlainEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
DstIP: binary.LittleEndian.Uint32(data[4:8]),
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
DstPort: binary.LittleEndian.Uint16(data[10:12]),
Payload: payload,
PayloadLen: plen,
Timestamp: ts,
}, nil
}
// ReadAcceptEvent lit un événement accept4 depuis le RingBuffer.
func (l *Loader) ReadAcceptEvent(ctx context.Context) (*AcceptEvent, error) {
rec, err := readRecord(ctx, l.AcceptReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct accept_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+ts(8) = 26
if len(data) < 26 {
return nil, fmt.Errorf("accept_event trop court: %d octets", len(data))
}
pidTGID := binary.LittleEndian.Uint64(data[0:8])
return &AcceptEvent{
PID: uint32(pidTGID & 0xFFFFFFFF),
TGID: uint32(pidTGID >> 32),
FD: binary.LittleEndian.Uint32(data[8:12]),
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
Timestamp: binary.LittleEndian.Uint64(data[18:26]),
}, nil
}
// readRecord lit un record brut depuis un RingBuffer avec annulation via context.
func readRecord(ctx context.Context, rd *ringbuf.Reader) (ringbuf.Record, error) {
// readRecord lit un record brut depuis un PerfReader avec annulation via context.
func readRecord(ctx context.Context, rd *perf.Reader) (perf.Record, error) {
type result struct {
rec ringbuf.Record
rec perf.Record
err error
}
ch := make(chan result, 1)
@ -517,8 +348,8 @@ func readRecord(ctx context.Context, rd *ringbuf.Reader) (ringbuf.Record, error)
}()
select {
case <-ctx.Done():
rd.Close() // débloque le Read() bloquant
return ringbuf.Record{}, ctx.Err()
rd.Close()
return perf.Record{}, ctx.Err()
case r := <-ch:
return r.rec, r.err
}

View File

@ -52,14 +52,28 @@ type sessionRecord struct {
TLSVersion string `json:"tls_version,omitempty"`
// HTTP
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
QueryString string `json:"query_string,omitempty"`
StatusCode *int `json:"status_code,omitempty"`
ResponseSize *int64 `json:"response_size,omitempty"`
DurationMS *float64 `json:"duration_ms,omitempty"`
KeepAlives int `json:"keepalives,omitempty"`
HeaderOrderSig string `json:"header_order_signature,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
QueryString string `json:"query_string,omitempty"`
StatusCode *int `json:"status_code,omitempty"`
ResponseSize *int64 `json:"response_size,omitempty"`
DurationMS *float64 `json:"duration_ms,omitempty"`
KeepAlives int `json:"keepalives,omitempty"`
HeaderOrderSig string `json:"header_order_signature,omitempty"`
// HTTP/2 fingerprinting passif
H2Fingerprint string `json:"h2_fingerprint,omitempty"`
H2SettingsFP string `json:"h2_settings_fp,omitempty"`
H2WindowUpdate uint32 `json:"h2_window_update,omitempty"`
H2PseudoOrder string `json:"h2_pseudo_order,omitempty"`
H2HasPriority uint8 `json:"h2_has_priority,omitempty"`
H2HeaderTableSize int32 `json:"h2_header_table_size"`
H2EnablePush int32 `json:"h2_enable_push"`
H2MaxConcurrentStreams int32 `json:"h2_max_concurrent_streams"`
H2InitialWindowSize int64 `json:"h2_initial_window_size"`
H2MaxFrameSize int32 `json:"h2_max_frame_size"`
H2MaxHeaderListSize int32 `json:"h2_max_header_list_size"`
H2EnableConnectProtocol int32 `json:"h2_enable_connect_protocol"`
}
// NewClickHouseWriter crée un writer et établit la connexion ClickHouse.
@ -192,37 +206,142 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord {
// Champs métadonnées IP/TCP
if s.L3L4 != nil {
rec.IPMetaDF = &s.L3L4.DFBit
rec.IPMetaID = &s.L3L4.IPID
rec.IPMetaTTL = &s.L3L4.TTL
rec.TCPMetaWindowSize = &s.L3L4.WindowSize
rec.IPMetaDF = &s.L3L4.DFBit
rec.IPMetaID = &s.L3L4.IPID
rec.IPMetaTTL = &s.L3L4.TTL
rec.TCPMetaWindowSize = &s.L3L4.WindowSize
rec.TCPMetaWindowScale = &s.L3L4.WindowScale
rec.TCPMetaMSS = &s.L3L4.MSS
rec.TCPMetaMSS = &s.L3L4.MSS
}
// Champs TLS
if s.TLS != nil {
rec.JA4Hash = s.TLS.JA4Hash
rec.TLSSNI = s.TLS.SNI
rec.TLSALPN = strings.Join(s.TLS.ALPN, ",")
rec.JA4Hash = s.TLS.JA4Hash
rec.TLSSNI = s.TLS.SNI
rec.TLSALPN = strings.Join(s.TLS.ALPN, ",")
rec.TLSVersion = formatTLSVersion(s.TLS.TLSVersion)
}
// Champs HTTP (dernière requête)
if len(s.Requests) > 0 {
last := &s.Requests[len(s.Requests)-1]
rec.Method = last.Method
rec.Path = last.Path
rec.QueryString = last.QueryString
rec.StatusCode = &last.StatusCode
rec.ResponseSize = &last.ResponseSize
rec.DurationMS = &last.DurationMS
rec.HeaderOrderSig = last.HeaderOrderSig
rec.Method = last.Method
rec.Path = last.Path
rec.QueryString = last.QueryString
rec.StatusCode = &last.StatusCode
rec.ResponseSize = &last.ResponseSize
rec.DurationMS = &last.DurationMS
rec.HeaderOrderSig = last.HeaderOrderSig
// Champs HTTP/2 passifs
if last.HTTP2Settings != nil {
h2 := last.HTTP2Settings
rec.H2WindowUpdate = h2.WindowUpdateIncrement
// Ordre des pseudo-headers → notation abrégée "m,a,s,p"
if len(h2.PseudoHeaderOrder) > 0 {
rec.H2PseudoOrder = pseudoOrderToShort(h2.PseudoHeaderOrder)
}
// Paramètres SETTINGS individuels (-1 = absent)
rec.H2HeaderTableSize = h2.HeaderTableSize
rec.H2EnablePush = h2.EnablePush
rec.H2MaxConcurrentStreams = h2.MaxConcurrentStreams
rec.H2InitialWindowSize = int64(h2.InitialWindowSize)
rec.H2MaxFrameSize = h2.MaxFrameSize
rec.H2MaxHeaderListSize = h2.MaxHeaderListSize
// Fingerprints composites Akamai
rec.H2Fingerprint = buildH2Fingerprint(h2)
rec.H2SettingsFP = buildH2SettingsFP(h2)
}
}
return rec
}
// pseudoOrderToShort convertit la liste de pseudo-headers en notation abrégée.
// Ex: [":method", ":authority", ":scheme", ":path"] → "m,a,s,p"
func pseudoOrderToShort(headers []string) string {
short := make([]byte, 0, len(headers)*2-1)
for i, h := range headers {
if i > 0 {
short = append(short, ',')
}
switch {
case h == ":method":
short = append(short, 'm')
case h == ":authority":
short = append(short, 'a')
case h == ":scheme":
short = append(short, 's')
case h == ":path":
short = append(short, 'p')
default:
short = append(short, '?')
}
}
return string(short)
}
// buildH2Fingerprint construit le fingerprint composite au format Akamai.
// Format : SETTINGS[pairs]|WINDOW_UPDATE[value]|PRIORITY[0/1]|PSEUDO_ORDER[order]
func buildH2Fingerprint(h2 *correlation.HTTP2Settings) string {
var b strings.Builder
// SETTINGS
b.WriteString("1:")
b.WriteString(fmt.Sprintf("%d", h2.HeaderTableSize))
b.WriteString(",2:")
b.WriteString(fmt.Sprintf("%d", h2.EnablePush))
if h2.MaxConcurrentStreams >= 0 {
b.WriteString(",3:")
b.WriteString(fmt.Sprintf("%d", h2.MaxConcurrentStreams))
}
b.WriteString(",4:")
b.WriteString(fmt.Sprintf("%d", h2.InitialWindowSize))
if h2.MaxFrameSize >= 0 {
b.WriteString(",5:")
b.WriteString(fmt.Sprintf("%d", h2.MaxFrameSize))
}
if h2.MaxHeaderListSize >= 0 {
b.WriteString(",6:")
b.WriteString(fmt.Sprintf("%d", h2.MaxHeaderListSize))
}
// WINDOW_UPDATE
b.WriteByte('|')
if h2.WindowUpdateIncrement > 0 {
b.WriteString(fmt.Sprintf("%d", h2.WindowUpdateIncrement))
}
// PRIORITY (non capturé actuellement)
b.WriteString("|0")
// PSEUDO_ORDER
b.WriteByte('|')
if len(h2.PseudoHeaderOrder) > 0 {
b.WriteString(pseudoOrderToShort(h2.PseudoHeaderOrder))
}
return b.String()
}
// buildH2SettingsFP construit la chaîne brute des entrées SETTINGS.
func buildH2SettingsFP(h2 *correlation.HTTP2Settings) string {
var parts []string
if h2.MaxConcurrentStreams >= 0 {
parts = append(parts, fmt.Sprintf("3:%d", h2.MaxConcurrentStreams))
}
if h2.InitialWindowSize >= 0 {
parts = append(parts, fmt.Sprintf("4:%d", h2.InitialWindowSize))
}
if h2.EnablePush >= 0 {
parts = append(parts, fmt.Sprintf("2:%d", h2.EnablePush))
}
return strings.Join(parts, ",")
}
// formatTLSVersion convertit la valeur numérique TLS en chaîne lisible.
func formatTLSVersion(v uint16) string {
switch v {

View File

@ -82,5 +82,23 @@ chown -R ja4ebpf:ja4ebpf \
%dir %attr(0750, ja4ebpf, ja4ebpf) %{_localstatedir}/log/ja4ebpf
%changelog
* %(date "+%a %b %d %Y") Build System <build@antitbone.local> - %{build_version}-1
- Build automatique via Dockerfile.package
* Sat Apr 12 2025 Antoine Jacquin <antoine@antitbone.dev> - 0.2.0-1
- feat(writer): sérialisation complète des 12 champs HTTP/2 passifs vers ClickHouse
(SETTINGS individuels, WINDOW_UPDATE, pseudo-headers, fingerprints composites Akamai)
- fix(writer): le parser H2 fonctionnait mais le writer ignorait HTTP2Settings
- fix(sql): TTL http_logs corrigé de 30 jours à 2 heures (conforme thèse §3.7)
- feat(browser_matcher): redistribution des poids CDN (0.35 HTTP + 0.35 TLS)
- feat(browser_matcher): exposition des 5 features browser_match_* dans le vecteur ML
- feat(shap): TreeExplainer XGBoost en priorité, ExIFFI + SHAP coexistants
- feat(pipeline): root_to_first_asset_delay et asset_load_stddev intégrés au vecteur ML
- feat(signatures): table browser_h2_signatures + rechargement 24h depuis ClickHouse
- feat(cycle): queue unknown_h2_fingerprints pour signatures H2 inconnues
* Thu Mar 27 2025 Antoine Jacquin <antoine@antitbone.dev> - 0.1.0-1
- Initial RPM package
- eBPF CO-RE agent: TC ingress + uprobe SSL_read
- JA4/JA4T TLS/TCP fingerprinting
- HTTP/2 passive fingerprinting (SETTINGS, WINDOW_UPDATE, pseudo-headers)
- Go Magic Bytes dispatcher with circular reassembly buffer
- 256-shard correlation engine, 500ms orphan timeout
- Multi-distro support: RHEL/CentOS/Rocky/AlmaLinux 8, 9, 10

View File

@ -23,7 +23,8 @@ Type=simple
User=ja4ebpf
Group=ja4ebpf
ExecStart=/usr/sbin/ja4ebpf -config /etc/ja4ebpf/config.yml
ExecStart=/usr/sbin/ja4ebpf
Environment=JA4EBPF_CONFIG=/etc/ja4ebpf/config.yml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s