feat(pr2): HTTP/2+HTTP/3 fingerprint extractors — JA4H, H2 SETTINGS, JA4-QUIC
This commit is contained in:
@@ -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