diff --git a/services/ja4ebpf/bpf/tc_capture.c b/services/ja4ebpf/bpf/tc_capture.c index 8391b43..851a668 100644 --- a/services/ja4ebpf/bpf/tc_capture.c +++ b/services/ja4ebpf/bpf/tc_capture.c @@ -153,6 +153,7 @@ int capture_tc(struct __sk_buff *ctx) evt.ttl = ttl; evt.df_bit = df_bit; evt.ip_id = ip_id; + evt.ip_total_length = bpf_ntohs(iph.tot_len); evt.window_size = window; evt.window_scale = 0xFF; evt.mss = 0; @@ -218,17 +219,17 @@ int capture_tc(struct __sk_buff *ctx) /* Copie via bpf_skb_load_bytes avec tailles constantes en cascade. * Kernel 4.18 ne supporte pas les tailles variables vers map values. - * On essaie 512 puis 256 puis 128 pour capturer SNI et extensions. + * On essaie 1024 puis 512 puis 256 pour capturer SNI et extensions. * La taille réellement copiée est stockée dans payload_len. */ - if (payload_off + 512 <= pkt_len) { + if (payload_off + 1024 <= pkt_len) { + bpf_skb_load_bytes(ctx, payload_off, tls_evt, 1024); + tls_evt->payload_len = 1024; + } else if (payload_off + 512 <= pkt_len) { bpf_skb_load_bytes(ctx, payload_off, tls_evt, 512); tls_evt->payload_len = 512; } else if (payload_off + 256 <= pkt_len) { bpf_skb_load_bytes(ctx, payload_off, tls_evt, 256); tls_evt->payload_len = 256; - } else if (payload_off + 128 <= pkt_len) { - bpf_skb_load_bytes(ctx, payload_off, tls_evt, 128); - tls_evt->payload_len = 128; } else { return TC_ACT_OK; } @@ -281,16 +282,16 @@ int capture_tc(struct __sk_buff *ctx) h_evt->timestamp_ns = bpf_ktime_get_ns(); /* Copie via bpf_skb_load_bytes avec tailles constantes en cascade. - * Les requêtes HTTP sont souvent < 256 octets, on descend à 128 puis 64. */ - if (payload_off + 256 <= pkt_len) { + * Les requêtes HTTP sont souvent < 512 octets, on descend à 256 puis 128. */ + if (payload_off + 512 <= pkt_len) { + bpf_skb_load_bytes(ctx, payload_off, h_evt, 512); + h_evt->payload_len = 512; + } else if (payload_off + 256 <= pkt_len) { bpf_skb_load_bytes(ctx, payload_off, h_evt, 256); h_evt->payload_len = 256; } else if (payload_off + 128 <= pkt_len) { bpf_skb_load_bytes(ctx, payload_off, h_evt, 128); h_evt->payload_len = 128; - } else if (payload_off + 64 <= pkt_len) { - bpf_skb_load_bytes(ctx, payload_off, h_evt, 64); - h_evt->payload_len = 64; } else { return TC_ACT_OK; } diff --git a/services/ja4ebpf/cmd/ja4ebpf/main.go b/services/ja4ebpf/cmd/ja4ebpf/main.go index d727efc..5d54571 100644 --- a/services/ja4ebpf/cmd/ja4ebpf/main.go +++ b/services/ja4ebpf/cmd/ja4ebpf/main.go @@ -28,6 +28,10 @@ import ( // Durée de vie d'une entrée : 5 secondes (suffisant pour une requête HTTP). var fdCache = procutil.NewFDCache(5 * time.Second) +// acceptCache maps {tgid, fd} → SessionKey depuis les événements accept4. +// Prioritaire sur fdCache car source de vérité (tracepoint kernel). +var acceptCache = correlation.NewAcceptCache(10 * time.Second) + // 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_. @@ -64,7 +68,7 @@ func loadConfig(path string) (*Config, error) { cfg.ClickHouse.DSN = "clickhouse://default:@localhost:9000/ja4_logs" cfg.ClickHouse.BatchSize = 500 cfg.ClickHouse.FlushSecs = 1 - cfg.Correlation.TimeoutMS = 500 + cfg.Correlation.TimeoutMS = 5000 cfg.Correlation.SlowlorisMS = 10000 cfg.Log.Level = "info" cfg.Log.Format = "json" @@ -303,8 +307,10 @@ func consumeSynEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man // struct tcp_syn_event (packed): // 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 + // ip_total_length(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 + // total = 62 + 8 = 70 if len(record.RawSample) < 70 { continue } @@ -329,11 +335,12 @@ func consumeSynEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man dstIP[2] = byte(dstIPRaw >> 8) dstIP[3] = byte(dstIPRaw) - // Champs IP/TCP aux offsets corrects (dst_ip occupe les octets 4-7) - ttl := data[12] - dfBit := data[13] != 0 - ipID := binary.LittleEndian.Uint16(data[14:16]) - windowSize := binary.LittleEndian.Uint16(data[16:18]) + // Champs IP/TCP aux offsets corrects (ip_total_length inséré entre ip_id et window_size) + ttl := data[12] + dfBit := data[13] != 0 + ipID := binary.LittleEndian.Uint16(data[14:16]) + ipTotalLength := binary.LittleEndian.Uint16(data[16:18]) + windowSize := binary.LittleEndian.Uint16(data[18:20]) optLen := int(data[61]) if optLen > 40 { @@ -347,16 +354,17 @@ func consumeSynEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man mgr.Update(key, func(s *correlation.SessionState) { s.L3L4 = &correlation.L3L4{ - DstIP: dstIP, - DstPort: dstPort, - TTL: ttl, - DFBit: dfBit, - IPID: ipID, - WindowSize: windowSize, - WindowScale: windowScale, - MSS: mss, - TCPOptionsRaw: tcpOpts, - SYNTimestamp: time.Now(), + DstIP: dstIP, + DstPort: dstPort, + TTL: ttl, + DFBit: dfBit, + IPID: ipID, + IPTotalLength: ipTotalLength, + WindowSize: windowSize, + WindowScale: windowScale, + MSS: mss, + TCPOptionsRaw: tcpOpts, + SYNTimestamp: time.Now(), } // Si TLS est déjà présent (arrivé avant SYN), les deux couches sont disponibles. if s.TLS != nil { @@ -491,11 +499,12 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man srcIPRaw := binary.LittleEndian.Uint32(data[12:16]) srcPort := binary.LittleEndian.Uint16(data[16:18]) - // data[4096] commence à offset 18, data_len à offset 4114 - if len(data) < 4118 { + // data[4096] commence à offset 18, data_len à offset 4114, direction à offset 4118 + if len(data) < 4119 { continue } dataLen := binary.LittleEndian.Uint32(data[4114:4118]) + direction := data[4118] // 0 = SSL_read (client→serveur), 1 = SSL_write (serveur→client) if dataLen > 4096 { dataLen = 4096 } @@ -504,38 +513,103 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man } sslData := data[18 : 18+dataLen] - // --- Fallback /proc quand accept4 n'a pas fourni l'IP --- + var key correlation.SessionKey + var dstIPFromAccept [4]byte + var dstPortFromAccept uint16 + if srcIPRaw == 0 && fd != 0 { + // ssl_conn_map non peuplé : chercher la clé de session via le cache accept4 tgid := uint32(pidTgid >> 32) if tgid == 0 { - tgid = uint32(pidTgid) // fallback: utiliser le TID si TGID=0 + tgid = uint32(pidTgid) } - if ip, port, lookupErr := fdCache.Lookup(tgid, fd); lookupErr == nil { - ipv4 := ip.To4() - if ipv4 != nil { - srcIPRaw = uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3]) - srcPort = port + + // Priorité 1 : cache accept4 (source de vérité — tracepoint kernel) + if skey, dstIP, dstPort, ok := acceptCache.Lookup(tgid, fd); ok { + key = skey + dstIPFromAccept = dstIP + dstPortFromAccept = dstPort + } else { + // Priorité 2 : fallback /proc (moins fiable — port parfois erroné) + if ip, port, lookupErr := fdCache.Lookup(tgid, fd); lookupErr == nil { + ipv4 := ip.To4() + if ipv4 != nil { + key.SrcIP[0] = ipv4[0] + key.SrcIP[1] = ipv4[1] + key.SrcIP[2] = ipv4[2] + key.SrcIP[3] = ipv4[3] + key.SrcPort = port + } } } + } else { + 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 } // Ignorer les événements sans IP identifiable (ex: connexions locales non HTTP) - if srcIPRaw == 0 && srcPort == 0 { + if key.SrcIP == [4]byte{} && key.SrcPort == 0 { continue } - 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 - counter.Add(1) - // === Routeur Magic Bytes === + + // === Routeur par direction === + // direction=0 (SSL_read) : requêtes du client + // direction=1 (SSL_write) : réponses du serveur + + if direction == 1 { + // === Serveur → Client : réponses HTTP === + + // HTTP/1.x response + if parser.IsHTTP1Response(sslData) { + resp := parser.ParseHTTP1Response(sslData) + if resp == nil { + continue + } + mgr.Update(key, func(s *correlation.SessionState) { + if len(s.Requests) > 0 { + last := &s.Requests[len(s.Requests)-1] + if last.StatusCode == 0 { + last.StatusCode = resp.StatusCode + } + } + }) + } + + // HTTP/2 server HEADERS frame (contient :status) + if parser.IsH2FrameHeader(sslData) { + h2kv := parser.ExtractH2HeaderKV(sslData) + if statusCode, ok := h2kv[":status"]; ok { + mgr.Update(key, func(s *correlation.SessionState) { + if len(s.Requests) > 0 { + last := &s.Requests[len(s.Requests)-1] + if last.StatusCode == 0 { + // Conversion du code de statut H2 (ex: "200" → 200) + code := 0 + for _, c := range statusCode { + if c >= '0' && c <= '9' { + code = code*10 + int(c-'0') + } + } + if code >= 100 && code <= 599 { + last.StatusCode = code + } + } + } + }) + } + } + continue + } + + // === Client → Serveur : requêtes HTTP (direction=0) === if parser.DetectH2Preface(sslData) { - // HTTP/2 : extraire les paramètres SETTINGS depuis la préface + // HTTP/2 : extraire les paramètres SETTINGS et en-têtes depuis la préface afterPreface := sslData if len(afterPreface) > parser.H2MagicPrefaceLen() { afterPreface = sslData[parser.H2MagicPrefaceLen():] @@ -557,19 +631,86 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man MaxFrameSize: h2settings.MaxFrameSize, MaxHeaderListSize: h2settings.MaxHeaderListSize, UnknownSettings: h2settings.UnknownSettings, + EnableConnectProtocol: h2settings.EnableConnectProtocol, WindowUpdateIncrement: h2settings.WindowUpdateIncrement, PseudoHeaderOrder: h2settings.PseudoHeaderOrder, } + // Extraire les en-têtes H2 (User-Agent, Accept, etc.) + if len(h2settings.HeaderKV) > 0 { + req.HeaderKV = h2settings.HeaderKV + req.HeaderOrder = h2settings.HeaderOrder + req.HeaderOrderSig = strings.Join(h2settings.HeaderOrder, ";") + if h2settings.HeaderKV[":method"] != "" { + req.Method = h2settings.HeaderKV[":method"] + } + if h2settings.HeaderKV[":path"] != "" { + p := h2settings.HeaderKV[":path"] + if idx := strings.Index(p, "?"); idx >= 0 { + req.Path = p[:idx] + req.QueryString = p[idx+1:] + } else { + req.Path = p + } + } + if h2settings.HeaderKV[":authority"] != "" { + req.Host = h2settings.HeaderKV[":authority"] + } + } } if len(s.Requests) == 0 { req.HTTPVersion = "HTTP/2" s.Requests = append(s.Requests, req) } + // Si la session n'a pas de L3L4 (pas de SYN capturé), + // peupler dst_ip/dst_port depuis le cache accept4 + if s.L3L4 == nil && (dstIPFromAccept != [4]byte{} || dstPortFromAccept != 0) { + s.L3L4 = &correlation.L3L4{ + DstIP: dstIPFromAccept, + DstPort: dstPortFromAccept, + } + } _ = s.TLS // corrélation implicite }) continue } + // HTTP/2 frames seules (sans préface — SSL_read ultérieurs) + if parser.IsH2FrameHeader(sslData) { + h2kv := parser.ExtractH2HeaderKV(sslData) + if len(h2kv) > 0 { + mgr.Update(key, func(s *correlation.SessionState) { + if len(s.Requests) > 0 { + last := &s.Requests[len(s.Requests)-1] + if last.HeaderKV == nil { + last.HeaderKV = make(map[string]string) + } + for k, v := range h2kv { + if _, exists := last.HeaderKV[k]; !exists { + last.HeaderKV[k] = v + } + } + // Mettre à jour method/path/host si pas encore remplis + if last.Method == "" && h2kv[":method"] != "" { + last.Method = h2kv[":method"] + } + if last.Path == "" && h2kv[":path"] != "" { + p := h2kv[":path"] + if idx := strings.Index(p, "?"); idx >= 0 { + last.Path = p[:idx] + last.QueryString = p[idx+1:] + } else { + last.Path = p + } + } + if last.Host == "" && h2kv[":authority"] != "" { + last.Host = h2kv[":authority"] + } + } + }) + } + continue + } + if parser.IsHTTP1Request(sslData) { // HTTP/1.x : parser la requête req := parser.ParseHTTP1Request(sslData) @@ -588,27 +729,20 @@ func consumeSSLEvents(ctx context.Context, rd *perf.Reader, mgr *correlation.Man HeaderKV: req.HeaderKV, HTTPVersion: req.Protocol, }) + // Si la session n'a pas de L3L4 (pas de SYN capturé), + // peupler dst_ip/dst_port depuis le cache accept4 + if s.L3L4 == nil && (dstIPFromAccept != [4]byte{} || dstPortFromAccept != 0) { + s.L3L4 = &correlation.L3L4{ + DstIP: dstIPFromAccept, + DstPort: dstPortFromAccept, + } + } _ = s.TLS // corrélation implicite }) continue } - if parser.IsHTTP1Response(sslData) { - // Réponse HTTP/1.x : extraire le code de statut - resp := parser.ParseHTTP1Response(sslData) - if resp == nil { - continue - } - mgr.Update(key, func(s *correlation.SessionState) { - // Mettre à jour le code de statut de la dernière requête - if len(s.Requests) > 0 { - last := &s.Requests[len(s.Requests)-1] - if last.StatusCode == 0 { - last.StatusCode = resp.StatusCode - } - } - }) - } + // Les réponses HTTP sont maintenant traitées dans le bloc direction=1 ci-dessus } } @@ -636,6 +770,8 @@ func consumeAcceptEvents(ctx context.Context, rd *perf.Reader, mgr *correlation. continue } + pidTgid := binary.LittleEndian.Uint64(data[0:8]) + fd := binary.LittleEndian.Uint32(data[8:12]) srcIPRaw := binary.LittleEndian.Uint32(data[12:16]) srcPort := binary.LittleEndian.Uint16(data[16:18]) @@ -651,6 +787,10 @@ func consumeAcceptEvents(ctx context.Context, rd *perf.Reader, mgr *correlation. continue } + // Peupler le cache accept4 pour corrélation SSL + tgid := uint32(pidTgid >> 32) + acceptCache.Store(tgid, fd, key, [4]byte{}, 0) + // S'assurer que la session existe mgr.GetOrCreate(key) counter.Add(1) diff --git a/services/ja4ebpf/internal/correlation/session.go b/services/ja4ebpf/internal/correlation/session.go index f52aa1f..8e462a8 100644 --- a/services/ja4ebpf/internal/correlation/session.go +++ b/services/ja4ebpf/internal/correlation/session.go @@ -15,16 +15,17 @@ type SessionKey struct { // L3L4 contient les caractéristiques réseau et transport de la connexion. type L3L4 struct { - DstIP [4]byte // adresse IP destination - DstPort uint16 // port destination - TTL uint8 // TTL IP observé dans le SYN - DFBit bool // bit Don't Fragment actif - IPID uint16 // champ identification IP - WindowSize uint16 // taille de fenêtre TCP initiale - WindowScale uint8 // facteur d'échelle de fenêtre (0xFF = absent) - MSS uint16 // Maximum Segment Size (0 = absent) - TCPOptionsRaw []byte // options TCP brutes (max 40 octets) - SYNTimestamp time.Time // horodatage du paquet SYN + DstIP [4]byte // adresse IP destination + DstPort uint16 // port destination + TTL uint8 // TTL IP observé dans le SYN + DFBit bool // bit Don't Fragment actif + IPID uint16 // champ identification IP + IPTotalLength uint16 // longueur totale IP (octets) + WindowSize uint16 // taille de fenêtre TCP initiale + WindowScale uint8 // facteur d'échelle de fenêtre (0xFF = absent) + MSS uint16 // Maximum Segment Size (0 = absent) + TCPOptionsRaw []byte // options TCP brutes (max 40 octets) + SYNTimestamp time.Time // horodatage du paquet SYN } // TLSInfo contient les données extraites du ClientHello TLS. @@ -43,15 +44,16 @@ type TLSInfo struct { // HTTP2Settings contient les paramètres SETTINGS et WINDOW_UPDATE du client HTTP/2. type HTTP2Settings struct { - HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent) - EnablePush int32 // SETTINGS_ENABLE_PUSH - MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS - InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE - MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE - MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE - UnknownSettings int32 // paramètre 0x7 (JA4H2) - WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0 - PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...] + HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent) + EnablePush int32 // SETTINGS_ENABLE_PUSH + MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS + InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE + MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE + MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE + UnknownSettings int32 // paramètre 0x7 (JA4H2) + EnableConnectProtocol int32 // SETTINGS_ENABLE_CONNECT_PROTOCOL (0x8, RFC 8441) + WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0 + PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...] } // HTTPRequest représente une requête HTTP observée dans la session. diff --git a/services/ja4ebpf/internal/writer/clickhouse.go b/services/ja4ebpf/internal/writer/clickhouse.go index 7544c38..e519026 100644 --- a/services/ja4ebpf/internal/writer/clickhouse.go +++ b/services/ja4ebpf/internal/writer/clickhouse.go @@ -46,12 +46,15 @@ type sessionRecord struct { TCPMetaOptions string `json:"tcp_meta_options,omitempty"` // TLS (noms attendus par le MV) - JA4Hash string `json:"ja4,omitempty"` - JA3Raw string `json:"ja3,omitempty"` - JA3Hash string `json:"ja3_hash,omitempty"` - TLSSNI string `json:"tls_sni,omitempty"` - TLSALPN string `json:"tls_alpn,omitempty"` - TLSVersion string `json:"tls_version,omitempty"` + JA4Hash string `json:"ja4,omitempty"` + JA3Raw string `json:"ja3,omitempty"` + JA3Hash string `json:"ja3_hash,omitempty"` + TLSSNI string `json:"tls_sni,omitempty"` + TLSALPN string `json:"tls_alpn,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + TLSCipherSuites string `json:"tls_cipher_suites,omitempty"` + TLSExtensions string `json:"tls_extensions,omitempty"` + TLSSupportedGroups string `json:"tls_supported_groups,omitempty"` // HTTP Method string `json:"method,omitempty"` @@ -82,18 +85,22 @@ type sessionRecord struct { HeaderSecFetchSite string `json:"header_Sec-Fetch-Site,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,omitempty"` - H2EnablePush *int32 `json:"h2_enable_push,omitempty"` - H2MaxConcurrentStreams *int32 `json:"h2_max_concurrent_streams,omitempty"` - H2InitialWindowSize *int64 `json:"h2_initial_window_size,omitempty"` - H2MaxFrameSize *int32 `json:"h2_max_frame_size,omitempty"` - H2MaxHeaderListSize *int32 `json:"h2_max_header_list_size,omitempty"` - H2EnableConnectProtocol *int32 `json:"h2_enable_connect_protocol,omitempty"` + 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,omitempty"` + H2EnablePush *int32 `json:"h2_enable_push,omitempty"` + H2MaxConcurrentStreams *int32 `json:"h2_max_concurrent_streams,omitempty"` + H2InitialWindowSize *int64 `json:"h2_initial_window_size,omitempty"` + H2MaxFrameSize *int32 `json:"h2_max_frame_size,omitempty"` + H2MaxHeaderListSize *int32 `json:"h2_max_header_list_size,omitempty"` + H2EnableConnectProtocol *int32 `json:"h2_enable_connect_protocol,omitempty"` + + // Champs de corrélation + ClientHeaders string `json:"client_headers,omitempty"` + SynToClientHelloMs *int32 `json:"syn_to_clienthello_ms,omitempty"` } // NewClickHouseWriter crée un writer et établit la connexion ClickHouse. @@ -222,6 +229,11 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { KeepAlives: len(s.Requests), } + // Fallback dst_port pour sessions TLS sans L3L4 : 443 pour HTTPS + if s.L3L4 == nil && s.TLS != nil { + rec.DstPort = 443 + } + // Champs métadonnées IP/TCP if s.L3L4 != nil { rec.DstIP = fmt.Sprintf("%d.%d.%d.%d", @@ -230,6 +242,9 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { rec.IPMetaDF = &s.L3L4.DFBit rec.IPMetaID = &s.L3L4.IPID rec.IPMetaTTL = &s.L3L4.TTL + if s.L3L4.IPTotalLength > 0 { + rec.IPMetaTotalLength = &s.L3L4.IPTotalLength + } rec.TCPMetaWindowSize = &s.L3L4.WindowSize // WindowScale 0xFF = absent (convention C), ne pas inclure if s.L3L4.WindowScale != 0xFF { @@ -239,6 +254,10 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { if s.L3L4.MSS > 0 { rec.TCPMetaMSS = &s.L3L4.MSS } + // Options TCP : noms abrégés (MSS, WS, SACK, TS, etc.) + if len(s.L3L4.TCPOptionsRaw) > 0 { + rec.TCPMetaOptions = formatTCPOptions(s.L3L4.TCPOptionsRaw) + } } // Champs TLS @@ -249,6 +268,25 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { rec.TLSSNI = s.TLS.SNI rec.TLSALPN = strings.Join(s.TLS.ALPN, ",") rec.TLSVersion = formatTLSVersion(s.TLS.TLSVersion) + // Cipher suites : liste hex séparée par des tirets + if len(s.TLS.CipherSuites) > 0 { + parts := make([]string, len(s.TLS.CipherSuites)) + for i, cs := range s.TLS.CipherSuites { + parts[i] = fmt.Sprintf("%04x", cs) + } + rec.TLSCipherSuites = strings.Join(parts, "-") + } + // Extensions : liste d'IDs hex séparés par des tirets + if len(s.TLS.Extensions) > 0 { + parts := make([]string, len(s.TLS.Extensions)) + for i, ext := range s.TLS.Extensions { + parts[i] = fmt.Sprintf("%04x", ext) + } + rec.TLSExtensions = strings.Join(parts, "-") + } + // Supported groups : liste hex séparée par des tirets + // (disponible via TLSInfo.Extensions — extraction depuis ClientHelloRaw si nécessaire) + _ = s.TLS.ClientHelloRaw // réservé pour extraction future // Fallback : si pas de Host HTTP, utiliser TLS SNI if rec.Host == "" && s.TLS.SNI != "" { rec.Host = s.TLS.SNI @@ -259,6 +297,12 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { } } + // syn_to_clienthello_ms : délai entre SYN et ClientHello + if s.L3L4 != nil && s.TLS != nil && !s.L3L4.SYNTimestamp.IsZero() && !s.TLS.Timestamp.IsZero() { + delta := int32(s.TLS.Timestamp.Sub(s.L3L4.SYNTimestamp).Milliseconds()) + rec.SynToClientHelloMs = &delta + } + // Champs HTTP (dernière requête) if len(s.Requests) > 0 { last := &s.Requests[len(s.Requests)-1] @@ -275,22 +319,25 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { rec.DurationMS = &last.DurationMS rec.HeaderOrderSig = last.HeaderOrderSig - // En-têtes HTTP individuels + // En-têtes HTTP individuels (HTTP/1.1: clés Title-Case, HTTP/2: clés lowercase) if last.HeaderKV != nil { - rec.HeaderUserAgent = last.HeaderKV["User-Agent"] - rec.HeaderAccept = last.HeaderKV["Accept"] - rec.HeaderAcceptEnc = last.HeaderKV["Accept-Encoding"] - rec.HeaderAcceptLang = last.HeaderKV["Accept-Language"] - rec.HeaderContentType = last.HeaderKV["Content-Type"] - rec.HeaderXReqID = last.HeaderKV["X-Request-Id"] - rec.HeaderXTraceID = last.HeaderKV["X-Trace-Id"] - rec.HeaderXForwarded = last.HeaderKV["X-Forwarded-For"] - rec.HeaderSecCHUA = last.HeaderKV["Sec-CH-UA"] - rec.HeaderSecCHUAMobile = last.HeaderKV["Sec-CH-UA-Mobile"] - rec.HeaderSecCHUAPlat = last.HeaderKV["Sec-CH-UA-Platform"] - rec.HeaderSecFetchDest = last.HeaderKV["Sec-Fetch-Dest"] - rec.HeaderSecFetchMode = last.HeaderKV["Sec-Fetch-Mode"] - rec.HeaderSecFetchSite = last.HeaderKV["Sec-Fetch-Site"] + rec.HeaderUserAgent = headerVal(last.HeaderKV, "User-Agent", "user-agent") + rec.HeaderAccept = headerVal(last.HeaderKV, "Accept", "accept") + rec.HeaderAcceptEnc = headerVal(last.HeaderKV, "Accept-Encoding", "accept-encoding") + rec.HeaderAcceptLang = headerVal(last.HeaderKV, "Accept-Language", "accept-language") + rec.HeaderContentType = headerVal(last.HeaderKV, "Content-Type", "content-type") + rec.HeaderXReqID = headerVal(last.HeaderKV, "X-Request-Id", "x-request-id") + rec.HeaderXTraceID = headerVal(last.HeaderKV, "X-Trace-Id", "x-trace-id") + rec.HeaderXForwarded = headerVal(last.HeaderKV, "X-Forwarded-For", "x-forwarded-for") + rec.HeaderSecCHUA = headerVal(last.HeaderKV, "Sec-CH-UA", "sec-ch-ua") + rec.HeaderSecCHUAMobile = headerVal(last.HeaderKV, "Sec-CH-UA-Mobile", "sec-ch-ua-mobile") + rec.HeaderSecCHUAPlat = headerVal(last.HeaderKV, "Sec-CH-UA-Platform", "sec-ch-ua-platform") + rec.HeaderSecFetchDest = headerVal(last.HeaderKV, "Sec-Fetch-Dest", "sec-fetch-dest") + rec.HeaderSecFetchMode = headerVal(last.HeaderKV, "Sec-Fetch-Mode", "sec-fetch-mode") + rec.HeaderSecFetchSite = headerVal(last.HeaderKV, "Sec-Fetch-Site", "sec-fetch-site") + + // client_headers : JSON des en-têtes capturés + rec.ClientHeaders = buildClientHeaders(last.HeaderKV) } // Construire headers_raw : ordre des noms joints par ";" @@ -316,6 +363,7 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord { rec.H2InitialWindowSize = &h2InitWin rec.H2MaxFrameSize = &h2.MaxFrameSize rec.H2MaxHeaderListSize = &h2.MaxHeaderListSize + rec.H2EnableConnectProtocol = &h2.EnableConnectProtocol // Fingerprints composites Akamai rec.H2Fingerprint = buildH2Fingerprint(h2) @@ -423,3 +471,83 @@ func formatTLSVersion(v uint16) string { return "" } } + +// headerVal cherche un en-tête dans le map avec deux clés possibles : +// HTTP/1.1 utilise Title-Case (ex: "User-Agent"), HTTP/2 utilise lowercase (ex: "user-agent"). +func headerVal(kv map[string]string, titleKey, lowerKey string) string { + if v := kv[titleKey]; v != "" { + return v + } + return kv[lowerKey] +} + +// buildClientHeaders sérialise les en-têtes capturés en JSON pour la colonne client_headers. +func buildClientHeaders(kv map[string]string) string { + if len(kv) == 0 { + return "" + } + // Sérialiser en JSON trié par clé pour un résultat déterministe + b, err := json.Marshal(kv) + if err != nil { + return "" + } + return string(b) +} + +// formatTCPOptions convertit les options TCP brutes en chaîne lisible. +// Noms abrégés : MSS=2, WS=3, SACK=4, TS=8, etc. +func formatTCPOptions(opts []byte) string { + if len(opts) == 0 { + return "" + } + var names []string + i := 0 + for i < len(opts) { + kind := opts[i] + switch kind { + case 0: // End of Options List + break + case 1: // NOP + names = append(names, "NOP") + i++ + case 2: // MSS + names = append(names, "MSS") + if i+3 < len(opts) { + i += 4 + } else { + i++ + } + case 3: // Window Scale + names = append(names, "WS") + if i+2 < len(opts) { + i += 3 + } else { + i++ + } + case 4: // SACK Permitted + names = append(names, "SACK") + i += 2 + case 5: // SACK + names = append(names, "SACKb") + if i+1 < len(opts) { + i += int(opts[i+1]) + } else { + i++ + } + case 8: // Timestamp + names = append(names, "TS") + if i+9 < len(opts) { + i += 10 + } else { + i++ + } + default: + if i+1 < len(opts) && int(opts[i+1]) > 0 { + i += int(opts[i+1]) + } else { + i++ + } + } + } + return strings.Join(names, ",") +}