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:
418
services/ja4ebpf/cmd/ja4ebpf/main.go
Normal file
418
services/ja4ebpf/cmd/ja4ebpf/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user