Files
anti 6a6f5807aa fix(pr3): adapt to quic-go v0.59.0 API — drop H3App, capture h3 SETTINGS via http3.Settingser
quic-go v0.59.0 (shipped with Caddy v2.11.2) removed quic.Connection as
a public interface and quic-go/logging as a public package, breaking
H3App's connection-wrapping approach.

Resolution:
- Remove H3App (h3app.go) entirely; Caddy handles h3 natively when h3
  is in the protocols list.
- Rewrite h3conn.go to keep only tryParseH3ControlStream + varint/name
  utilities (tested, useful for future stream-level tapping if the API
  ever re-exposes it).
- FPHandler.ServeHTTP: for h3 requests, type-assert ResponseWriter to
  http3.Settingser (the public interface exposed by quic-go/http3 v0.59),
  read the peer's Settings after ReceivedSettings channel closes, emit
  h3_settings fp record.
- https/entrypoint.sh: include h3 in CADDY_PROTOCOLS (Caddy now owns
  UDP/443); remove DECNET_H3_GLOBAL block.
- Update go.mod/go.sum to caddy v2.11.2 + quic-go v0.59.0.
- Update test_https_compose_h3_app.py to expect h3 in protocols when
  http/3 is selected, and assert decnet_h3 block is absent.
- All Go tests (9) and Python tests (15) remain green.
2026-05-10 03:43:34 -04:00

675 lines
19 KiB
Go

