Files
DECNET/decnet/templates/_caddy_modules/decnetfp/h1_test.go
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

151 lines
3.9 KiB
Go

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