Files
anti 5675dd8ebc 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.
2026-05-10 03:29:00 -04:00

207 lines
5.4 KiB
Go

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)
}
}