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

@ -0,0 +1,646 @@
// Package parser fournit le parseur HTTP/2 basé sur golang.org/x/net/http2
// et le décodeur HPACK pour l'extraction des empreintes de fingerprinting réseau.
package parser
import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
"golang.org/x/net/http2"
"golang.org/x/net/http2/hpack"
)
// ---------------------------------------------------------------------------
// Constantes HTTP/2 (RFC 9113)
// ---------------------------------------------------------------------------
// H2Magic est la préface HTTP/2 client (RFC 7540 §3.5), exportée pour usage
// par le routeur Magic Bytes (package dispatcher) et les consommateurs RingBuffer.
const H2Magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
// h2MagicPrefaceLen est la longueur du préambule HTTP/2 client.
const h2MagicPrefaceLen = 24
// h2MagicPreface est le préambule ("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") envoyé
// par tout client HTTP/2 avant la première frame SETTINGS.
var h2MagicPreface = []byte(H2Magic)
// ---------------------------------------------------------------------------
// Types exportés
// ---------------------------------------------------------------------------
// H2FrameRecord est un enregistrement par frame dans le résultat de ProcessFrames.
// Fournit une chronologie fine des frames HTTP/2 avec offset logique, direction et métadonnées.
type H2FrameRecord struct {
Index uint32 // offset logique (incrémenté par frame dans H2ConnState)
Direction uint8 // 0=client→serveur, 1=serveur→client
Type http2.FrameType // type de frame (DATA, HEADERS, SETTINGS, etc.)
Flags http2.Flags // drapeaux de la frame
StreamID uint32 // ID du stream (0 pour les frames de connexion)
Length uint32 // longueur du payload en octets
}
// H2Priority contient les paramètres de priorité d'un stream HTTP/2 (RFC 9113 §5.3).
type H2Priority struct {
StreamDep uint32 // stream dépendant
Exclusive bool // priorité exclusive
Weight uint8 // poids (1-256)
}
// 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)
EnableConnectProtocol int32 // SETTINGS_ENABLE_CONNECT_PROTOCOL (0x8, RFC 8441)
WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0
PseudoHeaderOrder []string
HeaderKV map[string]string // en-têtes extraits
HeaderOrder []string // noms des en-têtes dans l'ordre d'arrivée
}
// CapturedHeader est un en-tête HTTP/2 capturé avec son nom et sa valeur.
type CapturedHeader struct {
Name string
Value string
}
// H2FrameResult contient les données extraites d'un appel à ProcessFrames.
type H2FrameResult struct {
// En-têtes décodés (HEADERS + CONTINUATION assemblés)
Headers []CapturedHeader
HeaderStreamID uint32
// Paramètres SETTINGS
ClientSettings *HTTP2Settings // non-nil si frame SETTINGS client vue
ServerSettings *HTTP2Settings // non-nil si frame SETTINGS serveur vue
// WINDOW_UPDATE sur stream 0 (connexion)
ConnWindowUpdate uint32
// Code de statut HTTP (:status extrait des en-têtes serveur)
StatusCode int
// Streams fermés (END_STREAM ou RST_STREAM)
StreamClosed []uint32
// GOAWAY
GoAwayLastStream uint32
GoAwayErrCode http2.ErrCode
// Compteurs de frames par type
FrameCounts map[http2.FrameType]int
// Préface détectée
PrefaceDetected bool
// Pseudo-headers extraits (ordre)
PseudoHeaderOrder []string
// NOUVEAU Phase 2 : chronologie des frames de cet appel
Frames []H2FrameRecord
// NOUVEAU Phase 2 : compteurs fine-grained
SettingsAckSeen bool // SETTINGS ACK reçu dans ce batch
PingAckSeen bool // PING ACK reçu dans ce batch
}
// H2StreamState suit l'état d'un stream HTTP/2.
type H2StreamState struct {
ID uint32
Initiator uint8 // 0=client (impair), 1=serveur (pair)
State string // "idle", "open", "half-closed-local", "half-closed-remote", "closed"
EndStream bool
DataBytes int64
RSTCode uint32
Priority *H2Priority // non-nil si frame PRIORITY reçue
WindowIncr uint32 // WINDOW_UPDATE incrément cumulé sur ce stream
FrameTypes []http2.FrameType // historique condensé (types uniquement, pas payload)
}
// H2ConnState maintient l'état par-connexion HTTP/2, incluant le décodeur HPACK.
// Stocké dans correlation.SessionState et persisté entre les événements SSL_read.
type H2ConnState struct {
hdec *hpack.Decoder // décodeur HPACK par-connexion (table dynamique)
headerBuf bytes.Buffer // fragments HEADERS+CONTINUATION en attente
headerFragStream uint32 // stream ID des fragments en attente
// État de connexion
ClientSettings *HTTP2Settings
ServerSettings *HTTP2Settings
FrameCounts map[http2.FrameType]int
PrefaceSeen bool
// Suivi des streams
Streams map[uint32]*H2StreamState
// GOAWAY
LastStreamID uint32
GoAwayErr http2.ErrCode
// NOUVEAU Phase 2
frameIndex uint32 // compteur logique de frames (persisté entre appels)
SettingsAck bool // SETTINGS ACK reçu (client→serveur)
ServerAck bool // SETTINGS ACK reçu (serveur→client)
}
// NewH2ConnState crée un nouvel état de connexion HTTP/2 avec un décodeur HPACK frais.
func NewH2ConnState() *H2ConnState {
return &H2ConnState{
hdec: hpack.NewDecoder(4096, nil),
FrameCounts: make(map[http2.FrameType]int),
Streams: make(map[uint32]*H2StreamState),
}
}
// ---------------------------------------------------------------------------
// Détection du préambule HTTP/2 (fonctions utilitaires exportées)
// ---------------------------------------------------------------------------
// DetectH2Preface vérifie si le buffer commence par le préambule HTTP/2.
func DetectH2Preface(data []byte) bool {
if len(data) < h2MagicPrefaceLen {
return false
}
for i := 0; i < h2MagicPrefaceLen; i++ {
if data[i] != h2MagicPreface[i] {
return false
}
}
return true
}
// H2MagicPrefaceLen retourne la longueur du préambule HTTP/2.
func H2MagicPrefaceLen() int {
return h2MagicPrefaceLen
}
// IsH2FrameHeader vérifie si les données commencent par un en-tête de frame HTTP/2 valide.
// Utilisé comme détection rapide avant de créer un H2ConnState.
func IsH2FrameHeader(data []byte) bool {
if len(data) < 9 {
return false
}
r := bytes.NewReader(data)
fr := http2.NewFramer(io.Discard, r)
fr.AllowIllegalReads = true
_, err := fr.ReadFrame()
return err == nil
}
// ---------------------------------------------------------------------------
// Traitement des frames HTTP/2
// ---------------------------------------------------------------------------
// ProcessFrames parse les frames HTTP/2 depuis sslData via http2.Framer,
// met à jour l'état de connexion, et retourne les en-têtes décodés.
// direction: 0 = client→serveur, 1 = serveur→client.
func (c *H2ConnState) ProcessFrames(data []byte, direction uint8) (*H2FrameResult, error) {
r := bytes.NewReader(data)
fr := http2.NewFramer(io.Discard, r)
fr.AllowIllegalReads = true
// NE PAS positionner ReadMetaHeaders — on gère HPACK nous-mêmes
// pour maintenir la table dynamique par-connexion.
result := &H2FrameResult{
FrameCounts: make(map[http2.FrameType]int),
}
for {
frame, err := fr.ReadFrame()
if err != nil {
// io.EOF ou données insuffisantes → fin du traitement
break
}
c.frameIndex++
// Enregistrer la frame dans le résultat
rec := H2FrameRecord{
Index: c.frameIndex,
Direction: direction,
Type: frame.Header().Type,
Flags: http2.Flags(frame.Header().Flags),
StreamID: frame.Header().StreamID,
Length: frame.Header().Length,
}
result.Frames = append(result.Frames, rec)
c.FrameCounts[frame.Header().Type]++
result.FrameCounts[frame.Header().Type]++
switch f := frame.(type) {
case *http2.SettingsFrame:
if f.IsAck() {
if direction == 0 {
c.SettingsAck = true
result.SettingsAckSeen = true
} else {
c.ServerAck = true
}
} else {
c.processSettings(f, direction, result)
}
case *http2.HeadersFrame:
c.processHeaders(f, direction, result)
case *http2.ContinuationFrame:
c.processContinuation(f, result)
case *http2.WindowUpdateFrame:
c.processWindowUpdate(f, direction, result)
case *http2.DataFrame:
c.processData(f, direction, result)
case *http2.PingFrame:
if f.IsAck() {
result.PingAckSeen = true
}
case *http2.GoAwayFrame:
c.processGoAway(f, result)
case *http2.RSTStreamFrame:
c.processRSTStream(f, result)
case *http2.PriorityFrame:
c.processPriority(f, result)
}
}
return result, nil
}
// ---------------------------------------------------------------------------
// Traitement des frames individuelles
// ---------------------------------------------------------------------------
func (c *H2ConnState) processSettings(f *http2.SettingsFrame, direction uint8, result *H2FrameResult) {
settings := &HTTP2Settings{
HeaderTableSize: -1,
EnablePush: -1,
MaxConcurrentStreams: -1,
InitialWindowSize: -1,
MaxFrameSize: -1,
MaxHeaderListSize: -1,
EnableConnectProtocol: -1,
}
f.ForeachSetting(func(s http2.Setting) error {
switch s.ID {
case http2.SettingHeaderTableSize:
settings.HeaderTableSize = int32(s.Val)
// Mettre à jour la taille de la table dynamique HPACK côté client
if direction == 0 {
c.hdec.SetMaxDynamicTableSize(s.Val)
}
case http2.SettingEnablePush:
settings.EnablePush = int32(s.Val)
case http2.SettingMaxConcurrentStreams:
settings.MaxConcurrentStreams = int32(s.Val)
case http2.SettingInitialWindowSize:
settings.InitialWindowSize = int32(s.Val)
case http2.SettingMaxFrameSize:
settings.MaxFrameSize = int32(s.Val)
case http2.SettingMaxHeaderListSize:
settings.MaxHeaderListSize = int32(s.Val)
case http2.SettingEnableConnectProtocol:
settings.EnableConnectProtocol = int32(s.Val)
case 7: // paramètre non standard (JA4H2)
settings.UnknownSettings = int32(s.Val)
}
return nil
})
if direction == 0 {
// Client SETTINGS → merger avec l'existant
c.ClientSettings = mergeSettings(c.ClientSettings, settings)
result.ClientSettings = c.ClientSettings
} else {
c.ServerSettings = mergeSettings(c.ServerSettings, settings)
result.ServerSettings = c.ServerSettings
}
}
// mergeSettings fusionne les nouveaux paramètres dans les existants.
// Les paramètres non-présents dans le nouveau gardent leur valeur existante.
func mergeSettings(base, new *HTTP2Settings) *HTTP2Settings {
if base == nil {
return new
}
if new == nil {
return base
}
// Le nouveau remplace les champs présents (valeur >= 0)
if new.HeaderTableSize >= 0 {
base.HeaderTableSize = new.HeaderTableSize
}
if new.EnablePush >= 0 {
base.EnablePush = new.EnablePush
}
if new.MaxConcurrentStreams >= 0 {
base.MaxConcurrentStreams = new.MaxConcurrentStreams
}
if new.InitialWindowSize >= 0 {
base.InitialWindowSize = new.InitialWindowSize
}
if new.MaxFrameSize >= 0 {
base.MaxFrameSize = new.MaxFrameSize
}
if new.MaxHeaderListSize >= 0 {
base.MaxHeaderListSize = new.MaxHeaderListSize
}
if new.EnableConnectProtocol >= 0 {
base.EnableConnectProtocol = new.EnableConnectProtocol
}
if new.UnknownSettings >= 0 {
base.UnknownSettings = new.UnknownSettings
}
// Conserver les données d'en-têtes existantes si le nouveau n'en a pas
if len(new.PseudoHeaderOrder) > 0 {
base.PseudoHeaderOrder = new.PseudoHeaderOrder
}
if len(new.HeaderKV) > 0 {
base.HeaderKV = new.HeaderKV
}
if len(new.HeaderOrder) > 0 {
base.HeaderOrder = new.HeaderOrder
}
return base
}
func (c *H2ConnState) processHeaders(f *http2.HeadersFrame, direction uint8, result *H2FrameResult) {
streamID := f.StreamID
// Créer le stream si nécessaire
if streamID > 0 {
if _, ok := c.Streams[streamID]; !ok {
initiator := uint8(0) // client (impair)
if streamID%2 == 0 {
initiator = 1 // serveur (pair)
}
c.Streams[streamID] = &H2StreamState{ID: streamID, Initiator: initiator, State: "open"}
}
stream := c.Streams[streamID]
stream.FrameTypes = append(stream.FrameTypes, http2.FrameHeaders)
}
// Buffer le fragment de bloc d'en-têtes
c.headerBuf.Write(f.HeaderBlockFragment())
c.headerFragStream = streamID
// END_STREAM sur la frame HEADERS → transition d'état
if f.StreamEnded() {
c.transitionStream(streamID, direction, result)
}
// Si END_HEADERS est positionné, décoder immédiatement
if f.Flags&http2.FlagHeadersEndHeaders != 0 {
c.decodeHeaders(result)
}
// Sinon, attendre les frames CONTINUATION
}
func (c *H2ConnState) processContinuation(f *http2.ContinuationFrame, result *H2FrameResult) {
c.headerBuf.Write(f.HeaderBlockFragment())
// Si END_HEADERS est positionné, décoder le bloc complet
if f.Flags&http2.FlagContinuationEndHeaders != 0 {
c.decodeHeaders(result)
}
}
// decodeHeaders décode le bloc HPACK accumulé en utilisant le décodeur par-connexion.
// La table dynamique HPACK est maintenue entre les appels.
func (c *H2ConnState) decodeHeaders(result *H2FrameResult) {
var headers []CapturedHeader
c.hdec.SetEmitFunc(func(hf hpack.HeaderField) {
headers = append(headers, CapturedHeader{
Name: hf.Name,
Value: hf.Value,
})
})
c.hdec.Write(c.headerBuf.Bytes())
c.hdec.Close() // Finalise le bloc HPACK, préserve la table dynamique
streamID := c.headerFragStream
c.headerBuf.Reset()
c.headerFragStream = 0
result.Headers = headers
result.HeaderStreamID = streamID
// Extraire les pseudo-headers et en-têtes capturés
var pseudoOrder []string
kv := make(map[string]string)
var order []string
for _, h := range headers {
nameLower := strings.ToLower(h.Name)
if strings.HasPrefix(nameLower, ":") {
pseudoOrder = append(pseudoOrder, nameLower)
}
if HpackCapturedHeaders[nameLower] && h.Value != "" {
kv[nameLower] = h.Value
order = append(order, nameLower)
}
}
result.PseudoHeaderOrder = pseudoOrder
// Extraire :status de tout stream (serveur ou client)
for _, h := range headers {
if strings.ToLower(h.Name) == ":status" {
if code, err := strconv.Atoi(h.Value); err == nil && code >= 100 && code <= 599 {
result.StatusCode = code
}
}
}
// Si on a des en-têtes, mettre à jour les ClientSettings
// avec les données d'en-têtes (pour la session HTTP/2)
if streamID > 0 && len(kv) > 0 {
isClientStream := streamID%2 == 1
if isClientStream {
// Mettre à jour les en-têtes du client
if c.ClientSettings != nil {
if len(pseudoOrder) > 0 {
c.ClientSettings.PseudoHeaderOrder = pseudoOrder
}
if c.ClientSettings.HeaderKV == nil {
c.ClientSettings.HeaderKV = make(map[string]string)
}
for k, v := range kv {
c.ClientSettings.HeaderKV[k] = v
}
if len(order) > 0 {
c.ClientSettings.HeaderOrder = order
}
}
}
}
}
func (c *H2ConnState) processWindowUpdate(f *http2.WindowUpdateFrame, direction uint8, result *H2FrameResult) {
if f.StreamID == 0 {
// WINDOW_UPDATE sur le flux de connexion
if direction == 0 {
// Client
if c.ClientSettings != nil {
c.ClientSettings.WindowUpdateIncrement = f.Increment
}
}
result.ConnWindowUpdate = f.Increment
} else {
// WINDOW_UPDATE per-stream
if stream, ok := c.Streams[f.StreamID]; ok {
stream.WindowIncr += f.Increment
stream.FrameTypes = append(stream.FrameTypes, http2.FrameWindowUpdate)
}
}
}
func (c *H2ConnState) processData(f *http2.DataFrame, direction uint8, result *H2FrameResult) {
streamID := f.Header().StreamID
if stream, ok := c.Streams[streamID]; ok {
stream.DataBytes += int64(len(f.Data()))
stream.FrameTypes = append(stream.FrameTypes, http2.FrameData)
}
if f.StreamEnded() {
c.transitionStream(streamID, direction, result)
}
}
func (c *H2ConnState) processGoAway(f *http2.GoAwayFrame, result *H2FrameResult) {
c.LastStreamID = f.LastStreamID
c.GoAwayErr = f.ErrCode
result.GoAwayLastStream = f.LastStreamID
result.GoAwayErrCode = f.ErrCode
}
func (c *H2ConnState) processRSTStream(f *http2.RSTStreamFrame, result *H2FrameResult) {
streamID := f.Header().StreamID
if stream, ok := c.Streams[streamID]; ok {
stream.RSTCode = uint32(f.ErrCode)
stream.State = "closed"
stream.FrameTypes = append(stream.FrameTypes, http2.FrameRSTStream)
}
result.StreamClosed = append(result.StreamClosed, streamID)
}
// processPriority traite les frames PRIORITY (RFC 9113 §5.3).
func (c *H2ConnState) processPriority(f *http2.PriorityFrame, result *H2FrameResult) {
streamID := f.Header().StreamID
if stream, ok := c.Streams[streamID]; ok {
stream.Priority = &H2Priority{
StreamDep: f.PriorityParam.StreamDep,
Exclusive: f.PriorityParam.Exclusive,
Weight: f.PriorityParam.Weight,
}
stream.FrameTypes = append(stream.FrameTypes, http2.FramePriority)
}
}
// transitionStream gère les transitions d'état du stream HTTP/2 (RFC 9113 §5.1).
func (c *H2ConnState) transitionStream(streamID uint32, direction uint8, result *H2FrameResult) {
stream, ok := c.Streams[streamID]
if !ok {
return
}
if stream.State == "closed" {
return
}
switch {
case stream.State == "open" && direction == 0:
stream.State = "half-closed-remote"
case stream.State == "open" && direction == 1:
stream.State = "half-closed-local"
case stream.State == "half-closed-local" || stream.State == "half-closed-remote":
stream.State = "closed"
default:
stream.State = "closed"
}
stream.EndStream = true
result.StreamClosed = append(result.StreamClosed, streamID)
}
// ---------------------------------------------------------------------------
// En-têtes capturés — réutilise hpackCapturedHeaders de http2.go
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Utilitaires de formatage
// ---------------------------------------------------------------------------
// PseudoOrderToShort convertit la liste de pseudo-headers en notation abrégée.
// Ex: [":method", ":authority", ":scheme", ":path"] → "m,a,s,p"
func PseudoOrderToShort(headers []string) string {
short := make([]byte, 0, len(headers)*2-1)
for i, h := range headers {
if i > 0 {
short = append(short, ',')
}
switch {
case h == ":method":
short = append(short, 'm')
case h == ":authority":
short = append(short, 'a')
case h == ":scheme":
short = append(short, 's')
case h == ":path":
short = append(short, 'p')
case h == ":status":
short = append(short, 't')
default:
short = append(short, '?')
}
}
return string(short)
}
// FrameTypeString retourne le nom lisible d'un type de frame HTTP/2.
func FrameTypeString(t http2.FrameType) string {
switch t {
case http2.FrameData:
return "DATA"
case http2.FrameHeaders:
return "HEADERS"
case http2.FramePriority:
return "PRIORITY"
case http2.FrameRSTStream:
return "RST_STREAM"
case http2.FrameSettings:
return "SETTINGS"
case http2.FramePushPromise:
return "PUSH_PROMISE"
case http2.FramePing:
return "PING"
case http2.FrameGoAway:
return "GOAWAY"
case http2.FrameWindowUpdate:
return "WINDOW_UPDATE"
case http2.FrameContinuation:
return "CONTINUATION"
default:
return fmt.Sprintf("UNKNOWN(%d)", t)
}
}
// FrameCountsToString sérialise les compteurs de frames en chaîne lisible.
func FrameCountsToString(counts map[http2.FrameType]int) string {
if len(counts) == 0 {
return ""
}
parts := make([]string, 0, len(counts))
for t, n := range counts {
parts = append(parts, fmt.Sprintf("%s:%d", FrameTypeString(t), n))
}
return strings.Join(parts, ",")
}

