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:
150
decnet/templates/https/_caddy_modules/decnetfp/h1_test.go
Normal file
150
decnet/templates/https/_caddy_modules/decnetfp/h1_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package decnetfp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bindSock creates a unix datagram socket at path and returns it.
|
||||
// The caller must close it (or register a t.Cleanup).
|
||||
func bindSock(t *testing.T, path string) *net.UnixConn {
|
||||
t.Helper()
|
||||
sock, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Fatalf("listen %s: %v", path, err)
|
||||
}
|
||||
return sock
|
||||
}
|
||||
|
||||
// drainSock reads up to n records from an already-bound unix datagram socket.
|
||||
func drainSock(t *testing.T, sock *net.UnixConn, count int, timeout time.Duration) []map[string]interface{} {
|
||||
t.Helper()
|
||||
sock.SetDeadline(time.Now().Add(timeout)) //nolint:errcheck
|
||||
var records []map[string]interface{}
|
||||
buf := make([]byte, 65536)
|
||||
for len(records) < count {
|
||||
n, err := sock.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(buf[:n], &m); err != nil {
|
||||
t.Logf("unmarshal: %v", err)
|
||||
continue
|
||||
}
|
||||
records = append(records, m)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func TestParseAndSendH1Headers(t *testing.T) {
|
||||
path := t.TempDir() + "/fp_h1.sock"
|
||||
t.Setenv("DECNET_FP_SOCK", path)
|
||||
// Reset the global socket so it reconnects to the test socket.
|
||||
sockMu.Lock()
|
||||
sockConn = nil
|
||||
sockMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
sockMu.Lock()
|
||||
if sockConn != nil {
|
||||
sockConn.Close()
|
||||
sockConn = nil
|
||||
}
|
||||
sockMu.Unlock()
|
||||
})
|
||||
|
||||
srv := bindSock(t, path)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
done := make(chan []map[string]interface{}, 1)
|
||||
go func() {
|
||||
done <- drainSock(t, srv, 1, 2*time.Second)
|
||||
}()
|
||||
|
||||
// Typical curl HTTP/1.1 request bytes.
|
||||
raw := "GET /robots.txt HTTP/1.1\r\n" +
|
||||
"Host: example.com\r\n" +
|
||||
"User-Agent: curl/8.0.1\r\n" +
|
||||
"Accept: */*\r\n" +
|
||||
"X-Custom-A: alpha\r\n" +
|
||||
"X-Custom-B: beta\r\n" +
|
||||
"\r\n"
|
||||
|
||||
parseAndSendH1Headers("1.2.3.4:9999", []byte(raw))
|
||||
|
||||
records := <-done
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(records))
|
||||
}
|
||||
rec := records[0]
|
||||
|
||||
if rec["kind"] != "http_request_headers" {
|
||||
t.Errorf("kind: got %v, want http_request_headers", rec["kind"])
|
||||
}
|
||||
if rec["proto_tag"] != "h1" {
|
||||
t.Errorf("proto_tag: got %v, want h1", rec["proto_tag"])
|
||||
}
|
||||
if rec["method"] != "GET" {
|
||||
t.Errorf("method: got %v, want GET", rec["method"])
|
||||
}
|
||||
if rec["path"] != "/robots.txt" {
|
||||
t.Errorf("path: got %v, want /robots.txt", rec["path"])
|
||||
}
|
||||
|
||||
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) != 5 {
|
||||
t.Fatalf("want 5 headers, got %d: %v", len(ordered), ordered)
|
||||
}
|
||||
// Wire order must be preserved exactly.
|
||||
want := []string{"host", "user-agent", "accept", "x-custom-a", "x-custom-b"}
|
||||
for i, pair := range ordered {
|
||||
if pair[0] != want[i] {
|
||||
t.Errorf("header[%d]: got %q, want %q", i, pair[0], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndSendH1Headers_StopsAtEmptyLine(t *testing.T) {
|
||||
// Headers should not include body bytes after \r\n\r\n.
|
||||
raw := "POST /login HTTP/1.1\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\nuser=bob&pass=secret"
|
||||
path := t.TempDir() + "/fp_h1b.sock"
|
||||
t.Setenv("DECNET_FP_SOCK", path)
|
||||
sockMu.Lock()
|
||||
sockConn = nil
|
||||
sockMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
sockMu.Lock()
|
||||
if sockConn != nil {
|
||||
sockConn.Close()
|
||||
sockConn = nil
|
||||
}
|
||||
sockMu.Unlock()
|
||||
})
|
||||
|
||||
srv := bindSock(t, path)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
done := make(chan []map[string]interface{}, 1)
|
||||
go func() { done <- drainSock(t, srv, 1, 2*time.Second) }()
|
||||
parseAndSendH1Headers("10.0.0.1:1234", []byte(raw))
|
||||
|
||||
records := <-done
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(records))
|
||||
}
|
||||
rawOrdered, _ := json.Marshal(records[0]["headers_ordered"])
|
||||
var ordered [][]string
|
||||
json.Unmarshal(rawOrdered, &ordered) //nolint:errcheck
|
||||
if len(ordered) != 1 {
|
||||
t.Fatalf("want 1 header (Content-Type), got %d: %v", len(ordered), ordered)
|
||||
}
|
||||
if ordered[0][0] != "content-type" {
|
||||
t.Errorf("header[0]: got %q, want content-type", ordered[0][0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user