feat(ja4ebpf): add multi-interface TC, LPM_TRIE ignore_src, unit tests, and fix bugs

- Add multi-interface TC attachment (default "any" = all UP interfaces)
- Add BPF LPM_TRIE map ignored_src for kernel-side CIDR filtering
- Add userspace ignore_src filtering for SSL/accept4 path via net.IPNet.Contains()
- Add AcceptCache for fd→SessionKey correlation with TTL and Close()
- Add 5 test files covering writer, procutil, dispatcher, accept_cache, and cmd
- Fix formatTCPOptions infinite loop on EOL (case 0 break→return)
- Fix pseudoOrderToShort panic on empty slice (negative cap)
- Fix AcceptCache goroutine leak (add done channel + Close())
- Update config.yml.example with interfaces, listen_ports, ignore_src
- Rewrite docs/services/ja4ebpf.md (was massively stale: XDP, RingBuffer, etc.)
- Fix stale XDP/RingBuffer references in docs/architecture.md, thesis, tls.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-16 01:49:26 +02:00
parent fd84aebc44
commit f0c8fe81c6
20 changed files with 3053 additions and 1261 deletions

View File

@ -116,19 +116,21 @@ type Ja4TcProgramSpecs struct {
//
// It can be passed ebpf.CollectionSpec.Assign.
type Ja4TcMapSpecs struct {
HttpBuf *ebpf.MapSpec `ebpf:"__http_buf"`
SslBuf *ebpf.MapSpec `ebpf:"__ssl_buf"`
TlsBuf *ebpf.MapSpec `ebpf:"__tls_buf"`
AcceptMap *ebpf.MapSpec `ebpf:"accept_map"`
FdConnMap *ebpf.MapSpec `ebpf:"fd_conn_map"`
PbAccept *ebpf.MapSpec `ebpf:"pb_accept"`
PbHttpPlain *ebpf.MapSpec `ebpf:"pb_http_plain"`
PbSslData *ebpf.MapSpec `ebpf:"pb_ssl_data"`
PbTcpSyn *ebpf.MapSpec `ebpf:"pb_tcp_syn"`
PbTlsHello *ebpf.MapSpec `ebpf:"pb_tls_hello"`
SslArgsMap *ebpf.MapSpec `ebpf:"ssl_args_map"`
SslConnMap *ebpf.MapSpec `ebpf:"ssl_conn_map"`
TcStats *ebpf.MapSpec `ebpf:"tc_stats"`
HttpBuf *ebpf.MapSpec `ebpf:"__http_buf"`
SslBuf *ebpf.MapSpec `ebpf:"__ssl_buf"`
TlsBuf *ebpf.MapSpec `ebpf:"__tls_buf"`
AcceptMap *ebpf.MapSpec `ebpf:"accept_map"`
AllowedPorts *ebpf.MapSpec `ebpf:"allowed_ports"`
FdConnMap *ebpf.MapSpec `ebpf:"fd_conn_map"`
IgnoredSrc *ebpf.MapSpec `ebpf:"ignored_src"`
PbAccept *ebpf.MapSpec `ebpf:"pb_accept"`
PbHttpPlain *ebpf.MapSpec `ebpf:"pb_http_plain"`
PbSslData *ebpf.MapSpec `ebpf:"pb_ssl_data"`
PbTcpSyn *ebpf.MapSpec `ebpf:"pb_tcp_syn"`
PbTlsHello *ebpf.MapSpec `ebpf:"pb_tls_hello"`
SslArgsMap *ebpf.MapSpec `ebpf:"ssl_args_map"`
SslConnMap *ebpf.MapSpec `ebpf:"ssl_conn_map"`
TcStats *ebpf.MapSpec `ebpf:"tc_stats"`
}
// Ja4TcObjects contains all objects after they have been loaded into the kernel.
@ -150,19 +152,21 @@ func (o *Ja4TcObjects) Close() error {
//
// It can be passed to LoadJa4TcObjects or ebpf.CollectionSpec.LoadAndAssign.
type Ja4TcMaps struct {
HttpBuf *ebpf.Map `ebpf:"__http_buf"`
SslBuf *ebpf.Map `ebpf:"__ssl_buf"`
TlsBuf *ebpf.Map `ebpf:"__tls_buf"`
AcceptMap *ebpf.Map `ebpf:"accept_map"`
FdConnMap *ebpf.Map `ebpf:"fd_conn_map"`
PbAccept *ebpf.Map `ebpf:"pb_accept"`
PbHttpPlain *ebpf.Map `ebpf:"pb_http_plain"`
PbSslData *ebpf.Map `ebpf:"pb_ssl_data"`
PbTcpSyn *ebpf.Map `ebpf:"pb_tcp_syn"`
PbTlsHello *ebpf.Map `ebpf:"pb_tls_hello"`
SslArgsMap *ebpf.Map `ebpf:"ssl_args_map"`
SslConnMap *ebpf.Map `ebpf:"ssl_conn_map"`
TcStats *ebpf.Map `ebpf:"tc_stats"`
HttpBuf *ebpf.Map `ebpf:"__http_buf"`
SslBuf *ebpf.Map `ebpf:"__ssl_buf"`
TlsBuf *ebpf.Map `ebpf:"__tls_buf"`
AcceptMap *ebpf.Map `ebpf:"accept_map"`
AllowedPorts *ebpf.Map `ebpf:"allowed_ports"`
FdConnMap *ebpf.Map `ebpf:"fd_conn_map"`
IgnoredSrc *ebpf.Map `ebpf:"ignored_src"`
PbAccept *ebpf.Map `ebpf:"pb_accept"`
PbHttpPlain *ebpf.Map `ebpf:"pb_http_plain"`
PbSslData *ebpf.Map `ebpf:"pb_ssl_data"`
PbTcpSyn *ebpf.Map `ebpf:"pb_tcp_syn"`
PbTlsHello *ebpf.Map `ebpf:"pb_tls_hello"`
SslArgsMap *ebpf.Map `ebpf:"ssl_args_map"`
SslConnMap *ebpf.Map `ebpf:"ssl_conn_map"`
TcStats *ebpf.Map `ebpf:"tc_stats"`
}
func (m *Ja4TcMaps) Close() error {
@ -171,7 +175,9 @@ func (m *Ja4TcMaps) Close() error {
m.SslBuf,
m.TlsBuf,
m.AcceptMap,
m.AllowedPorts,
m.FdConnMap,
m.IgnoredSrc,
m.PbAccept,
m.PbHttpPlain,
m.PbSslData,

View File

@ -6,7 +6,7 @@ package loader
import (
"context"
"fmt"
"net"
"log"
"os"
"github.com/cilium/ebpf"
@ -28,9 +28,11 @@ const perCPUBufferSize = 256 * 1024
type Loader struct {
tcObjs *Ja4TcObjects // généré par bpf2go (tc_capture.c)
sslObjs *Ja4SslObjects // généré par bpf2go (uprobe_ssl.c)
tcNlLink netlink.Link // interface netlink pour cleanup TC
tcLinks []netlink.Link // interfaces netlink pour cleanup TC
uprobeLinks []link.Link
statsMap *ebpf.Map // map tc_stats pour lecture des compteurs BPF (mode debug)
statsMap *ebpf.Map // map tc_stats pour lecture des compteurs BPF (mode debug)
allowedPorts *ebpf.Map // map allowed_ports pour filtrage par port
ignoredSrc *ebpf.Map // map ignored_src (LPM_TRIE) pour filtrage IP/CIDR
// SynReader lit les événements TCP SYN depuis pb_tcp_syn.
SynReader *perf.Reader
@ -78,6 +80,45 @@ func (l *Loader) ReadStats() (map[uint32]uint64, error) {
return result, nil
}
// PopulatePorts remplit la map BPF allowed_ports avec les ports spécifiés.
// Doit être appelé avant AttachTC. Chaque port autorisé reçoit la valeur 1.
func (l *Loader) PopulatePorts(ports []uint16) error {
if l.allowedPorts == nil {
return fmt.Errorf("map allowed_ports non disponible")
}
for _, port := range ports {
var key uint16 = port
var val uint8 = 1
if err := l.allowedPorts.Put(key, val); err != nil {
return fmt.Errorf("ajout port %d dans allowed_ports: %w", port, err)
}
}
return nil
}
// LPMKey est la clé pour BPF_MAP_TYPE_LPM_TRIE (IPv4).
// Data est stocké en network byte order (big-endian) en mémoire
// pour correspondre à iph.saddr dans le programme BPF.
type LPMKey struct {
Prefixlen uint32
Data [4]byte // IP en network byte order
}
// PopulateIgnoredSrc remplit la map BPF ignored_src (LPM_TRIE) avec les CIDR/IP à ignorer.
// Les IPs doivent être en network byte order (big-endian) pour le LPM_TRIE.
func (l *Loader) PopulateIgnoredSrc(cidrs []LPMKey) error {
if l.ignoredSrc == nil {
return fmt.Errorf("map ignored_src non disponible")
}
for _, key := range cidrs {
var val uint8 = 1
if err := l.ignoredSrc.Put(key, val); err != nil {
return fmt.Errorf("ajout CIDR dans ignored_src: %w", err)
}
}
return nil
}
// New charge le bytecode eBPF embarqué, supprime la limite mémoire
// RLIMIT_MEMLOCK (requise pour les maps eBPF),
// et retourne un Loader prêt à être attaché aux hooks.
@ -99,28 +140,6 @@ 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 {
@ -175,9 +194,11 @@ func New() (*Loader, error) {
}
return &Loader{
tcObjs: tcObjs,
sslObjs: sslObjs,
statsMap: statsMap,
tcObjs: tcObjs,
sslObjs: sslObjs,
statsMap: tcObjs.TcStats,
allowedPorts: tcObjs.AllowedPorts,
ignoredSrc: tcObjs.IgnoredSrc,
SynReader: synReader,
TLSReader: tlsReader,
SSLReader: sslReader,
@ -190,17 +211,49 @@ func New() (*Loader, error) {
// 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)
nlLink, err := netlink.LinkByName(iface)
if err != nil {
return fmt.Errorf("interface réseau %q introuvable: %w", iface, err)
}
// Obtenir le link netlink par index (plus fiable que par nom)
nlLink, err := netlink.LinkByIndex(netIface.Index)
if err != nil {
return fmt.Errorf("netlink link index %d introuvable: %w", netIface.Index, err)
if err := l.attachTCOnLink(nlLink); err != nil {
return err
}
l.tcLinks = append(l.tcLinks, nlLink)
return nil
}
// AttachTCAll attache le programme TC ingress sur toutes les interfaces
// réseau non-loopback et opérationnelles (OperUp).
// Retourne la liste des noms d'interfaces attachées.
func (l *Loader) AttachTCAll() ([]string, error) {
links, err := netlink.LinkList()
if err != nil {
return nil, fmt.Errorf("énumération interfaces: %w", err)
}
var attached []string
for _, nlLink := range links {
if nlLink.Type() == "loopback" {
continue
}
if nlLink.Attrs().OperState != netlink.OperUp {
continue
}
if err := l.attachTCOnLink(nlLink); err != nil {
log.Printf("[loader] TC %s: %v (ignoré)", nlLink.Attrs().Name, err)
continue
}
attached = append(attached, nlLink.Attrs().Name)
l.tcLinks = append(l.tcLinks, nlLink)
}
if len(attached) == 0 {
return nil, fmt.Errorf("aucune interface TC attachée")
}
return attached, nil
}
// attachTCOnLink attache le programme TC ingress sur un link netlink donné.
func (l *Loader) attachTCOnLink(nlLink netlink.Link) error {
iface := nlLink.Attrs().Name
// Créer le qdisc clsact (idempotent via QdiscReplace)
qdisc := &netlink.Clsact{
@ -230,8 +283,6 @@ func (l *Loader) AttachTC(iface string) error {
if err := netlink.FilterReplace(filter); err != nil {
return fmt.Errorf("TC filter ingress sur %q: %w", iface, err)
}
l.tcNlLink = nlLink
return nil
}
@ -328,11 +379,11 @@ func (l *Loader) Close() error {
l.SynReader.Close()
}
// Détacher le filtre TC ingress
if l.tcNlLink != nil {
// Détacher les filtres TC ingress sur toutes les interfaces
for _, nlLink := range l.tcLinks {
filter := &netlink.BpfFilter{
FilterAttrs: netlink.FilterAttrs{
LinkIndex: l.tcNlLink.Attrs().Index,
LinkIndex: nlLink.Attrs().Index,
Parent: netlink.HANDLE_MIN_INGRESS,
Handle: 1,
Priority: 1,