- 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.
207 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|