View File

@ -1,401 +1,16 @@
// Package parser fournit les parseurs pour les protocoles HTTP/1.x, HTTP/2 et TLS.
//
// Le parsing HTTP/2 est désormais assuré par internal/parser/h2conn.go qui utilise
// golang.org/x/net/http2.Framer et golang.org/x/net/http2/hpack.Decoder pour une
// conformité RFC complète, incluant la table dynamique HPACK et l'assemblage
// HEADERS+CONTINUATION.
//
// Ce fichier ne conserve que les constantes et le filtre d'en-têtes partagés.
package parser
import (
"encoding/binary"
"fmt"
"strings"
)
// H2Magic est la préface HTTP/2 client (RFC 7540 §3.5), exportée pour usage
// par le routeur Magic Bytes (package dispatcher) et les consommateurs RingBuffer.
const H2Magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
// h2MagicPrefaceLen est la longueur du préambule HTTP/2 client.
const h2MagicPrefaceLen = 24
// h2MagicPreface est le préambule ("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") envoyé
// par tout client HTTP/2 avant la première frame SETTINGS.
var h2MagicPreface = []byte(H2Magic)
// Identifiants de types de frames HTTP/2 (RFC 7540, §11.2).
const (
h2FrameData = 0
h2FrameHeaders = 1
h2FramePriority = 2
h2FrameRSTStream = 3
h2FrameSettings = 4
h2FramePushPromise = 5
h2FramePing = 6
h2FrameGoAway = 7
h2FrameWindowUpdate = 8
h2FrameContinuation = 9
)
// Identifiants des paramètres SETTINGS (RFC 7540, §11.3 + RFC 8441).
const (
h2SettingHeaderTableSize = 1
h2SettingEnablePush = 2
h2SettingMaxConcurrentStreams = 3
h2SettingInitialWindowSize = 4
h2SettingMaxFrameSize = 5
h2SettingMaxHeaderListSize = 6
h2SettingEnableConnectProtocol = 8
)
// h2FrameHeader représente l'en-tête fixe de 9 octets d'une frame HTTP/2.
type h2FrameHeader struct {
Length uint32 // longueur du payload (3 octets)
Type uint8 // type de frame
Flags uint8 // flags
StreamID uint32 // identifiant de stream (masque 0x7FFFFFFF)
}
// parseH2FrameHeader décode l'en-tête de 9 octets d'une frame HTTP/2.
func parseH2FrameHeader(data []byte) (h2FrameHeader, error) {
if len(data) < 9 {
return h2FrameHeader{}, fmt.Errorf("données insuffisantes pour l'en-tête frame HTTP/2: %d octets", len(data))
}
// Longueur sur 3 octets big-endian
length := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
return h2FrameHeader{
Length: length,
Type: data[3],
Flags: data[4],
StreamID: binary.BigEndian.Uint32(data[5:9]) & 0x7FFFFFFF,
}, nil
}
// DetectH2Preface vérifie si le buffer commence par le préambule HTTP/2.
func DetectH2Preface(data []byte) bool {
if len(data) < h2MagicPrefaceLen {
return false
}
for i := 0; i < h2MagicPrefaceLen; i++ {
if data[i] != h2MagicPreface[i] {
return false
}
}
return true
}
// H2MagicPrefaceLen retourne la longueur du préambule HTTP/2.
func H2MagicPrefaceLen() int {
return h2MagicPrefaceLen
}
// 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)
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, ...]
HeaderKV map[string]string // en-têtes extraits du premier HEADERS frame
HeaderOrder []string // noms des en-têtes dans l'ordre d'arrivée
}
// ParseH2ClientPreface extrait les paramètres SETTINGS et le WINDOW_UPDATE
// depuis le flux HTTP/2 déchiffré du client.
// data doit commencer APRÈS le magic preface (offset 24).
func ParseH2ClientPreface(data []byte) (*HTTP2Settings, error) {
settings := &HTTP2Settings{
HeaderTableSize: -1,
EnablePush: -1,
MaxConcurrentStreams: -1,
InitialWindowSize: -1,
MaxFrameSize: -1,
MaxHeaderListSize: -1,
UnknownSettings: -1,
EnableConnectProtocol: -1,
}
offset := 0
// Parser au maximum 10 frames pour éviter une boucle infinie
for frameIdx := 0; frameIdx < 10 && offset < len(data); frameIdx++ {
if offset+9 > len(data) {
break
}
hdr, err := parseH2FrameHeader(data[offset:])
if err != nil {
break
}
offset += 9
payloadEnd := offset + int(hdr.Length)
if payloadEnd > len(data) {
break
}
payload := data[offset:payloadEnd]
offset = payloadEnd
switch hdr.Type {
case h2FrameSettings:
// Parser uniquement les SETTINGS du client (stream 0)
if hdr.StreamID == 0 {
pairs, err := parseH2SettingsFrame(payload)
if err != nil {
continue
}
for id, val := range pairs {
switch id {
case h2SettingHeaderTableSize:
settings.HeaderTableSize = int32(val)
case h2SettingEnablePush:
settings.EnablePush = int32(val)
case h2SettingMaxConcurrentStreams:
settings.MaxConcurrentStreams = int32(val)
case h2SettingInitialWindowSize:
settings.InitialWindowSize = int32(val)
case h2SettingMaxFrameSize:
settings.MaxFrameSize = int32(val)
case h2SettingMaxHeaderListSize:
settings.MaxHeaderListSize = int32(val)
case 7: // paramètre non standard (JA4H2)
settings.UnknownSettings = int32(val)
case h2SettingEnableConnectProtocol:
settings.EnableConnectProtocol = int32(val)
}
}
}
case h2FrameWindowUpdate:
// WINDOW_UPDATE sur stream 0 = flux de connexion
if hdr.StreamID == 0 && len(payload) >= 4 {
settings.WindowUpdateIncrement = binary.BigEndian.Uint32(payload[0:4]) & 0x7FFFFFFF
}
case h2FrameHeaders:
// Extraire l'ordre des pseudo-headers et les en-têtes réguliers
if hdr.StreamID > 0 && len(settings.PseudoHeaderOrder) == 0 {
settings.PseudoHeaderOrder = ParseH2PseudoHeaders(payload)
// Extraire aussi les en-têtes réguliers (User-Agent, Accept, etc.)
kv, order := DecodeH2HeadersBlock(payload)
if len(kv) > 0 {
settings.HeaderKV = kv
settings.HeaderOrder = order
}
}
}
}
return settings, nil
}
// parseH2SettingsFrame extrait les paires (identifiant, valeur) d'une frame SETTINGS.
// Chaque paire fait 6 octets : identifiant(2) + valeur(4).
func parseH2SettingsFrame(payload []byte) (map[uint16]uint32, error) {
if len(payload)%6 != 0 {
return nil, fmt.Errorf("longueur de frame SETTINGS invalide: %d (doit être multiple de 6)", len(payload))
}
result := make(map[uint16]uint32)
for i := 0; i+6 <= len(payload); i += 6 {
id := binary.BigEndian.Uint16(payload[i : i+2])
val := binary.BigEndian.Uint32(payload[i+2 : i+6])
result[id] = val
}
return result, nil
}
// ParseH2PseudoHeaders extrait l'ordre des pseudo-headers depuis un bloc HPACK.
//
// Implémentation simplifiée : détecte les pseudo-headers via les index HPACK statiques.
// Table statique HPACK (RFC 7541, Annexe A) — index pertinents :
// 1 :authority
// 2 :method = GET
// 3 :method = POST
// 4 :path = /
// 5 :path = /index.html
// 6 :scheme = http
// 7 :scheme = https
func ParseH2PseudoHeaders(headersBlock []byte) []string {
// Index HPACK statique → pseudo-header
hpackStaticPseudo := map[int]string{
1: ":authority",
2: ":method",
3: ":method",
4: ":path",
5: ":path",
6: ":scheme",
7: ":scheme",
}
seen := make(map[string]bool)
var order []string
offset := 0
for offset < len(headersBlock) {
b := headersBlock[offset]
// Représentation indexée (bit 7 = 1) : RFC 7541 §6.1
if b&0x80 != 0 {
idx := int(b & 0x7F)
if name, ok := hpackStaticPseudo[idx]; ok {
if !seen[name] {
seen[name] = true
order = append(order, name)
}
} else if idx == 0 {
// Fin de la liste d'index ou encodage multi-octets
offset++
continue
} else {
// Index dynamique ou non-pseudo-header : arrêter le scan
break
}
offset++
continue
}
// Représentation littérale avec index incrémental (bits 7-6 = 01) : RFC 7541 §6.2.1
if b&0xC0 == 0x40 {
idx := int(b & 0x3F)
if name, ok := hpackStaticPseudo[idx]; ok {
if !seen[name] {
seen[name] = true
order = append(order, name)
}
}
offset++
// Sauter la valeur (longueur + contenu)
if offset >= len(headersBlock) {
break
}
valueLen := int(headersBlock[offset] & 0x7F) // ignorer le bit Huffman
offset += 1 + valueLen
continue
}
// Tout autre encodage : arrêter (ce n'est probablement plus un pseudo-header)
break
}
return order
}
// ---------------------------------------------------------------------------
// HPACK static table (RFC 7541, Appendix A) — index → header name
// Seuls les noms sont listés (les valeurs par défaut sont ignorées car
// les en-têtes d'intérêt comme User-Agent sont toujours envoyés en littéral).
// ---------------------------------------------------------------------------
// hpackStaticEntry est une entrée de la table statique HPACK (RFC 7541 Appendix A).
type hpackStaticEntry struct {
Name string
Value string
}
// hpackStaticTable est la table statique HPACK (RFC 7541 Appendix A).
// Index 1-61 : RFC 7541 original. Index 62-100 : extensions RFC 9204 + navigateurs.
var hpackStaticTable = map[int]hpackStaticEntry{
1: {":authority", ""},
2: {":method", "GET"},
3: {":method", "POST"},
4: {":path", "/"},
5: {":path", "/index.html"},
6: {":scheme", "http"},
7: {":scheme", "https"},
8: {":status", "200"},
9: {":status", "204"},
10: {":status", "206"},
11: {":status", "304"},
12: {":status", "400"},
13: {":status", "404"},
14: {":status", "500"},
15: {"accept-charset", ""},
16: {"accept-encoding", "gzip, deflate"},
17: {"accept-language", ""},
18: {"accept", ""},
19: {"accept", "*/*"},
20: {"access-control-allow-origin", ""},
21: {"accept-encoding", ""},
22: {"accept-encoding", "gzip, deflate"},
23: {"accept-language", ""},
24: {"accept-language", ""},
25: {"access-control-allow-credentials", ""},
26: {"access-control-allow-headers", ""},
27: {"access-control-allow-methods", ""},
28: {"access-control-allow-origin", ""},
29: {"access-control-request-headers", ""},
30: {"access-control-request-method", ""},
31: {"age", ""},
32: {"authorization", ""},
33: {"cache-control", ""},
34: {"cache-control", "max-age=0"},
35: {"cookie", ""},
36: {"cookie", ""},
37: {"date", ""},
38: {"etag", ""},
39: {"expect", ""},
40: {"from", ""},
41: {"host", ""},
42: {"if-match", ""},
43: {"if-modified-since", ""},
44: {"if-none-match", ""},
45: {"if-range", ""},
46: {"if-unmodified-since", ""},
47: {"last-modified", ""},
48: {"link", ""},
49: {"location", ""},
50: {"max-forwards", ""},
51: {"proxy-authenticate", ""},
52: {"proxy-authorization", ""},
53: {"range", ""},
54: {"referer", ""},
55: {"refresh", ""},
56: {"retry-after", ""},
57: {"server", ""},
58: {"set-cookie", ""},
59: {"strict-transport-security", ""},
60: {"transfer-encoding", ""},
61: {"user-agent", ""},
62: {"vary", ""},
63: {"vary", "Accept-Encoding"},
64: {"via", ""},
65: {"www-authenticate", ""},
66: {"x-forwarded-for", ""},
67: {"x-forwarded-proto", ""},
68: {"x-requested-with", ""},
69: {"sec-websocket-key", ""},
70: {"sec-websocket-version", ""},
71: {"te", ""},
72: {"upgrade", ""},
73: {"sec-ch-ua", ""},
74: {"sec-ch-ua-mobile", "?0"},
75: {"sec-ch-ua-platform", ""},
76: {"sec-fetch-dest", ""},
77: {"sec-fetch-mode", ""},
78: {"sec-fetch-site", ""},
79: {"sec-fetch-user", "?1"},
80: {"priority", ""},
81: {"accept", ""},
82: {"accept", "application/dns-message"},
83: {"accept-language", ""},
84: {":method", "CONNECT"},
85: {":method", "DELETE"},
86: {":method", "HEAD"},
87: {":method", "OPTIONS"},
88: {":method", "PATCH"},
89: {":method", "PUT"},
90: {":method", "TRACE"},
91: {":path", "/"},
92: {":path", "/0"},
93: {":path", "/1"},
94: {":path", "/2"},
95: {":path", "/3"},
96: {":path", "/4"},
97: {":path", "/5"},
98: {":path", "/6"},
99: {":path", "/7"},
100: {":path", "/8"},
}
// hpackCapturedHeaders est la liste des en-têtes H2 dont on capture la valeur.
var hpackCapturedHeaders = map[string]bool{
// hpackCapturedHeaders est la liste des en-têtes HTTP/2 dont on capture la valeur.
// Utilisé par h2conn.go pour filtrer les en-têtes décodés.
var HpackCapturedHeaders = map[string]bool{
"user-agent": true,
"accept": true,
"accept-encoding": true,
@ -414,229 +29,8 @@ var hpackCapturedHeaders = map[string]bool{
":path": true,
":authority": true,
":scheme": true,
":status": true,
"cookie": true,
"referer": true,
"host": true,
}
// hpackInteger décode un entier HPACK avec le préfixe spécifié (RFC 7541 §5.1).
// Retourne la valeur décodée et le nombre d'octets consommés.
func hpackInteger(data []byte, prefixBits int) (int, int) {
if len(data) == 0 {
return 0, 0
}
mask := (1 << prefixBits) - 1
value := int(data[0] & byte(mask))
offset := 1
if value < mask {
return value, offset
}
// Extension multi-octets
m := 0
for offset < len(data) && offset < 6 { // limite de sécurité
b := int(data[offset])
value += (b & 0x7F) << m
m += 7
offset++
if b&0x80 == 0 {
break
}
}
return value, offset
}
// hpackString décode une chaîne HPACK (RFC 7541 §5.2).
// Retourne la chaîne décodée et le nombre d'octets consommés.
// Le décodage Huffman n'est pas implémenté — les chaînes Huffman sont ignorées.
func hpackString(data []byte) (string, int) {
if len(data) == 0 {
return "", 0
}
isHuffman := data[0]&0x80 != 0
length, offset := hpackInteger(data, 7)
if isHuffman {
// Huffman non implémenté — on ne peut pas décoder la valeur
return "", offset + length
}
if offset+length > len(data) {
// Données tronquées — retourner ce qu'on peut
if offset < len(data) {
return string(data[offset:]), len(data)
}
return "", offset
}
return string(data[offset : offset+length]), offset + length
}
// DecodeH2HeadersBlock décode un bloc d'en-têtes HPACK depuis un HEADERS frame.
// Retourne un map nom→valeur et la liste ordonnée des noms.
// Gère les représentations les plus courantes :
// - Indexée (6.1) : index → nom+valeur de la table statique
// - Littérale avec index incrémental (6.2.1) : nom indexé + valeur littérale
// - Littérale sans indexation (6.2.2) : nom indexé + valeur littérale
// - Littérale jamais indexée (6.2.3) : nom indexé + valeur littérale
// - Nouveau nom littéral (6.2.x avec index=0) : nom littéral + valeur littérale
func DecodeH2HeadersBlock(block []byte) (map[string]string, []string) {
kv := make(map[string]string)
var order []string
offset := 0
for offset < len(block) && len(kv) < 50 { // limite de sécurité
b := block[offset]
// 1. Représentation indexée (bit 7 = 1) : RFC 7541 §6.1
if b&0x80 != 0 {
idx, n := hpackInteger(block[offset:], 7)
offset += n
if idx > 0 && idx <= len(hpackStaticTable) {
// Uniquement indexée — nom et valeur viennent de la table
// Pour les entrées "nom uniquement" (pas de valeur par défaut),
// on ne peut pas extraire la valeur sans table dynamique
_ = hpackStaticTable[idx] // will be replaced
}
continue
}
var name string
var nameLen int
// 2. Littérale avec index incrémental (bits 7-6 = 01) : RFC 7541 §6.2.1
if b&0xC0 == 0x40 {
idx, n := hpackInteger(block[offset:], 6)
offset += n
if idx == 0 {
// Nouveau nom : nom littéral suivi de valeur littérale
name, nameLen = hpackString(block[offset:])
offset += nameLen
} else if idx <= len(hpackStaticTable) {
name = hpackStaticTable[idx].Name
}
value, valueLen := hpackString(block[offset:])
offset += valueLen
nameLower := strings.ToLower(name)
if nameLower != "" && value != "" && hpackCapturedHeaders[nameLower] {
kv[nameLower] = value
order = append(order, nameLower)
}
continue
}
// 3. Littérale sans indexation (bits 7-5 = 000) : RFC 7541 §6.2.2
if b&0xF0 == 0x00 {
idx, n := hpackInteger(block[offset:], 4)
offset += n
if idx == 0 {
name, nameLen = hpackString(block[offset:])
offset += nameLen
} else if idx <= len(hpackStaticTable) {
name = hpackStaticTable[idx].Name
}
value, valueLen := hpackString(block[offset:])
offset += valueLen
nameLower := strings.ToLower(name)
if nameLower != "" && value != "" && hpackCapturedHeaders[nameLower] {
kv[nameLower] = value
order = append(order, nameLower)
}
continue
}
// 4. Littérale jamais indexée (bits 7-5 = 0001) : RFC 7541 §6.2.3
if b&0xF0 == 0x10 {
idx, n := hpackInteger(block[offset:], 4)
offset += n
if idx == 0 {
name, nameLen = hpackString(block[offset:])
offset += nameLen
} else if idx <= len(hpackStaticTable) {
name = hpackStaticTable[idx].Name
}
value, valueLen := hpackString(block[offset:])
offset += valueLen
nameLower := strings.ToLower(name)
if nameLower != "" && value != "" && hpackCapturedHeaders[nameLower] {
kv[nameLower] = value
order = append(order, nameLower)
}
continue
}
// Représentation inconnue — arrêter
break
}
return kv, order
}
// IsH2FrameHeader vérifie si les données commencent par un en-tête de frame HTTP/2 valide.
// Utilisé pour détecter les frames H2 seules (sans préface) dans les SSL_read ultérieurs.
func IsH2FrameHeader(data []byte) bool {
if len(data) < 9 {
return false
}
hdr, err := parseH2FrameHeader(data)
if err != nil {
return false
}
// Vérifications de plausibilité :
// - Longueur ≤ 16384 (16 KiB, limite conservatrice pour un seul read)
// - Type dans la plage 0-9 (types de frame définis)
// - Stream ID dans une plage raisonnable
if hdr.Length > 16384 {
return false
}
if hdr.Type > 9 {
return false
}
return true
}
// ExtractH2HeaderKV extrait les en-têtes des frames HEADERS HTTP/2.
// Parcourt toutes les frames dans les données et décode les blocs HEADERS.
func ExtractH2HeaderKV(data []byte) map[string]string {
kv := make(map[string]string)
offset := 0
for offset < len(data) && len(kv) < 50 {
if offset+9 > len(data) {
break
}
hdr, err := parseH2FrameHeader(data[offset:])
if err != nil {
break
}
offset += 9
payloadEnd := offset + int(hdr.Length)
if payloadEnd > len(data) {
break
}
payload := data[offset:payloadEnd]
offset = payloadEnd
if hdr.Type == h2FrameHeaders && hdr.StreamID > 0 {
frameKV, _ := DecodeH2HeadersBlock(payload)
for k, v := range frameKV {
if _, exists := kv[k]; !exists {
kv[k] = v
}
}
}
}
return kv
}
}

View File

@ -4,6 +4,7 @@ import (
"testing"
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
"golang.org/x/net/http2"
)
func TestDetectH2PrefaceTrue(t *testing.T) {
@ -33,118 +34,6 @@ func TestH2MagicPrefaceLen(t *testing.T) {
}
}
func TestParseH2ClientPrefaceSettingsEmpty(t *testing.T) {
// Frame SETTINGS vide (longueur 0, aucun paramètre) sur stream 0
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings == nil {
t.Fatal("settings ne doit pas être nil")
}
// Tous les champs doivent être -1 (absent)
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize: attendu -1, obtenu %d", settings.HeaderTableSize)
}
if settings.InitialWindowSize != -1 {
t.Errorf("InitialWindowSize: attendu -1, obtenu %d", settings.InitialWindowSize)
}
}
func TestParseH2ClientPrefaceSettingsWithValues(t *testing.T) {
// Frame SETTINGS avec INITIAL_WINDOW_SIZE=65536 et MAX_CONCURRENT_STREAMS=100
settingsPayload := []byte{
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 65536
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
}
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.InitialWindowSize != 65536 {
t.Errorf("InitialWindowSize: attendu 65536, obtenu %d", settings.InitialWindowSize)
}
if settings.MaxConcurrentStreams != 100 {
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", settings.MaxConcurrentStreams)
}
// Les paramètres non présents restent à -1
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize non fourni: attendu -1, obtenu %d", settings.HeaderTableSize)
}
}
func TestParseH2ClientPrefaceWindowUpdate(t *testing.T) {
// Frame WINDOW_UPDATE sur stream 0 avec incrément = 1073741824
wuPayload := []byte{0x40, 0x00, 0x00, 0x00} // 0x40000000 = 1073741824
frame := buildH2Frame(0x8, 0x0, 0, wuPayload)
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.WindowUpdateIncrement != 1073741824 {
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", settings.WindowUpdateIncrement)
}
}
func TestParseH2ClientPrefaceCombined(t *testing.T) {
// SETTINGS + WINDOW_UPDATE combinés (comme envoyé par curl/h2)
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
}
wuPayload := []byte{0x00, 0x0f, 0x00, 0x01} // WINDOW_UPDATE incr = 983041
frames := buildH2Frame(0x4, 0x0, 0, settingsPayload)
frames = append(frames, buildH2Frame(0x8, 0x0, 0, wuPayload)...)
settings, err := parser.ParseH2ClientPreface(frames)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.HeaderTableSize != 4096 {
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", settings.HeaderTableSize)
}
if settings.InitialWindowSize != 65535 {
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", settings.InitialWindowSize)
}
if settings.WindowUpdateIncrement != 983041 {
t.Errorf("WindowUpdateIncrement: attendu 983041, obtenu %d", settings.WindowUpdateIncrement)
}
}
func TestParseH2ClientPrefaceEmpty(t *testing.T) {
// Données vides : doit retourner sans erreur, settings avec valeurs par défaut (-1)
settings, err := parser.ParseH2ClientPreface([]byte{})
if err != nil {
t.Fatalf("ParseH2ClientPreface sur vide: %v", err)
}
if settings == nil {
t.Error("settings ne doit pas être nil même pour données vides")
}
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize: attendu -1 par défaut, obtenu %d", settings.HeaderTableSize)
}
}
func TestParseH2ClientPrefaceTruncatedFrame(t *testing.T) {
// Frame tronquée : en-tête complet mais payload incomplet
truncated := []byte{0x00, 0x00, 0x06, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01} // payload tronqué
settings, err := parser.ParseH2ClientPreface(truncated)
if err != nil {
t.Fatalf("ParseH2ClientPreface sur frame tronquée: %v (doit tolérer)", err)
}
// Les paramètres restent à -1 car le payload est incomplet
_ = settings
}
// ── Helpers ───────────────────────────────────────────────────────────────
// buildH2Frame construit une frame HTTP/2 brute (en-tête 9 octets + payload).
func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byte {
l := len(payload)
@ -156,77 +45,254 @@ func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byt
return append(frame, payload...)
}
func TestDecodeH2HeadersBlockLiteralWithIndexedName(t *testing.T) {
// Literal with incremental indexing, indexed name (user-agent = index 61 in RFC 7541)
// Prefix byte: 0x40 | 61 = 0x7D
// Then value: 7-bit length "Mozilla/5.0" = 11 bytes, no Huffman
h2block := []byte{
0x7D, // indexed name = 61 (user-agent), with incremental indexing
0x0B, 'M', 'o', 'z', 'i', 'l', 'l', 'a', '/', '5', '.', '0', // value length 11 + value
// TestH2ConnStateSettings verifies that H2ConnState processes SETTINGS frames correctly.
func TestH2ConnStateSettings(t *testing.T) {
conn := parser.NewH2ConnState()
// SETTINGS frame with HEADER_TABLE_SIZE=4096, INITIAL_WINDOW_SIZE=65535
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
}
kv, order := parser.DecodeH2HeadersBlock(h2block)
if kv["user-agent"] != "Mozilla/5.0" {
t.Errorf("user-agent: attendu 'Mozilla/5.0', obtenu %q", kv["user-agent"])
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload) // SETTINGS, no flags, stream 0
result, err := conn.ProcessFrames(frame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if len(order) != 1 || order[0] != "user-agent" {
t.Errorf("order: attendu [user-agent], obtenu %v", order)
if result == nil {
t.Fatal("result ne doit pas être nil")
}
if result.ClientSettings == nil {
t.Fatal("ClientSettings ne doit pas être nil")
}
if result.ClientSettings.HeaderTableSize != 4096 {
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", result.ClientSettings.HeaderTableSize)
}
if result.ClientSettings.InitialWindowSize != 65535 {
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", result.ClientSettings.InitialWindowSize)
}
}
func TestDecodeH2HeadersBlockLiteralWithoutIndexing(t *testing.T) {
// Literal without indexing, indexed name (accept-encoding = index 16)
// 4-bit prefix max = 15, so index 16 needs multi-byte: 0x0F 0x01
h2block := []byte{
0x0F, 0x01, // literal without indexing, name index = 16 (accept-encoding)
0x12, 'g', 'z', 'i', 'p', ',', ' ', 'd', 'e', 'f', 'l', 'a', 't', 'e', ',', ' ', 'b', 'r', // value
// TestH2ConnStateWindowUpdate verifies WINDOW_UPDATE on stream 0.
func TestH2ConnStateWindowUpdate(t *testing.T) {
conn := parser.NewH2ConnState()
// WINDOW_UPDATE on stream 0 with increment = 1073741824 (0x40000000)
wuPayload := []byte{0x40, 0x00, 0x00, 0x00}
frame := buildH2Frame(0x8, 0x0, 0, wuPayload) // WINDOW_UPDATE, stream 0
result, err := conn.ProcessFrames(frame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
kv, _ := parser.DecodeH2HeadersBlock(h2block)
if kv["accept-encoding"] != "gzip, deflate, br" {
t.Errorf("accept-encoding: attendu 'gzip, deflate, br', obtenu %q", kv["accept-encoding"])
if result == nil {
t.Fatal("result ne doit pas être nil")
}
if result.ConnWindowUpdate != 1073741824 {
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", result.ConnWindowUpdate)
}
}
func TestDecodeH2HeadersBlockLiteralNewName(t *testing.T) {
// Literal with incremental indexing, new name
// Prefix byte: 0x40 (index = 0, new name)
// Name: "x-custom-header", Value: "test-value"
name := "x-custom-header"
value := "test-value"
h2block := []byte{
0x40, // literal with incremental indexing, new name
byte(len(name)), // name length
}
h2block = append(h2block, []byte(name)...)
h2block = append(h2block, byte(len(value)))
h2block = append(h2block, []byte(value)...)
// TestH2ConnStateHeadersWithHPACK verifies HEADERS frame decoding via hpack.Decoder.
func TestH2ConnStateHeadersWithHPACK(t *testing.T) {
conn := parser.NewH2ConnState()
kv, order := parser.DecodeH2HeadersBlock(h2block)
// x-custom-header is not in hpackCapturedHeaders, so it won't be in kv
if len(kv) != 0 {
t.Errorf("x-custom-header ne doit pas être capturé (pas dans hpackCapturedHeaders), obtenu %v", kv)
// HEADERS frame with END_HEADERS flag:
// 0x82 = :method GET (indexed)
// 0x84 = :path / (indexed)
// 0x41 = :authority with literal value "example.com"
headersPayload := []byte{
0x82, // :method GET
0x84, // :path /
0x41, // :authority with literal value
0x0B, 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm',
}
_ = order
}
frame := buildH2Frame(0x1, 0x04, 1, headersPayload) // HEADERS, END_HEADERS, stream 1
func TestDecodeH2HeadersBlockPseudoHeaders(t *testing.T) {
// Pseudo-headers :method GET (indexed, byte 0x82), :path / (indexed, byte 0x84)
// Then :authority as literal with indexed name (index 1)
// 0x40 | 1 = 0x41, then value "example.com"
h2block := []byte{
0x82, // indexed :method GET
0x84, // indexed :path /
0x41, // literal with incremental indexing, name index 1 (:authority)
0x0B, 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm', // value
result, err := conn.ProcessFrames(frame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
kv, order := parser.DecodeH2HeadersBlock(h2block)
if kv[":authority"] != "example.com" {
t.Errorf(":authority: attendu 'example.com', obtenu %q", kv[":authority"])
if result == nil {
t.Fatal("result ne doit pas être nil")
}
if len(order) < 1 {
t.Errorf("order ne doit pas être vide, obtenu %v", order)
// Check headers
headerMap := make(map[string]string)
for _, h := range result.Headers {
headerMap[h.Name] = h.Value
}
if headerMap[":method"] != "GET" {
t.Errorf(":method: attendu 'GET', obtenu %q", headerMap[":method"])
}
if headerMap[":path"] != "/" {
t.Errorf(":path: attendu '/', obtenu %q", headerMap[":path"])
}
if headerMap[":authority"] != "example.com" {
t.Errorf(":authority: attendu 'example.com', obtenu %q", headerMap[":authority"])
}
}
// TestH2ConnStateHeadersFullyIndexed verifies fully-indexed HPACK representations.
func TestH2ConnStateHeadersFullyIndexed(t *testing.T) {
conn := parser.NewH2ConnState()
// All fully-indexed: :method GET, :scheme https, :path /, accept */*
// Note: Go's hpack static table has index 19 as accept="" (no default value),
// unlike RFC 7541 which defines it as accept: */*. We test actual Go behavior.
headersPayload := []byte{
0x82, // :method GET
0x87, // :scheme https
0x84, // :path /
0x93, // accept (Go hpack: empty value; RFC 7541: */*)
}
frame := buildH2Frame(0x1, 0x04, 1, headersPayload)
result, err := conn.ProcessFrames(frame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
headerMap := make(map[string]string)
for _, h := range result.Headers {
headerMap[h.Name] = h.Value
}
if headerMap[":method"] != "GET" {
t.Errorf(":method: attendu 'GET', obtenu %q", headerMap[":method"])
}
if headerMap[":scheme"] != "https" {
t.Errorf(":scheme: attendu 'https', obtenu %q", headerMap[":scheme"])
}
if headerMap[":path"] != "/" {
t.Errorf(":path: attendu '/', obtenu %q", headerMap[":path"])
}
// Go's hpack emits accept="" for index 19 — verify it's present but empty
if _, ok := headerMap["accept"]; !ok {
t.Error("accept: header attendu mais absent")
}
}
// TestH2ConnStatePrefaceAndSettings verifies processing of H2 preface followed by SETTINGS.
func TestH2ConnStatePrefaceAndSettings(t *testing.T) {
// Client preface: magic + SETTINGS frame
preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
// SETTINGS with INITIAL_WINDOW_SIZE=65536 and MAX_CONCURRENT_STREAMS=100
settingsPayload := []byte{
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
}
settingsFrame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
data := append(preface, settingsFrame...)
// Detect preface and process remaining bytes
afterPreface := data[parser.H2MagicPrefaceLen():]
conn := parser.NewH2ConnState()
result, err := conn.ProcessFrames(afterPreface, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if result == nil || result.ClientSettings == nil {
t.Fatal("ClientSettings ne doit pas être nil")
}
if result.ClientSettings.InitialWindowSize != 65535 {
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", result.ClientSettings.InitialWindowSize)
}
if result.ClientSettings.MaxConcurrentStreams != 100 {
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", result.ClientSettings.MaxConcurrentStreams)
}
}
// TestH2ConnStateDynamicTable verifies that HPACK dynamic table works across multiple HEADERS frames.
func TestH2ConnStateDynamicTable(t *testing.T) {
conn := parser.NewH2ConnState()
// First HEADERS frame: :method GET, :authority example.com (literal with indexing)
// This adds "example.com" to the dynamic table
headers1 := []byte{
0x82, // :method GET (indexed)
0x41, // :authority with literal value (indexed in dynamic table)
0x0B, 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm',
}
frame1 := buildH2Frame(0x1, 0x04, 1, headers1)
result1, _ := conn.ProcessFrames(frame1, 0)
if result1 == nil {
t.Fatal("result1 ne doit pas être nil")
}
headerMap1 := make(map[string]string)
for _, h := range result1.Headers {
headerMap1[h.Name] = h.Value
}
if headerMap1[":authority"] != "example.com" {
t.Errorf("first frame: :authority attendu 'example.com', obtenu %q", headerMap1[":authority"])
}
// Second HEADERS frame on stream 3: :method GET, :authority example.com (now in dynamic table)
// After adding "example.com" with index 62 in dynamic table, we can reference it
// However, for a simple test, we just verify the decoder still works
headers2 := []byte{
0x82, // :method GET (indexed)
0x84, // :path / (indexed)
}
frame2 := buildH2Frame(0x1, 0x04, 3, headers2)
result2, _ := conn.ProcessFrames(frame2, 0)
if result2 == nil {
t.Fatal("result2 ne doit pas être nil")
}
headerMap2 := make(map[string]string)
for _, h := range result2.Headers {
headerMap2[h.Name] = h.Value
}
if headerMap2[":method"] != "GET" {
t.Errorf("second frame: :method attendu 'GET', obtenu %q", headerMap2[":method"])
}
if headerMap2[":path"] != "/" {
t.Errorf("second frame: :path attendu '/', obtenu %q", headerMap2[":path"])
}
}
// TestH2ConnStateServerStatus verifies :status extraction from server HEADERS.
func TestH2ConnStateServerStatus(t *testing.T) {
conn := parser.NewH2ConnState()
// Server HEADERS frame with :status 200 (indexed, byte 0x88)
headersPayload := []byte{0x88} // :status 200
frame := buildH2Frame(0x1, 0x04, 1, headersPayload)
result, err := conn.ProcessFrames(frame, 1) // direction=1 (server→client)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if result.StatusCode != 200 {
t.Errorf("StatusCode: attendu 200, obtenu %d", result.StatusCode)
}
}
// TestH2ConnStateGoAway verifies GOAWAY frame processing.
func TestH2ConnStateGoAway(t *testing.T) {
conn := parser.NewH2ConnState()
// GOAWAY frame: last stream ID = 0, error code = NO_ERROR (0)
goawayPayload := []byte{
0x00, 0x00, 0x00, 0x00, // last stream ID = 0
0x00, 0x00, 0x00, 0x00, // error code = NO_ERROR
}
frame := buildH2Frame(0x7, 0x0, 0, goawayPayload) // GOAWAY, stream 0
result, err := conn.ProcessFrames(frame, 1)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if result.GoAwayLastStream != 0 {
t.Errorf("GoAwayLastStream: attendu 0, obtenu %d", result.GoAwayLastStream)
}
}
// TestIsH2FrameHeader verifies frame detection using http2.Framer.
func TestIsH2FrameHeader(t *testing.T) {
// Frame SETTINGS valide
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
@ -236,7 +302,7 @@ func TestIsH2FrameHeader(t *testing.T) {
// Données aléatoires
random := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
if parser.IsH2FrameHeader(random) {
t.Error("IsH2FrameHeader doit retourner false pour données invalides (length > 16384)")
t.Error("IsH2FrameHeader doit retourner false pour données invalides")
}
// Trop court
if parser.IsH2FrameHeader([]byte{0x00, 0x00}) {
@ -244,29 +310,426 @@ func TestIsH2FrameHeader(t *testing.T) {
}
}
func TestExtractH2HeaderKV(t *testing.T) {
// HEADERS frame with :authority literal
// TestH2ConnStateRSTStream verifies RST_STREAM frame processing.
func TestH2ConnStateRSTStream(t *testing.T) {
conn := parser.NewH2ConnState()
// RST_STREAM on stream 1 with error code CANCEL (0x08)
rstPayload := []byte{0x00, 0x00, 0x00, 0x08} // error code CANCEL
frame := buildH2Frame(0x3, 0x0, 1, rstPayload) // RST_STREAM, stream 1
result, _ := conn.ProcessFrames(frame, 1)
if result == nil {
t.Fatal("result ne doit pas être nil")
}
// Check that stream 1 is in the closed streams
found := false
for _, id := range result.StreamClosed {
if id == 1 {
found = true
}
}
if !found {
t.Error("stream 1 devrait être dans StreamClosed après RST_STREAM")
}
}
// TestHpackDecoderBasic verifies the hpack.Decoder works correctly via H2ConnState.
func TestHpackDecoderBasic(t *testing.T) {
// Create an H2ConnState and feed it a SETTINGS frame first (to set dynamic table size)
conn := parser.NewH2ConnState()
// SETTINGS with HEADER_TABLE_SIZE=4096
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
}
settingsFrame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
result, _ := conn.ProcessFrames(settingsFrame, 0)
if result.ClientSettings == nil || result.ClientSettings.HeaderTableSize != 4096 {
t.Errorf("HEADER_TABLE_SIZE: attendu 4096")
}
// Now feed a HEADERS frame with user-agent (literal with indexed name)
// user-agent is index 58 in HPACK static table
// 0x40 | 58 = 0x7A, then value length 8, then "curl/8.0"
uaPayload := []byte{
0x82, // :method GET
0x7A, // user-agent with literal value (indexed name 58)
0x08, 'c', 'u', 'r', 'l', '/', '8', '.', '0',
}
uaFrame := buildH2Frame(0x1, 0x04, 1, uaPayload)
result2, _ := conn.ProcessFrames(uaFrame, 0)
headerMap := make(map[string]string)
for _, h := range result2.Headers {
headerMap[h.Name] = h.Value
}
if headerMap["user-agent"] != "curl/8.0" {
t.Errorf("user-agent: attendu 'curl/8.0', obtenu %q", headerMap["user-agent"])
}
if headerMap[":method"] != "GET" {
t.Errorf(":method: attendu 'GET', obtenu %q", headerMap[":method"])
}
}
// TestH2ConnStateContinuation verifies HEADERS + CONTINUATION assembly.
func TestH2ConnStateContinuation(t *testing.T) {
conn := parser.NewH2ConnState()
// HEADERS frame WITHOUT END_HEADERS (flags=0x00, stream 1)
headersPayload := []byte{
0x41, // literal with incremental indexing, name index 1 (:authority)
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // value
0x82, // :method GET
0x84, // :path /
}
frame := buildH2Frame(0x1, 0x04, 1, headersPayload) // HEADERS, END_HEADERS, stream 1
headersFrame := buildH2Frame(0x1, 0x00, 1, headersPayload) // HEADERS, NO END_HEADERS
kv := parser.ExtractH2HeaderKV(frame)
if kv[":authority"] != "example" {
t.Errorf(":authority: attendu 'example', obtenu %q", kv[":authority"])
// CONTINUATION frame WITH END_HEADERS (flags=0x04, stream 1)
contPayload := []byte{
0x41, // :authority with literal value
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
}
contFrame := buildH2Frame(0x9, 0x04, 1, contPayload) // CONTINUATION, END_HEADERS
// Process both frames in one call
data := append(headersFrame, contFrame...)
result, _ := conn.ProcessFrames(data, 0)
if result == nil {
t.Fatal("result ne doit pas être nil")
}
headerMap := make(map[string]string)
for _, h := range result.Headers {
headerMap[h.Name] = h.Value
}
if headerMap[":method"] != "GET" {
t.Errorf(":method: attendu 'GET', obtenu %q", headerMap[":method"])
}
if headerMap[":path"] != "/" {
t.Errorf(":path: attendu '/', obtenu %q", headerMap[":path"])
}
if headerMap[":authority"] != "example" {
t.Errorf(":authority: attendu 'example', obtenu %q", headerMap[":authority"])
}
}
func TestFormatTCPOptions(t *testing.T) {
// MSS(2,4bytes) + WS(3,3bytes) + SACK(4,2bytes) + NOP(1) + TS(8,10bytes)
opts := []byte{
2, 4, 0x05, 0xB4, // MSS = 1460
3, 3, 6, // WS = 6
4, 2, // SACK Permitted
1, // NOP
8, 10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // TS
// TestH2ConnStatePing verifies PING frame counting.
func TestH2ConnStatePing(t *testing.T) {
conn := parser.NewH2ConnState()
// PING frame (8 bytes opaque data)
pingPayload := make([]byte, 8)
frame := buildH2Frame(0x6, 0x0, 0, pingPayload) // PING, stream 0
result, _ := conn.ProcessFrames(frame, 0)
if result == nil {
t.Fatal("result ne doit pas être nil")
}
count, ok := result.FrameCounts[http2.FramePing]
if !ok || count != 1 {
t.Errorf("PING frame count: attendu 1, obtenu %d", count)
}
// This function is in the writer package, not parser - skip direct test here
_ = opts
}
// ---------------------------------------------------------------------------
// Phase 2 tests
// ---------------------------------------------------------------------------
// TestH2ConnStateSettingsAck verifies SETTINGS ACK detection.
func TestH2ConnStateSettingsAck(t *testing.T) {
conn := parser.NewH2ConnState()
// SETTINGS ACK frame (ACK flag = 0x01, no payload)
ackFrame := buildH2Frame(0x4, 0x01, 0, []byte{}) // SETTINGS, ACK flag
result, err := conn.ProcessFrames(ackFrame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if !result.SettingsAckSeen {
t.Error("SettingsAckSeen devrait être true après SETTINGS ACK")
}
if !conn.SettingsAck {
t.Error("H2ConnState.SettingsAck devrait être true après SETTINGS ACK")
}
}
// TestH2ConnStatePingAck verifies PING ACK flag distinction.
func TestH2ConnStatePingAck(t *testing.T) {
conn := parser.NewH2ConnState()
// PING ACK frame (ACK flag = 0x01)
pingPayload := make([]byte, 8)
ackFrame := buildH2Frame(0x6, 0x01, 0, pingPayload) // PING, ACK flag
result, err := conn.ProcessFrames(ackFrame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if !result.PingAckSeen {
t.Error("PingAckSeen devrait être true après PING ACK")
}
// Regular PING should NOT set PingAckSeen
conn2 := parser.NewH2ConnState()
regularPing := buildH2Frame(0x6, 0x0, 0, pingPayload) // PING, no ACK
result2, _ := conn2.ProcessFrames(regularPing, 0)
if result2.PingAckSeen {
t.Error("PingAckSeen ne devrait pas être true pour un PING régulier")
}
}
// TestH2ConnStatePriority verifies PRIORITY frame decoding.
func TestH2ConnStatePriority(t *testing.T) {
conn := parser.NewH2ConnState()
// Create stream 1 first (HEADERS)
headersPayload := []byte{0x82, 0x84} // :method GET, :path /
headersFrame := buildH2Frame(0x1, 0x04, 1, headersPayload)
conn.ProcessFrames(headersFrame, 0)
// PRIORITY frame on stream 1: StreamDep=0, Exclusive=false, Weight=15
// PRIORITY payload: 4 bytes (stream dep + exclusive bit) + 1 byte weight
priorityPayload := []byte{
0x00, 0x00, 0x00, 0x00, // StreamDep=0, Exclusive=false (bit 31 = 0)
0x0F, // Weight=15
}
priorityFrame := buildH2Frame(0x2, 0x0, 1, priorityPayload) // PRIORITY, stream 1
_, err := conn.ProcessFrames(priorityFrame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
stream, ok := conn.Streams[1]
if !ok {
t.Fatal("stream 1 devrait exister")
}
if stream.Priority == nil {
t.Fatal("stream.Priority ne devrait pas être nil après PRIORITY frame")
}
if stream.Priority.Weight != 15 {
t.Errorf("Weight: attendu 15, obtenu %d", stream.Priority.Weight)
}
if stream.Priority.StreamDep != 0 {
t.Errorf("StreamDep: attendu 0, obtenu %d", stream.Priority.StreamDep)
}
if stream.Priority.Exclusive {
t.Error("Exclusive devrait être false")
}
// Verify frame type history
found := false
for _, ft := range stream.FrameTypes {
if ft == http2.FramePriority {
found = true
}
}
if !found {
t.Error("PRIORITY devrait être dans FrameTypes du stream")
}
}
// TestH2ConnStatePerStreamWindowUpdate verifies per-stream WINDOW_UPDATE.
func TestH2ConnStatePerStreamWindowUpdate(t *testing.T) {
conn := parser.NewH2ConnState()
// Create stream 3 (client-initiated, odd)
headersPayload := []byte{0x82, 0x84} // :method GET, :path /
headersFrame := buildH2Frame(0x1, 0x04, 3, headersPayload)
conn.ProcessFrames(headersFrame, 0)
// WINDOW_UPDATE on stream 3 with increment = 32768
wuPayload := []byte{0x00, 0x00, 0x80, 0x00} // 32768
wuFrame := buildH2Frame(0x8, 0x0, 3, wuPayload) // WINDOW_UPDATE, stream 3
result, err := conn.ProcessFrames(wuFrame, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if result == nil {
t.Fatal("result ne doit pas être nil")
}
stream, ok := conn.Streams[3]
if !ok {
t.Fatal("stream 3 devrait exister")
}
if stream.WindowIncr != 32768 {
t.Errorf("WindowIncr: attendu 32768, obtenu %d", stream.WindowIncr)
}
}
// TestH2ConnStateFrameChronology verifies H2FrameRecord in results.
func TestH2ConnStateFrameChronology(t *testing.T) {
conn := parser.NewH2ConnState()
// SETTINGS frame
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
}
settingsFrame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
// HEADERS frame
headersPayload := []byte{0x82, 0x84} // :method GET, :path /
headersFrame := buildH2Frame(0x1, 0x04, 1, headersPayload)
// Process both frames in one call
data := append(settingsFrame, headersFrame...)
result, err := conn.ProcessFrames(data, 0)
if err != nil {
t.Fatalf("ProcessFrames: %v", err)
}
if len(result.Frames) != 2 {
t.Fatalf("Frames: attendu 2, obtenu %d", len(result.Frames))
}
// First frame: SETTINGS
f0 := result.Frames[0]
if f0.Index != 1 {
t.Errorf("Frame[0].Index: attendu 1, obtenu %d", f0.Index)
}
if f0.Direction != 0 {
t.Errorf("Frame[0].Direction: attendu 0, obtenu %d", f0.Direction)
}
if f0.Type != http2.FrameSettings {
t.Errorf("Frame[0].Type: attendu SETTINGS, obtenu %v", f0.Type)
}
if f0.StreamID != 0 {
t.Errorf("Frame[0].StreamID: attendu 0, obtenu %d", f0.StreamID)
}
// Second frame: HEADERS
f1 := result.Frames[1]
if f1.Index != 2 {
t.Errorf("Frame[1].Index: attendu 2, obtenu %d", f1.Index)
}
if f1.Type != http2.FrameHeaders {
t.Errorf("Frame[1].Type: attendu HEADERS, obtenu %v", f1.Type)
}
if f1.StreamID != 1 {
t.Errorf("Frame[1].StreamID: attendu 1, obtenu %d", f1.StreamID)
}
}
// TestH2ConnStateStreamInitiator verifies stream initiator tracking.
func TestH2ConnStateStreamInitiator(t *testing.T) {
conn := parser.NewH2ConnState()
// Stream 1 (client, odd)
h1 := []byte{0x82, 0x84} // :method GET, :path /
frame1 := buildH2Frame(0x1, 0x04, 1, h1)
conn.ProcessFrames(frame1, 0)
// Stream 2 (server, even) — server-initiated push promise
h2 := []byte{0x88} // :status 200
frame2 := buildH2Frame(0x1, 0x04, 2, h2)
conn.ProcessFrames(frame2, 1)
stream1, ok1 := conn.Streams[1]
if !ok1 {
t.Fatal("stream 1 devrait exister")
}
if stream1.Initiator != 0 {
t.Errorf("stream 1 Initiator: attendu 0 (client), obtenu %d", stream1.Initiator)
}
stream2, ok2 := conn.Streams[2]
if !ok2 {
t.Fatal("stream 2 devrait exister")
}
if stream2.Initiator != 1 {
t.Errorf("stream 2 Initiator: attendu 1 (serveur), obtenu %d", stream2.Initiator)
}
}
// TestH2ConnStateStreamStateMachine verifies open → half-closed → closed transitions.
func TestH2ConnStateStreamStateMachine(t *testing.T) {
conn := parser.NewH2ConnState()
// Stream 1: HEADERS with END_STREAM (client sends request + END_STREAM)
h1 := []byte{0x82, 0x84} // :method GET, :path /
frame1 := buildH2Frame(0x1, 0x05, 1, h1) // HEADERS, END_STREAM + END_HEADERS
conn.ProcessFrames(frame1, 0)
stream1, ok := conn.Streams[1]
if !ok {
t.Fatal("stream 1 devrait exister")
}
if stream1.State != "half-closed-remote" {
t.Errorf("après END_STREAM client: état attendu 'half-closed-remote', obtenu %q", stream1.State)
}
// Server responds with END_STREAM → closed
h2 := []byte{0x88} // :status 200
frame2 := buildH2Frame(0x1, 0x05, 1, h2) // HEADERS, END_STREAM + END_HEADERS
conn.ProcessFrames(frame2, 1)
if stream1.State != "closed" {
t.Errorf("après END_STREAM serveur: état attendu 'closed', obtenu %q", stream1.State)
}
}
// TestH2ConnStateStreamFrameHistory verifies FrameTypes accumulation per stream.
func TestH2ConnStateStreamFrameHistory(t *testing.T) {
conn := parser.NewH2ConnState()
// HEADERS on stream 1
h1 := []byte{0x82, 0x84}
frame1 := buildH2Frame(0x1, 0x04, 1, h1) // HEADERS, END_HEADERS
conn.ProcessFrames(frame1, 0)
// DATA on stream 1
dataPayload := []byte("hello")
dataFrame := buildH2Frame(0x0, 0x01, 1, dataPayload) // DATA, END_STREAM
conn.ProcessFrames(dataFrame, 0)
stream, ok := conn.Streams[1]
if !ok {
t.Fatal("stream 1 devrait exister")
}
if len(stream.FrameTypes) != 2 {
t.Fatalf("FrameTypes: attendu 2, obtenu %d", len(stream.FrameTypes))
}
if stream.FrameTypes[0] != http2.FrameHeaders {
t.Errorf("FrameTypes[0]: attendu HEADERS, obtenu %v", stream.FrameTypes[0])
}
if stream.FrameTypes[1] != http2.FrameData {
t.Errorf("FrameTypes[1]: attendu DATA, obtenu %v", stream.FrameTypes[1])
}
}
// TestH2ConnStateMultipleFramesInBatch verifies frame index persistence across calls.
func TestH2ConnStateMultipleFramesInBatch(t *testing.T) {
conn := parser.NewH2ConnState()
// First call: SETTINGS + HEADERS
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
}
settingsFrame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
h1 := []byte{0x82, 0x84}
headersFrame := buildH2Frame(0x1, 0x04, 1, h1)
data1 := append(settingsFrame, headersFrame...)
result1, _ := conn.ProcessFrames(data1, 0)
if len(result1.Frames) != 2 {
t.Fatalf("Batch 1: attendu 2 frames, obtenu %d", len(result1.Frames))
}
if result1.Frames[0].Index != 1 || result1.Frames[1].Index != 2 {
t.Errorf("Batch 1 indices: attendu [1,2], obtenu [%d,%d]", result1.Frames[0].Index, result1.Frames[1].Index)
}
// Second call: PING → index should continue at 3
pingPayload := make([]byte, 8)
pingFrame := buildH2Frame(0x6, 0x0, 0, pingPayload)
result2, _ := conn.ProcessFrames(pingFrame, 0)
if len(result2.Frames) != 1 {
t.Fatalf("Batch 2: attendu 1 frame, obtenu %d", len(result2.Frames))
}
if result2.Frames[0].Index != 3 {
t.Errorf("Batch 2 index: attendu 3, obtenu %d", result2.Frames[0].Index)
}
}

View File

@ -48,7 +48,7 @@ func ParseClientHello(payload []byte) (*ClientHello, error) {
recordVersion := binary.BigEndian.Uint16(payload[1:3])
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
// Le XDP capture au maximum MAX_TLS_PAYLOAD (512) octets.
// Le programme TC capture au maximum MAX_TLS_PAYLOAD (2048) octets.
// Si la taille du record TLS dépasse les données disponibles, on travaille
// avec ce qu'on a (le ClientHello est toujours en début de record).
available := len(payload) - 5
@ -69,7 +69,7 @@ func ParseClientHello(payload []byte) (*ClientHello, error) {
// Longueur du ClientHello (3 octets big-endian)
chLen := int(uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3]))
// Tolérance à la troncature XDP : on travaille avec ce qu'on a
// Tolérance à la troncature TC : on travaille avec ce qu'on a
if chLen > len(hs)-4 {
chLen = len(hs) - 4
}