feat(pr2): HTTP/2+HTTP/3 fingerprint extractors — JA4H, H2 SETTINGS, JA4-QUIC
This commit is contained in:
9
decnet/templates/_caddy_modules/decnetfp/go.mod
Normal file
9
decnet/templates/_caddy_modules/decnetfp/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/decnet/caddy-fp
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/caddyserver/caddy/v2 v2.8.4
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/net v0.27.0
|
||||
)
|
||||
332
decnet/templates/_caddy_modules/decnetfp/module.go
Normal file
332
decnet/templates/_caddy_modules/decnetfp/module.go
Normal file
@@ -0,0 +1,332 @@
|
||||
// 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/caddyhttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
caddy.RegisterModule(H2FPListenerWrapper{})
|
||||
caddy.RegisterModule(FPHandler{})
|
||||
caddy.RegisterModule(DecnetJSONLEncoder{})
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
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) 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)
|
||||
_ caddy.Provisioner = (*FPHandler)(nil)
|
||||
_ caddyhttp.MiddlewareHandler = (*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
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
FROM caddy:2 AS caddy-bin
|
||||
FROM caddy:2-builder AS caddy-build
|
||||
COPY _caddy_modules/decnetfp /src/decnetfp
|
||||
RUN xcaddy build \
|
||||
--with github.com/decnet/caddy-fp=/src/decnetfp \
|
||||
--output /usr/bin/caddy
|
||||
|
||||
ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
|
||||
COPY --from=caddy-build /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
@@ -20,7 +24,9 @@ RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
RUN mkdir -p /run/decnet
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& chown -R logrelay:logrelay /run/decnet \
|
||||
&& mkdir -p /etc/caddy /opt/.local/share/caddy /opt/.config/caddy \
|
||||
&& chown -R logrelay:logrelay /etc/caddy /opt/.local /opt/.config \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
|
||||
@@ -13,6 +13,9 @@ if 'http/2' in versions:
|
||||
print(' '.join(tokens) if tokens else 'h1')
|
||||
")
|
||||
|
||||
DECNET_FP_SOCK="${DECNET_FP_SOCK:-/run/decnet/fp.sock}"
|
||||
rm -f "$DECNET_FP_SOCK"
|
||||
|
||||
cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
admin off
|
||||
@@ -22,7 +25,10 @@ cat > /etc/caddy/Caddyfile <<EOF
|
||||
}
|
||||
|
||||
:80 {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
route {
|
||||
decnet_fp
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from syslog_bridge import (
|
||||
classify_authorization,
|
||||
extract_form_credentials,
|
||||
forward_syslog,
|
||||
start_fp_socket_reader,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
@@ -163,5 +164,6 @@ class _SilentHandler(WSGIRequestHandler):
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"HTTP server starting as {NODE_NAME}")
|
||||
start_fp_socket_reader(NODE_NAME, SERVICE_NAME, LOG_TARGET)
|
||||
srv = make_server("127.0.0.1", PORT, app, request_handler=_SilentHandler)
|
||||
srv.serve_forever()
|
||||
|
||||
@@ -14,7 +14,11 @@ Facility: local0 (16). SD element ID uses PEN 55555.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib as _hashlib
|
||||
import json as _json
|
||||
import re
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -260,3 +264,107 @@ def write_syslog_file(line: str) -> None:
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
|
||||
# ─── Caddy fp-socket reader ───────────────────────────────────────────────────
|
||||
|
||||
_FP_SOCK_SIZE = 65536
|
||||
|
||||
|
||||
def _ja4h_from_record(rec: dict) -> str:
|
||||
method = rec.get("method", "")[:2].upper() or "UN"
|
||||
proto = rec.get("proto", "")
|
||||
ver_map = {
|
||||
"HTTP/1.0": "10", "HTTP/1.1": "11", "HTTP/2.0": "20", "HTTP/3.0": "30",
|
||||
}
|
||||
ver_tag = ver_map.get(proto.upper(), "00")
|
||||
headers: list[str] = rec.get("headers_ordered", [])
|
||||
has_cookie = "c" if any(h.lower() == "cookie" for h in headers) else "n"
|
||||
has_referer = "r" if any(h.lower() == "referer" for h in headers) else "n"
|
||||
lang = rec.get("accept_language", "") or ""
|
||||
lang_tag = (lang[:4].ljust(4, "0") if lang else "0000")
|
||||
filtered = [h for h in headers if h.lower() not in ("cookie", "referer")]
|
||||
count_tag = f"{min(len(filtered), 99):02d}"
|
||||
header_hash = _hashlib.sha256(",".join(h.lower() for h in filtered).encode()).hexdigest()[:12]
|
||||
cookie_val = rec.get("cookie", "") or ""
|
||||
if cookie_val:
|
||||
pairs = sorted(p.strip() for p in cookie_val.split(";") if "=" in p.strip())
|
||||
cookie_hash = _hashlib.sha256(";".join(pairs).encode()).hexdigest()[:12]
|
||||
else:
|
||||
cookie_hash = "000000000000"
|
||||
return f"{method}{ver_tag}{has_cookie}{has_referer}{lang_tag}_{count_tag}_{header_hash}_{cookie_hash}"
|
||||
|
||||
|
||||
def _fp_socket_reader(
|
||||
node_name: str,
|
||||
service_name: str,
|
||||
log_target: str,
|
||||
sock_path: str = "/run/decnet/fp.sock",
|
||||
) -> None:
|
||||
import os as _os
|
||||
try:
|
||||
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
|
||||
_os.makedirs(_os.path.dirname(sock_path), exist_ok=True)
|
||||
try:
|
||||
_os.unlink(sock_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
sock.bind(sock_path)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = sock.recv(_FP_SOCK_SIZE)
|
||||
rec = _json.loads(data.decode("utf-8", errors="replace"))
|
||||
kind = rec.get("kind", "")
|
||||
remote = rec.get("remote_addr", "").split(":")[0]
|
||||
|
||||
if kind == "http_request":
|
||||
ja4h = _ja4h_from_record(rec)
|
||||
proto_tag = rec.get("proto_tag", "h1")
|
||||
line = syslog_line(
|
||||
service_name, node_name, "http_request_fingerprint",
|
||||
attacker_ip=remote,
|
||||
ja4h=ja4h,
|
||||
protocol=proto_tag,
|
||||
method=rec.get("method", ""),
|
||||
path=rec.get("path", ""),
|
||||
)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, log_target)
|
||||
|
||||
elif kind == "h2_settings":
|
||||
settings_hash = _hashlib.sha256(
|
||||
_json.dumps(rec.get("settings", {}), sort_keys=True).encode()
|
||||
).hexdigest()[:12]
|
||||
line = syslog_line(
|
||||
service_name, node_name, "http2_settings",
|
||||
attacker_ip=remote,
|
||||
settings=_json.dumps(rec.get("settings", {})),
|
||||
frame_order=_json.dumps(rec.get("frame_order", [])),
|
||||
settings_hash=settings_hash,
|
||||
)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, log_target)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_fp_socket_reader(
|
||||
node_name: str,
|
||||
service_name: str,
|
||||
log_target: str = "",
|
||||
sock_path: str = "/run/decnet/fp.sock",
|
||||
) -> None:
|
||||
import os as _os
|
||||
if not _os.path.isdir(_os.path.dirname(sock_path) or "."):
|
||||
return
|
||||
t = _threading.Thread(
|
||||
target=_fp_socket_reader,
|
||||
args=(node_name, service_name, log_target, sock_path),
|
||||
daemon=True,
|
||||
name="fp-socket-reader",
|
||||
)
|
||||
t.start()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
FROM caddy:2 AS caddy-bin
|
||||
FROM caddy:2-builder AS caddy-build
|
||||
COPY _caddy_modules/decnetfp /src/decnetfp
|
||||
RUN xcaddy build \
|
||||
--with github.com/decnet/caddy-fp=/src/decnetfp \
|
||||
--output /usr/bin/caddy
|
||||
|
||||
ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
|
||||
COPY --from=caddy-build /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip openssl \
|
||||
@@ -18,12 +22,12 @@ COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
RUN mkdir -p /opt/tls
|
||||
RUN mkdir -p /opt/tls /run/decnet
|
||||
|
||||
EXPOSE 443
|
||||
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& chown -R logrelay:logrelay /opt/tls \
|
||||
&& chown -R logrelay:logrelay /opt/tls /run/decnet \
|
||||
&& mkdir -p /etc/caddy /opt/.local/share/caddy /opt/.config/caddy \
|
||||
&& chown -R logrelay:logrelay /etc/caddy /opt/.local /opt/.config \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
|
||||
@@ -43,17 +43,28 @@ if 'http/3' in versions:
|
||||
print(' '.join(tokens) if tokens else 'h1')
|
||||
")
|
||||
|
||||
DECNET_FP_SOCK="${DECNET_FP_SOCK:-/run/decnet/fp.sock}"
|
||||
# Remove stale socket from a previous run
|
||||
rm -f "$DECNET_FP_SOCK"
|
||||
|
||||
cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
admin off
|
||||
servers :443 {
|
||||
protocols ${CADDY_PROTOCOLS}
|
||||
listener_wrappers {
|
||||
tls
|
||||
decnet_h2fp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:443 {
|
||||
tls ${CERT} ${KEY}
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
route {
|
||||
decnet_fp
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from syslog_bridge import (
|
||||
classify_authorization,
|
||||
extract_form_credentials,
|
||||
forward_syslog,
|
||||
start_fp_socket_reader,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
@@ -154,5 +155,6 @@ class _SilentHandler(WSGIRequestHandler):
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"HTTPS server starting as {NODE_NAME}")
|
||||
start_fp_socket_reader(NODE_NAME, SERVICE_NAME, LOG_TARGET)
|
||||
srv = make_server("127.0.0.1", PORT, app, request_handler=_SilentHandler)
|
||||
srv.serve_forever()
|
||||
|
||||
@@ -14,7 +14,11 @@ Facility: local0 (16). SD element ID uses PEN 55555.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib as _hashlib
|
||||
import json as _json
|
||||
import re
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -260,3 +264,122 @@ def write_syslog_file(line: str) -> None:
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
|
||||
# ─── Caddy fp-socket reader ───────────────────────────────────────────────────
|
||||
|
||||
_FP_SOCK_SIZE = 65536 # max unix datagram payload
|
||||
|
||||
|
||||
def _ja4h_from_record(rec: dict) -> str:
|
||||
"""Compute JA4H from a Caddy decnet_fp 'http_request' record."""
|
||||
method = rec.get("method", "")[:2].upper() or "UN"
|
||||
proto = rec.get("proto", "")
|
||||
ver_map = {
|
||||
"HTTP/1.0": "10", "HTTP/1.1": "11", "HTTP/2.0": "20", "HTTP/3.0": "30",
|
||||
}
|
||||
ver_tag = ver_map.get(proto.upper(), "00")
|
||||
headers: list[str] = rec.get("headers_ordered", [])
|
||||
has_cookie = "c" if any(h.lower() == "cookie" for h in headers) else "n"
|
||||
has_referer = "r" if any(h.lower() == "referer" for h in headers) else "n"
|
||||
lang = rec.get("accept_language", "") or ""
|
||||
lang_tag = (lang[:4].ljust(4, "0") if lang else "0000")
|
||||
filtered = [h for h in headers if h.lower() not in ("cookie", "referer")]
|
||||
count_tag = f"{min(len(filtered), 99):02d}"
|
||||
header_str = ",".join(h.lower() for h in filtered)
|
||||
header_hash = _hashlib.sha256(header_str.encode()).hexdigest()[:12]
|
||||
cookie_val = rec.get("cookie", "") or ""
|
||||
if cookie_val:
|
||||
pairs = sorted(p.strip() for p in cookie_val.split(";") if "=" in p.strip())
|
||||
cookie_hash = _hashlib.sha256(";".join(pairs).encode()).hexdigest()[:12]
|
||||
else:
|
||||
cookie_hash = "000000000000"
|
||||
return f"{method}{ver_tag}{has_cookie}{has_referer}{lang_tag}_{count_tag}_{header_hash}_{cookie_hash}"
|
||||
|
||||
|
||||
def _fp_socket_reader(
|
||||
node_name: str,
|
||||
service_name: str,
|
||||
log_target: str,
|
||||
sock_path: str = "/run/decnet/fp.sock",
|
||||
) -> None:
|
||||
"""Read JSON fingerprint records from the Caddy fp unix datagram socket."""
|
||||
import os as _os
|
||||
# Create the socket as the receiver (we bind, Caddy writes)
|
||||
try:
|
||||
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
|
||||
_os.makedirs(_os.path.dirname(sock_path), exist_ok=True)
|
||||
try:
|
||||
_os.unlink(sock_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
sock.bind(sock_path)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = sock.recv(_FP_SOCK_SIZE)
|
||||
rec = _json.loads(data.decode("utf-8", errors="replace"))
|
||||
kind = rec.get("kind", "")
|
||||
remote = rec.get("remote_addr", "").split(":")[0] # strip port
|
||||
|
||||
if kind == "http_request":
|
||||
ja4h = _ja4h_from_record(rec)
|
||||
proto_tag = rec.get("proto_tag", "h1")
|
||||
line = syslog_line(
|
||||
service_name, node_name, "http_request_fingerprint",
|
||||
attacker_ip=remote,
|
||||
ja4h=ja4h,
|
||||
protocol=proto_tag,
|
||||
method=rec.get("method", ""),
|
||||
path=rec.get("path", ""),
|
||||
)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, log_target)
|
||||
|
||||
elif kind == "h2_settings":
|
||||
settings_hash = _hashlib.sha256(
|
||||
_json.dumps(rec.get("settings", {}), sort_keys=True).encode()
|
||||
).hexdigest()[:12]
|
||||
line = syslog_line(
|
||||
service_name, node_name, "http2_settings",
|
||||
attacker_ip=remote,
|
||||
settings=_json.dumps(rec.get("settings", {})),
|
||||
frame_order=_json.dumps(rec.get("frame_order", [])),
|
||||
settings_hash=settings_hash,
|
||||
)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, log_target)
|
||||
|
||||
elif kind == "h3_settings":
|
||||
line = syslog_line(
|
||||
service_name, node_name, "http3_settings",
|
||||
attacker_ip=remote,
|
||||
settings=_json.dumps(rec.get("settings", {})),
|
||||
frame_order=_json.dumps(rec.get("frame_order", [])),
|
||||
)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, log_target)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_fp_socket_reader(
|
||||
node_name: str,
|
||||
service_name: str,
|
||||
log_target: str = "",
|
||||
sock_path: str = "/run/decnet/fp.sock",
|
||||
) -> None:
|
||||
"""Start the Caddy fp-socket reader in a daemon thread."""
|
||||
import os as _os
|
||||
if not _os.path.isdir(_os.path.dirname(sock_path) or "."):
|
||||
return
|
||||
t = _threading.Thread(
|
||||
target=_fp_socket_reader,
|
||||
args=(node_name, service_name, log_target, sock_path),
|
||||
daemon=True,
|
||||
name="fp-socket-reader",
|
||||
)
|
||||
t.start()
|
||||
|
||||
Reference in New Issue
Block a user