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:
2026-05-10 03:29:00 -04:00
parent 8d1f26c0c7
commit 5675dd8ebc
33 changed files with 7240 additions and 124 deletions

View File

@@ -0,0 +1,184 @@
package decnetfp
import (
"bytes"
"context"
"io"
"sync"
"time"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/logging"
)
// newH3SettingsTracer is the quic.Config.Tracer factory. We don't use
// quic-go's logging.ConnectionTracer for SETTINGS (its ReceivedStreamFrame
// hook gives only metadata, not payload bytes). The actual h3 SETTINGS
// capture happens in h3TappingUniStream by wrapping AcceptUniStream.
// This function returns nil (no-op tracer) so quic-go uses its default path.
func newH3SettingsTracer(_ context.Context, _ logging.Perspective, _ quic.ConnectionID) *logging.ConnectionTracer {
return nil
}
// ── QUIC connection wrapper ───────────────────────────────────────────────────
// h3SettingsTappingConn wraps quic.Connection and intercepts AcceptUniStream
// so the first bytes of each client-initiated unidirectional stream can be
// inspected for h3 control stream SETTINGS before being replayed to the
// http3.Server.
type h3SettingsTappingConn struct {
quic.Connection
remoteAddr string
}
func (c *h3SettingsTappingConn) AcceptUniStream(ctx context.Context) (quic.ReceiveStream, error) {
stream, err := c.Connection.AcceptUniStream(ctx)
if err != nil {
return stream, err
}
return &h3TappingUniStream{ReceiveStream: stream, remoteAddr: c.remoteAddr}, nil
}
// ── QUIC receive-stream wrapper ───────────────────────────────────────────────
// h3TappingUniStream peeks at the first bytes of a unidirectional stream to
// identify the h3 control stream (stream type 0x00, RFC 9114 §6.2.1) and
// extract its first SETTINGS frame, then replays all bytes to the caller.
type h3TappingUniStream struct {
quic.ReceiveStream
once sync.Once
buf bytes.Buffer
reader io.Reader
remoteAddr string
}
// maxH3ControlPeek is enough to cover the stream-type varint + SETTINGS
// frame type varint + frame-length varint + a typical SETTINGS frame body
// (6 settings × 8 bytes each = 48 bytes, plus 3 varint headers ≈ 64 bytes).
const maxH3ControlPeek = 256
func (s *h3TappingUniStream) Read(p []byte) (int, error) {
s.once.Do(func() {
scratch := make([]byte, maxH3ControlPeek)
n, _ := s.ReceiveStream.Read(scratch)
s.buf.Write(scratch[:n])
go tryParseH3ControlStream(s.remoteAddr, s.buf.Bytes())
s.reader = io.MultiReader(&s.buf, s.ReceiveStream)
})
if s.reader != nil {
return s.reader.Read(p)
}
return s.ReceiveStream.Read(p)
}
// tryParseH3ControlStream examines the peeked bytes. If the stream opens
// with stream-type 0x00 (h3 control stream) and the first frame is SETTINGS
// (type 0x04), it emits an h3_settings fp record. All errors are silent —
// this is a best-effort tap.
func tryParseH3ControlStream(remoteAddr string, data []byte) {
streamType, c0 := quicVarint(data)
if c0 == 0 || streamType != 0x00 {
return // not the h3 control stream
}
data = data[c0:]
frameType, c1 := quicVarint(data)
if c1 == 0 {
return
}
data = data[c1:]
frameLen, c2 := quicVarint(data)
if c2 == 0 {
return
}
data = data[c2:]
// Per RFC 9114 §7.2.4: the first frame on the control stream MUST be SETTINGS.
if frameType != 0x04 {
return
}
if uint64(len(data)) < frameLen {
return // need more bytes — we only peeked 256
}
body := data[:frameLen]
settings := make(map[string]uint64)
frameOrder := make([]uint64, 0, 8)
for len(body) > 0 {
id, ci := quicVarint(body)
if ci == 0 {
break
}
body = body[ci:]
val, cv := quicVarint(body)
if cv == 0 {
break
}
body = body[cv:]
settings[h3SettingName(id)] = val
frameOrder = append(frameOrder, id)
}
sendFP(map[string]interface{}{
"kind": "h3_settings",
"remote_addr": remoteAddr,
"settings": settings,
"frame_order": frameOrder,
"ts": time.Now().UTC().Format(time.RFC3339),
})
}
// quicVarint decodes an RFC 9000 §16 variable-length integer.
// Returns (value, bytes_consumed); bytes_consumed == 0 on failure.
func quicVarint(b []byte) (uint64, int) {
if len(b) == 0 {
return 0, 0
}
prefix := b[0] >> 6
switch prefix {
case 0:
return uint64(b[0] & 0x3f), 1
case 1:
if len(b) < 2 {
return 0, 0
}
return uint64(b[0]&0x3f)<<8 | uint64(b[1]), 2
case 2:
if len(b) < 4 {
return 0, 0
}
return uint64(b[0]&0x3f)<<24 | uint64(b[1])<<16 | uint64(b[2])<<8 | uint64(b[3]), 4
case 3:
if len(b) < 8 {
return 0, 0
}
return uint64(b[0]&0x3f)<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]), 8
}
return 0, 0
}
// h3SettingName maps RFC 9114 and extension SETTINGS IDs to human-readable names.
func h3SettingName(id uint64) string {
switch id {
case 0x01:
return "QPACK_MAX_TABLE_CAPACITY"
case 0x06:
return "MAX_FIELD_SECTION_SIZE"
case 0x07:
return "QPACK_BLOCKED_STREAMS"
case 0x08:
return "ENABLE_CONNECT_PROTOCOL"
case 0x33:
return "H3_DATAGRAM"
case 0xc671706a:
return "ENABLE_WEBTRANSPORT"
default:
// GREASE values per RFC 9114 §7.2.8 pattern (0x1f * N + 0x21)
if id > 0x20 && (id-0x21)%0x1f == 0 {
return "GREASE"
}
return "UNKNOWN"
}
}