- 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.
171 lines
4.6 KiB
Go
171 lines
4.6 KiB
Go
package decnetfp
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// encodeVarint encodes a uint64 as an RFC 9000 variable-length integer.
|
|
func encodeVarint(v uint64) []byte {
|
|
switch {
|
|
case v <= 0x3f:
|
|
return []byte{byte(v)}
|
|
case v <= 0x3fff:
|
|
return []byte{0x40 | byte(v>>8), byte(v)}
|
|
case v <= 0x3fffffff:
|
|
b := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(b, uint32(v)|0x80000000)
|
|
return b
|
|
default:
|
|
b := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(b, v|0xc000000000000000)
|
|
return b
|
|
}
|
|
}
|
|
|
|
// buildH3ControlStream builds the opening bytes of an h3 control stream
|
|
// with a SETTINGS frame containing the given id/val pairs.
|
|
func buildH3ControlStream(settings [][2]uint64) []byte {
|
|
// Stream type = 0x00 (control stream)
|
|
var body []byte
|
|
for _, s := range settings {
|
|
body = append(body, encodeVarint(s[0])...)
|
|
body = append(body, encodeVarint(s[1])...)
|
|
}
|
|
// h3 frame: type=0x04 (SETTINGS), length=len(body), body
|
|
frame := append(encodeVarint(0x04), encodeVarint(uint64(len(body)))...)
|
|
frame = append(frame, body...)
|
|
|
|
return append(encodeVarint(0x00), frame...)
|
|
}
|
|
|
|
func TestTryParseH3ControlStream_ParsesSettings(t *testing.T) {
|
|
sockPath := t.TempDir() + "/fp_h3.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 := [][2]uint64{
|
|
{0x01, 0}, // QPACK_MAX_TABLE_CAPACITY
|
|
{0x06, 0}, // MAX_FIELD_SECTION_SIZE (0 = unlimited)
|
|
{0x07, 0}, // QPACK_BLOCKED_STREAMS
|
|
{0x4242, 1}, // GREASE-like unknown value
|
|
}
|
|
data := buildH3ControlStream(settings)
|
|
|
|
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
|
|
}()
|
|
|
|
tryParseH3ControlStream("9.8.7.6:443", data)
|
|
|
|
rec := <-done
|
|
if rec == nil {
|
|
t.Fatal("no record received")
|
|
}
|
|
if rec["kind"] != "h3_settings" {
|
|
t.Errorf("kind: got %v, want h3_settings", rec["kind"])
|
|
}
|
|
if rec["remote_addr"] != "9.8.7.6:443" {
|
|
t.Errorf("remote_addr: got %v", rec["remote_addr"])
|
|
}
|
|
|
|
rawSettings, _ := json.Marshal(rec["settings"])
|
|
var gotSettings map[string]interface{}
|
|
json.Unmarshal(rawSettings, &gotSettings) //nolint:errcheck
|
|
if _, ok := gotSettings["QPACK_MAX_TABLE_CAPACITY"]; !ok {
|
|
t.Errorf("missing QPACK_MAX_TABLE_CAPACITY in settings: %v", gotSettings)
|
|
}
|
|
if _, ok := gotSettings["MAX_FIELD_SECTION_SIZE"]; !ok {
|
|
t.Errorf("missing MAX_FIELD_SECTION_SIZE in settings: %v", gotSettings)
|
|
}
|
|
|
|
rawOrder, _ := json.Marshal(rec["frame_order"])
|
|
var order []interface{}
|
|
json.Unmarshal(rawOrder, &order) //nolint:errcheck
|
|
if len(order) != 4 {
|
|
t.Errorf("want 4 frame_order entries, got %d: %v", len(order), order)
|
|
}
|
|
}
|
|
|
|
func TestTryParseH3ControlStream_WrongStreamType(t *testing.T) {
|
|
// Stream type 0x02 = QPACK encoder stream — should be ignored.
|
|
data := append(encodeVarint(0x02), []byte{0x00}...)
|
|
// Should not panic or emit any record.
|
|
tryParseH3ControlStream("1.2.3.4:443", data) // no socket — will silently drop
|
|
}
|
|
|
|
func TestTryParseH3ControlStream_TruncatedData(t *testing.T) {
|
|
// Only stream-type prefix, no SETTINGS frame yet.
|
|
data := encodeVarint(0x00)
|
|
tryParseH3ControlStream("1.2.3.4:443", data) // must not panic
|
|
}
|
|
|
|
func TestQuicVarint(t *testing.T) {
|
|
cases := []struct {
|
|
input []byte
|
|
want uint64
|
|
wantN int
|
|
}{
|
|
{[]byte{0x00}, 0, 1},
|
|
{[]byte{0x3f}, 63, 1},
|
|
{[]byte{0x40, 0x00}, 0, 2},
|
|
{[]byte{0x7f, 0xff}, 16383, 2},
|
|
{[]byte{0x80, 0x00, 0x00, 0x00}, 0, 4},
|
|
{[]byte{0xbf, 0xff, 0xff, 0xff}, 1073741823, 4},
|
|
// Empty input
|
|
{[]byte{}, 0, 0},
|
|
// Truncated 2-byte
|
|
{[]byte{0x40}, 0, 0},
|
|
}
|
|
for _, c := range cases {
|
|
v, n := quicVarint(c.input)
|
|
if v != c.want || n != c.wantN {
|
|
t.Errorf("quicVarint(%x) = (%d, %d), want (%d, %d)", c.input, v, n, c.want, c.wantN)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestH3SettingName(t *testing.T) {
|
|
cases := []struct {
|
|
id uint64
|
|
want string
|
|
}{
|
|
{0x01, "QPACK_MAX_TABLE_CAPACITY"},
|
|
{0x06, "MAX_FIELD_SECTION_SIZE"},
|
|
{0x07, "QPACK_BLOCKED_STREAMS"},
|
|
{0x08, "ENABLE_CONNECT_PROTOCOL"},
|
|
{0x33, "H3_DATAGRAM"},
|
|
{0x41, "UNKNOWN"}, // not a GREASE pattern (GREASE = 0x1f*N+0x21; 0x41-0x21=0x20, not div by 0x1f)
|
|
}
|
|
for _, c := range cases {
|
|
if got := h3SettingName(c.id); got != c.want {
|
|
t.Errorf("h3SettingName(0x%x) = %q, want %q", c.id, got, c.want)
|
|
}
|
|
}
|
|
}
|