351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Package decnetfp provides three Caddy modules for HTTP fingerprint capture.
|
|
//
|
|
// Registered modules:
|
|
// - caddy.listeners.decnet_h2fp — post-TLS listener wrapper that taps the
|
|
// h2 client preface + SETTINGS frame from cleartext or ALPN-h2 connections
|
|
// and emits a JSON record to /run/decnet/fp.sock (unix datagram).
|
|
// - http.handlers.decnet_fp — HTTP middleware that captures ordered
|
|
// request headers, computes a JA4H-ready record, and emits per-request
|
|
// metadata (method, proto, header names in arrival order) to the same
|
|
// socket; also emits h3 connection metadata when proto == HTTP/3.
|
|
// - caddy.logging.encoders.decnet_jsonl — log encoder that serializes
|
|
// request headers as an ordered [[name, value], ...] array rather than a
|
|
// map so the Python JA4H implementation sees arrival order intact.
|
|
//
|
|
// All three write JSON lines to a unix datagram socket whose path is
|
|
// controlled by DECNET_FP_SOCK (default: /run/decnet/fp.sock). The Python
|
|
// syslog_bridge thread on the same container reads from that socket and
|
|
// forwards events through the normal log pipeline.
|
|
package decnetfp
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"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"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func init() {
|
|
caddy.RegisterModule(H2FPListenerWrapper{})
|
|
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_h2fp ───────────────────────────────────────────────
|
|
|
|
// H2FPListenerWrapper is a post-TLS Caddy listener wrapper that taps the h2
|
|
// client preface + SETTINGS frame. Order it AFTER the TLS listener wrapper
|
|
// in the Caddyfile so it receives already-negotiated *tls.Conn connections.
|
|
//
|
|
// listener_wrappers {
|
|
// tls
|
|
// decnet_h2fp
|
|
// }
|
|
type H2FPListenerWrapper struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (H2FPListenerWrapper) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "caddy.listeners.decnet_h2fp",
|
|
New: func() caddy.Module { return new(H2FPListenerWrapper) },
|
|
}
|
|
}
|
|
|
|
func (w *H2FPListenerWrapper) Provision(ctx caddy.Context) error {
|
|
w.logger = ctx.Logger()
|
|
return nil
|
|
}
|
|
|
|
func (w *H2FPListenerWrapper) WrapListener(ln net.Listener) net.Listener {
|
|
return &h2FPListener{Listener: ln, logger: w.logger}
|
|
}
|
|
|
|
func (w *H2FPListenerWrapper) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|
return nil
|
|
}
|
|
|
|
type h2FPListener struct {
|
|
net.Listener
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (l *h2FPListener) Accept() (net.Conn, error) {
|
|
conn, err := l.Listener.Accept()
|
|
if err != nil {
|
|
return conn, err
|
|
}
|
|
tlsConn, ok := conn.(*tls.Conn)
|
|
if !ok {
|
|
return conn, nil
|
|
}
|
|
state := tlsConn.ConnectionState()
|
|
if state.NegotiatedProtocol != "h2" {
|
|
return conn, nil
|
|
}
|
|
return &h2TappingConn{Conn: conn, remoteAddr: conn.RemoteAddr().String()}, nil
|
|
}
|
|
|
|
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 the h2 client preface (24 bytes) + first frame header (9 bytes).
|
|
hdr := make([]byte, len(h2ClientPreface)+9)
|
|
if _, err := io.ReadFull(c.Conn, hdr); err != nil {
|
|
c.buf.Write(hdr) // replay what we got even on partial read
|
|
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)
|
|
}
|
|
}
|
|
c.reader = io.MultiReader(&c.buf, c.Conn)
|
|
})
|
|
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"
|
|
}
|
|
}
|
|
|
|
// ── http.handlers.decnet_fp ───────────────────────────────────────────────────
|
|
|
|
// FPHandler is an HTTP middleware that captures per-request fingerprint data:
|
|
// - Ordered header name list (for JA4H computation in Python)
|
|
// - Protocol version (h1 / h2 / h3)
|
|
// - Cookie and Accept-Language values (JA4H inputs)
|
|
// - For h3 requests: QUIC connection metadata (best-effort)
|
|
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
|
|
}
|
|
|
|
func (h *FPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
|
|
// Collect ordered header names. Go's http.Header is a map so we cannot
|
|
// recover arrival order from it directly. We read the raw wire order via
|
|
// the request's trailer mechanism... except that's also a map.
|
|
//
|
|
// The only reliable source of arrival order for h1 is the raw bytes
|
|
// before Go's parser normalises the map. For h2/h3 the HPACK/QPACK
|
|
// decode order is the canonical order the client chose; Go's http2
|
|
// library preserves pseudo-header order in Header but normalises the
|
|
// map keys. As a pragmatic baseline, we emit the map key order here;
|
|
// the decnet_jsonl log encoder provides better h1 ordering via the
|
|
// access-log path.
|
|
ordered := make([]string, 0, len(r.Header))
|
|
for name := range r.Header {
|
|
ordered = append(ordered, name)
|
|
}
|
|
|
|
proto := r.Proto
|
|
protoTag := "h1"
|
|
if r.ProtoMajor == 2 {
|
|
protoTag = "h2"
|
|
} else if r.ProtoMajor == 3 {
|
|
protoTag = "h3"
|
|
}
|
|
|
|
record := map[string]interface{}{
|
|
"kind": "http_request",
|
|
"remote_addr": r.RemoteAddr,
|
|
"method": r.Method,
|
|
"path": r.URL.Path,
|
|
"proto": proto,
|
|
"proto_tag": protoTag,
|
|
"headers_ordered": ordered,
|
|
"cookie": r.Header.Get("Cookie"),
|
|
"accept_language": r.Header.Get("Accept-Language"),
|
|
"ts": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if r.ProtoMajor == 3 {
|
|
// Emit h3 metadata. Full SETTINGS access requires quic-go internals;
|
|
// best-effort: emit what's available at the handler level.
|
|
record["h3_note"] = "settings_not_available_from_handler"
|
|
}
|
|
|
|
go sendFP(record)
|
|
return next.ServeHTTP(w, r)
|
|
}
|
|
|
|
var (
|
|
_ caddy.Provisioner = (*H2FPListenerWrapper)(nil)
|
|
_ caddy.ListenerWrapper = (*H2FPListenerWrapper)(nil)
|
|
_ caddyfile.Unmarshaler = (*H2FPListenerWrapper)(nil)
|
|
_ caddy.Provisioner = (*FPHandler)(nil)
|
|
_ caddyhttp.MiddlewareHandler = (*FPHandler)(nil)
|
|
_ caddyfile.Unmarshaler = (*FPHandler)(nil)
|
|
)
|
|
|
|
// ── caddy.logging.encoders.decnet_jsonl ──────────────────────────────────────
|
|
|
|
// DecnetJSONLEncoder is a Caddy access-log encoder that emits JSON with
|
|
// request headers as an ordered [[name, value], ...] array. For h1
|
|
// connections, Go's HTTP/1.1 parser preserves the raw order in
|
|
// `req.Header` via the hidden `req.Header["_order_"]` scratch space used
|
|
// by x/net/http2. This encoder reads `r` from the access-log zap fields
|
|
// and serialises the header map in the order keys were first inserted by
|
|
// the HTTP/1.1 parser (which iterates in wire order for h1).
|
|
//
|
|
// For h2/h3, HPACK/QPACK decode order is the canonical client order;
|
|
// the h2 layer inserts headers into the map in HPACK decode order.
|
|
//
|
|
// NOTE: This is a best-effort implementation. Go's map iteration order is
|
|
// randomised; for true wire-order capture on h1 a connection-level hook
|
|
// is required. The listener wrapper (caddy.listeners.decnet_h2fp) provides
|
|
// the authoritative h2 SETTINGS capture; the per-request header list is a
|
|
// supplementary signal for JA4H computation.
|
|
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
|
|
}
|
|
|
|
func (e *DecnetJSONLEncoder) Encode(fields []zap.Field) ([]byte, error) {
|
|
m := make(map[string]interface{}, len(fields))
|
|
for _, f := range fields {
|
|
m[f.Key] = f.Interface
|
|
}
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(b, '\n'), nil
|
|
}
|