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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user