feat: add ja4ebpf service — eBPF-based TLS/TCP fingerprinting daemon

- TC ingress hook captures TCP SYN (L3/L4) and TLS ClientHello
- Uprobes on SSL_read/SSL_set_fd capture decrypted TLS data
- Kprobes on accept4 correlate socket FDs to client IP:port
- JA4 fingerprint computed from parsed TLS ClientHello
- HTTP/2 SETTINGS and WINDOW_UPDATE extracted from decrypted streams
- Session manager with sharded map (256 shards) and GC goroutine
- Slowloris detection: sessions with no requests after 10s threshold
- ClickHouse batch writer to ja4_logs.http_logs_raw (raw_json)
- All tests pass: 17 parser + 10 correlation tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-11 22:43:26 +02:00
parent 7eb3ad21fd
commit a1e4c1dad5
24 changed files with 3984 additions and 0 deletions

View File

@ -0,0 +1,418 @@
// Package main est le point d'entrée du démon ja4ebpf.
// Il initialise la configuration, charge les programmes eBPF, démarre
// les goroutines de traitement et gère les signaux système.
package main
import (
"context"
"encoding/binary"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
"github.com/antitbone/ja4/ja4ebpf/internal/loader"
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
"github.com/antitbone/ja4/ja4ebpf/internal/writer"
"github.com/cilium/ebpf/ringbuf"
"gopkg.in/yaml.v3"
)
// Config décrit la configuration complète du démon ja4ebpf.
// Chargée depuis un fichier YAML et enrichie par les variables d'environnement
// avec le préfixe JA4EBPF_.
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")
ClickHouse struct {
DSN string `yaml:"dsn"` // DSN ClickHouse natif
BatchSize int `yaml:"batch_size"` // nombre de sessions par batch
FlushSecs int `yaml:"flush_secs"` // intervalle de flush en secondes
} `yaml:"clickhouse"`
Correlation struct {
TimeoutMS int `yaml:"timeout_ms"` // délai d'expiration session (ms)
SlowlorisMS int `yaml:"slowloris_ms"` // seuil Slowloris (ms)
} `yaml:"correlation"`
Log struct {
Level string `yaml:"level"` // niveau de log (debug, info, warn, error)
Format string `yaml:"format"` // format de log ("json" ou "text")
} `yaml:"log"`
}
// loadConfig charge la configuration depuis le fichier YAML spécifié,
// puis applique les surcharges depuis les variables d'environnement.
func loadConfig(path string) (*Config, error) {
cfg := &Config{}
// Valeurs par défaut
cfg.Interface = "eth0"
cfg.SSLLibPath = "/usr/lib64/libssl.so.3"
cfg.ClickHouse.DSN = "clickhouse://default:@localhost:9000/ja4_logs"
cfg.ClickHouse.BatchSize = 500
cfg.ClickHouse.FlushSecs = 1
cfg.Correlation.TimeoutMS = 500
cfg.Correlation.SlowlorisMS = 10000
cfg.Log.Level = "info"
cfg.Log.Format = "json"
// Charger depuis le fichier YAML si spécifié
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("lecture fichier config %q: %w", path, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing YAML config: %w", err)
}
}
// Surcharges via variables d'environnement
if v := os.Getenv("JA4EBPF_INTERFACE"); v != "" {
cfg.Interface = v
}
if v := os.Getenv("JA4EBPF_SSL_LIB_PATH"); v != "" {
cfg.SSLLibPath = v
}
if v := os.Getenv("JA4EBPF_CLICKHOUSE_DSN"); v != "" {
cfg.ClickHouse.DSN = v
}
return cfg, nil
}
// main est le point d'entrée du programme.
func main() {
// Déterminer le chemin du fichier de configuration
configPath := os.Getenv("JA4EBPF_CONFIG")
if configPath == "" {
configPath = "/etc/ja4ebpf/config.yml"
}
cfg, err := loadConfig(configPath)
if err != nil {
log.Fatalf("erreur chargement configuration: %v", err)
}
log.Printf("[ja4ebpf] démarrage — interface=%s ssl=%s", cfg.Interface, cfg.SSLLibPath)
// Contexte principal avec annulation sur signal système
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Intercepter SIGTERM et SIGINT pour l'arrêt gracieux
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
// --- 1. Chargement des programmes eBPF ---
ldr, err := loader.New()
if err != nil {
log.Fatalf("erreur chargement eBPF: %v", err)
}
defer ldr.Close()
// --- 2. Attachement TC ingress ---
if err := ldr.AttachTC(cfg.Interface); err != nil {
log.Fatalf("erreur attachement TC sur %s: %v", cfg.Interface, err)
}
// --- 3. Attachement uprobes SSL ---
if err := ldr.AttachUprobes(cfg.SSLLibPath); err != nil {
log.Printf("[ja4ebpf] avertissement uprobes SSL: %v (désactivation uprobes)", err)
// Continuer sans uprobes SSL (capture L3/L4 toujours active)
}
// --- 4. Attachement kprobes accept4 ---
if err := ldr.AttachAcceptProbe(); err != nil {
log.Printf("[ja4ebpf] avertissement kprobe accept4: %v", err)
}
// --- 5. Gestionnaire de sessions ---
sessionTimeout := time.Duration(cfg.Correlation.TimeoutMS) * time.Millisecond
mgr := correlation.NewManager(sessionTimeout)
mgr.StartGC(ctx)
defer mgr.Close()
// --- 6. Writer ClickHouse ---
flushInterval := time.Duration(cfg.ClickHouse.FlushSecs) * time.Second
w, err := writer.NewClickHouseWriter(cfg.ClickHouse.DSN, cfg.ClickHouse.BatchSize, flushInterval)
if err != nil {
log.Fatalf("erreur initialisation writer ClickHouse: %v", err)
}
w.Start(ctx)
// --- 7. Goroutine : écriture des sessions prêtes ---
go func() {
for s := range mgr.ReadyCh {
w.Write(s)
}
}()
// --- 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)
log.Printf("[ja4ebpf] démon actif — en attente des événements")
// Attendre un signal d'arrêt
select {
case sig := <-sigCh:
log.Printf("[ja4ebpf] signal reçu: %v — arrêt gracieux", sig)
case <-ctx.Done():
}
cancel()
log.Printf("[ja4ebpf] arrêt terminé")
}
// 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) {
for {
select {
case <-ctx.Done():
return
default:
}
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
return
}
continue
}
// Taille minimale attendue (voir struct tcp_syn_event)
if len(record.RawSample) < 20 {
continue
}
data := record.RawSample
// Décoder les champs de tcp_syn_event
srcIPRaw := binary.BigEndian.Uint32(data[0:4])
srcPort := binary.LittleEndian.Uint16(data[8:10])
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
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])
optLen := int(data[55])
if optLen > 40 {
optLen = 40
}
tcpOpts := make([]byte, optLen)
copy(tcpOpts, data[15:15+optLen])
mgr.Update(key, func(s *correlation.SessionState) {
s.L3L4 = &correlation.L3L4{
TTL: ttl,
DFBit: dfBit,
IPID: ipID,
WindowSize: windowSize,
WindowScale: windowScale,
MSS: mss,
TCPOptionsRaw: tcpOpts,
SYNTimestamp: time.Now(),
}
})
}
}
// 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) {
for {
select {
case <-ctx.Done():
return
default:
}
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
return
}
continue
}
// Taille minimale : src_ip(4) + src_port(2) + payload[512] + payload_len(2)
if len(record.RawSample) < 8 {
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])
if int(payloadLen) > 512 {
payloadLen = 512
}
payload := make([]byte, payloadLen)
copy(payload, data[6:6+payloadLen])
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
// Parser le ClientHello et calculer JA4
ch, err := parser.ParseClientHello(payload)
if err != nil {
continue
}
ja4 := parser.ComputeJA4(ch)
var alpn []string
var ciphers, extensions []uint16
for _, e := range ch.Extensions {
extensions = append(extensions, e.Type)
}
ciphers = ch.CipherSuites
alpn = ch.ALPN
mgr.Update(key, func(s *correlation.SessionState) {
s.TLS = &correlation.TLSInfo{
ClientHelloRaw: payload,
JA4Hash: ja4,
SNI: ch.SNI,
ALPN: alpn,
CipherSuites: ciphers,
Extensions: extensions,
Timestamp: time.Now(),
}
// Corréler si L3/L4 est déjà présent
if s.L3L4 != nil {
s.Correlated = true
}
})
}
}
// consumeSSLEvents lit les données SSL déchiffrées depuis le ring buffer.
// Détecte le préambule HTTP/2 et extrait les paramètres SETTINGS.
func consumeSSLEvents(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
// Taille minimale : pid_tgid(8) + fd(4) + src_ip(4) + src_port(2) = 18
if len(data) < 18 {
continue
}
srcIPRaw := binary.LittleEndian.Uint32(data[12:16])
srcPort := binary.LittleEndian.Uint16(data[16:18])
// data_len à l'offset 4112 (8+4+4+2 + data[4096] = offset 18, data_len à 18+4096)
if len(data) < 4118 {
continue
}
dataLen := binary.LittleEndian.Uint32(data[4114:4118])
if dataLen > 4096 {
dataLen = 4096
}
sslData := data[18 : 18+dataLen]
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
// Détecter le préambule HTTP/2
if parser.DetectH2Preface(sslData) {
afterPreface := sslData
if len(afterPreface) > parser.H2MagicPrefaceLen() {
afterPreface = sslData[parser.H2MagicPrefaceLen():]
}
_, err := parser.ParseH2ClientPreface(afterPreface)
if err == nil {
mgr.Update(key, func(s *correlation.SessionState) {
if len(s.Requests) == 0 {
s.Requests = append(s.Requests, correlation.HTTPRequest{
Timestamp: time.Now(),
})
}
if s.TLS != nil {
s.Correlated = true
}
})
}
}
}
}
// 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) {
for {
select {
case <-ctx.Done():
return
default:
}
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
return
}
continue
}
data := record.RawSample
// Taille attendue : pid_tgid(8) + fd(4) + src_ip(4) + src_port(2) + timestamp(8) = 26
if len(data) < 22 {
continue
}
srcIPRaw := binary.LittleEndian.Uint32(data[12:16])
srcPort := binary.LittleEndian.Uint16(data[16:18])
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
// S'assurer que la session existe
mgr.GetOrCreate(key)
}
}