// Package decnetfp provides Caddy modules for HTTP fingerprint capture.
//
// Registered modules:
// - caddy.listeners.decnet_fp — post-TLS listener wrapper that taps
// the h2 client preface (SETTINGS + HEADERS frames via persistent HPACK
// decoder) and h1 request-line / header bytes, emitting ordered header
// name lists to /run/decnet/fp.sock (unix datagram).
// - http.handlers.decnet_fp — HTTP middleware that emits an
// access_log record (status code, bytes, protocol) after each response.
// - caddy.logging.encoders.decnet_jsonl — log encoder stub (registered
// but not wired into Caddyfile; access_log comes from the handler above).
//
// All modules write JSON lines to a unix datagram socket whose path is
// controlled by DECNET_FP_SOCK (default: /run/decnet/fp.sock).
package decnetfp
import (
"bytes"
"crypto/tls"
"encoding/binary"
"encoding/json"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/quic-go/quic-go/http3"
"go.uber.org/zap"
"golang.org/x/net/http2/hpack"
)
func init() {
caddy.RegisterModule(FPListenerWrapper{})
caddy.RegisterModule(FPHandler{})
caddy.RegisterModule(DecnetJSONLEncoder{})
httpcaddyfile.RegisterHandlerDirective("decnet_fp", parseFPHandler)
}
func parseFPHandler(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var fp FPHandler
return &fp, fp.UnmarshalCaddyfile(h.Dispenser)
}
func sockPath() string {
if p := os.Getenv("DECNET_FP_SOCK"); p != "" {
return p
}
return "/run/decnet/fp.sock"
}
// ── unix datagram sender ──────────────────────────────────────────────────────
var (
sockMu sync.Mutex
sockConn *net.UnixConn
)
func sendFP(record map[string]interface{}) {
b, err := json.Marshal(record)
if err != nil {
return
}
sockMu.Lock()
defer sockMu.Unlock()
if sockConn == nil {
conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: sockPath(), Net: "unixgram"})
if err != nil {
return
}
sockConn = conn
}
sockConn.SetWriteDeadline(time.Now().Add(50 * time.Millisecond)) //nolint:errcheck
sockConn.Write(b) //nolint:errcheck
}
// ── caddy.listeners.decnet_fp ─────────────────────────────────────────────────
// FPListenerWrapper is a post-TLS Caddy listener wrapper that:
// - For h2 ALPN connections: taps the h2 client preface (SETTINGS frame)
// and then continuously parses HEADERS/CONTINUATION frames via a
// persistent HPACK decoder, emitting ordered header name lists.
// - For h1 ALPN connections (or plain connections): taps the first request's
// header bytes, emitting ordered header names from the wire.
//
// Place it AFTER the TLS listener wrapper so it sees post-TLS data:
//
// listener_wrappers {
// tls
// decnet_fp
// }
type FPListenerWrapper struct {
logger *zap.Logger
}
func (FPListenerWrapper) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "caddy.listeners.decnet_fp",
New: func() caddy.Module { return new(FPListenerWrapper) },
}
}
func (w *FPListenerWrapper) Provision(ctx caddy.Context) error {
w.logger = ctx.Logger()
return nil
}
func (w *FPListenerWrapper) WrapListener(ln net.Listener) net.Listener {
return &fpListener{Listener: ln, logger: w.logger}
}
func (w *FPListenerWrapper) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
type fpListener struct {
net.Listener
logger *zap.Logger
}
func (l *fpListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return conn, err
}
remote := conn.RemoteAddr().String()
tlsConn, ok := conn.(*tls.Conn)
if !ok {
// Plain (cleartext) connection — peek to distinguish h2c from h1.
return &plainTappingConn{Conn: conn, remoteAddr: remote}, nil
}
state := tlsConn.ConnectionState()
switch state.NegotiatedProtocol {
case "h2":
return &h2TappingConn{Conn: conn, remoteAddr: remote}, nil
default:
// http/1.1 ALPN or no ALPN — post-TLS plaintext is h1; safe to
// use plainTappingConn (h2c preface won't appear after TLS).
return &plainTappingConn{Conn: conn, remoteAddr: remote}, nil
}
}
// ── Plain connection tap (h1 and h2c) ────────────────────────────────────────
const h1MaxHeaderBuf = 8192
// plainTappingConn handles post-accept connections that are not TLS h2.
// It peeks the first bytes to distinguish h2c prior-knowledge from h1,
// then routes to the appropriate parser. Used for both cleartext (port 80)
// and TLS-h1 (ALPN "" or "http/1.1") connections.
type plainTappingConn struct {
net.Conn
once sync.Once
buf bytes.Buffer
reader io.Reader
remoteAddr string
}
// tapWriter is a non-blocking io.Writer that drops if the channel is full.
type tapWriter struct {
ch chan<- []byte
}
func (t tapWriter) Write(p []byte) (int, error) {
cp := make([]byte, len(p))
copy(cp, p)
select {
case t.ch <- cp:
default:
}
return len(p), nil
}
func (c *plainTappingConn) Read(b []byte) (int, error) {
c.once.Do(func() {
// Peek exactly len(h2ClientPreface) bytes to detect h2c prior knowledge.
preface := make([]byte, len(h2ClientPreface))
n, _ := io.ReadFull(c.Conn, preface)
c.buf.Write(preface[:n])
if n == len(h2ClientPreface) && string(preface) == h2ClientPreface {
// h2c prior-knowledge connection — run the same SETTINGS+HPACK tap.
hdr9 := make([]byte, 9)
if m, err := io.ReadFull(c.Conn, hdr9); m == 9 && err == nil {
c.buf.Write(hdr9)
frameLen := int(hdr9[0])<<16 | int(hdr9[1])<<8 | int(hdr9[2])
frameType := hdr9[3]
if frameType == 0x4 && frameLen > 0 && frameLen <= 16384 {
payload := make([]byte, frameLen)
if _, err := io.ReadFull(c.Conn, payload); err == nil {
c.buf.Write(payload)
go parseAndSendH2Settings(c.remoteAddr, payload)
}
}
}
tap := make(chan []byte, 256)
c.reader = io.TeeReader(io.MultiReader(&c.buf, c.Conn), tapWriter{ch: tap})
go parseH2HeadersLoop(c.remoteAddr, tap)
return
}
// h1 — buffer up to h1MaxHeaderBuf or until \r\n\r\n.
scratch := make([]byte, h1MaxHeaderBuf)
for c.buf.Len() < h1MaxHeaderBuf {
nn, err := c.Conn.Read(scratch[:h1MaxHeaderBuf-c.buf.Len()])
c.buf.Write(scratch[:nn])
if bytes.Contains(c.buf.Bytes(), []byte("\r\n\r\n")) {
break
}
if err != nil {
break
}
}
go parseAndSendH1Headers(c.remoteAddr, c.buf.Bytes())
c.reader = io.MultiReader(&c.buf, c.Conn)
})
if c.reader == nil {
return c.Conn.Read(b)
}
return c.reader.Read(b)
}
func parseAndSendH1Headers(remoteAddr string, raw []byte) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
idx = len(raw)
}
lines := bytes.Split(raw[:idx], []byte("\r\n"))
if len(lines) == 0 {
return
}
// First line: "GET /path HTTP/1.1"
requestLine := string(lines[0])
var method, path, proto string
parts := bytes.Fields(lines[0])
if len(parts) >= 3 {
method = string(parts[0])
path = string(parts[1])
proto = string(parts[2])
}
var ordered [][]string // [[name, value], ...]
var cookie, acceptLang string
for _, line := range lines[1:] {
sep := bytes.IndexByte(line, ':')
if sep < 0 {
continue
}
name := string(bytes.ToLower(bytes.TrimSpace(line[:sep])))
value := string(bytes.TrimSpace(line[sep+1:]))
ordered = append(ordered, []string{name, value})
switch name {
case "cookie":
cookie = value
case "accept-language":
acceptLang = value
}
}
sendFP(map[string]interface{}{
"kind": "http_request_headers",
"remote_addr": remoteAddr,
"proto_tag": "h1",
"request_line": requestLine,
"method": method,
"path": path,
"proto": proto,
"headers_ordered": ordered,
"cookie": cookie,
"accept_language": acceptLang,
"ts": time.Now().UTC().Format(time.RFC3339),
})
}
// ── H2 client preface + HPACK continuous tap ──────────────────────────────────
const h2ClientPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
type h2TappingConn struct {
net.Conn
once sync.Once
buf bytes.Buffer
reader io.Reader
remoteAddr string
}
func (c *h2TappingConn) Read(b []byte) (int, error) {
c.once.Do(func() {
// Buffer client preface (24 B) + first frame header (9 B).
hdr := make([]byte, len(h2ClientPreface)+9)
if _, err := io.ReadFull(c.Conn, hdr); err != nil {
c.buf.Write(hdr)
c.reader = io.MultiReader(&c.buf, c.Conn)
return
}
c.buf.Write(hdr)
frameLen := int(hdr[len(h2ClientPreface)])<<16 |
int(hdr[len(h2ClientPreface)+1])<<8 |
int(hdr[len(h2ClientPreface)+2])
frameType := hdr[len(h2ClientPreface)+3]
if frameType == 0x4 && frameLen > 0 && frameLen <= 16384 {
payload := make([]byte, frameLen)
if _, err := io.ReadFull(c.Conn, payload); err == nil {
c.buf.Write(payload)
go parseAndSendH2Settings(c.remoteAddr, payload)
}
}
// Start HPACK continuous parse on a non-blocking channel tap.
tap := make(chan []byte, 256)
c.reader = io.TeeReader(io.MultiReader(&c.buf, c.Conn), tapWriter{ch: tap})
go parseH2HeadersLoop(c.remoteAddr, tap)
})
if c.reader == nil {
return c.Conn.Read(b)
}
return c.reader.Read(b)
}
func parseAndSendH2Settings(remoteAddr string, payload []byte) {
settings := make(map[string]uint32)
frameOrder := make([]uint16, 0, len(payload)/6)
for i := 0; i+6 <= len(payload); i += 6 {
id := binary.BigEndian.Uint16(payload[i : i+2])
val := binary.BigEndian.Uint32(payload[i+2 : i+6])
settings[settingName(id)] = val
frameOrder = append(frameOrder, id)
}
sendFP(map[string]interface{}{
"kind": "h2_settings",
"remote_addr": remoteAddr,
"settings": settings,
"frame_order": frameOrder,
"ts": time.Now().UTC().Format(time.RFC3339),
})
}
func settingName(id uint16) string {
switch id {
case 0x1:
return "HEADER_TABLE_SIZE"
case 0x2:
return "ENABLE_PUSH"
case 0x3:
return "MAX_CONCURRENT_STREAMS"
case 0x4:
return "INITIAL_WINDOW_SIZE"
case 0x5:
return "MAX_FRAME_SIZE"
case 0x6:
return "MAX_HEADER_LIST_SIZE"
case 0x8:
return "ENABLE_CONNECT_PROTOCOL"
default:
if id >= 0xf000 {
return "GREASE"
}
return "UNKNOWN"
}
}
// hpackConn holds per-connection HPACK decoder state.
type hpackConn struct {
decoder *hpack.Decoder
currentFields []hpack.HeaderField
}
func newHpackConn(maxTableSize uint32) *hpackConn {
hc := &hpackConn{}
hc.decoder = hpack.NewDecoder(maxTableSize, func(f hpack.HeaderField) {
hc.currentFields = append(hc.currentFields, f)
})
return hc
}
// decode decodes a header block fragment, appending to existing and returning combined.
func (hc *hpackConn) decode(fragment []byte, existing []hpack.HeaderField) []hpack.HeaderField {
hc.currentFields = existing
hc.decoder.Write(fragment) //nolint:errcheck
return hc.currentFields
}
func parseH2HeadersLoop(remoteAddr string, tap <-chan []byte) {
var buf []byte
prefaceLen := len(h2ClientPreface)
prefaceSkipped := false
hc := newHpackConn(4096)
type streamState struct {
fields []hpack.HeaderField
}
streams := make(map[uint32]*streamState)
for chunk := range tap {
buf = append(buf, chunk...)
if !prefaceSkipped {
if len(buf) < prefaceLen {
continue
}
buf = buf[prefaceLen:]
prefaceSkipped = true
}
for len(buf) >= 9 {
frameLen := int(buf[0])<<16 | int(buf[1])<<8 | int(buf[2])
frameType := buf[3]
flags := buf[4]
streamID := binary.BigEndian.Uint32(buf[5:9]) & 0x7fffffff
total := 9 + frameLen
if len(buf) < total {
break
}
payload := buf[9:total]
buf = buf[total:]
switch frameType {
case 0x4: // SETTINGS from client
if flags&0x1 != 0 { // ACK
break
}
for i := 0; i+6 <= len(payload); i += 6 {
id := binary.BigEndian.Uint16(payload[i : i+2])
val := binary.BigEndian.Uint32(payload[i+2 : i+6])
if id == 0x1 { // SETTINGS_HEADER_TABLE_SIZE
hc.decoder.SetMaxDynamicTableSize(val)
}
}
case 0x1, 0x9: // HEADERS, CONTINUATION
fragment := payload
if frameType == 0x1 {
off := 0
var padLen byte
if flags&0x08 != 0 { // PADDED
if len(fragment) < 1 {
continue
}
padLen = fragment[0]
off = 1
}
if flags&0x20 != 0 { // PRIORITY
off += 5
}
end := len(fragment) - int(padLen)
if off > end || end < 0 {
continue
}
fragment = fragment[off:end]
}
ss, ok := streams[streamID]
if !ok {
ss = &streamState{}
streams[streamID] = ss
}
ss.fields = hc.decode(fragment, ss.fields)
if flags&0x04 != 0 { // END_HEADERS
emitH2RequestHeaders(remoteAddr, streamID, ss.fields)
delete(streams, streamID)
}
}
}
}
}
func emitH2RequestHeaders(remoteAddr string, streamID uint32, fields []hpack.HeaderField) {
ordered := make([][]string, 0, len(fields))
var cookie, acceptLang, method, path string
for _, f := range fields {
ordered = append(ordered, []string{f.Name, f.Value})
switch f.Name {
case "cookie":
cookie = f.Value
case "accept-language":
acceptLang = f.Value
case ":method":
method = f.Value
case ":path":
path = f.Value
}
}
sendFP(map[string]interface{}{
"kind": "http_request_headers",
"remote_addr": remoteAddr,
"stream_id": streamID,
"proto_tag": "h2",
"method": method,
"path": path,
"headers_ordered": ordered,
"cookie": cookie,
"accept_language": acceptLang,
"ts": time.Now().UTC().Format(time.RFC3339),
})
}
// ── http.handlers.decnet_fp ───────────────────────────────────────────────────
// FPHandler is HTTP middleware that emits an access_log record (status code,
// bytes, proto) after each response via the fp socket. For h3 requests it
// also emits a best-effort http_request_headers record (header order degraded
// — QPACK decode order is preserved by quic-go but the Go map randomises it
// further; canonical h3 header order requires request-stream QPACK tapping
// which is a follow-up task).
type FPHandler struct {
logger *zap.Logger
}
func (FPHandler) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.decnet_fp",
New: func() caddy.Module { return new(FPHandler) },
}
}
func (h *FPHandler) Provision(ctx caddy.Context) error {
h.logger = ctx.Logger()
return nil
}
func (h *FPHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
// responseCapture captures the status code and bytes written.
type responseCapture struct {
http.ResponseWriter
status int
bytes int
}
func (rc *responseCapture) WriteHeader(status int) {
rc.status = status
rc.ResponseWriter.WriteHeader(status)
}
func (rc *responseCapture) Write(b []byte) (int, error) {
n, err := rc.ResponseWriter.Write(b)
rc.bytes += n
if rc.status == 0 {
rc.status = 200
}
return n, err
}
func (h *FPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
rc := &responseCapture{ResponseWriter: w}
err := next.ServeHTTP(rc, r)
protoTag := "h1"
if r.ProtoMajor == 2 {
protoTag = "h2"
} else if r.ProtoMajor == 3 {
protoTag = "h3"
}
status := rc.status
if status == 0 {
status = 200
}
go sendFP(map[string]interface{}{
"kind": "access_log",
"remote_addr": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"proto": r.Proto,
"proto_tag": protoTag,
"status": status,
"bytes": rc.bytes,
"ts": time.Now().UTC().Format(time.RFC3339),
})
// For h3: emit h3_settings (once per connection, deduped by remote_addr),
// then emit best-effort http_request_headers (map order — QPACK frame order
// is not available without a stream tap; degraded but usable for JA4H).
if r.ProtoMajor == 3 {
if settingser, ok := w.(http3.Settingser); ok {
select {
case <-settingser.ReceivedSettings():
s := settingser.Settings()
settingsMap := make(map[string]interface{})
if s.EnableDatagrams {
settingsMap["H3_DATAGRAM"] = uint64(1)
}
if s.EnableExtendedConnect {
settingsMap["ENABLE_CONNECT_PROTOCOL"] = uint64(1)
}
for id, val := range s.Other {
settingsMap[h3SettingName(id)] = val
}
go sendFP(map[string]interface{}{
"kind": "h3_settings",
"remote_addr": r.RemoteAddr,
"settings": settingsMap,
"ts": time.Now().UTC().Format(time.RFC3339),
})
default:
// Settings not yet received — skip; the access_log record is sufficient.
}
}
ordered := make([][]string, 0, len(r.Header))
var cookie, acceptLang string
for name, vals := range r.Header {
v := ""
if len(vals) > 0 {
v = vals[0]
}
ordered = append(ordered, []string{strings.ToLower(name), v})
switch http.CanonicalHeaderKey(name) {
case "Cookie":
cookie = v
case "Accept-Language":
acceptLang = v
}
}
go sendFP(map[string]interface{}{
"kind": "http_request_headers",
"remote_addr": r.RemoteAddr,
"proto_tag": "h3",
"method": r.Method,
"path": r.URL.Path,
"headers_ordered": ordered,
"cookie": cookie,
"accept_language": acceptLang,
"h3_order_note": "degraded_map_iteration",
"ts": time.Now().UTC().Format(time.RFC3339),
})
}
return err
}
var (
_ caddy.Provisioner = (*FPListenerWrapper)(nil)
_ caddy.ListenerWrapper = (*FPListenerWrapper)(nil)
_ caddyfile.Unmarshaler = (*FPListenerWrapper)(nil)
_ caddy.Provisioner = (*FPHandler)(nil)
_ caddyhttp.MiddlewareHandler = (*FPHandler)(nil)
_ caddyfile.Unmarshaler = (*FPHandler)(nil)
)
// ── caddy.logging.encoders.decnet_jsonl ──────────────────────────────────────
// DecnetJSONLEncoder is a registered Caddy module stub. A full zapcore.Encoder
// implementation (required for `log { format decnet_jsonl }`) is deferred;
// access_log records are emitted by FPHandler.ServeHTTP instead.
type DecnetJSONLEncoder struct {
logger *zap.Logger
}
func (DecnetJSONLEncoder) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "caddy.logging.encoders.decnet_jsonl",
New: func() caddy.Module { return new(DecnetJSONLEncoder) },
}
}
func (e *DecnetJSONLEncoder) Provision(ctx caddy.Context) error {
e.logger = ctx.Logger()
return nil
}