feat(pr3): canonical wire-order header capture for h1/h2 + H3App for SETTINGS
- Renames caddy.listeners.decnet_h2fp → decnet_fp; adds h1 raw-byte header capture (plainTappingConn) and h2 continuous HPACK decode loop (parseH2HeadersLoop) so headers_ordered reflects actual wire order, not Go map iteration order. - Adds H3App Caddy module (decnet_h3) that owns UDP/443 via quic-go, wraps accepted QUIC connections with h3SettingsTappingConn to intercept the h3 control stream and extract RFC 9114 SETTINGS in wire order. - Wires access_log emission from FPHandler.ServeHTTP via responseCapture. - Updates syslog_bridge.py (canonical + per-service copies) with inline _compute_ja4h and new fp socket record branches: http_request_headers, h3_settings, access_log. - Fixes ingester proto field alias (bridge emits 'proto', ingester expected 'protocol') and exposes _process_fingerprint_bounties test alias. - Go tests: h1/h2/h3 golden-byte tests all green; h3_tracer_test covers varint parser, GREASE detection, truncated-stream safety. - Python tests: 15/15 green across bridge JA4H hash parity, ingester compat (old + new event shapes), and Caddyfile h3 template assertions.
This commit is contained in:
206
decnet/templates/https/_caddy_modules/decnetfp/h2_test.go
Normal file
206
decnet/templates/https/_caddy_modules/decnetfp/h2_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package decnetfp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// buildH2Preface returns the 24-byte h2 client preface.
|
||||
func buildH2Preface() []byte {
|
||||
return []byte(h2ClientPreface)
|
||||
}
|
||||
|
||||
// buildH2Settings builds a SETTINGS frame payload from id/val pairs.
|
||||
func buildH2Settings(pairs [][2]uint32) []byte {
|
||||
payload := make([]byte, len(pairs)*6)
|
||||
for i, p := range pairs {
|
||||
binary.BigEndian.PutUint16(payload[i*6:], uint16(p[0]))
|
||||
binary.BigEndian.PutUint32(payload[i*6+2:], p[1])
|
||||
}
|
||||
return buildH2Frame(0x4, 0, 0, payload)
|
||||
}
|
||||
|
||||
// buildH2Frame builds a raw h2 frame (9-byte header + payload).
|
||||
func buildH2Frame(typ, flags byte, streamID uint32, payload []byte) []byte {
|
||||
frame := make([]byte, 9+len(payload))
|
||||
frame[0] = byte(len(payload) >> 16)
|
||||
frame[1] = byte(len(payload) >> 8)
|
||||
frame[2] = byte(len(payload))
|
||||
frame[3] = typ
|
||||
frame[4] = flags
|
||||
binary.BigEndian.PutUint32(frame[5:], streamID)
|
||||
copy(frame[9:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
// buildH2HeadersFrame encodes headers with HPACK and wraps in a HEADERS frame.
|
||||
func buildH2HeadersFrame(streamID uint32, headers [][2]string, endHeaders bool) []byte {
|
||||
var hbuf []byte
|
||||
enc := hpack.NewEncoder(writeCloser{&hbuf})
|
||||
for _, h := range headers {
|
||||
enc.WriteField(hpack.HeaderField{Name: h[0], Value: h[1]}) //nolint:errcheck
|
||||
}
|
||||
flags := byte(0)
|
||||
if endHeaders {
|
||||
flags |= 0x04
|
||||
}
|
||||
return buildH2Frame(0x1, flags, streamID, hbuf)
|
||||
}
|
||||
|
||||
type writeCloser struct{ b *[]byte }
|
||||
|
||||
func (w writeCloser) Write(p []byte) (int, error) {
|
||||
*w.b = append(*w.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestParseH2HeadersLoop_DecodesHPACKOrder(t *testing.T) {
|
||||
sockPath := t.TempDir() + "/fp_h2.sock"
|
||||
t.Setenv("DECNET_FP_SOCK", sockPath)
|
||||
sockMu.Lock()
|
||||
sockConn = nil
|
||||
sockMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
sockMu.Lock()
|
||||
if sockConn != nil {
|
||||
sockConn.Close()
|
||||
sockConn = nil
|
||||
}
|
||||
sockMu.Unlock()
|
||||
})
|
||||
|
||||
srv := bindSock(t, sockPath)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
done := make(chan []map[string]interface{}, 1)
|
||||
go func() { done <- drainSock(t, srv, 1, 3*time.Second) }()
|
||||
|
||||
// Build: preface + SETTINGS + HEADERS (stream 1, END_HEADERS).
|
||||
// Headers in a specific order to verify HPACK decode order is preserved.
|
||||
wantHeaders := [][2]string{
|
||||
{":method", "GET"},
|
||||
{":path", "/index.html"},
|
||||
{":scheme", "https"},
|
||||
{":authority", "example.com"},
|
||||
{"accept", "text/html"},
|
||||
{"user-agent", "Go-http-client/2.0"},
|
||||
{"accept-language", "en-US"},
|
||||
}
|
||||
|
||||
preface := buildH2Preface()
|
||||
settings := buildH2Settings([][2]uint32{{0x3, 100}}) // MAX_CONCURRENT_STREAMS=100
|
||||
headers := buildH2HeadersFrame(1, wantHeaders, true)
|
||||
|
||||
tap := make(chan []byte, 256)
|
||||
|
||||
// Feed the preface through the tap.
|
||||
chunks := [][]byte{preface, settings, headers}
|
||||
for _, c := range chunks {
|
||||
cp := make([]byte, len(c))
|
||||
copy(cp, c)
|
||||
tap <- cp
|
||||
}
|
||||
close(tap)
|
||||
|
||||
go parseH2HeadersLoop("5.6.7.8:443", tap)
|
||||
|
||||
records := <-done
|
||||
if len(records) == 0 {
|
||||
t.Fatal("no records received")
|
||||
}
|
||||
rec := records[0]
|
||||
if rec["kind"] != "http_request_headers" {
|
||||
t.Errorf("kind: got %v", rec["kind"])
|
||||
}
|
||||
if rec["proto_tag"] != "h2" {
|
||||
t.Errorf("proto_tag: got %v", rec["proto_tag"])
|
||||
}
|
||||
if rec["method"] != "GET" {
|
||||
t.Errorf("method: got %v", rec["method"])
|
||||
}
|
||||
if rec["path"] != "/index.html" {
|
||||
t.Errorf("path: got %v", rec["path"])
|
||||
}
|
||||
if rec["accept_language"] != "en-US" {
|
||||
t.Errorf("accept_language: got %v", rec["accept_language"])
|
||||
}
|
||||
|
||||
rawOrdered, _ := json.Marshal(rec["headers_ordered"])
|
||||
var ordered [][]string
|
||||
if err := json.Unmarshal(rawOrdered, &ordered); err != nil {
|
||||
t.Fatalf("unmarshal headers_ordered: %v", err)
|
||||
}
|
||||
if len(ordered) != len(wantHeaders) {
|
||||
t.Fatalf("want %d headers, got %d: %v", len(wantHeaders), len(ordered), ordered)
|
||||
}
|
||||
for i, pair := range ordered {
|
||||
if pair[0] != wantHeaders[i][0] {
|
||||
t.Errorf("header[%d]: got %q, want %q", i, pair[0], wantHeaders[i][0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2Settings_FrameOrder(t *testing.T) {
|
||||
sockPath := t.TempDir() + "/fp_h2s.sock"
|
||||
t.Setenv("DECNET_FP_SOCK", sockPath)
|
||||
sockMu.Lock()
|
||||
sockConn = nil
|
||||
sockMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
sockMu.Lock()
|
||||
if sockConn != nil {
|
||||
sockConn.Close()
|
||||
sockConn = nil
|
||||
}
|
||||
sockMu.Unlock()
|
||||
})
|
||||
|
||||
srv, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sockPath, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
// SETTINGS frame with 3 params in a specific order.
|
||||
settings := [][2]uint32{
|
||||
{0x4, 65535}, // INITIAL_WINDOW_SIZE
|
||||
{0x3, 1000}, // MAX_CONCURRENT_STREAMS
|
||||
{0x1, 65536}, // HEADER_TABLE_SIZE
|
||||
}
|
||||
payload := make([]byte, len(settings)*6)
|
||||
for i, s := range settings {
|
||||
binary.BigEndian.PutUint16(payload[i*6:], uint16(s[0]))
|
||||
binary.BigEndian.PutUint32(payload[i*6+2:], s[1])
|
||||
}
|
||||
|
||||
done := make(chan map[string]interface{}, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 65536)
|
||||
srv.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
n, _ := srv.Read(buf)
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(buf[:n], &m) //nolint:errcheck
|
||||
done <- m
|
||||
}()
|
||||
|
||||
parseAndSendH2Settings("1.2.3.4:1234", payload)
|
||||
|
||||
rec := <-done
|
||||
if rec["kind"] != "h2_settings" {
|
||||
t.Errorf("kind: got %v", rec["kind"])
|
||||
}
|
||||
rawOrder, _ := json.Marshal(rec["frame_order"])
|
||||
var order []float64 // JSON numbers decode as float64
|
||||
json.Unmarshal(rawOrder, &order) //nolint:errcheck
|
||||
if len(order) != 3 {
|
||||
t.Fatalf("want 3 frame_order entries, got %d", len(order))
|
||||
}
|
||||
if order[0] != 4 || order[1] != 3 || order[2] != 1 {
|
||||
t.Errorf("frame_order: got %v, want [4 3 1]", order)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user