feat: add ja4ebpf service — eBPF-based TLS/TCP fingerprinting daemon
- TC ingress hook captures TCP SYN (L3/L4) and TLS ClientHello - Uprobes on SSL_read/SSL_set_fd capture decrypted TLS data - Kprobes on accept4 correlate socket FDs to client IP:port - JA4 fingerprint computed from parsed TLS ClientHello - HTTP/2 SETTINGS and WINDOW_UPDATE extracted from decrypted streams - Session manager with sharded map (256 shards) and GC goroutine - Slowloris detection: sessions with no requests after 10s threshold - ClickHouse batch writer to ja4_logs.http_logs_raw (raw_json) - All tests pass: 17 parser + 10 correlation tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1
go.work
1
go.work
@ -4,4 +4,5 @@ use (
|
|||||||
./services/sentinel
|
./services/sentinel
|
||||||
./services/correlator
|
./services/correlator
|
||||||
./shared/go/ja4common
|
./shared/go/ja4common
|
||||||
|
./services/ja4ebpf
|
||||||
)
|
)
|
||||||
|
|||||||
76
services/ja4ebpf/Dockerfile
Normal file
76
services/ja4ebpf/Dockerfile
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Dockerfile — Construction multi-stage du démon ja4ebpf
|
||||||
|
# Stage 1 : compilation eBPF (Rocky Linux 9 + clang + bpftool)
|
||||||
|
# Stage 2 : compilation Go + génération des bindings bpf2go
|
||||||
|
# Stage 3 : image finale minimale (Rocky Linux 9 minimal)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Stage 1 : eBPF builder ---
|
||||||
|
FROM rockylinux:9 AS ebpf-builder
|
||||||
|
|
||||||
|
# Installation des outils de compilation eBPF
|
||||||
|
RUN dnf install -y epel-release && \
|
||||||
|
dnf install -y \
|
||||||
|
clang \
|
||||||
|
llvm \
|
||||||
|
bpftool \
|
||||||
|
kernel-headers \
|
||||||
|
libbpf-devel \
|
||||||
|
make \
|
||||||
|
&& \
|
||||||
|
dnf clean all
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copier les sources eBPF
|
||||||
|
COPY services/ja4ebpf/bpf/ ./bpf/
|
||||||
|
|
||||||
|
# Créer le répertoire des headers et générer vmlinux.h depuis le BTF du kernel
|
||||||
|
RUN mkdir -p bpf/headers && \
|
||||||
|
bpftool btf dump file /sys/kernel/btf/vmlinux format c > bpf/headers/vmlinux.h 2>/dev/null || \
|
||||||
|
echo "/* vmlinux.h placeholder — généré au runtime */" > bpf/headers/vmlinux.h
|
||||||
|
|
||||||
|
# --- Stage 2 : Go builder ---
|
||||||
|
FROM rockylinux:9 AS go-builder
|
||||||
|
|
||||||
|
# Installation de Go et des outils nécessaires
|
||||||
|
RUN dnf install -y epel-release && \
|
||||||
|
dnf install -y \
|
||||||
|
golang \
|
||||||
|
clang \
|
||||||
|
llvm \
|
||||||
|
libbpf-devel \
|
||||||
|
kernel-headers \
|
||||||
|
bpftool \
|
||||||
|
make \
|
||||||
|
&& \
|
||||||
|
dnf clean all
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copier les headers eBPF générés au stage précédent
|
||||||
|
COPY --from=ebpf-builder /build/bpf/headers/ ./services/ja4ebpf/bpf/headers/
|
||||||
|
|
||||||
|
# Copier le workspace Go complet (nécessaire pour go.work)
|
||||||
|
COPY go.work go.work.sum ./
|
||||||
|
COPY shared/go/ja4common/ ./shared/go/ja4common/
|
||||||
|
COPY services/ja4ebpf/ ./services/ja4ebpf/
|
||||||
|
|
||||||
|
WORKDIR /build/services/ja4ebpf
|
||||||
|
|
||||||
|
# Générer les bindings Go depuis le bytecode eBPF
|
||||||
|
RUN go generate ./internal/loader/
|
||||||
|
|
||||||
|
# Compilation du binaire final
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/ja4ebpf ./cmd/ja4ebpf/
|
||||||
|
|
||||||
|
# --- Stage 3 : image finale ---
|
||||||
|
FROM rockylinux:9-minimal AS final
|
||||||
|
|
||||||
|
# Copier uniquement le binaire compilé
|
||||||
|
COPY --from=go-builder /out/ja4ebpf /usr/local/bin/ja4ebpf
|
||||||
|
|
||||||
|
# Créer le répertoire de configuration
|
||||||
|
RUN mkdir -p /etc/ja4ebpf
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/ja4ebpf"]
|
||||||
114
services/ja4ebpf/Dockerfile.package
Normal file
114
services/ja4ebpf/Dockerfile.package
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Dockerfile.package — Build multi-distro du RPM ja4ebpf
|
||||||
|
#
|
||||||
|
# Cible : RHEL/CentOS/Rocky/AlmaLinux 8, 9 et 10.
|
||||||
|
# Le BTF natif (/sys/kernel/btf/vmlinux) est disponible sur tous ces kernels.
|
||||||
|
#
|
||||||
|
# Stages :
|
||||||
|
# go-builder : compile le binaire Go statique (clang + bpf2go + go build)
|
||||||
|
# rpm-el8 : assemble le RPM pour el8 (AlmaLinux 8 / RHEL 8)
|
||||||
|
# rpm-el9 : assemble le RPM pour el9 (Rocky Linux 9 / RHEL 9)
|
||||||
|
# rpm-el10 : assemble le RPM pour el10 (AlmaLinux 10)
|
||||||
|
# output : collecte tous les RPMs dans /output
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# docker build -f services/ja4ebpf/Dockerfile.package \
|
||||||
|
# --build-arg BUILD_VERSION=1.2.3 \
|
||||||
|
# -t ja4ebpf:package \
|
||||||
|
# .
|
||||||
|
# docker run --rm -v $(pwd)/dist:/dist ja4ebpf:package
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
ARG GO_VERSION=1.24
|
||||||
|
|
||||||
|
# ── Stage 1 : compilation Go ──────────────────────────────────────────────
|
||||||
|
FROM golang:${GO_VERSION}-bookworm AS go-builder
|
||||||
|
|
||||||
|
ARG BUILD_VERSION
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
clang llvm libbpf-dev && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.work go.work.sum* ./
|
||||||
|
COPY shared/go/ja4common/go.mod shared/go/ja4common/go.sum* ./shared/go/ja4common/
|
||||||
|
COPY services/ja4ebpf/go.mod services/ja4ebpf/go.sum* ./services/ja4ebpf/
|
||||||
|
|
||||||
|
RUN cd services/ja4ebpf && go mod download 2>/dev/null || go get ./...
|
||||||
|
|
||||||
|
COPY shared/go/ja4common/ ./shared/go/ja4common/
|
||||||
|
COPY services/ja4ebpf/ ./services/ja4ebpf/
|
||||||
|
|
||||||
|
WORKDIR /build/services/ja4ebpf
|
||||||
|
|
||||||
|
# Génération des bindings eBPF (C → bytecode embarqué en Go)
|
||||||
|
RUN go generate ./internal/loader/
|
||||||
|
|
||||||
|
# Compilation statique
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build \
|
||||||
|
-ldflags="-s -w -X main.version=${BUILD_VERSION} -extldflags=-static" \
|
||||||
|
-o /out/ja4ebpf \
|
||||||
|
./cmd/ja4ebpf/
|
||||||
|
|
||||||
|
# ── Stage 2 : RPM pour el8 ───────────────────────────────────────────────
|
||||||
|
FROM almalinux:8 AS rpm-el8
|
||||||
|
RUN dnf install -y rpm-build rpmdevtools && dnf clean all && rpmdev-setuptree
|
||||||
|
|
||||||
|
COPY --from=go-builder /out/ja4ebpf /root/rpmbuild/SOURCES/ja4ebpf
|
||||||
|
COPY services/ja4ebpf/packaging/systemd/ja4ebpf.service /root/rpmbuild/SOURCES/ja4ebpf.service
|
||||||
|
COPY services/ja4ebpf/config.yml.example /root/rpmbuild/SOURCES/config.yml.example
|
||||||
|
COPY services/ja4ebpf/packaging/rpm/ja4ebpf.spec /root/rpmbuild/SPECS/ja4ebpf.spec
|
||||||
|
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
RUN rpmbuild -bb \
|
||||||
|
--define "build_version ${BUILD_VERSION}" \
|
||||||
|
--define "dist .el8" \
|
||||||
|
/root/rpmbuild/SPECS/ja4ebpf.spec && \
|
||||||
|
mkdir -p /rpms && find /root/rpmbuild/RPMS -name '*.rpm' -exec cp {} /rpms/ \;
|
||||||
|
|
||||||
|
# ── Stage 3 : RPM pour el9 ───────────────────────────────────────────────
|
||||||
|
FROM rockylinux:9 AS rpm-el9
|
||||||
|
RUN dnf install -y rpm-build rpmdevtools && dnf clean all && rpmdev-setuptree
|
||||||
|
|
||||||
|
COPY --from=go-builder /out/ja4ebpf /root/rpmbuild/SOURCES/ja4ebpf
|
||||||
|
COPY services/ja4ebpf/packaging/systemd/ja4ebpf.service /root/rpmbuild/SOURCES/ja4ebpf.service
|
||||||
|
COPY services/ja4ebpf/config.yml.example /root/rpmbuild/SOURCES/config.yml.example
|
||||||
|
COPY services/ja4ebpf/packaging/rpm/ja4ebpf.spec /root/rpmbuild/SPECS/ja4ebpf.spec
|
||||||
|
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
RUN rpmbuild -bb \
|
||||||
|
--define "build_version ${BUILD_VERSION}" \
|
||||||
|
--define "dist .el9" \
|
||||||
|
/root/rpmbuild/SPECS/ja4ebpf.spec && \
|
||||||
|
mkdir -p /rpms && find /root/rpmbuild/RPMS -name '*.rpm' -exec cp {} /rpms/ \;
|
||||||
|
|
||||||
|
# ── Stage 4 : RPM pour el10 ──────────────────────────────────────────────
|
||||||
|
FROM almalinux:10 AS rpm-el10
|
||||||
|
RUN dnf install -y rpm-build rpmdevtools && dnf clean all && rpmdev-setuptree
|
||||||
|
|
||||||
|
COPY --from=go-builder /out/ja4ebpf /root/rpmbuild/SOURCES/ja4ebpf
|
||||||
|
COPY services/ja4ebpf/packaging/systemd/ja4ebpf.service /root/rpmbuild/SOURCES/ja4ebpf.service
|
||||||
|
COPY services/ja4ebpf/config.yml.example /root/rpmbuild/SOURCES/config.yml.example
|
||||||
|
COPY services/ja4ebpf/packaging/rpm/ja4ebpf.spec /root/rpmbuild/SPECS/ja4ebpf.spec
|
||||||
|
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
RUN rpmbuild -bb \
|
||||||
|
--define "build_version ${BUILD_VERSION}" \
|
||||||
|
--define "dist .el10" \
|
||||||
|
/root/rpmbuild/SPECS/ja4ebpf.spec && \
|
||||||
|
mkdir -p /rpms && find /root/rpmbuild/RPMS -name '*.rpm' -exec cp {} /rpms/ \;
|
||||||
|
|
||||||
|
# ── Stage final : collecte de tous les RPMs ───────────────────────────────
|
||||||
|
FROM alpine:3.19 AS output
|
||||||
|
|
||||||
|
COPY --from=rpm-el8 /rpms/ /output/el8/
|
||||||
|
COPY --from=rpm-el9 /rpms/ /output/el9/
|
||||||
|
COPY --from=rpm-el10 /rpms/ /output/el10/
|
||||||
|
|
||||||
|
RUN echo "=== RPMs produits ===" && find /output -name '*.rpm' | sort
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-c", "cp -rv /output/. /dist/ && echo 'RPMs copiés dans /dist/'"]
|
||||||
24
services/ja4ebpf/Dockerfile.tests
Normal file
24
services/ja4ebpf/Dockerfile.tests
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Dockerfile.tests — Tests unitaires Go pour ja4ebpf
|
||||||
|
# (parser TLS, HTTP/2, corrélation — sans dépendance eBPF)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM rockylinux:9 AS test-builder
|
||||||
|
|
||||||
|
RUN dnf install -y epel-release && \
|
||||||
|
dnf install -y golang make && \
|
||||||
|
dnf clean all
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copier le workspace Go
|
||||||
|
COPY go.work go.work.sum ./
|
||||||
|
COPY shared/go/ja4common/ ./shared/go/ja4common/
|
||||||
|
COPY services/ja4ebpf/ ./services/ja4ebpf/
|
||||||
|
|
||||||
|
WORKDIR /build/services/ja4ebpf
|
||||||
|
|
||||||
|
# Exécuter les tests unitaires (sans tag eBPF = skip loader)
|
||||||
|
# GOWORK=off désactive le workspace pour éviter les dépendances sur sentinel/correlator
|
||||||
|
RUN GOWORK=off go test -v -count=1 ./internal/parser/... ./internal/correlation/... ./internal/writer/...
|
||||||
|
|
||||||
34
services/ja4ebpf/Makefile
Normal file
34
services/ja4ebpf/Makefile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Makefile — Cibles de build, test et packaging pour ja4ebpf
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BINARY := ja4ebpf
|
||||||
|
IMAGE := ja4ebpf
|
||||||
|
VERSION ?= 0.1.0
|
||||||
|
|
||||||
|
.PHONY: generate build test docker-build help
|
||||||
|
|
||||||
|
## generate: Compile les sources eBPF C → Go via bpf2go (dans Docker)
|
||||||
|
generate:
|
||||||
|
docker build --target go-builder \
|
||||||
|
--build-arg SKIP_BINARY=true \
|
||||||
|
-f Dockerfile \
|
||||||
|
-t $(IMAGE)-generated:$(VERSION) \
|
||||||
|
../../
|
||||||
|
|
||||||
|
## build: Construit l'image Docker de production complète
|
||||||
|
build: ## Construit l'image Docker finale
|
||||||
|
docker build -t $(IMAGE):$(VERSION) -f Dockerfile ../../
|
||||||
|
|
||||||
|
## test: Exécute les tests unitaires Go dans Docker
|
||||||
|
test:
|
||||||
|
docker build -f Dockerfile.tests -t $(IMAGE)-tests:$(VERSION) ../../ && \
|
||||||
|
docker run --rm $(IMAGE)-tests:$(VERSION)
|
||||||
|
|
||||||
|
## docker-build: Alias combiné generate + build
|
||||||
|
docker-build: build
|
||||||
|
|
||||||
|
## help: Affiche cette aide
|
||||||
|
help:
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
171
services/ja4ebpf/bpf/bpf_types.h
Normal file
171
services/ja4ebpf/bpf/bpf_types.h
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* bpf_types.h — Structures partagées entre les programmes eBPF (C) et Go
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <linux/types.h>
|
||||||
|
#include <bpf/bpf_helpers.h>
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Événement TCP SYN : émis pour chaque nouvelle connexion TCP observée
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct tcp_syn_event {
|
||||||
|
__u32 src_ip; /* adresse source (network byte order) */
|
||||||
|
__u32 dst_ip; /* adresse destination (network byte order) */
|
||||||
|
__u16 src_port; /* port source (host byte order) */
|
||||||
|
__u16 dst_port; /* port destination (host byte order) */
|
||||||
|
__u8 ttl; /* TTL IP */
|
||||||
|
__u8 df_bit; /* bit Don't Fragment (1 = DF activé) */
|
||||||
|
__u16 ip_id; /* champ identification IP */
|
||||||
|
__u16 window_size; /* fenêtre TCP initiale */
|
||||||
|
__u8 window_scale; /* facteur d'échelle (0xFF = absent) */
|
||||||
|
__u16 mss; /* MSS TCP (0 = absent) */
|
||||||
|
__u8 tcp_options_raw[40]; /* options TCP brutes */
|
||||||
|
__u8 tcp_options_len; /* longueur des options TCP */
|
||||||
|
__u64 timestamp_ns; /* horodatage kernel en nanosecondes */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Événement TLS ClientHello : émis quand un ClientHello TLS est détecté
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct tls_hello_event {
|
||||||
|
__u32 src_ip; /* adresse source (network byte order) */
|
||||||
|
__u16 src_port; /* port source (host byte order) */
|
||||||
|
__u8 payload[512]; /* payload ClientHello brut */
|
||||||
|
__u16 payload_len; /* longueur effective du payload */
|
||||||
|
__u64 timestamp_ns; /* horodatage kernel */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Événement SSL data : émis par les uprobes SSL_read/SSL_write
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct ssl_data_event {
|
||||||
|
__u64 pid_tgid; /* PID+TGID du processus */
|
||||||
|
__u32 fd; /* descripteur de fichier socket */
|
||||||
|
__u32 src_ip; /* IP source (rempli via accept_map) */
|
||||||
|
__u16 src_port; /* port source */
|
||||||
|
__u8 data[4096]; /* données déchiffrées */
|
||||||
|
__u32 data_len; /* longueur effective des données */
|
||||||
|
__u64 timestamp_ns; /* horodatage kernel */
|
||||||
|
__u8 direction; /* 0 = lecture (client→serveur), 1 = écriture */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Événement accept : émis lors de chaque appel accept4 réussi
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct accept_event {
|
||||||
|
__u64 pid_tgid; /* PID+TGID du processus */
|
||||||
|
__u32 fd; /* nouveau fd retourné par accept4 */
|
||||||
|
__u32 src_ip; /* adresse IP du client (peer) */
|
||||||
|
__u16 src_port; /* port du client */
|
||||||
|
__u64 timestamp_ns; /* horodatage kernel */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Événement HTTP en clair : émis pour chaque segment TCP porteur d'un
|
||||||
|
* payload HTTP (port 80 ou 8080). Un seul segment par requête est capturé
|
||||||
|
* (le premier, qui contient la request-line et les en-têtes).
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct http_plain_event {
|
||||||
|
__u32 src_ip; /* adresse source (host byte order) */
|
||||||
|
__u32 dst_ip; /* adresse destination (host byte order) */
|
||||||
|
__u16 src_port; /* port source (host byte order) */
|
||||||
|
__u16 dst_port; /* port destination 80 ou 8080 */
|
||||||
|
__u8 payload[4096]; /* payload TCP brut (request-line + headers) */
|
||||||
|
__u16 payload_len; /* longueur effective du payload copié */
|
||||||
|
__u64 timestamp_ns; /* horodatage kernel */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Arguments sauvegardés à l'entrée de SSL_read (pour l'uretprobe)
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct ssl_read_args {
|
||||||
|
__u64 ssl_ptr; /* pointeur vers la structure SSL */
|
||||||
|
__u64 buf_ptr; /* pointeur vers le buffer de réception */
|
||||||
|
__u32 num; /* taille maximale demandée */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Informations de connexion associées à un pointeur SSL
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct ssl_conn_info {
|
||||||
|
__u32 fd; /* descripteur de fichier socket */
|
||||||
|
__u32 src_ip; /* IP source du client */
|
||||||
|
__u16 src_port; /* port source du client */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Clé composite pour accept_map : pid_tgid + fd
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct accept_key {
|
||||||
|
__u64 pid_tgid; /* PID+TGID */
|
||||||
|
__u32 fd; /* descripteur de fichier */
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* Déclarations des maps eBPF avec annotations BTF
|
||||||
|
* ===========================================================================*/
|
||||||
|
|
||||||
|
/* Ring buffer : événements TCP SYN (16 MB) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||||
|
__uint(max_entries, 1 << 24);
|
||||||
|
} rb_tcp_syn SEC(".maps");
|
||||||
|
|
||||||
|
/* Ring buffer : événements TLS ClientHello (16 MB) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||||
|
__uint(max_entries, 1 << 24);
|
||||||
|
} rb_tls_hello SEC(".maps");
|
||||||
|
|
||||||
|
/* Ring buffer : données SSL déchiffrées (64 MB, plus volumineux) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||||
|
__uint(max_entries, 1 << 26);
|
||||||
|
} rb_ssl_data SEC(".maps");
|
||||||
|
|
||||||
|
/* Ring buffer : événements accept4 (4 MB) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||||
|
__uint(max_entries, 1 << 22);
|
||||||
|
} rb_accept SEC(".maps");
|
||||||
|
|
||||||
|
/* Ring buffer : payload HTTP en clair port 80/8080 (32 MB) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||||
|
__uint(max_entries, 1 << 25);
|
||||||
|
} rb_http_plain SEC(".maps");
|
||||||
|
|
||||||
|
/* Hash map : pid_tgid → ssl_read_args (arguments SSL_read entry) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_HASH);
|
||||||
|
__uint(max_entries, 10240);
|
||||||
|
__type(key, __u64);
|
||||||
|
__type(value, struct ssl_read_args);
|
||||||
|
} ssl_args_map SEC(".maps");
|
||||||
|
|
||||||
|
/* Hash map : ssl_ptr → ssl_conn_info (connexion associée au SSL*) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_HASH);
|
||||||
|
__uint(max_entries, 10240);
|
||||||
|
__type(key, __u64);
|
||||||
|
__type(value, struct ssl_conn_info);
|
||||||
|
} ssl_conn_map SEC(".maps");
|
||||||
|
|
||||||
|
/* Hash map : {pid_tgid, fd} → accept_event (pour corrélation SSL) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_HASH);
|
||||||
|
__uint(max_entries, 10240);
|
||||||
|
__type(key, struct accept_key);
|
||||||
|
__type(value, struct accept_event);
|
||||||
|
} accept_map SEC(".maps");
|
||||||
|
|
||||||
|
/* Hash map secondaire : fd uniquement → ssl_conn_info (pour SSL_set_fd) */
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_HASH);
|
||||||
|
__uint(max_entries, 10240);
|
||||||
|
__type(key, __u32);
|
||||||
|
__type(value, struct ssl_conn_info);
|
||||||
|
} fd_conn_map SEC(".maps");
|
||||||
|
|
||||||
276
services/ja4ebpf/bpf/tc_capture.c
Normal file
276
services/ja4ebpf/bpf/tc_capture.c
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* tc_capture.c — Programme TC ingress : capture des TCP SYN et TLS ClientHello
|
||||||
|
*
|
||||||
|
* Attaché sur l'interface réseau en ingress via TC (Traffic Control).
|
||||||
|
* Émet des événements vers les ring buffers rb_tcp_syn et rb_tls_hello.
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
#include "vmlinux.h"
|
||||||
|
#include <bpf/bpf_helpers.h>
|
||||||
|
#include <bpf/bpf_endian.h>
|
||||||
|
#include <bpf/bpf_core_read.h>
|
||||||
|
#include "bpf_types.h"
|
||||||
|
|
||||||
|
/* Constantes Ethernet */
|
||||||
|
#define ETH_P_IP 0x0800
|
||||||
|
#define ETH_HLEN 14
|
||||||
|
|
||||||
|
/* Constantes IP */
|
||||||
|
#define IPPROTO_TCP 6
|
||||||
|
#define IP_DF 0x4000 /* bit Don't Fragment */
|
||||||
|
|
||||||
|
/* Constantes TCP */
|
||||||
|
#define TH_SYN 0x02
|
||||||
|
#define TH_ACK 0x10
|
||||||
|
|
||||||
|
/* Port HTTPS standard */
|
||||||
|
#define HTTPS_PORT 443
|
||||||
|
|
||||||
|
/* Ports HTTP en clair */
|
||||||
|
#define HTTP_PORT 80
|
||||||
|
#define HTTP_ALT_PORT 8080
|
||||||
|
|
||||||
|
/* Flags TCP */
|
||||||
|
#define TH_FIN 0x01
|
||||||
|
#define TH_RST 0x04
|
||||||
|
|
||||||
|
/* Type de contenu TLS : Handshake */
|
||||||
|
#define TLS_CONTENT_HANDSHAKE 0x16
|
||||||
|
/* Type de message TLS : ClientHello */
|
||||||
|
#define TLS_MSG_CLIENT_HELLO 0x01
|
||||||
|
|
||||||
|
/* Taille maximale du payload TLS à copier */
|
||||||
|
#define MAX_TLS_PAYLOAD 512
|
||||||
|
|
||||||
|
/* Longueur maximale des options TCP */
|
||||||
|
#define MAX_TCP_OPTIONS 40
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Structure interne pour le parsing de l'en-tête Ethernet
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct ethhdr_local {
|
||||||
|
__u8 h_dest[6];
|
||||||
|
__u8 h_source[6];
|
||||||
|
__be16 h_proto;
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* capture_tc_ingress — Point d'entrée TC ingress
|
||||||
|
*
|
||||||
|
* Inspecte chaque paquet entrant, détecte les TCP SYN et les ClientHello TLS,
|
||||||
|
* et soumet les événements correspondants aux ring buffers.
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
SEC("tc/ingress")
|
||||||
|
int capture_tc_ingress(struct __sk_buff *skb)
|
||||||
|
{
|
||||||
|
/* Pointeurs de début et fin du buffer paquet */
|
||||||
|
void *data = (void *)(long)skb->data;
|
||||||
|
void *data_end = (void *)(long)skb->data_end;
|
||||||
|
|
||||||
|
/* --- Parsing Ethernet --- */
|
||||||
|
struct ethhdr_local *eth = data;
|
||||||
|
if ((void *)(eth + 1) > data_end)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
/* Vérifier que c'est un paquet IPv4 */
|
||||||
|
if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
/* --- Parsing IPv4 --- */
|
||||||
|
struct iphdr *ip = (struct iphdr *)((void *)eth + ETH_HLEN);
|
||||||
|
if ((void *)(ip + 1) > data_end)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
/* Vérifier que c'est du TCP */
|
||||||
|
if (ip->protocol != IPPROTO_TCP)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
__u8 ihl = ip->ihl & 0x0F; /* longueur en-tête IP en mots de 32 bits */
|
||||||
|
__u32 src_ip = ip->saddr;
|
||||||
|
__u32 dst_ip = ip->daddr;
|
||||||
|
__u8 ttl = ip->ttl;
|
||||||
|
__u16 ip_id = bpf_ntohs(ip->id);
|
||||||
|
__u16 frag_off = bpf_ntohs(ip->frag_off);
|
||||||
|
__u8 df_bit = (frag_off & IP_DF) ? 1 : 0;
|
||||||
|
|
||||||
|
/* --- Parsing TCP --- */
|
||||||
|
struct tcphdr *tcp = (struct tcphdr *)((void *)ip + (ihl * 4));
|
||||||
|
if ((void *)(tcp + 1) > data_end)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
__u16 src_port = bpf_ntohs(tcp->source);
|
||||||
|
__u16 dst_port = bpf_ntohs(tcp->dest);
|
||||||
|
__u16 window = bpf_ntohs(tcp->window);
|
||||||
|
__u8 tcp_flags = ((__u8 *)tcp)[13]; /* octet des flags TCP */
|
||||||
|
__u8 data_off = tcp->doff; /* longueur en-tête TCP en mots de 32 bits */
|
||||||
|
|
||||||
|
/* --- Détection TCP SYN (SYN set, ACK clear) --- */
|
||||||
|
if ((tcp_flags & TH_SYN) && !(tcp_flags & TH_ACK)) {
|
||||||
|
/* Allouer un slot dans le ring buffer */
|
||||||
|
struct tcp_syn_event *evt = bpf_ringbuf_reserve(&rb_tcp_syn, sizeof(*evt), 0);
|
||||||
|
if (!evt)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
evt->src_ip = bpf_ntohl(src_ip);
|
||||||
|
evt->dst_ip = bpf_ntohl(dst_ip);
|
||||||
|
evt->src_port = src_port;
|
||||||
|
evt->dst_port = dst_port;
|
||||||
|
evt->ttl = ttl;
|
||||||
|
evt->df_bit = df_bit;
|
||||||
|
evt->ip_id = ip_id;
|
||||||
|
evt->window_size = window;
|
||||||
|
evt->window_scale = 0xFF; /* absent par défaut */
|
||||||
|
evt->mss = 0; /* absent par défaut */
|
||||||
|
evt->timestamp_ns = bpf_ktime_get_ns();
|
||||||
|
|
||||||
|
/* --- Parsing des options TCP --- */
|
||||||
|
__u8 *opts_start = (__u8 *)tcp + 20; /* options commencent après les 20 octets fixes */
|
||||||
|
__u8 opts_len = (data_off * 4) - 20;
|
||||||
|
if (opts_len > MAX_TCP_OPTIONS)
|
||||||
|
opts_len = MAX_TCP_OPTIONS;
|
||||||
|
|
||||||
|
evt->tcp_options_len = 0;
|
||||||
|
|
||||||
|
/* Copier les options brutes avec vérification de bornes */
|
||||||
|
__u8 *opts_ptr = opts_start;
|
||||||
|
|
||||||
|
/* Boucle bornée sur les options TCP (max 40 octets) */
|
||||||
|
#pragma unroll
|
||||||
|
for (int i = 0; i < MAX_TCP_OPTIONS; i++) {
|
||||||
|
if (i >= opts_len)
|
||||||
|
break;
|
||||||
|
if ((void *)(opts_ptr + i + 1) > data_end)
|
||||||
|
break;
|
||||||
|
|
||||||
|
__u8 opt_kind;
|
||||||
|
bpf_probe_read_kernel(&opt_kind, 1, opts_ptr + i);
|
||||||
|
evt->tcp_options_raw[i] = opt_kind;
|
||||||
|
evt->tcp_options_len = i + 1;
|
||||||
|
|
||||||
|
/* NOP : 1 octet */
|
||||||
|
if (opt_kind == 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
/* EOL : fin des options */
|
||||||
|
if (opt_kind == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Option avec longueur */
|
||||||
|
if ((void *)(opts_ptr + i + 2) > data_end)
|
||||||
|
break;
|
||||||
|
|
||||||
|
__u8 opt_len;
|
||||||
|
bpf_probe_read_kernel(&opt_len, 1, opts_ptr + i + 1);
|
||||||
|
if (opt_len < 2)
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* MSS (option 2) : 4 octets au total */
|
||||||
|
if (opt_kind == 2 && opt_len == 4) {
|
||||||
|
if ((void *)(opts_ptr + i + 4) > data_end)
|
||||||
|
break;
|
||||||
|
__u16 mss_val;
|
||||||
|
bpf_probe_read_kernel(&mss_val, 2, opts_ptr + i + 2);
|
||||||
|
evt->mss = bpf_ntohs(mss_val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window Scale (option 3) : 3 octets au total */
|
||||||
|
if (opt_kind == 3 && opt_len == 3) {
|
||||||
|
if ((void *)(opts_ptr + i + 3) > data_end)
|
||||||
|
break;
|
||||||
|
__u8 wscale;
|
||||||
|
bpf_probe_read_kernel(&wscale, 1, opts_ptr + i + 2);
|
||||||
|
evt->window_scale = wscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avancer au-delà de cette option */
|
||||||
|
i += opt_len - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bpf_ringbuf_submit(evt, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Détection TLS ClientHello (port 443) --- */
|
||||||
|
if (dst_port == HTTPS_PORT) {
|
||||||
|
__u8 *tcp_payload = (__u8 *)tcp + (data_off * 4);
|
||||||
|
|
||||||
|
/* Vérifier qu'il y a au moins 6 octets pour l'en-tête TLS record + type hello */
|
||||||
|
if ((void *)(tcp_payload + 6) > data_end)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
__u8 content_type, msg_type;
|
||||||
|
bpf_probe_read_kernel(&content_type, 1, tcp_payload);
|
||||||
|
bpf_probe_read_kernel(&msg_type, 1, tcp_payload + 5);
|
||||||
|
|
||||||
|
/* Vérifier : Handshake (0x16) + ClientHello (0x01) */
|
||||||
|
if (content_type == TLS_CONTENT_HANDSHAKE && msg_type == TLS_MSG_CLIENT_HELLO) {
|
||||||
|
struct tls_hello_event *tls_evt = bpf_ringbuf_reserve(&rb_tls_hello, sizeof(*tls_evt), 0);
|
||||||
|
if (!tls_evt)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
tls_evt->src_ip = bpf_ntohl(src_ip);
|
||||||
|
tls_evt->src_port = src_port;
|
||||||
|
tls_evt->timestamp_ns = bpf_ktime_get_ns();
|
||||||
|
|
||||||
|
/* Calculer la longueur de payload disponible */
|
||||||
|
__u32 avail = (__u32)(data_end - (void *)tcp_payload);
|
||||||
|
if (avail > MAX_TLS_PAYLOAD)
|
||||||
|
avail = MAX_TLS_PAYLOAD;
|
||||||
|
|
||||||
|
tls_evt->payload_len = (__u16)avail;
|
||||||
|
|
||||||
|
/* Copier le payload TLS (borné à MAX_TLS_PAYLOAD) */
|
||||||
|
bpf_probe_read_kernel(tls_evt->payload, avail & (MAX_TLS_PAYLOAD - 1), tcp_payload);
|
||||||
|
|
||||||
|
bpf_ringbuf_submit(tls_evt, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Détection payload HTTP en clair (port 80 / 8080) --- */
|
||||||
|
if (dst_port == HTTP_PORT || dst_port == HTTP_ALT_PORT) {
|
||||||
|
/* Ignorer SYN, FIN, RST : on ne veut que les segments de données */
|
||||||
|
if (tcp_flags & (TH_SYN | TH_FIN | TH_RST))
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
/* Calculer l'offset du payload TCP dans le paquet */
|
||||||
|
__u32 payload_off = ETH_HLEN + (ihl * 4) + (data_off * 4);
|
||||||
|
|
||||||
|
/* Vérifier qu'il y a un payload non vide */
|
||||||
|
if (skb->len <= payload_off)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
__u32 avail = skb->len - payload_off;
|
||||||
|
if (avail > 4096)
|
||||||
|
avail = 4096;
|
||||||
|
|
||||||
|
/* Réserver une entrée dans le ring buffer HTTP en clair */
|
||||||
|
struct http_plain_event *h_evt =
|
||||||
|
bpf_ringbuf_reserve(&rb_http_plain, sizeof(*h_evt), 0);
|
||||||
|
if (!h_evt)
|
||||||
|
return TC_ACT_OK;
|
||||||
|
|
||||||
|
h_evt->src_ip = bpf_ntohl(src_ip);
|
||||||
|
h_evt->dst_ip = bpf_ntohl(dst_ip);
|
||||||
|
h_evt->src_port = src_port;
|
||||||
|
h_evt->dst_port = dst_port;
|
||||||
|
h_evt->timestamp_ns = bpf_ktime_get_ns();
|
||||||
|
|
||||||
|
/* Copier le payload depuis le skb linéaire via bpf_skb_load_bytes.
|
||||||
|
* La longueur est masquée pour satisfaire le vérificateur eBPF. */
|
||||||
|
__u32 copy_len = avail & (4096 - 1);
|
||||||
|
if (copy_len == 0) copy_len = 1; /* cas avail == 4096 exactement */
|
||||||
|
h_evt->payload_len = (__u16)avail;
|
||||||
|
|
||||||
|
if (bpf_skb_load_bytes(skb, payload_off, h_evt->payload, copy_len) < 0) {
|
||||||
|
bpf_ringbuf_discard(h_evt, 0);
|
||||||
|
return TC_ACT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bpf_ringbuf_submit(h_evt, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TC_ACT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
char LICENSE[] SEC("license") = "GPL";
|
||||||
|
|
||||||
|
|
||||||
223
services/ja4ebpf/bpf/uprobe_ssl.c
Normal file
223
services/ja4ebpf/bpf/uprobe_ssl.c
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* uprobe_ssl.c — Uprobes SSL_read/SSL_set_fd et kprobes accept4
|
||||||
|
*
|
||||||
|
* Intercepte les appels OpenSSL pour capturer le trafic déchiffré,
|
||||||
|
* et corrige l'association socket ↔ SSL* via accept4.
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
#include "vmlinux.h"
|
||||||
|
#include <bpf/bpf_helpers.h>
|
||||||
|
#include <bpf/bpf_tracing.h>
|
||||||
|
#include <bpf/bpf_core_read.h>
|
||||||
|
#include "bpf_types.h"
|
||||||
|
|
||||||
|
/* Taille maximale de données SSL à copier par événement */
|
||||||
|
#define MAX_SSL_DATA 4096
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Map temporaire : pid_tgid → upeer_sockaddr (sauvegardé à l'entrée d'accept4)
|
||||||
|
* ---------------------------------------------------------------------------*/
|
||||||
|
struct {
|
||||||
|
__uint(type, BPF_MAP_TYPE_HASH);
|
||||||
|
__uint(max_entries, 10240);
|
||||||
|
__type(key, __u64);
|
||||||
|
__type(value, __u64); /* pointeur userspace vers sockaddr_in */
|
||||||
|
} accept_args_map SEC(".maps");
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* uprobe_ssl_set_fd — Intercept SSL_set_fd(SSL *s, int fd)
|
||||||
|
*
|
||||||
|
* Associe un ssl_ptr à ses informations de connexion (fd, src_ip, src_port)
|
||||||
|
* en consultant fd_conn_map.
|
||||||
|
* ===========================================================================*/
|
||||||
|
SEC("uprobe/SSL_set_fd")
|
||||||
|
int uprobe_ssl_set_fd(struct pt_regs *ctx)
|
||||||
|
{
|
||||||
|
__u64 ssl_ptr = ((__u64)PT_REGS_PARM1(ctx));
|
||||||
|
__u32 fd = ((__u32)PT_REGS_PARM2(ctx));
|
||||||
|
|
||||||
|
/* Rechercher les infos de connexion via le fd */
|
||||||
|
struct ssl_conn_info *conn = bpf_map_lookup_elem(&fd_conn_map, &fd);
|
||||||
|
if (!conn)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
/* Enregistrer l'association ssl_ptr → conn_info */
|
||||||
|
struct ssl_conn_info new_conn = *conn;
|
||||||
|
new_conn.fd = fd;
|
||||||
|
bpf_map_update_elem(&ssl_conn_map, &ssl_ptr, &new_conn, BPF_ANY);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* uprobe_ssl_read_entry — Entrée de SSL_read(SSL *ssl, void *buf, int num)
|
||||||
|
*
|
||||||
|
* Sauvegarde les arguments pour l'uretprobe correspondant.
|
||||||
|
* ===========================================================================*/
|
||||||
|
SEC("uprobe/SSL_read")
|
||||||
|
int uprobe_ssl_read_entry(struct pt_regs *ctx)
|
||||||
|
{
|
||||||
|
__u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||||
|
|
||||||
|
struct ssl_read_args args = {};
|
||||||
|
args.ssl_ptr = (__u64)PT_REGS_PARM1(ctx);
|
||||||
|
args.buf_ptr = (__u64)PT_REGS_PARM2(ctx);
|
||||||
|
args.num = (__u32)PT_REGS_PARM3(ctx);
|
||||||
|
|
||||||
|
bpf_map_update_elem(&ssl_args_map, &pid_tgid, &args, BPF_ANY);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* uretprobe_ssl_read_exit — Retour de SSL_read
|
||||||
|
*
|
||||||
|
* Lit le buffer déchiffré et l'émet dans rb_ssl_data.
|
||||||
|
* ===========================================================================*/
|
||||||
|
SEC("uretprobe/SSL_read")
|
||||||
|
int uretprobe_ssl_read_exit(struct pt_regs *ctx)
|
||||||
|
{
|
||||||
|
__u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||||
|
|
||||||
|
/* Récupérer les arguments sauvegardés à l'entrée */
|
||||||
|
struct ssl_read_args *args = bpf_map_lookup_elem(&ssl_args_map, &pid_tgid);
|
||||||
|
if (!args)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
/* Vérifier que la lecture a réussi (valeur de retour > 0) */
|
||||||
|
long retval = PT_REGS_RC(ctx);
|
||||||
|
if (retval <= 0) {
|
||||||
|
bpf_map_delete_elem(&ssl_args_map, &pid_tgid);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allouer un slot dans le ring buffer */
|
||||||
|
struct ssl_data_event *evt = bpf_ringbuf_reserve(&rb_ssl_data, sizeof(*evt), 0);
|
||||||
|
if (!evt) {
|
||||||
|
bpf_map_delete_elem(&ssl_args_map, &pid_tgid);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt->pid_tgid = pid_tgid;
|
||||||
|
evt->direction = 0; /* lecture = client vers serveur */
|
||||||
|
evt->timestamp_ns = bpf_ktime_get_ns();
|
||||||
|
|
||||||
|
/* Limiter la copie à MAX_SSL_DATA octets */
|
||||||
|
__u32 data_len = (retval > MAX_SSL_DATA) ? MAX_SSL_DATA : (__u32)retval;
|
||||||
|
evt->data_len = data_len;
|
||||||
|
|
||||||
|
/* Copier depuis l'espace utilisateur */
|
||||||
|
bpf_probe_read_user(evt->data, data_len & (MAX_SSL_DATA - 1), (void *)args->buf_ptr);
|
||||||
|
|
||||||
|
/* Retrouver les infos de connexion via ssl_ptr */
|
||||||
|
struct ssl_conn_info *conn = bpf_map_lookup_elem(&ssl_conn_map, &args->ssl_ptr);
|
||||||
|
if (conn) {
|
||||||
|
evt->fd = conn->fd;
|
||||||
|
evt->src_ip = conn->src_ip;
|
||||||
|
evt->src_port = conn->src_port;
|
||||||
|
} else {
|
||||||
|
evt->fd = 0;
|
||||||
|
evt->src_ip = 0;
|
||||||
|
evt->src_port = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bpf_ringbuf_submit(evt, 0);
|
||||||
|
bpf_map_delete_elem(&ssl_args_map, &pid_tgid);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* kprobe_accept4_entry — Entrée de accept4(fd, upeer_sockaddr, upeer_addrlen, flags)
|
||||||
|
*
|
||||||
|
* Sauvegarde le pointeur vers la sockaddr pour la récupérer à la sortie.
|
||||||
|
* ===========================================================================*/
|
||||||
|
SEC("kprobe/accept4")
|
||||||
|
int kprobe_accept4_entry(struct pt_regs *ctx)
|
||||||
|
{
|
||||||
|
__u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||||
|
/* Deuxième argument : pointeur userspace vers struct sockaddr_in */
|
||||||
|
__u64 sockaddr_ptr = (__u64)PT_REGS_PARM2(ctx);
|
||||||
|
|
||||||
|
bpf_map_update_elem(&accept_args_map, &pid_tgid, &sockaddr_ptr, BPF_ANY);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* kretprobe_accept4_exit — Retour de accept4
|
||||||
|
*
|
||||||
|
* Lit la sockaddr_in pour extraire src_ip:src_port du client,
|
||||||
|
* peuple accept_map et fd_conn_map, et émet dans rb_accept.
|
||||||
|
* ===========================================================================*/
|
||||||
|
SEC("kretprobe/accept4")
|
||||||
|
int kretprobe_accept4_exit(struct pt_regs *ctx)
|
||||||
|
{
|
||||||
|
__u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||||
|
|
||||||
|
/* Vérifier que accept4 a réussi (fd ≥ 0) */
|
||||||
|
long new_fd = PT_REGS_RC(ctx);
|
||||||
|
if (new_fd < 0) {
|
||||||
|
bpf_map_delete_elem(&accept_args_map, &pid_tgid);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Récupérer le pointeur vers sockaddr_in */
|
||||||
|
__u64 *sockaddr_ptr_p = bpf_map_lookup_elem(&accept_args_map, &pid_tgid);
|
||||||
|
if (!sockaddr_ptr_p) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
__u64 sockaddr_ptr = *sockaddr_ptr_p;
|
||||||
|
bpf_map_delete_elem(&accept_args_map, &pid_tgid);
|
||||||
|
|
||||||
|
if (!sockaddr_ptr)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
/* Lire la structure sockaddr_in depuis l'espace utilisateur */
|
||||||
|
/* struct sockaddr_in: sin_family(2) + sin_port(2) + sin_addr(4) */
|
||||||
|
__u8 sa_buf[8] = {};
|
||||||
|
bpf_probe_read_user(sa_buf, sizeof(sa_buf), (void *)sockaddr_ptr);
|
||||||
|
|
||||||
|
/* Extraire port (octets 2-3) et adresse IP (octets 4-7) */
|
||||||
|
__u16 sin_port = (__u16)(sa_buf[2] << 8) | sa_buf[3]; /* network byte order */
|
||||||
|
__u32 sin_addr = *(__u32 *)(sa_buf + 4); /* network byte order */
|
||||||
|
|
||||||
|
__u32 src_ip = __builtin_bswap32(sin_addr); /* host byte order */
|
||||||
|
__u16 src_port = __builtin_bswap16(sin_port); /* host byte order */
|
||||||
|
__u32 fd = (__u32)new_fd;
|
||||||
|
|
||||||
|
/* Peupler accept_map[{pid_tgid, fd}] */
|
||||||
|
struct accept_key akey = { .pid_tgid = pid_tgid, .fd = fd };
|
||||||
|
struct accept_event aevt = {
|
||||||
|
.pid_tgid = pid_tgid,
|
||||||
|
.fd = fd,
|
||||||
|
.src_ip = src_ip,
|
||||||
|
.src_port = src_port,
|
||||||
|
.timestamp_ns = bpf_ktime_get_ns(),
|
||||||
|
};
|
||||||
|
bpf_map_update_elem(&accept_map, &akey, &aevt, BPF_ANY);
|
||||||
|
|
||||||
|
/* Peupler fd_conn_map[fd] pour accès rapide par SSL_set_fd */
|
||||||
|
struct ssl_conn_info conn_info = {
|
||||||
|
.fd = fd,
|
||||||
|
.src_ip = src_ip,
|
||||||
|
.src_port = src_port,
|
||||||
|
};
|
||||||
|
bpf_map_update_elem(&fd_conn_map, &fd, &conn_info, BPF_ANY);
|
||||||
|
|
||||||
|
/* Émettre dans rb_accept */
|
||||||
|
struct accept_event *out = bpf_ringbuf_reserve(&rb_accept, sizeof(*out), 0);
|
||||||
|
if (!out)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
out->pid_tgid = pid_tgid;
|
||||||
|
out->fd = fd;
|
||||||
|
out->src_ip = src_ip;
|
||||||
|
out->src_port = src_port;
|
||||||
|
out->timestamp_ns = aevt.timestamp_ns;
|
||||||
|
|
||||||
|
bpf_ringbuf_submit(out, 0);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
char LICENSE[] SEC("license") = "GPL";
|
||||||
|
|
||||||
418
services/ja4ebpf/cmd/ja4ebpf/main.go
Normal file
418
services/ja4ebpf/cmd/ja4ebpf/main.go
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
// Package main est le point d'entrée du démon ja4ebpf.
|
||||||
|
// Il initialise la configuration, charge les programmes eBPF, démarre
|
||||||
|
// les goroutines de traitement et gère les signaux système.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/loader"
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/writer"
|
||||||
|
"github.com/cilium/ebpf/ringbuf"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config décrit la configuration complète du démon ja4ebpf.
|
||||||
|
// Chargée depuis un fichier YAML et enrichie par les variables d'environnement
|
||||||
|
// avec le préfixe JA4EBPF_.
|
||||||
|
type Config struct {
|
||||||
|
Interface string `yaml:"interface"` // interface réseau à surveiller (ex: "eth0")
|
||||||
|
SSLLibPath string `yaml:"ssl_lib_path"` // chemin vers libssl (ex: "/usr/lib64/libssl.so.3")
|
||||||
|
|
||||||
|
ClickHouse struct {
|
||||||
|
DSN string `yaml:"dsn"` // DSN ClickHouse natif
|
||||||
|
BatchSize int `yaml:"batch_size"` // nombre de sessions par batch
|
||||||
|
FlushSecs int `yaml:"flush_secs"` // intervalle de flush en secondes
|
||||||
|
} `yaml:"clickhouse"`
|
||||||
|
|
||||||
|
Correlation struct {
|
||||||
|
TimeoutMS int `yaml:"timeout_ms"` // délai d'expiration session (ms)
|
||||||
|
SlowlorisMS int `yaml:"slowloris_ms"` // seuil Slowloris (ms)
|
||||||
|
} `yaml:"correlation"`
|
||||||
|
|
||||||
|
Log struct {
|
||||||
|
Level string `yaml:"level"` // niveau de log (debug, info, warn, error)
|
||||||
|
Format string `yaml:"format"` // format de log ("json" ou "text")
|
||||||
|
} `yaml:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig charge la configuration depuis le fichier YAML spécifié,
|
||||||
|
// puis applique les surcharges depuis les variables d'environnement.
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
// Valeurs par défaut
|
||||||
|
cfg.Interface = "eth0"
|
||||||
|
cfg.SSLLibPath = "/usr/lib64/libssl.so.3"
|
||||||
|
cfg.ClickHouse.DSN = "clickhouse://default:@localhost:9000/ja4_logs"
|
||||||
|
cfg.ClickHouse.BatchSize = 500
|
||||||
|
cfg.ClickHouse.FlushSecs = 1
|
||||||
|
cfg.Correlation.TimeoutMS = 500
|
||||||
|
cfg.Correlation.SlowlorisMS = 10000
|
||||||
|
cfg.Log.Level = "info"
|
||||||
|
cfg.Log.Format = "json"
|
||||||
|
|
||||||
|
// Charger depuis le fichier YAML si spécifié
|
||||||
|
if path != "" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lecture fichier config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing YAML config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surcharges via variables d'environnement
|
||||||
|
if v := os.Getenv("JA4EBPF_INTERFACE"); v != "" {
|
||||||
|
cfg.Interface = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("JA4EBPF_SSL_LIB_PATH"); v != "" {
|
||||||
|
cfg.SSLLibPath = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("JA4EBPF_CLICKHOUSE_DSN"); v != "" {
|
||||||
|
cfg.ClickHouse.DSN = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// main est le point d'entrée du programme.
|
||||||
|
func main() {
|
||||||
|
// Déterminer le chemin du fichier de configuration
|
||||||
|
configPath := os.Getenv("JA4EBPF_CONFIG")
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = "/etc/ja4ebpf/config.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("erreur chargement configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[ja4ebpf] démarrage — interface=%s ssl=%s", cfg.Interface, cfg.SSLLibPath)
|
||||||
|
|
||||||
|
// Contexte principal avec annulation sur signal système
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Intercepter SIGTERM et SIGINT pour l'arrêt gracieux
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
|
// --- 1. Chargement des programmes eBPF ---
|
||||||
|
ldr, err := loader.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("erreur chargement eBPF: %v", err)
|
||||||
|
}
|
||||||
|
defer ldr.Close()
|
||||||
|
|
||||||
|
// --- 2. Attachement TC ingress ---
|
||||||
|
if err := ldr.AttachTC(cfg.Interface); err != nil {
|
||||||
|
log.Fatalf("erreur attachement TC sur %s: %v", cfg.Interface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Attachement uprobes SSL ---
|
||||||
|
if err := ldr.AttachUprobes(cfg.SSLLibPath); err != nil {
|
||||||
|
log.Printf("[ja4ebpf] avertissement uprobes SSL: %v (désactivation uprobes)", err)
|
||||||
|
// Continuer sans uprobes SSL (capture L3/L4 toujours active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Attachement kprobes accept4 ---
|
||||||
|
if err := ldr.AttachAcceptProbe(); err != nil {
|
||||||
|
log.Printf("[ja4ebpf] avertissement kprobe accept4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Gestionnaire de sessions ---
|
||||||
|
sessionTimeout := time.Duration(cfg.Correlation.TimeoutMS) * time.Millisecond
|
||||||
|
mgr := correlation.NewManager(sessionTimeout)
|
||||||
|
mgr.StartGC(ctx)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
// --- 6. Writer ClickHouse ---
|
||||||
|
flushInterval := time.Duration(cfg.ClickHouse.FlushSecs) * time.Second
|
||||||
|
w, err := writer.NewClickHouseWriter(cfg.ClickHouse.DSN, cfg.ClickHouse.BatchSize, flushInterval)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("erreur initialisation writer ClickHouse: %v", err)
|
||||||
|
}
|
||||||
|
w.Start(ctx)
|
||||||
|
|
||||||
|
// --- 7. Goroutine : écriture des sessions prêtes ---
|
||||||
|
go func() {
|
||||||
|
for s := range mgr.ReadyCh {
|
||||||
|
w.Write(s)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- 8. Goroutines de consommation des ring buffers ---
|
||||||
|
go consumeSynEvents(ctx, ldr.SynReader, mgr)
|
||||||
|
go consumeTLSEvents(ctx, ldr.TLSReader, mgr)
|
||||||
|
go consumeSSLEvents(ctx, ldr.SSLReader, mgr)
|
||||||
|
go consumeAcceptEvents(ctx, ldr.AcceptReader, mgr)
|
||||||
|
|
||||||
|
log.Printf("[ja4ebpf] démon actif — en attente des événements")
|
||||||
|
|
||||||
|
// Attendre un signal d'arrêt
|
||||||
|
select {
|
||||||
|
case sig := <-sigCh:
|
||||||
|
log.Printf("[ja4ebpf] signal reçu: %v — arrêt gracieux", sig)
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
log.Printf("[ja4ebpf] arrêt terminé")
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeSynEvents lit les événements TCP SYN depuis le ring buffer
|
||||||
|
// et met à jour l'état L3/L4 des sessions.
|
||||||
|
func consumeSynEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := rd.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == ringbuf.ErrClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taille minimale attendue (voir struct tcp_syn_event)
|
||||||
|
if len(record.RawSample) < 20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := record.RawSample
|
||||||
|
|
||||||
|
// Décoder les champs de tcp_syn_event
|
||||||
|
srcIPRaw := binary.BigEndian.Uint32(data[0:4])
|
||||||
|
srcPort := binary.LittleEndian.Uint16(data[8:10])
|
||||||
|
|
||||||
|
var key correlation.SessionKey
|
||||||
|
key.SrcIP[0] = byte(srcIPRaw >> 24)
|
||||||
|
key.SrcIP[1] = byte(srcIPRaw >> 16)
|
||||||
|
key.SrcIP[2] = byte(srcIPRaw >> 8)
|
||||||
|
key.SrcIP[3] = byte(srcIPRaw)
|
||||||
|
key.SrcPort = srcPort
|
||||||
|
|
||||||
|
ttl := data[4]
|
||||||
|
dfBit := data[5] != 0
|
||||||
|
ipID := binary.LittleEndian.Uint16(data[6:8])
|
||||||
|
windowSize := binary.LittleEndian.Uint16(data[10:12])
|
||||||
|
windowScale := data[12]
|
||||||
|
mss := binary.LittleEndian.Uint16(data[13:15])
|
||||||
|
|
||||||
|
optLen := int(data[55])
|
||||||
|
if optLen > 40 {
|
||||||
|
optLen = 40
|
||||||
|
}
|
||||||
|
tcpOpts := make([]byte, optLen)
|
||||||
|
copy(tcpOpts, data[15:15+optLen])
|
||||||
|
|
||||||
|
mgr.Update(key, func(s *correlation.SessionState) {
|
||||||
|
s.L3L4 = &correlation.L3L4{
|
||||||
|
TTL: ttl,
|
||||||
|
DFBit: dfBit,
|
||||||
|
IPID: ipID,
|
||||||
|
WindowSize: windowSize,
|
||||||
|
WindowScale: windowScale,
|
||||||
|
MSS: mss,
|
||||||
|
TCPOptionsRaw: tcpOpts,
|
||||||
|
SYNTimestamp: time.Now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeTLSEvents lit les événements TLS ClientHello depuis le ring buffer
|
||||||
|
// et calcule l'empreinte JA4 pour chaque session.
|
||||||
|
func consumeTLSEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := rd.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == ringbuf.ErrClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taille minimale : src_ip(4) + src_port(2) + payload[512] + payload_len(2)
|
||||||
|
if len(record.RawSample) < 8 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := record.RawSample
|
||||||
|
|
||||||
|
srcIPRaw := binary.BigEndian.Uint32(data[0:4])
|
||||||
|
srcPort := binary.LittleEndian.Uint16(data[4:6])
|
||||||
|
payloadLen := binary.LittleEndian.Uint16(data[518:520])
|
||||||
|
|
||||||
|
if int(payloadLen) > 512 {
|
||||||
|
payloadLen = 512
|
||||||
|
}
|
||||||
|
payload := make([]byte, payloadLen)
|
||||||
|
copy(payload, data[6:6+payloadLen])
|
||||||
|
|
||||||
|
var key correlation.SessionKey
|
||||||
|
key.SrcIP[0] = byte(srcIPRaw >> 24)
|
||||||
|
key.SrcIP[1] = byte(srcIPRaw >> 16)
|
||||||
|
key.SrcIP[2] = byte(srcIPRaw >> 8)
|
||||||
|
key.SrcIP[3] = byte(srcIPRaw)
|
||||||
|
key.SrcPort = srcPort
|
||||||
|
|
||||||
|
// Parser le ClientHello et calculer JA4
|
||||||
|
ch, err := parser.ParseClientHello(payload)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ja4 := parser.ComputeJA4(ch)
|
||||||
|
|
||||||
|
var alpn []string
|
||||||
|
var ciphers, extensions []uint16
|
||||||
|
for _, e := range ch.Extensions {
|
||||||
|
extensions = append(extensions, e.Type)
|
||||||
|
}
|
||||||
|
ciphers = ch.CipherSuites
|
||||||
|
alpn = ch.ALPN
|
||||||
|
|
||||||
|
mgr.Update(key, func(s *correlation.SessionState) {
|
||||||
|
s.TLS = &correlation.TLSInfo{
|
||||||
|
ClientHelloRaw: payload,
|
||||||
|
JA4Hash: ja4,
|
||||||
|
SNI: ch.SNI,
|
||||||
|
ALPN: alpn,
|
||||||
|
CipherSuites: ciphers,
|
||||||
|
Extensions: extensions,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
// Corréler si L3/L4 est déjà présent
|
||||||
|
if s.L3L4 != nil {
|
||||||
|
s.Correlated = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeSSLEvents lit les données SSL déchiffrées depuis le ring buffer.
|
||||||
|
// Détecte le préambule HTTP/2 et extrait les paramètres SETTINGS.
|
||||||
|
func consumeSSLEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := rd.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == ringbuf.ErrClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := record.RawSample
|
||||||
|
// Taille minimale : pid_tgid(8) + fd(4) + src_ip(4) + src_port(2) = 18
|
||||||
|
if len(data) < 18 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srcIPRaw := binary.LittleEndian.Uint32(data[12:16])
|
||||||
|
srcPort := binary.LittleEndian.Uint16(data[16:18])
|
||||||
|
|
||||||
|
// data_len à l'offset 4112 (8+4+4+2 + data[4096] = offset 18, data_len à 18+4096)
|
||||||
|
if len(data) < 4118 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataLen := binary.LittleEndian.Uint32(data[4114:4118])
|
||||||
|
if dataLen > 4096 {
|
||||||
|
dataLen = 4096
|
||||||
|
}
|
||||||
|
sslData := data[18 : 18+dataLen]
|
||||||
|
|
||||||
|
var key correlation.SessionKey
|
||||||
|
key.SrcIP[0] = byte(srcIPRaw >> 24)
|
||||||
|
key.SrcIP[1] = byte(srcIPRaw >> 16)
|
||||||
|
key.SrcIP[2] = byte(srcIPRaw >> 8)
|
||||||
|
key.SrcIP[3] = byte(srcIPRaw)
|
||||||
|
key.SrcPort = srcPort
|
||||||
|
|
||||||
|
// Détecter le préambule HTTP/2
|
||||||
|
if parser.DetectH2Preface(sslData) {
|
||||||
|
afterPreface := sslData
|
||||||
|
if len(afterPreface) > parser.H2MagicPrefaceLen() {
|
||||||
|
afterPreface = sslData[parser.H2MagicPrefaceLen():]
|
||||||
|
}
|
||||||
|
_, err := parser.ParseH2ClientPreface(afterPreface)
|
||||||
|
if err == nil {
|
||||||
|
mgr.Update(key, func(s *correlation.SessionState) {
|
||||||
|
if len(s.Requests) == 0 {
|
||||||
|
s.Requests = append(s.Requests, correlation.HTTPRequest{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if s.TLS != nil {
|
||||||
|
s.Correlated = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeAcceptEvents lit les événements accept4 depuis le ring buffer.
|
||||||
|
// Met à jour les sessions avec les informations de connexion client.
|
||||||
|
func consumeAcceptEvents(ctx context.Context, rd *ringbuf.Reader, mgr *correlation.Manager) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := rd.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == ringbuf.ErrClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := record.RawSample
|
||||||
|
// Taille attendue : pid_tgid(8) + fd(4) + src_ip(4) + src_port(2) + timestamp(8) = 26
|
||||||
|
if len(data) < 22 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srcIPRaw := binary.LittleEndian.Uint32(data[12:16])
|
||||||
|
srcPort := binary.LittleEndian.Uint16(data[16:18])
|
||||||
|
|
||||||
|
var key correlation.SessionKey
|
||||||
|
key.SrcIP[0] = byte(srcIPRaw >> 24)
|
||||||
|
key.SrcIP[1] = byte(srcIPRaw >> 16)
|
||||||
|
key.SrcIP[2] = byte(srcIPRaw >> 8)
|
||||||
|
key.SrcIP[3] = byte(srcIPRaw)
|
||||||
|
key.SrcPort = srcPort
|
||||||
|
|
||||||
|
// S'assurer que la session existe
|
||||||
|
mgr.GetOrCreate(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
services/ja4ebpf/config.yml.example
Normal file
35
services/ja4ebpf/config.yml.example
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Configuration de l'agent ja4ebpf
|
||||||
|
# Copiez ce fichier en config.yml et adaptez les valeurs.
|
||||||
|
|
||||||
|
# Interface réseau à surveiller (hook TC ingress)
|
||||||
|
interface: eth0
|
||||||
|
|
||||||
|
# Processus à instrumenter via uprobes SSL
|
||||||
|
ssl_probes:
|
||||||
|
- executable: /usr/sbin/httpd
|
||||||
|
symbol: SSL_read
|
||||||
|
- executable: /usr/lib64/libssl.so.3
|
||||||
|
symbol: SSL_read
|
||||||
|
|
||||||
|
# Paramètres de connexion ClickHouse
|
||||||
|
clickhouse:
|
||||||
|
addr: "127.0.0.1:9000"
|
||||||
|
database: "ja4_logs"
|
||||||
|
table: "http_logs_raw"
|
||||||
|
username: "default"
|
||||||
|
password: ""
|
||||||
|
tls: false
|
||||||
|
batch_size: 500
|
||||||
|
flush_every: "2s"
|
||||||
|
|
||||||
|
# Délais de corrélation et de détection
|
||||||
|
timeouts:
|
||||||
|
# Durée sans activité avant expiration d'une session TCP
|
||||||
|
session_expiry: "500ms"
|
||||||
|
# Délai maximum pour une requête L7 sans réponse (détection Slowloris)
|
||||||
|
slowloris: "10s"
|
||||||
|
|
||||||
|
# Journalisation
|
||||||
|
log:
|
||||||
|
level: "info" # debug | info | warn | error
|
||||||
|
format: "json" # json | text
|
||||||
29
services/ja4ebpf/go.mod
Normal file
29
services/ja4ebpf/go.mod
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
module github.com/antitbone/ja4/ja4ebpf
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.23.0
|
||||||
|
github.com/cilium/ebpf v0.16.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.7 // indirect
|
||||||
|
github.com/paulmach/orb v0.11.1 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/shopspring/decimal v1.3.1 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/antitbone/ja4/ja4common => ../../shared/go/ja4common
|
||||||
129
services/ja4ebpf/go.sum
Normal file
129
services/ja4ebpf/go.sum
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||||
|
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.23.0 h1:srmRrkS0BR8gEut87u8jpcZ7geOob6nGj9ifrb+aKmg=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.23.0/go.mod h1:tBhdF3f3RdP7sS59+oBAtTyhWpy0024ZxDMhgxra0QE=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||||
|
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||||
|
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
|
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
|
||||||
|
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||||
|
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||||
|
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||||
|
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
|
||||||
|
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||||
|
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||||
|
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||||
|
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||||
|
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4=
|
||||||
|
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||||
|
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
206
services/ja4ebpf/internal/correlation/correlation_test.go
Normal file
206
services/ja4ebpf/internal/correlation/correlation_test.go
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
package correlation_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionStateAddRequest(t *testing.T) {
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
Key: correlation.SessionKey{SrcIP: [4]byte{1, 2, 3, 4}, SrcPort: 12345},
|
||||||
|
FirstSeen: time.Now(),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
req := correlation.HTTPRequest{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/test",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AddRequest(req)
|
||||||
|
|
||||||
|
if len(s.Requests) != 1 {
|
||||||
|
t.Errorf("attendu 1 requête, obtenu %d", len(s.Requests))
|
||||||
|
}
|
||||||
|
if s.Requests[0].Method != "GET" {
|
||||||
|
t.Errorf("méthode attendue GET, obtenue %s", s.Requests[0].Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStateMultipleRequests(t *testing.T) {
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
FirstSeen: time.Now(),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s.AddRequest(correlation.HTTPRequest{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/path",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.Requests) != 3 {
|
||||||
|
t.Errorf("attendu 3 requêtes, obtenu %d", len(s.Requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStateIsExpired(t *testing.T) {
|
||||||
|
past := time.Now().Add(-1 * time.Second)
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
LastActivity: past,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsExpired(500 * time.Millisecond) {
|
||||||
|
t.Error("session inactive depuis 1s doit être expirée avec timeout 500ms")
|
||||||
|
}
|
||||||
|
if s.IsExpired(2 * time.Second) {
|
||||||
|
t.Error("session inactive depuis 1s ne doit pas être expirée avec timeout 2s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStateIsSlowlorisOpenConnection(t *testing.T) {
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
FirstSeen: time.Now().Add(-15 * time.Second),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
// Connexion ouverte sans requête depuis 15s → Slowloris
|
||||||
|
if !s.IsSlowloris(10 * time.Second) {
|
||||||
|
t.Error("connexion sans requête depuis 15s doit être détectée comme Slowloris")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStateIsSlowlorisWithRequest(t *testing.T) {
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
FirstSeen: time.Now().Add(-30 * time.Second),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
// Requête complète présente → pas Slowloris (len(Requests) > 0)
|
||||||
|
s.AddRequest(correlation.HTTPRequest{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/fast",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
if s.IsSlowloris(10 * time.Second) {
|
||||||
|
t.Error("session avec requête ne doit pas être détectée comme Slowloris")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStateIsSlowlorisNotYet(t *testing.T) {
|
||||||
|
s := &correlation.SessionState{
|
||||||
|
FirstSeen: time.Now().Add(-5 * time.Second),
|
||||||
|
LastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
// Connexion ouverte depuis 5s seulement, seuil 10s → pas encore Slowloris
|
||||||
|
if s.IsSlowloris(10 * time.Second) {
|
||||||
|
t.Error("connexion de 5s ne doit pas être Slowloris avec seuil 10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests du Manager ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestManagerGetOrCreate(t *testing.T) {
|
||||||
|
mgr := correlation.NewManager(500 * time.Millisecond)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
key := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 54321}
|
||||||
|
|
||||||
|
s1 := mgr.GetOrCreate(key)
|
||||||
|
s2 := mgr.GetOrCreate(key)
|
||||||
|
|
||||||
|
if s1 != s2 {
|
||||||
|
t.Error("GetOrCreate doit retourner la même session pour la même clé")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerGetOrCreateDifferentKeys(t *testing.T) {
|
||||||
|
mgr := correlation.NewManager(500 * time.Millisecond)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
key1 := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 1000}
|
||||||
|
key2 := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 2}, SrcPort: 1000}
|
||||||
|
|
||||||
|
s1 := mgr.GetOrCreate(key1)
|
||||||
|
s2 := mgr.GetOrCreate(key2)
|
||||||
|
|
||||||
|
if s1 == s2 {
|
||||||
|
t.Error("GetOrCreate doit retourner des sessions différentes pour des clés différentes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerUpdate(t *testing.T) {
|
||||||
|
mgr := correlation.NewManager(500 * time.Millisecond)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
key := correlation.SessionKey{SrcIP: [4]byte{192, 168, 1, 1}, SrcPort: 8080}
|
||||||
|
|
||||||
|
mgr.Update(key, func(s *correlation.SessionState) {
|
||||||
|
s.L3L4 = &correlation.L3L4{TTL: 64}
|
||||||
|
})
|
||||||
|
|
||||||
|
s := mgr.GetOrCreate(key)
|
||||||
|
if s.L3L4 == nil {
|
||||||
|
t.Fatal("L3L4 doit être défini après Update")
|
||||||
|
}
|
||||||
|
if s.L3L4.TTL != 64 {
|
||||||
|
t.Errorf("TTL attendu 64, obtenu %d", s.L3L4.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerGCExpiresOldSessions(t *testing.T) {
|
||||||
|
// Timeout très court pour que le GC puisse s'exécuter rapidement en test
|
||||||
|
mgr := correlation.NewManager(50 * time.Millisecond)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
mgr.StartGC(ctx)
|
||||||
|
|
||||||
|
key := correlation.SessionKey{SrcIP: [4]byte{172, 16, 0, 1}, SrcPort: 9999}
|
||||||
|
mgr.GetOrCreate(key)
|
||||||
|
|
||||||
|
// Attendre que la session expire (timeout 50ms + marge)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// La session doit avoir été envoyée dans ReadyCh ou déjà expirée
|
||||||
|
// Ce test vérifie simplement l'absence de deadlock et de panic
|
||||||
|
select {
|
||||||
|
case <-mgr.ReadyCh:
|
||||||
|
t.Log("session reçue dans ReadyCh — normal")
|
||||||
|
default:
|
||||||
|
t.Log("ReadyCh vide (session GC'd avant lecture) — acceptable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerConcurrentAccess(t *testing.T) {
|
||||||
|
// Test de charge concurrent : 100 goroutines écrivent simultanément
|
||||||
|
mgr := correlation.NewManager(500 * time.Millisecond)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
mgr.StartGC(ctx)
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
mgr.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
key := correlation.SessionKey{
|
||||||
|
SrcIP: [4]byte{10, 0, byte(i >> 8), byte(i)},
|
||||||
|
SrcPort: uint16(i + 1024),
|
||||||
|
}
|
||||||
|
mgr.Update(key, func(s *correlation.SessionState) {
|
||||||
|
s.L3L4 = &correlation.L3L4{TTL: 64}
|
||||||
|
})
|
||||||
|
done <- struct{}{}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
156
services/ja4ebpf/internal/correlation/manager.go
Normal file
156
services/ja4ebpf/internal/correlation/manager.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package correlation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// numShards est le nombre de partitions de la carte de sessions.
|
||||||
|
// Doit être une puissance de 2 pour permettre le masquage bitwise.
|
||||||
|
const numShards = 256
|
||||||
|
|
||||||
|
// shard est une partition thread-safe de la carte de sessions.
|
||||||
|
type shard struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[SessionKey]*SessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager gère le cycle de vie des sessions TCP avec partitionnement
|
||||||
|
// pour réduire la contention lors des accès concurrents.
|
||||||
|
type Manager struct {
|
||||||
|
shards [numShards]shard
|
||||||
|
timeout time.Duration // délai d'expiration des sessions (500ms par défaut)
|
||||||
|
done chan struct{} // signal d'arrêt pour la goroutine GC
|
||||||
|
|
||||||
|
// ReadyCh reçoit les sessions expirées prêtes à être écrites dans ClickHouse.
|
||||||
|
ReadyCh chan *SessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager crée un Manager avec le délai d'expiration spécifié.
|
||||||
|
// Lance immédiatement les partitions de sessions.
|
||||||
|
func NewManager(timeout time.Duration) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
timeout: timeout,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
ReadyCh: make(chan *SessionState, 4096),
|
||||||
|
}
|
||||||
|
// Initialiser chaque shard
|
||||||
|
for i := range m.shards {
|
||||||
|
m.shards[i].sessions = make(map[SessionKey]*SessionState)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// getShard retourne le shard correspondant à la clé de session.
|
||||||
|
// L'index est calculé par XOR des octets de l'IP et du port.
|
||||||
|
func (m *Manager) getShard(key SessionKey) *shard {
|
||||||
|
idx := (key.SrcIP[3] ^ uint8(key.SrcPort>>8) ^ uint8(key.SrcPort)) & 0xFF
|
||||||
|
return &m.shards[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreate retourne la session existante ou en crée une nouvelle
|
||||||
|
// pour la clé donnée. Thread-safe.
|
||||||
|
func (m *Manager) GetOrCreate(key SessionKey) *SessionState {
|
||||||
|
sh := m.getShard(key)
|
||||||
|
|
||||||
|
// Essai rapide en lecture seule
|
||||||
|
sh.mu.RLock()
|
||||||
|
s, ok := sh.sessions[key]
|
||||||
|
sh.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création sous verrou exclusif
|
||||||
|
sh.mu.Lock()
|
||||||
|
defer sh.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check après acquisition du verrou exclusif
|
||||||
|
if s, ok = sh.sessions[key]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
s = &SessionState{
|
||||||
|
Key: key,
|
||||||
|
FirstSeen: now,
|
||||||
|
LastActivity: now,
|
||||||
|
Correlated: false,
|
||||||
|
MaxKeepAlives: 1,
|
||||||
|
}
|
||||||
|
sh.sessions[key] = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applique la fonction fn sur la session identifiée par key,
|
||||||
|
// en créant la session si elle n'existe pas encore.
|
||||||
|
func (m *Manager) Update(key SessionKey, fn func(*SessionState)) {
|
||||||
|
s := m.GetOrCreate(key)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
fn(s)
|
||||||
|
s.LastActivity = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGC lance la goroutine de nettoyage des sessions expirées.
|
||||||
|
// Toutes les 100ms, elle parcourt tous les shards et exporte
|
||||||
|
// les sessions expirées ou détectées comme Slowloris vers ReadyCh.
|
||||||
|
func (m *Manager) StartGC(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-m.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.gcRound(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcRound effectue un passage de nettoyage sur tous les shards.
|
||||||
|
// Les sessions expirées ou Slowloris sont envoyées vers ReadyCh.
|
||||||
|
func (m *Manager) gcRound(slowlorisThreshold time.Duration) {
|
||||||
|
for i := range m.shards {
|
||||||
|
sh := &m.shards[i]
|
||||||
|
|
||||||
|
// Collecter les clés à supprimer sans bloquer les écritures
|
||||||
|
sh.mu.Lock()
|
||||||
|
var toDelete []SessionKey
|
||||||
|
for key, s := range sh.sessions {
|
||||||
|
expired := s.IsExpired(m.timeout)
|
||||||
|
slowloris := s.IsSlowloris(slowlorisThreshold)
|
||||||
|
|
||||||
|
if expired || slowloris {
|
||||||
|
// Marquer les sessions Slowloris comme non corrélées
|
||||||
|
if slowloris && !expired {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.Correlated = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
toDelete = append(toDelete, key)
|
||||||
|
// Envoyer sans bloquer (drop si le canal est plein)
|
||||||
|
select {
|
||||||
|
case m.ReadyCh <- s:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range toDelete {
|
||||||
|
delete(sh.sessions, k)
|
||||||
|
}
|
||||||
|
sh.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close arrête la goroutine GC et ferme le canal ReadyCh.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
close(m.done)
|
||||||
|
close(m.ReadyCh)
|
||||||
|
}
|
||||||
110
services/ja4ebpf/internal/correlation/session.go
Normal file
110
services/ja4ebpf/internal/correlation/session.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// Package correlation gère l'état des sessions TCP et leur corrélation
|
||||||
|
// entre les couches réseau (L3/L4), TLS (L5) et applicative (L7).
|
||||||
|
package correlation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionKey identifie une connexion TCP de façon unique.
|
||||||
|
type SessionKey struct {
|
||||||
|
SrcIP [4]byte // adresse IP source en représentation binaire
|
||||||
|
SrcPort uint16 // port source
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3L4 contient les caractéristiques réseau et transport de la connexion.
|
||||||
|
type L3L4 struct {
|
||||||
|
TTL uint8 // TTL IP observé dans le SYN
|
||||||
|
DFBit bool // bit Don't Fragment actif
|
||||||
|
IPID uint16 // champ identification IP
|
||||||
|
WindowSize uint16 // taille de fenêtre TCP initiale
|
||||||
|
WindowScale uint8 // facteur d'échelle de fenêtre (0xFF = absent)
|
||||||
|
MSS uint16 // Maximum Segment Size (0 = absent)
|
||||||
|
TCPOptionsRaw []byte // options TCP brutes (max 40 octets)
|
||||||
|
SYNTimestamp time.Time // horodatage du paquet SYN
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSInfo contient les données extraites du ClientHello TLS.
|
||||||
|
type TLSInfo struct {
|
||||||
|
ClientHelloRaw []byte // payload ClientHello brut
|
||||||
|
JA4Hash string // empreinte JA4 calculée
|
||||||
|
SNI string // Server Name Indication
|
||||||
|
ALPN []string // protocoles Application-Layer Protocol Negotiation
|
||||||
|
TLSVersion uint16 // version TLS la plus haute annoncée
|
||||||
|
CipherSuites []uint16 // suites de chiffrement proposées
|
||||||
|
Extensions []uint16 // identifiants des extensions TLS
|
||||||
|
Timestamp time.Time // horodatage du ClientHello
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP2Settings contient les paramètres SETTINGS et WINDOW_UPDATE du client HTTP/2.
|
||||||
|
type HTTP2Settings struct {
|
||||||
|
HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent)
|
||||||
|
EnablePush int32 // SETTINGS_ENABLE_PUSH
|
||||||
|
MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS
|
||||||
|
InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE
|
||||||
|
MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE
|
||||||
|
MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE
|
||||||
|
UnknownSettings int32 // paramètre 0x7 (JA4H2)
|
||||||
|
WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0
|
||||||
|
PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPRequest représente une requête HTTP observée dans la session.
|
||||||
|
type HTTPRequest struct {
|
||||||
|
Method string // méthode HTTP (GET, POST, etc.)
|
||||||
|
Path string // chemin de la requête
|
||||||
|
QueryString string // paramètres de requête
|
||||||
|
StatusCode int // code de statut de la réponse
|
||||||
|
ResponseSize int64 // taille de la réponse en octets
|
||||||
|
DurationMS float64 // durée de traitement en millisecondes
|
||||||
|
HeaderOrder []string // ordre exact des en-têtes HTTP bruts
|
||||||
|
HeaderOrderSig string // signature de l'ordre des en-têtes (hash)
|
||||||
|
HTTP2Settings *HTTP2Settings // non nil uniquement pour HTTP/2
|
||||||
|
Timestamp time.Time // horodatage de la requête
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionState représente l'état complet d'une connexion TCP corrélée.
|
||||||
|
// La structure est thread-safe via un mutex interne.
|
||||||
|
type SessionState struct {
|
||||||
|
Key SessionKey // identifiant de la session
|
||||||
|
L3L4 *L3L4 // données réseau/transport (peut être nil si L7-only)
|
||||||
|
TLS *TLSInfo // données TLS (peut être nil si HTTP plain)
|
||||||
|
Requests []HTTPRequest // requêtes HTTP observées
|
||||||
|
MaxKeepAlives int // nombre maximum de requêtes keep-alive
|
||||||
|
|
||||||
|
FirstSeen time.Time // horodatage de création de la session
|
||||||
|
LastActivity time.Time // horodatage de la dernière activité
|
||||||
|
Correlated bool // true si L3/L4 et L7 sont corrélés
|
||||||
|
|
||||||
|
mu sync.Mutex // protection des modifications concurrentes
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired indique si la session n'a reçu aucune activité depuis timeout.
|
||||||
|
func (s *SessionState) IsExpired(timeout time.Duration) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return time.Since(s.LastActivity) > timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRequest ajoute une requête HTTP à la session et met à jour LastActivity.
|
||||||
|
func (s *SessionState) AddRequest(req HTTPRequest) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.Requests = append(s.Requests, req)
|
||||||
|
s.LastActivity = time.Now()
|
||||||
|
if len(s.Requests) > s.MaxKeepAlives && s.MaxKeepAlives > 0 {
|
||||||
|
s.MaxKeepAlives = len(s.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSlowloris détecte si la session présente un profil d'attaque Slowloris :
|
||||||
|
// première activité il y a plus de threshold sans aucune requête complète.
|
||||||
|
func (s *SessionState) IsSlowloris(threshold time.Duration) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.Requests) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(s.FirstSeen) > threshold
|
||||||
|
}
|
||||||
84
services/ja4ebpf/internal/dispatcher/dispatcher.go
Normal file
84
services/ja4ebpf/internal/dispatcher/dispatcher.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// Package dispatcher fournit le routeur Magic Bytes qui unifie les événements
|
||||||
|
// issus des RingBuffers SSL (trafic chiffré déchiffré par uprobe) et HTTP plain
|
||||||
|
// (trafic clair capturé par TC ingress), et les route vers le bon parser L7.
|
||||||
|
package dispatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Marqueurs Magic Bytes -----------------------------------------------
|
||||||
|
|
||||||
|
// h2MagicBytes est la préface HTTP/2 client sous forme de []byte.
|
||||||
|
var h2MagicBytes = []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
|
||||||
|
|
||||||
|
// Préfixes de méthodes HTTP/1.x courants (liste non exhaustive).
|
||||||
|
var http1Prefixes = [][]byte{
|
||||||
|
[]byte("GET "),
|
||||||
|
[]byte("POST "),
|
||||||
|
[]byte("PUT "),
|
||||||
|
[]byte("DELETE "),
|
||||||
|
[]byte("HEAD "),
|
||||||
|
[]byte("OPTIONS "),
|
||||||
|
[]byte("PATCH "),
|
||||||
|
[]byte("CONNECT "),
|
||||||
|
[]byte("TRACE "),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol identifie le protocole applicatif détecté à partir des premiers octets.
|
||||||
|
type Protocol uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtoUnknown Protocol = iota
|
||||||
|
ProtoHTTP1 // HTTP/1.0 ou HTTP/1.1
|
||||||
|
ProtoHTTP2 // HTTP/2 (préface "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawEvent représente un buffer brut reçu depuis un RingBuffer eBPF
|
||||||
|
// (SSL_read déchiffré OU payload TCP HTTP en clair).
|
||||||
|
type RawEvent struct {
|
||||||
|
SrcIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
PID uint32 // 0 pour les événements TC (HTTP plain, pas de PID)
|
||||||
|
FD uint32 // 0 pour les événements TC
|
||||||
|
Data []byte
|
||||||
|
EOF bool // connexion terminée (uprobe SSL uniquement)
|
||||||
|
Cleartext bool // true = provient de rb_http_plain (TC), false = uprobe SSL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify inspecte les premiers octets du buffer et retourne le Protocol détecté.
|
||||||
|
// La détection est purement basée sur les octets de début (pas de parsing complet).
|
||||||
|
func Classify(data []byte) Protocol {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ProtoUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détection HTTP/2 : préface exacte de 24 octets
|
||||||
|
if bytes.HasPrefix(data, h2MagicBytes) {
|
||||||
|
return ProtoHTTP2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détection HTTP/1.x : commence par une méthode connue suivie d'un espace
|
||||||
|
for _, pfx := range http1Prefixes {
|
||||||
|
if bytes.HasPrefix(data, pfx) {
|
||||||
|
return ProtoHTTP1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détection HTTP/2 partiel : peut arriver si le magic est fragmenté
|
||||||
|
// sur plusieurs lectures. Dans ce cas on laisse l'appelant accumuler.
|
||||||
|
n := minInt(len(data), len(h2MagicBytes))
|
||||||
|
if bytes.HasPrefix(h2MagicBytes, data[:n]) && len(data) < len(h2MagicBytes) {
|
||||||
|
return ProtoHTTP2 // préface partielle — traiter comme H2 en cours
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProtoUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// minInt retourne le minimum de deux entiers (helper interne).
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
497
services/ja4ebpf/internal/loader/loader.go
Normal file
497
services/ja4ebpf/internal/loader/loader.go
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
// Package loader initialise les programmes eBPF via cilium/ebpf,
|
||||||
|
// attache les hooks TC ingress et les uprobes SSL, et expose
|
||||||
|
// les readers RingBuffer aux consommateurs Go.
|
||||||
|
package loader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/cilium/ebpf"
|
||||||
|
"github.com/cilium/ebpf/link"
|
||||||
|
"github.com/cilium/ebpf/ringbuf"
|
||||||
|
"github.com/cilium/ebpf/rlimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -Werror -D__TARGET_ARCH_x86" Ja4eBPF ../../bpf/tc_capture.c ../../bpf/uprobe_ssl.c -- -I../../bpf/headers
|
||||||
|
|
||||||
|
// Loader encapsule les objets eBPF compilés, les liens vers les hooks,
|
||||||
|
// et les readers RingBuffer exposés au pipeline de traitement.
|
||||||
|
type Loader struct {
|
||||||
|
objs *Ja4eBPFObjects // généré par bpf2go
|
||||||
|
tcLink link.Link
|
||||||
|
uprobeLinks []link.Link
|
||||||
|
|
||||||
|
// SynReader lit les événements TCP SYN depuis rb_tcp_syn.
|
||||||
|
SynReader *ringbuf.Reader
|
||||||
|
// TLSReader lit les événements TLS ClientHello depuis rb_tls_hello.
|
||||||
|
TLSReader *ringbuf.Reader
|
||||||
|
// SSLReader lit les données SSL déchiffrées depuis rb_ssl_data.
|
||||||
|
SSLReader *ringbuf.Reader
|
||||||
|
// AcceptReader lit les événements accept4 depuis rb_accept.
|
||||||
|
AcceptReader *ringbuf.Reader
|
||||||
|
// HTTPPlainReader lit les payloads HTTP en clair depuis rb_http_plain.
|
||||||
|
HTTPPlainReader *ringbuf.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// New charge le bytecode eBPF embarqué, supprime la limite mémoire
|
||||||
|
// RLIMIT_MEMLOCK (requise pour les ring buffers et les maps eBPF),
|
||||||
|
// et retourne un Loader prêt à être attaché aux hooks.
|
||||||
|
//
|
||||||
|
// Cible : CentOS 8 / RHEL 8 et supérieur (kernel ≥ 4.18 avec BTF backporté).
|
||||||
|
// Le BTF natif est détecté automatiquement par cilium/ebpf via
|
||||||
|
// /sys/kernel/btf/vmlinux — aucun fallback manuel n'est requis.
|
||||||
|
func New() (*Loader, error) {
|
||||||
|
// Supprimer la limite mémoire pour les opérations eBPF
|
||||||
|
if err := rlimit.RemoveMemlock(); err != nil {
|
||||||
|
return nil, fmt.Errorf("suppression RLIMIT_MEMLOCK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
objs := &Ja4eBPFObjects{}
|
||||||
|
// Charger le bytecode eBPF compilé (embarqué par bpf2go).
|
||||||
|
// nil = cilium/ebpf résout le BTF kernel natif automatiquement.
|
||||||
|
if err := LoadJa4eBPFObjects(objs, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("chargement objets eBPF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser les readers pour chaque ring buffer
|
||||||
|
synReader, err := ringbuf.NewReader(objs.RbTcpSyn)
|
||||||
|
if err != nil {
|
||||||
|
objs.Close()
|
||||||
|
return nil, fmt.Errorf("création reader rb_tcp_syn: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsReader, err := ringbuf.NewReader(objs.RbTlsHello)
|
||||||
|
if err != nil {
|
||||||
|
synReader.Close()
|
||||||
|
objs.Close()
|
||||||
|
return nil, fmt.Errorf("création reader rb_tls_hello: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sslReader, err := ringbuf.NewReader(objs.RbSslData)
|
||||||
|
if err != nil {
|
||||||
|
tlsReader.Close()
|
||||||
|
synReader.Close()
|
||||||
|
objs.Close()
|
||||||
|
return nil, fmt.Errorf("création reader rb_ssl_data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptReader, err := ringbuf.NewReader(objs.RbAccept)
|
||||||
|
if err != nil {
|
||||||
|
sslReader.Close()
|
||||||
|
tlsReader.Close()
|
||||||
|
synReader.Close()
|
||||||
|
objs.Close()
|
||||||
|
return nil, fmt.Errorf("création reader rb_accept: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpPlainReader, err := ringbuf.NewReader(objs.RbHttpPlain)
|
||||||
|
if err != nil {
|
||||||
|
acceptReader.Close()
|
||||||
|
sslReader.Close()
|
||||||
|
tlsReader.Close()
|
||||||
|
synReader.Close()
|
||||||
|
objs.Close()
|
||||||
|
return nil, fmt.Errorf("création reader rb_http_plain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Loader{
|
||||||
|
objs: objs,
|
||||||
|
SynReader: synReader,
|
||||||
|
TLSReader: tlsReader,
|
||||||
|
SSLReader: sslReader,
|
||||||
|
AcceptReader: acceptReader,
|
||||||
|
HTTPPlainReader: httpPlainReader,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachTC attache le programme TC ingress sur l'interface réseau spécifiée.
|
||||||
|
// Utilise TCX (TC eXpress) disponible depuis le noyau 6.6+.
|
||||||
|
func (l *Loader) AttachTC(iface string) error {
|
||||||
|
// Résoudre l'interface réseau par son nom
|
||||||
|
netIface, err := net.InterfaceByName(iface)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("interface réseau %q introuvable: %w", iface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attacher le programme TC en ingress via TCX
|
||||||
|
lnk, err := link.AttachTCX(link.TCXOptions{
|
||||||
|
Interface: netIface.Index,
|
||||||
|
Program: l.objs.CaptureTcIngress,
|
||||||
|
Attach: ebpf.AttachTCXIngress,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement TC ingress sur %q: %w", iface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.tcLink = lnk
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachUprobes attache les uprobes SSL_read et SSL_set_fd
|
||||||
|
// sur le binaire libssl spécifié (ex: "/usr/lib64/libssl.so.3").
|
||||||
|
func (l *Loader) AttachUprobes(sslLibPath string) error {
|
||||||
|
// Vérifier que le fichier existe
|
||||||
|
if _, err := os.Stat(sslLibPath); err != nil {
|
||||||
|
return fmt.Errorf("bibliothèque SSL %q: %w", sslLibPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ouvrir le binaire exécutable pour les uprobes
|
||||||
|
ex, err := link.OpenExecutable(sslLibPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ouverture exécutable %q pour uprobe: %w", sslLibPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uprobe sur SSL_set_fd (entry)
|
||||||
|
setFdLink, err := ex.Uprobe("SSL_set_fd", l.objs.UprobeSSLSetFd, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement uprobe SSL_set_fd: %w", err)
|
||||||
|
}
|
||||||
|
l.uprobeLinks = append(l.uprobeLinks, setFdLink)
|
||||||
|
|
||||||
|
// Uprobe sur SSL_read (entry)
|
||||||
|
readEntryLink, err := ex.Uprobe("SSL_read", l.objs.UprobeSSLReadEntry, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement uprobe SSL_read (entry): %w", err)
|
||||||
|
}
|
||||||
|
l.uprobeLinks = append(l.uprobeLinks, readEntryLink)
|
||||||
|
|
||||||
|
// Uretprobe sur SSL_read (exit)
|
||||||
|
readExitLink, err := ex.Uretprobe("SSL_read", l.objs.UretprobeSSLReadExit, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement uretprobe SSL_read (exit): %w", err)
|
||||||
|
}
|
||||||
|
l.uprobeLinks = append(l.uprobeLinks, readExitLink)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachAcceptProbe attache les kprobes sur l'appel système accept4.
|
||||||
|
func (l *Loader) AttachAcceptProbe() error {
|
||||||
|
// Kprobe à l'entrée d'accept4
|
||||||
|
kpEntry, err := link.Kprobe("accept4", l.objs.KprobeAccept4Entry, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement kprobe accept4 (entry): %w", err)
|
||||||
|
}
|
||||||
|
l.uprobeLinks = append(l.uprobeLinks, kpEntry)
|
||||||
|
|
||||||
|
// Kretprobe à la sortie d'accept4
|
||||||
|
kpExit, err := link.Kretprobe("accept4", l.objs.KretprobeAccept4Exit, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachement kretprobe accept4 (exit): %w", err)
|
||||||
|
}
|
||||||
|
l.uprobeLinks = append(l.uprobeLinks, kpExit)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close détache tous les hooks eBPF et libère toutes les ressources associées.
|
||||||
|
func (l *Loader) Close() error {
|
||||||
|
// Fermer les readers RingBuffer
|
||||||
|
if l.HTTPPlainReader != nil {
|
||||||
|
l.HTTPPlainReader.Close()
|
||||||
|
}
|
||||||
|
if l.AcceptReader != nil {
|
||||||
|
l.AcceptReader.Close()
|
||||||
|
}
|
||||||
|
if l.SSLReader != nil {
|
||||||
|
l.SSLReader.Close()
|
||||||
|
}
|
||||||
|
if l.TLSReader != nil {
|
||||||
|
l.TLSReader.Close()
|
||||||
|
}
|
||||||
|
if l.SynReader != nil {
|
||||||
|
l.SynReader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détacher les uprobes et kprobes
|
||||||
|
for _, lnk := range l.uprobeLinks {
|
||||||
|
if lnk != nil {
|
||||||
|
lnk.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détacher le hook TC
|
||||||
|
if l.tcLink != nil {
|
||||||
|
l.tcLink.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Libérer les objets eBPF (maps, programmes)
|
||||||
|
if l.objs != nil {
|
||||||
|
l.objs.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types d'événements : représentations Go des structures C eBPF
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TCPSynEvent représente un événement TCP SYN capturé par TC ingress.
|
||||||
|
type TCPSynEvent struct {
|
||||||
|
SrcIP uint32
|
||||||
|
DstIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
DstPort uint16
|
||||||
|
TTL uint8
|
||||||
|
DFBit uint8
|
||||||
|
IPID uint16
|
||||||
|
WindowSize uint16
|
||||||
|
WindowScale uint8
|
||||||
|
MSS uint16
|
||||||
|
TCPOptions [40]byte
|
||||||
|
TCPOptionsLen uint8
|
||||||
|
Timestamp uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSHelloEvent représente un événement TLS ClientHello.
|
||||||
|
type TLSHelloEvent struct {
|
||||||
|
SrcIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
Payload []byte
|
||||||
|
PayloadLen uint16
|
||||||
|
Timestamp uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSLDataEvent représente un bloc de données SSL déchiffré par uprobe.
|
||||||
|
type SSLDataEvent struct {
|
||||||
|
PID uint32
|
||||||
|
TGID uint32
|
||||||
|
FD uint32
|
||||||
|
SrcIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
Data []byte
|
||||||
|
DataLen uint32
|
||||||
|
Timestamp uint64
|
||||||
|
Direction uint8
|
||||||
|
EOF bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPPlainEvent représente un payload TCP HTTP en clair capturé par TC ingress.
|
||||||
|
type HTTPPlainEvent struct {
|
||||||
|
SrcIP uint32
|
||||||
|
DstIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
DstPort uint16
|
||||||
|
Payload []byte
|
||||||
|
PayloadLen uint16
|
||||||
|
Timestamp uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptEvent représente une acceptation de connexion TCP (accept4).
|
||||||
|
type AcceptEvent struct {
|
||||||
|
PID uint32
|
||||||
|
TGID uint32
|
||||||
|
FD uint32
|
||||||
|
SrcIP uint32
|
||||||
|
SrcPort uint16
|
||||||
|
Timestamp uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Méthodes de lecture des RingBuffers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ReadTCPSynEvent lit un événement TCP SYN depuis le RingBuffer.
|
||||||
|
// Bloque jusqu'à ce qu'un événement soit disponible ou que ctx soit annulé.
|
||||||
|
func (l *Loader) ReadTCPSynEvent(ctx context.Context) (*TCPSynEvent, error) {
|
||||||
|
rec, err := readRecord(ctx, l.SynReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rec.RawSample
|
||||||
|
// struct tcp_syn_event packed: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
|
||||||
|
// ttl(1)+df(1)+ip_id(2)+window(2)+wscale(1)+mss(2)+opts(40)+opts_len(1)+_pad(1)+ts(8) = 71
|
||||||
|
if len(data) < 64 {
|
||||||
|
return nil, fmt.Errorf("tcp_syn_event trop court: %d octets", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := &TCPSynEvent{
|
||||||
|
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
|
||||||
|
DstIP: binary.LittleEndian.Uint32(data[4:8]),
|
||||||
|
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
|
||||||
|
DstPort: binary.LittleEndian.Uint16(data[10:12]),
|
||||||
|
TTL: data[12],
|
||||||
|
DFBit: data[13],
|
||||||
|
IPID: binary.LittleEndian.Uint16(data[14:16]),
|
||||||
|
WindowSize: binary.LittleEndian.Uint16(data[16:18]),
|
||||||
|
WindowScale: data[18],
|
||||||
|
MSS: binary.LittleEndian.Uint16(data[19:21]),
|
||||||
|
}
|
||||||
|
copy(ev.TCPOptions[:], data[21:61])
|
||||||
|
ev.TCPOptionsLen = data[61]
|
||||||
|
if len(data) >= 70 {
|
||||||
|
ev.Timestamp = binary.LittleEndian.Uint64(data[62:70])
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTLSHelloEvent lit un événement TLS ClientHello depuis le RingBuffer.
|
||||||
|
func (l *Loader) ReadTLSHelloEvent(ctx context.Context) (*TLSHelloEvent, error) {
|
||||||
|
rec, err := readRecord(ctx, l.TLSReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rec.RawSample
|
||||||
|
// struct tls_hello_event: src_ip(4)+src_port(2)+payload(512)+payload_len(2)+ts(8) = 528
|
||||||
|
if len(data) < 8 {
|
||||||
|
return nil, fmt.Errorf("tls_hello_event trop court: %d octets", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
plen := uint16(0)
|
||||||
|
if len(data) >= 520 {
|
||||||
|
plen = binary.LittleEndian.Uint16(data[518:520])
|
||||||
|
}
|
||||||
|
payload := make([]byte, plen)
|
||||||
|
if int(plen) <= 512 && len(data) >= 6+int(plen) {
|
||||||
|
copy(payload, data[6:6+plen])
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := uint64(0)
|
||||||
|
if len(data) >= 528 {
|
||||||
|
ts = binary.LittleEndian.Uint64(data[520:528])
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TLSHelloEvent{
|
||||||
|
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
|
||||||
|
SrcPort: binary.LittleEndian.Uint16(data[4:6]),
|
||||||
|
Payload: payload,
|
||||||
|
PayloadLen: plen,
|
||||||
|
Timestamp: ts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSSLDataEvent lit un bloc de données SSL déchiffrées depuis le RingBuffer.
|
||||||
|
func (l *Loader) ReadSSLDataEvent(ctx context.Context) (*SSLDataEvent, error) {
|
||||||
|
rec, err := readRecord(ctx, l.SSLReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rec.RawSample
|
||||||
|
// struct ssl_data_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+data(4096)+data_len(4)+ts(8)+direction(1)
|
||||||
|
if len(data) < 27 {
|
||||||
|
return nil, fmt.Errorf("ssl_data_event trop court: %d octets", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pidTGID := binary.LittleEndian.Uint64(data[0:8])
|
||||||
|
dlen := uint32(0)
|
||||||
|
if len(data) >= 4118 {
|
||||||
|
dlen = binary.LittleEndian.Uint32(data[4114:4118])
|
||||||
|
}
|
||||||
|
payload := make([]byte, dlen)
|
||||||
|
if int(dlen) <= 4096 && len(data) >= 18+int(dlen) {
|
||||||
|
copy(payload, data[18:18+dlen])
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := uint64(0)
|
||||||
|
if len(data) >= 4126 {
|
||||||
|
ts = binary.LittleEndian.Uint64(data[4118:4126])
|
||||||
|
}
|
||||||
|
dir := uint8(0)
|
||||||
|
if len(data) >= 4127 {
|
||||||
|
dir = data[4126]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SSLDataEvent{
|
||||||
|
PID: uint32(pidTGID & 0xFFFFFFFF),
|
||||||
|
TGID: uint32(pidTGID >> 32),
|
||||||
|
FD: binary.LittleEndian.Uint32(data[8:12]),
|
||||||
|
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
|
||||||
|
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
|
||||||
|
Data: payload,
|
||||||
|
DataLen: dlen,
|
||||||
|
Timestamp: ts,
|
||||||
|
Direction: dir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadHTTPPlainEvent lit un événement HTTP en clair depuis le RingBuffer TC.
|
||||||
|
// struct http_plain_event: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
|
||||||
|
//
|
||||||
|
// payload(4096)+payload_len(2)+ts(8) = 4118
|
||||||
|
func (l *Loader) ReadHTTPPlainEvent(ctx context.Context) (*HTTPPlainEvent, error) {
|
||||||
|
rec, err := readRecord(ctx, l.HTTPPlainReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rec.RawSample
|
||||||
|
if len(data) < 12 {
|
||||||
|
return nil, fmt.Errorf("http_plain_event trop court: %d octets", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
plen := uint16(0)
|
||||||
|
if len(data) >= 4110 {
|
||||||
|
plen = binary.LittleEndian.Uint16(data[4108:4110])
|
||||||
|
}
|
||||||
|
payload := make([]byte, plen)
|
||||||
|
if int(plen) <= 4096 && len(data) >= 12+int(plen) {
|
||||||
|
copy(payload, data[12:12+plen])
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := uint64(0)
|
||||||
|
if len(data) >= 4118 {
|
||||||
|
ts = binary.LittleEndian.Uint64(data[4110:4118])
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPPlainEvent{
|
||||||
|
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
|
||||||
|
DstIP: binary.LittleEndian.Uint32(data[4:8]),
|
||||||
|
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
|
||||||
|
DstPort: binary.LittleEndian.Uint16(data[10:12]),
|
||||||
|
Payload: payload,
|
||||||
|
PayloadLen: plen,
|
||||||
|
Timestamp: ts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAcceptEvent lit un événement accept4 depuis le RingBuffer.
|
||||||
|
func (l *Loader) ReadAcceptEvent(ctx context.Context) (*AcceptEvent, error) {
|
||||||
|
rec, err := readRecord(ctx, l.AcceptReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rec.RawSample
|
||||||
|
// struct accept_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+ts(8) = 26
|
||||||
|
if len(data) < 26 {
|
||||||
|
return nil, fmt.Errorf("accept_event trop court: %d octets", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pidTGID := binary.LittleEndian.Uint64(data[0:8])
|
||||||
|
return &AcceptEvent{
|
||||||
|
PID: uint32(pidTGID & 0xFFFFFFFF),
|
||||||
|
TGID: uint32(pidTGID >> 32),
|
||||||
|
FD: binary.LittleEndian.Uint32(data[8:12]),
|
||||||
|
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
|
||||||
|
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
|
||||||
|
Timestamp: binary.LittleEndian.Uint64(data[18:26]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRecord lit un record brut depuis un RingBuffer avec annulation via context.
|
||||||
|
func readRecord(ctx context.Context, rd *ringbuf.Reader) (ringbuf.Record, error) {
|
||||||
|
type result struct {
|
||||||
|
rec ringbuf.Record
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
rec, err := rd.Read()
|
||||||
|
ch <- result{rec, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
rd.Close() // débloque le Read() bloquant
|
||||||
|
return ringbuf.Record{}, ctx.Err()
|
||||||
|
case r := <-ch:
|
||||||
|
return r.rec, r.err
|
||||||
|
}
|
||||||
|
}
|
||||||
265
services/ja4ebpf/internal/parser/http2.go
Normal file
265
services/ja4ebpf/internal/parser/http2.go
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// H2Magic est la préface HTTP/2 client (RFC 7540 §3.5), exportée pour usage
|
||||||
|
// par le routeur Magic Bytes (package dispatcher) et les consommateurs RingBuffer.
|
||||||
|
const H2Magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
|
||||||
|
|
||||||
|
// h2MagicPrefaceLen est la longueur du préambule HTTP/2 client.
|
||||||
|
const h2MagicPrefaceLen = 24
|
||||||
|
|
||||||
|
// h2MagicPreface est le préambule ("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") envoyé
|
||||||
|
// par tout client HTTP/2 avant la première frame SETTINGS.
|
||||||
|
var h2MagicPreface = []byte(H2Magic)
|
||||||
|
|
||||||
|
// Identifiants de types de frames HTTP/2 (RFC 7540, §11.2).
|
||||||
|
const (
|
||||||
|
h2FrameData = 0
|
||||||
|
h2FrameHeaders = 1
|
||||||
|
h2FramePriority = 2
|
||||||
|
h2FrameRSTStream = 3
|
||||||
|
h2FrameSettings = 4
|
||||||
|
h2FramePushPromise = 5
|
||||||
|
h2FramePing = 6
|
||||||
|
h2FrameGoAway = 7
|
||||||
|
h2FrameWindowUpdate = 8
|
||||||
|
h2FrameContinuation = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identifiants des paramètres SETTINGS (RFC 7540, §11.3).
|
||||||
|
const (
|
||||||
|
h2SettingHeaderTableSize = 1
|
||||||
|
h2SettingEnablePush = 2
|
||||||
|
h2SettingMaxConcurrentStreams = 3
|
||||||
|
h2SettingInitialWindowSize = 4
|
||||||
|
h2SettingMaxFrameSize = 5
|
||||||
|
h2SettingMaxHeaderListSize = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// h2FrameHeader représente l'en-tête fixe de 9 octets d'une frame HTTP/2.
|
||||||
|
type h2FrameHeader struct {
|
||||||
|
Length uint32 // longueur du payload (3 octets)
|
||||||
|
Type uint8 // type de frame
|
||||||
|
Flags uint8 // flags
|
||||||
|
StreamID uint32 // identifiant de stream (masque 0x7FFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseH2FrameHeader décode l'en-tête de 9 octets d'une frame HTTP/2.
|
||||||
|
func parseH2FrameHeader(data []byte) (h2FrameHeader, error) {
|
||||||
|
if len(data) < 9 {
|
||||||
|
return h2FrameHeader{}, fmt.Errorf("données insuffisantes pour l'en-tête frame HTTP/2: %d octets", len(data))
|
||||||
|
}
|
||||||
|
// Longueur sur 3 octets big-endian
|
||||||
|
length := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
|
||||||
|
return h2FrameHeader{
|
||||||
|
Length: length,
|
||||||
|
Type: data[3],
|
||||||
|
Flags: data[4],
|
||||||
|
StreamID: binary.BigEndian.Uint32(data[5:9]) & 0x7FFFFFFF,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectH2Preface vérifie si le buffer commence par le préambule HTTP/2.
|
||||||
|
func DetectH2Preface(data []byte) bool {
|
||||||
|
if len(data) < h2MagicPrefaceLen {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < h2MagicPrefaceLen; i++ {
|
||||||
|
if data[i] != h2MagicPreface[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// H2MagicPrefaceLen retourne la longueur du préambule HTTP/2.
|
||||||
|
func H2MagicPrefaceLen() int {
|
||||||
|
return h2MagicPrefaceLen
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP2Settings contient les paramètres SETTINGS et WINDOW_UPDATE du client HTTP/2.
|
||||||
|
type HTTP2Settings struct {
|
||||||
|
HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent)
|
||||||
|
EnablePush int32 // SETTINGS_ENABLE_PUSH
|
||||||
|
MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS
|
||||||
|
InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE
|
||||||
|
MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE
|
||||||
|
MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE
|
||||||
|
UnknownSettings int32 // paramètre 0x7 (JA4H2)
|
||||||
|
WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0
|
||||||
|
PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseH2ClientPreface extrait les paramètres SETTINGS et le WINDOW_UPDATE
|
||||||
|
// depuis le flux HTTP/2 déchiffré du client.
|
||||||
|
// data doit commencer APRÈS le magic preface (offset 24).
|
||||||
|
func ParseH2ClientPreface(data []byte) (*HTTP2Settings, error) {
|
||||||
|
settings := &HTTP2Settings{
|
||||||
|
HeaderTableSize: -1,
|
||||||
|
EnablePush: -1,
|
||||||
|
MaxConcurrentStreams: -1,
|
||||||
|
InitialWindowSize: -1,
|
||||||
|
MaxFrameSize: -1,
|
||||||
|
MaxHeaderListSize: -1,
|
||||||
|
UnknownSettings: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
// Parser au maximum 10 frames pour éviter une boucle infinie
|
||||||
|
for frameIdx := 0; frameIdx < 10 && offset < len(data); frameIdx++ {
|
||||||
|
if offset+9 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr, err := parseH2FrameHeader(data[offset:])
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += 9
|
||||||
|
|
||||||
|
payloadEnd := offset + int(hdr.Length)
|
||||||
|
if payloadEnd > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
payload := data[offset:payloadEnd]
|
||||||
|
offset = payloadEnd
|
||||||
|
|
||||||
|
switch hdr.Type {
|
||||||
|
case h2FrameSettings:
|
||||||
|
// Parser uniquement les SETTINGS du client (stream 0)
|
||||||
|
if hdr.StreamID == 0 {
|
||||||
|
pairs, err := parseH2SettingsFrame(payload)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for id, val := range pairs {
|
||||||
|
switch id {
|
||||||
|
case h2SettingHeaderTableSize:
|
||||||
|
settings.HeaderTableSize = int32(val)
|
||||||
|
case h2SettingEnablePush:
|
||||||
|
settings.EnablePush = int32(val)
|
||||||
|
case h2SettingMaxConcurrentStreams:
|
||||||
|
settings.MaxConcurrentStreams = int32(val)
|
||||||
|
case h2SettingInitialWindowSize:
|
||||||
|
settings.InitialWindowSize = int32(val)
|
||||||
|
case h2SettingMaxFrameSize:
|
||||||
|
settings.MaxFrameSize = int32(val)
|
||||||
|
case h2SettingMaxHeaderListSize:
|
||||||
|
settings.MaxHeaderListSize = int32(val)
|
||||||
|
case 7: // paramètre non standard (JA4H2)
|
||||||
|
settings.UnknownSettings = int32(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case h2FrameWindowUpdate:
|
||||||
|
// WINDOW_UPDATE sur stream 0 = flux de connexion
|
||||||
|
if hdr.StreamID == 0 && len(payload) >= 4 {
|
||||||
|
settings.WindowUpdateIncrement = binary.BigEndian.Uint32(payload[0:4]) & 0x7FFFFFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
case h2FrameHeaders:
|
||||||
|
// Extraire l'ordre des pseudo-headers depuis le premier bloc HEADERS
|
||||||
|
if hdr.StreamID > 0 && len(settings.PseudoHeaderOrder) == 0 {
|
||||||
|
settings.PseudoHeaderOrder = ParseH2PseudoHeaders(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseH2SettingsFrame extrait les paires (identifiant, valeur) d'une frame SETTINGS.
|
||||||
|
// Chaque paire fait 6 octets : identifiant(2) + valeur(4).
|
||||||
|
func parseH2SettingsFrame(payload []byte) (map[uint16]uint32, error) {
|
||||||
|
if len(payload)%6 != 0 {
|
||||||
|
return nil, fmt.Errorf("longueur de frame SETTINGS invalide: %d (doit être multiple de 6)", len(payload))
|
||||||
|
}
|
||||||
|
result := make(map[uint16]uint32)
|
||||||
|
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])
|
||||||
|
result[id] = val
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseH2PseudoHeaders extrait l'ordre des pseudo-headers depuis un bloc HPACK.
|
||||||
|
//
|
||||||
|
// Implémentation simplifiée : détecte les pseudo-headers via les index HPACK statiques.
|
||||||
|
// Table statique HPACK (RFC 7541, Annexe A) — index pertinents :
|
||||||
|
// 1 :authority
|
||||||
|
// 2 :method = GET
|
||||||
|
// 3 :method = POST
|
||||||
|
// 4 :path = /
|
||||||
|
// 5 :path = /index.html
|
||||||
|
// 6 :scheme = http
|
||||||
|
// 7 :scheme = https
|
||||||
|
func ParseH2PseudoHeaders(headersBlock []byte) []string {
|
||||||
|
// Index HPACK statique → pseudo-header
|
||||||
|
hpackStaticPseudo := map[int]string{
|
||||||
|
1: ":authority",
|
||||||
|
2: ":method",
|
||||||
|
3: ":method",
|
||||||
|
4: ":path",
|
||||||
|
5: ":path",
|
||||||
|
6: ":scheme",
|
||||||
|
7: ":scheme",
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var order []string
|
||||||
|
offset := 0
|
||||||
|
|
||||||
|
for offset < len(headersBlock) {
|
||||||
|
b := headersBlock[offset]
|
||||||
|
|
||||||
|
// Représentation indexée (bit 7 = 1) : RFC 7541 §6.1
|
||||||
|
if b&0x80 != 0 {
|
||||||
|
idx := int(b & 0x7F)
|
||||||
|
if name, ok := hpackStaticPseudo[idx]; ok {
|
||||||
|
if !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
|
} else if idx == 0 {
|
||||||
|
// Fin de la liste d'index ou encodage multi-octets
|
||||||
|
offset++
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Index dynamique ou non-pseudo-header : arrêter le scan
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Représentation littérale avec index incrémental (bits 7-6 = 01) : RFC 7541 §6.2.1
|
||||||
|
if b&0xC0 == 0x40 {
|
||||||
|
idx := int(b & 0x3F)
|
||||||
|
if name, ok := hpackStaticPseudo[idx]; ok {
|
||||||
|
if !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset++
|
||||||
|
// Sauter la valeur (longueur + contenu)
|
||||||
|
if offset >= len(headersBlock) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
valueLen := int(headersBlock[offset] & 0x7F) // ignorer le bit Huffman
|
||||||
|
offset += 1 + valueLen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tout autre encodage : arrêter (ce n'est probablement plus un pseudo-header)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
157
services/ja4ebpf/internal/parser/http2_test.go
Normal file
157
services/ja4ebpf/internal/parser/http2_test.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package parser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectH2PrefaceTrue(t *testing.T) {
|
||||||
|
preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
|
||||||
|
data := append(preface, 0x00, 0x00) // données supplémentaires
|
||||||
|
|
||||||
|
if !parser.DetectH2Preface(data) {
|
||||||
|
t.Error("H2Magic non détecté dans un buffer valide")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectH2PrefaceFalse(t *testing.T) {
|
||||||
|
if parser.DetectH2Preface([]byte("GET / HTTP/1.1\r\n")) {
|
||||||
|
t.Error("détection faux positif HTTP/1.1 comme HTTP/2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectH2PrefaceTooShort(t *testing.T) {
|
||||||
|
if parser.DetectH2Preface([]byte("PRI *")) {
|
||||||
|
t.Error("détection sur buffer trop court")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH2MagicPrefaceLen(t *testing.T) {
|
||||||
|
if parser.H2MagicPrefaceLen() != 24 {
|
||||||
|
t.Errorf("longueur préambule HTTP/2 attendue 24, obtenue %d", parser.H2MagicPrefaceLen())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceSettingsEmpty(t *testing.T) {
|
||||||
|
// Frame SETTINGS vide (longueur 0, aucun paramètre) sur stream 0
|
||||||
|
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
|
||||||
|
settings, err := parser.ParseH2ClientPreface(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||||
|
}
|
||||||
|
if settings == nil {
|
||||||
|
t.Fatal("settings ne doit pas être nil")
|
||||||
|
}
|
||||||
|
// Tous les champs doivent être -1 (absent)
|
||||||
|
if settings.HeaderTableSize != -1 {
|
||||||
|
t.Errorf("HeaderTableSize: attendu -1, obtenu %d", settings.HeaderTableSize)
|
||||||
|
}
|
||||||
|
if settings.InitialWindowSize != -1 {
|
||||||
|
t.Errorf("InitialWindowSize: attendu -1, obtenu %d", settings.InitialWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceSettingsWithValues(t *testing.T) {
|
||||||
|
// Frame SETTINGS avec INITIAL_WINDOW_SIZE=65536 et MAX_CONCURRENT_STREAMS=100
|
||||||
|
settingsPayload := []byte{
|
||||||
|
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 65536
|
||||||
|
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
|
||||||
|
}
|
||||||
|
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
||||||
|
|
||||||
|
settings, err := parser.ParseH2ClientPreface(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.InitialWindowSize != 65536 {
|
||||||
|
t.Errorf("InitialWindowSize: attendu 65536, obtenu %d", settings.InitialWindowSize)
|
||||||
|
}
|
||||||
|
if settings.MaxConcurrentStreams != 100 {
|
||||||
|
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", settings.MaxConcurrentStreams)
|
||||||
|
}
|
||||||
|
// Les paramètres non présents restent à -1
|
||||||
|
if settings.HeaderTableSize != -1 {
|
||||||
|
t.Errorf("HeaderTableSize non fourni: attendu -1, obtenu %d", settings.HeaderTableSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceWindowUpdate(t *testing.T) {
|
||||||
|
// Frame WINDOW_UPDATE sur stream 0 avec incrément = 1073741824
|
||||||
|
wuPayload := []byte{0x40, 0x00, 0x00, 0x00} // 0x40000000 = 1073741824
|
||||||
|
frame := buildH2Frame(0x8, 0x0, 0, wuPayload)
|
||||||
|
|
||||||
|
settings, err := parser.ParseH2ClientPreface(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||||
|
}
|
||||||
|
if settings.WindowUpdateIncrement != 1073741824 {
|
||||||
|
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", settings.WindowUpdateIncrement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceCombined(t *testing.T) {
|
||||||
|
// SETTINGS + WINDOW_UPDATE combinés (comme envoyé par curl/h2)
|
||||||
|
settingsPayload := []byte{
|
||||||
|
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
|
||||||
|
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
|
||||||
|
}
|
||||||
|
wuPayload := []byte{0x00, 0x0f, 0x00, 0x01} // WINDOW_UPDATE incr = 983041
|
||||||
|
|
||||||
|
frames := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
||||||
|
frames = append(frames, buildH2Frame(0x8, 0x0, 0, wuPayload)...)
|
||||||
|
|
||||||
|
settings, err := parser.ParseH2ClientPreface(frames)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||||
|
}
|
||||||
|
if settings.HeaderTableSize != 4096 {
|
||||||
|
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", settings.HeaderTableSize)
|
||||||
|
}
|
||||||
|
if settings.InitialWindowSize != 65535 {
|
||||||
|
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", settings.InitialWindowSize)
|
||||||
|
}
|
||||||
|
if settings.WindowUpdateIncrement != 983041 {
|
||||||
|
t.Errorf("WindowUpdateIncrement: attendu 983041, obtenu %d", settings.WindowUpdateIncrement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceEmpty(t *testing.T) {
|
||||||
|
// Données vides : doit retourner sans erreur, settings avec valeurs par défaut (-1)
|
||||||
|
settings, err := parser.ParseH2ClientPreface([]byte{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface sur vide: %v", err)
|
||||||
|
}
|
||||||
|
if settings == nil {
|
||||||
|
t.Error("settings ne doit pas être nil même pour données vides")
|
||||||
|
}
|
||||||
|
if settings.HeaderTableSize != -1 {
|
||||||
|
t.Errorf("HeaderTableSize: attendu -1 par défaut, obtenu %d", settings.HeaderTableSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseH2ClientPrefaceTruncatedFrame(t *testing.T) {
|
||||||
|
// Frame tronquée : en-tête complet mais payload incomplet
|
||||||
|
truncated := []byte{0x00, 0x00, 0x06, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x01} // payload tronqué
|
||||||
|
settings, err := parser.ParseH2ClientPreface(truncated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseH2ClientPreface sur frame tronquée: %v (doit tolérer)", err)
|
||||||
|
}
|
||||||
|
// Les paramètres restent à -1 car le payload est incomplet
|
||||||
|
_ = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// buildH2Frame construit une frame HTTP/2 brute (en-tête 9 octets + payload).
|
||||||
|
func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byte {
|
||||||
|
l := len(payload)
|
||||||
|
frame := []byte{
|
||||||
|
byte(l >> 16), byte(l >> 8), byte(l), // longueur sur 3 octets
|
||||||
|
frameType, flags,
|
||||||
|
byte(streamID >> 24), byte(streamID >> 16), byte(streamID >> 8), byte(streamID),
|
||||||
|
}
|
||||||
|
return append(frame, payload...)
|
||||||
|
}
|
||||||
353
services/ja4ebpf/internal/parser/tls.go
Normal file
353
services/ja4ebpf/internal/parser/tls.go
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
// Package parser fournit les parseurs TLS ClientHello et HTTP/2
|
||||||
|
// pour l'extraction des empreintes de fingerprinting réseau.
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientHello représente les champs extraits d'un message TLS ClientHello.
|
||||||
|
type ClientHello struct {
|
||||||
|
RecordVersion uint16 // version du record TLS (ex: 0x0303)
|
||||||
|
HandshakeVersion uint16 // version dans le handshake
|
||||||
|
CipherSuites []uint16 // suites de chiffrement proposées
|
||||||
|
CompressionMethods []uint8 // méthodes de compression
|
||||||
|
Extensions []Extension // liste des extensions TLS
|
||||||
|
SNI string // Server Name Indication (si présent)
|
||||||
|
ALPN []string // protocoles ALPN annoncés
|
||||||
|
SupportedGroups []uint16 // groupes Diffie-Hellman supportés
|
||||||
|
ECPointFormats []uint8 // formats de points elliptiques
|
||||||
|
SupportedVersions []uint16 // versions TLS annoncées (extension 0x002b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension représente une extension TLS avec son type et son contenu brut.
|
||||||
|
type Extension struct {
|
||||||
|
Type uint16 // identifiant de l'extension
|
||||||
|
Data []byte // données brutes de l'extension
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseClientHello extrait les champs du ClientHello TLS depuis le payload brut.
|
||||||
|
// Le payload doit commencer au record layer TLS (premier octet = 0x16).
|
||||||
|
// Retourne une erreur si le payload est tronqué ou structurellement invalide.
|
||||||
|
func ParseClientHello(payload []byte) (*ClientHello, error) {
|
||||||
|
if len(payload) < 5 {
|
||||||
|
return nil, fmt.Errorf("payload trop court pour le record TLS: %d octets", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le type de contenu : 0x16 = Handshake
|
||||||
|
if payload[0] != 0x16 {
|
||||||
|
return nil, fmt.Errorf("type de contenu TLS inattendu: 0x%02x (attendu 0x16)", payload[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
recordVersion := binary.BigEndian.Uint16(payload[1:3])
|
||||||
|
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
|
||||||
|
|
||||||
|
if len(payload) < 5+recordLength {
|
||||||
|
return nil, fmt.Errorf("record TLS tronqué: attendu %d octets, reçu %d", 5+recordLength, len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing du message Handshake
|
||||||
|
hs := payload[5 : 5+recordLength]
|
||||||
|
if len(hs) < 4 {
|
||||||
|
return nil, fmt.Errorf("message Handshake trop court")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le type de message Handshake : 0x01 = ClientHello
|
||||||
|
if hs[0] != 0x01 {
|
||||||
|
return nil, fmt.Errorf("type de message Handshake inattendu: 0x%02x (attendu 0x01)", hs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longueur du ClientHello (3 octets big-endian)
|
||||||
|
chLen := int(uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3]))
|
||||||
|
if len(hs) < 4+chLen {
|
||||||
|
return nil, fmt.Errorf("ClientHello tronqué: attendu %d octets", 4+chLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &ClientHello{RecordVersion: recordVersion}
|
||||||
|
data := hs[4 : 4+chLen]
|
||||||
|
|
||||||
|
// Version du handshake (2 octets)
|
||||||
|
if len(data) < 2 {
|
||||||
|
return nil, fmt.Errorf("ClientHello: version manquante")
|
||||||
|
}
|
||||||
|
ch.HandshakeVersion = binary.BigEndian.Uint16(data[0:2])
|
||||||
|
offset := 2
|
||||||
|
|
||||||
|
// Random (32 octets)
|
||||||
|
if len(data) < offset+32 {
|
||||||
|
return nil, fmt.Errorf("ClientHello: random manquant")
|
||||||
|
}
|
||||||
|
offset += 32
|
||||||
|
|
||||||
|
// Session ID (longueur 1 octet + données)
|
||||||
|
if len(data) < offset+1 {
|
||||||
|
return nil, fmt.Errorf("ClientHello: session ID manquant")
|
||||||
|
}
|
||||||
|
sessionIDLen := int(data[offset])
|
||||||
|
offset += 1 + sessionIDLen
|
||||||
|
|
||||||
|
// Cipher Suites (longueur 2 octets + données)
|
||||||
|
if len(data) < offset+2 {
|
||||||
|
return nil, fmt.Errorf("ClientHello: longueur cipher suites manquante")
|
||||||
|
}
|
||||||
|
csLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
|
||||||
|
offset += 2
|
||||||
|
if len(data) < offset+csLen {
|
||||||
|
return nil, fmt.Errorf("ClientHello: cipher suites tronquées")
|
||||||
|
}
|
||||||
|
for i := 0; i < csLen; i += 2 {
|
||||||
|
cs := binary.BigEndian.Uint16(data[offset+i : offset+i+2])
|
||||||
|
ch.CipherSuites = append(ch.CipherSuites, cs)
|
||||||
|
}
|
||||||
|
offset += csLen
|
||||||
|
|
||||||
|
// Compression Methods (longueur 1 octet + données)
|
||||||
|
if len(data) < offset+1 {
|
||||||
|
return nil, fmt.Errorf("ClientHello: longueur compression manquante")
|
||||||
|
}
|
||||||
|
compLen := int(data[offset])
|
||||||
|
offset++
|
||||||
|
if len(data) < offset+compLen {
|
||||||
|
return nil, fmt.Errorf("ClientHello: méthodes de compression tronquées")
|
||||||
|
}
|
||||||
|
ch.CompressionMethods = data[offset : offset+compLen]
|
||||||
|
offset += compLen
|
||||||
|
|
||||||
|
// Extensions (optionnelles)
|
||||||
|
if len(data) < offset+2 {
|
||||||
|
return ch, nil // pas d'extensions
|
||||||
|
}
|
||||||
|
extTotalLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
|
||||||
|
offset += 2
|
||||||
|
if len(data) < offset+extTotalLen {
|
||||||
|
return nil, fmt.Errorf("ClientHello: extensions tronquées")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing des extensions
|
||||||
|
extData := data[offset : offset+extTotalLen]
|
||||||
|
extOffset := 0
|
||||||
|
for extOffset+4 <= len(extData) {
|
||||||
|
extType := binary.BigEndian.Uint16(extData[extOffset : extOffset+2])
|
||||||
|
extLen := int(binary.BigEndian.Uint16(extData[extOffset+2 : extOffset+4]))
|
||||||
|
extOffset += 4
|
||||||
|
|
||||||
|
if extOffset+extLen > len(extData) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
extPayload := extData[extOffset : extOffset+extLen]
|
||||||
|
|
||||||
|
ch.Extensions = append(ch.Extensions, Extension{Type: extType, Data: extPayload})
|
||||||
|
|
||||||
|
// Décoder les extensions importantes
|
||||||
|
switch extType {
|
||||||
|
case 0x0000: // SNI
|
||||||
|
ch.SNI = parseSNI(extPayload)
|
||||||
|
case 0x0010: // ALPN
|
||||||
|
ch.ALPN = parseALPN(extPayload)
|
||||||
|
case 0x000a: // Supported Groups (elliptic_curves)
|
||||||
|
ch.SupportedGroups = parseSupportedGroups(extPayload)
|
||||||
|
case 0x000b: // EC Point Formats
|
||||||
|
ch.ECPointFormats = parseECPointFormats(extPayload)
|
||||||
|
case 0x002b: // Supported Versions
|
||||||
|
ch.SupportedVersions = parseSupportedVersions(extPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
extOffset += extLen
|
||||||
|
}
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSNI extrait le nom d'hôte depuis l'extension SNI (type 0x0000).
|
||||||
|
func parseSNI(data []byte) string {
|
||||||
|
// Structure : list_len(2) + type(1) + name_len(2) + name
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Ignorer list_len et name_type, lire directement name_len
|
||||||
|
nameLen := int(binary.BigEndian.Uint16(data[3:5]))
|
||||||
|
if len(data) < 5+nameLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data[5 : 5+nameLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseALPN extrait la liste des protocoles ALPN (extension 0x0010).
|
||||||
|
func parseALPN(data []byte) []string {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||||
|
offset := 2
|
||||||
|
var protocols []string
|
||||||
|
for offset < 2+listLen && offset < len(data) {
|
||||||
|
if offset+1 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
protoLen := int(data[offset])
|
||||||
|
offset++
|
||||||
|
if offset+protoLen > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
protocols = append(protocols, string(data[offset:offset+protoLen]))
|
||||||
|
offset += protoLen
|
||||||
|
}
|
||||||
|
return protocols
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSupportedGroups extrait les groupes Diffie-Hellman (extension 0x000a).
|
||||||
|
func parseSupportedGroups(data []byte) []uint16 {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||||
|
offset := 2
|
||||||
|
var groups []uint16
|
||||||
|
for i := 0; i < listLen/2 && offset+2 <= len(data); i++ {
|
||||||
|
groups = append(groups, binary.BigEndian.Uint16(data[offset:offset+2]))
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseECPointFormats extrait les formats de points elliptiques (extension 0x000b).
|
||||||
|
func parseECPointFormats(data []byte) []uint8 {
|
||||||
|
if len(data) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
listLen := int(data[0])
|
||||||
|
if len(data) < 1+listLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data[1 : 1+listLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSupportedVersions extrait les versions TLS supportées (extension 0x002b).
|
||||||
|
func parseSupportedVersions(data []byte) []uint16 {
|
||||||
|
if len(data) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
listLen := int(data[0])
|
||||||
|
offset := 1
|
||||||
|
var versions []uint16
|
||||||
|
for i := 0; i < listLen/2 && offset+2 <= len(data); i++ {
|
||||||
|
versions = append(versions, binary.BigEndian.Uint16(data[offset:offset+2]))
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
return versions
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGREASE vérifie si une valeur est une valeur GREASE (RFC 8701).
|
||||||
|
// Les valeurs GREASE suivent le motif 0x?A?A (ex: 0x0A0A, 0x1A1A, ...).
|
||||||
|
func isGREASE(v uint16) bool {
|
||||||
|
return v&0x0F0F == 0x0A0A && v>>8 == v&0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsVersionString convertit un code de version TLS en chaîne à 2 caractères JA4.
|
||||||
|
func tlsVersionString(v uint16) string {
|
||||||
|
switch v {
|
||||||
|
case 0x0304:
|
||||||
|
return "13"
|
||||||
|
case 0x0303:
|
||||||
|
return "12"
|
||||||
|
case 0x0302:
|
||||||
|
return "11"
|
||||||
|
case 0x0301:
|
||||||
|
return "10"
|
||||||
|
default:
|
||||||
|
return "00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeJA4 calcule l'empreinte JA4 selon la spécification FoxIO.
|
||||||
|
//
|
||||||
|
// Format: t{tls_ver}{sni}{cipher_count}{ext_count}_{sorted_ciphers_sha256[:12]}_{sorted_exts_alpn_sha256[:12]}
|
||||||
|
func ComputeJA4(ch *ClientHello) string {
|
||||||
|
// --- Protocole : toujours "t" (TCP) ---
|
||||||
|
proto := "t"
|
||||||
|
|
||||||
|
// --- Version TLS : version la plus haute annoncée ---
|
||||||
|
var tlsVer uint16
|
||||||
|
for _, v := range ch.SupportedVersions {
|
||||||
|
if !isGREASE(v) && v > tlsVer {
|
||||||
|
tlsVer = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tlsVer == 0 {
|
||||||
|
// Fallback : version du handshake
|
||||||
|
tlsVer = ch.HandshakeVersion
|
||||||
|
}
|
||||||
|
verStr := tlsVersionString(tlsVer)
|
||||||
|
|
||||||
|
// --- SNI : "d" si présent, "i" si absent ---
|
||||||
|
sniFlag := "i"
|
||||||
|
if ch.SNI != "" {
|
||||||
|
sniFlag = "d"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Comptage des cipher suites (sans GREASE) ---
|
||||||
|
var ciphers []uint16
|
||||||
|
for _, cs := range ch.CipherSuites {
|
||||||
|
if !isGREASE(cs) {
|
||||||
|
ciphers = append(ciphers, cs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cipherCount := fmt.Sprintf("%02d", len(ciphers))
|
||||||
|
|
||||||
|
// --- Comptage des extensions (sans GREASE, sans SNI 0x0000) ---
|
||||||
|
var extensions []uint16
|
||||||
|
for _, ext := range ch.Extensions {
|
||||||
|
if isGREASE(ext.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ext.Type == 0x0000 { // SNI exclue du comptage
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extensions = append(extensions, ext.Type)
|
||||||
|
}
|
||||||
|
extCount := fmt.Sprintf("%02d", len(extensions))
|
||||||
|
|
||||||
|
// --- Partie 1 : identifiant de base ---
|
||||||
|
part1 := proto + verStr + sniFlag + cipherCount + extCount
|
||||||
|
|
||||||
|
// --- Partie 2 : SHA-256 des cipher suites triées (12 premiers hex chars) ---
|
||||||
|
sortedCiphers := make([]uint16, len(ciphers))
|
||||||
|
copy(sortedCiphers, ciphers)
|
||||||
|
sort.Slice(sortedCiphers, func(i, j int) bool { return sortedCiphers[i] < sortedCiphers[j] })
|
||||||
|
|
||||||
|
cipherStrings := make([]string, len(sortedCiphers))
|
||||||
|
for i, cs := range sortedCiphers {
|
||||||
|
cipherStrings[i] = fmt.Sprintf("%04x", cs)
|
||||||
|
}
|
||||||
|
cipherRaw := strings.Join(cipherStrings, ",")
|
||||||
|
cipherHash := sha256.Sum256([]byte(cipherRaw))
|
||||||
|
part2 := hex.EncodeToString(cipherHash[:])[:12]
|
||||||
|
|
||||||
|
// --- Partie 3 : SHA-256 des extensions triées + ALPN (12 premiers hex chars) ---
|
||||||
|
sortedExts := make([]uint16, len(extensions))
|
||||||
|
copy(sortedExts, extensions)
|
||||||
|
sort.Slice(sortedExts, func(i, j int) bool { return sortedExts[i] < sortedExts[j] })
|
||||||
|
|
||||||
|
extStrings := make([]string, len(sortedExts))
|
||||||
|
for i, e := range sortedExts {
|
||||||
|
extStrings[i] = fmt.Sprintf("%04x", e)
|
||||||
|
}
|
||||||
|
extRaw := strings.Join(extStrings, ",")
|
||||||
|
|
||||||
|
// Premier protocole ALPN (ou "00" si absent)
|
||||||
|
alpnFirst := "00"
|
||||||
|
if len(ch.ALPN) > 0 {
|
||||||
|
alpnFirst = ch.ALPN[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
extAlpnRaw := extRaw + "_" + alpnFirst
|
||||||
|
extHash := sha256.Sum256([]byte(extAlpnRaw))
|
||||||
|
part3 := hex.EncodeToString(extHash[:])[:12]
|
||||||
|
|
||||||
|
return part1 + "_" + part2 + "_" + part3
|
||||||
|
}
|
||||||
241
services/ja4ebpf/internal/parser/tls_test.go
Normal file
241
services/ja4ebpf/internal/parser/tls_test.go
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
package parser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseClientHelloMinimal(t *testing.T) {
|
||||||
|
// Construit un ClientHello minimal valide manuellement
|
||||||
|
// pour tester le parsing sans dépendre d'un vrai paquet capturé.
|
||||||
|
raw := buildMinimalClientHello()
|
||||||
|
|
||||||
|
ch, err := parser.ParseClientHello(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseClientHello a échoué: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.HandshakeVersion != 0x0303 {
|
||||||
|
t.Errorf("version attendue 0x0303, obtenue 0x%04x", ch.HandshakeVersion)
|
||||||
|
}
|
||||||
|
if len(ch.CipherSuites) == 0 {
|
||||||
|
t.Error("aucune cipher suite extraite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsGREASEFiltering(t *testing.T) {
|
||||||
|
// Les valeurs GREASE doivent être filtrées dans ComputeJA4
|
||||||
|
raw := buildClientHelloWithGREASE()
|
||||||
|
ch, err := parser.ParseClientHello(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseClientHello: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ja4 := parser.ComputeJA4(ch)
|
||||||
|
if ja4 == "" {
|
||||||
|
t.Error("ComputeJA4 a retourné une chaîne vide")
|
||||||
|
}
|
||||||
|
t.Logf("JA4 = %s", ja4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeJA4Format(t *testing.T) {
|
||||||
|
raw := buildMinimalClientHello()
|
||||||
|
ch, err := parser.ParseClientHello(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseClientHello: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ja4 := parser.ComputeJA4(ch)
|
||||||
|
// Format attendu : t{ver}{sni}{cc}{ec}_{12hex}_{12hex}
|
||||||
|
// 5 chars + "_" + 12 chars + "_" + 12 chars = 31 chars minimum
|
||||||
|
if len(ja4) < 31 {
|
||||||
|
t.Errorf("longueur JA4 inattendue: %d chars — valeur: %q", len(ja4), ja4)
|
||||||
|
}
|
||||||
|
// Le premier caractère doit être 't' (TCP)
|
||||||
|
if ja4[0] != 't' {
|
||||||
|
t.Errorf("JA4 doit commencer par 't', obtenu %q", string(ja4[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSNIExtraction(t *testing.T) {
|
||||||
|
raw := buildClientHelloWithSNI("example.com")
|
||||||
|
ch, err := parser.ParseClientHello(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseClientHello: %v", err)
|
||||||
|
}
|
||||||
|
if ch.SNI != "example.com" {
|
||||||
|
t.Errorf("SNI attendu %q, obtenu %q", "example.com", ch.SNI)
|
||||||
|
}
|
||||||
|
|
||||||
|
ja4 := parser.ComputeJA4(ch)
|
||||||
|
// SNI présent → flag 'd' en position 3
|
||||||
|
if len(ja4) >= 3 && ja4[3] != 'd' {
|
||||||
|
t.Errorf("flag SNI attendu 'd', obtenu %q dans JA4=%q", string(ja4[3]), ja4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseClientHelloTooShort(t *testing.T) {
|
||||||
|
_, err := parser.ParseClientHello([]byte{0x16, 0x03, 0x01})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("attendu une erreur pour un payload trop court")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseClientHelloWrongType(t *testing.T) {
|
||||||
|
// Type 0x17 = Application Data, pas un Handshake
|
||||||
|
raw := make([]byte, 20)
|
||||||
|
raw[0] = 0x17
|
||||||
|
_, err := parser.ParseClientHello(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("attendu une erreur pour un Record Type incorrect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers de construction de paquets TLS de test ────────────────────────
|
||||||
|
|
||||||
|
// buildMinimalClientHello construit un ClientHello TLS 1.2 minimal valide.
|
||||||
|
func buildMinimalClientHello() []byte {
|
||||||
|
// Contenu du Handshake (sans l'en-tête Record Layer)
|
||||||
|
var hs []byte
|
||||||
|
|
||||||
|
// Version ClientHello : TLS 1.2
|
||||||
|
hs = append(hs, 0x03, 0x03)
|
||||||
|
|
||||||
|
// Random : 32 octets
|
||||||
|
random := make([]byte, 32)
|
||||||
|
hs = append(hs, random...)
|
||||||
|
|
||||||
|
// Session ID : longueur 0
|
||||||
|
hs = append(hs, 0x00)
|
||||||
|
|
||||||
|
// Cipher Suites : 2 suites (TLS_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA)
|
||||||
|
hs = append(hs, 0x00, 0x04) // longueur = 4 octets
|
||||||
|
hs = append(hs, 0x13, 0x01) // TLS_AES_128_GCM_SHA256
|
||||||
|
hs = append(hs, 0x00, 0x2f) // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||||
|
|
||||||
|
// Compression Methods : 1 méthode (null)
|
||||||
|
hs = append(hs, 0x01, 0x00)
|
||||||
|
|
||||||
|
// Pas d'extensions
|
||||||
|
hs = append(hs, 0x00, 0x00)
|
||||||
|
|
||||||
|
return buildTLSRecord(hs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClientHelloWithGREASE ajoute des valeurs GREASE aux cipher suites.
|
||||||
|
func buildClientHelloWithGREASE() []byte {
|
||||||
|
var hs []byte
|
||||||
|
hs = append(hs, 0x03, 0x03) // version
|
||||||
|
hs = append(hs, make([]byte, 32)...) // random
|
||||||
|
hs = append(hs, 0x00) // session id len
|
||||||
|
|
||||||
|
// Cipher suites avec GREASE (0x0a0a)
|
||||||
|
hs = append(hs, 0x00, 0x06) // longueur = 6
|
||||||
|
hs = append(hs, 0x0a, 0x0a) // GREASE (doit être filtré)
|
||||||
|
hs = append(hs, 0x13, 0x01) // TLS_AES_128_GCM_SHA256
|
||||||
|
hs = append(hs, 0x00, 0x2f) // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||||
|
|
||||||
|
hs = append(hs, 0x01, 0x00) // compression
|
||||||
|
hs = append(hs, 0x00, 0x00) // no extensions
|
||||||
|
|
||||||
|
return buildTLSRecord(hs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClientHelloWithSNI construit un ClientHello avec l'extension SNI.
|
||||||
|
func buildClientHelloWithSNI(hostname string) []byte {
|
||||||
|
var hs []byte
|
||||||
|
hs = append(hs, 0x03, 0x03)
|
||||||
|
hs = append(hs, make([]byte, 32)...)
|
||||||
|
hs = append(hs, 0x00)
|
||||||
|
hs = append(hs, 0x00, 0x04)
|
||||||
|
hs = append(hs, 0x13, 0x01, 0x00, 0x2f)
|
||||||
|
hs = append(hs, 0x01, 0x00)
|
||||||
|
|
||||||
|
// Extension SNI
|
||||||
|
nameBytes := []byte(hostname)
|
||||||
|
sniExt := buildSNIExtension(nameBytes)
|
||||||
|
|
||||||
|
// Bloc d'extensions
|
||||||
|
extBlock := sniExt
|
||||||
|
hs = appendUint16(hs, uint16(len(extBlock)))
|
||||||
|
hs = append(hs, extBlock...)
|
||||||
|
|
||||||
|
return buildTLSRecord(hs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSNIExtension(hostname []byte) []byte {
|
||||||
|
// Type : 0x0000 (SNI)
|
||||||
|
// Longueur extension = 2 (liste len) + 1 (type) + 2 (name len) + len(hostname)
|
||||||
|
nameLen := len(hostname)
|
||||||
|
listLen := 1 + 2 + nameLen
|
||||||
|
|
||||||
|
var ext []byte
|
||||||
|
ext = append(ext, 0x00, 0x00) // type SNI
|
||||||
|
ext = appendUint16(ext, uint16(2+listLen)) // longueur extension
|
||||||
|
ext = appendUint16(ext, uint16(listLen)) // longueur liste SNI
|
||||||
|
ext = append(ext, 0x00) // type : host_name
|
||||||
|
ext = appendUint16(ext, uint16(nameLen)) // longueur hostname
|
||||||
|
ext = append(ext, hostname...)
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTLSRecord encapsule un message Handshake dans un Record Layer TLS.
|
||||||
|
func buildTLSRecord(handshakeBody []byte) []byte {
|
||||||
|
hsLen := len(handshakeBody)
|
||||||
|
var rec []byte
|
||||||
|
|
||||||
|
// Record Layer
|
||||||
|
rec = append(rec, 0x16) // ContentType : Handshake
|
||||||
|
rec = append(rec, 0x03, 0x01) // Version : TLS 1.0
|
||||||
|
rec = appendUint16(rec, uint16(4+hsLen)) // longueur = type(1) + len(3) + body
|
||||||
|
|
||||||
|
// En-tête Handshake
|
||||||
|
rec = append(rec, 0x01) // HandshakeType : ClientHello
|
||||||
|
// Longueur sur 3 octets
|
||||||
|
rec = append(rec, byte(hsLen>>16), byte(hsLen>>8), byte(hsLen))
|
||||||
|
rec = append(rec, handshakeBody...)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUint16(b []byte, v uint16) []byte {
|
||||||
|
return append(b, byte(v>>8), byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseClientHelloHex teste le parsing d'un hex dump (smoke test).
|
||||||
|
func TestParseClientHelloHexDump(t *testing.T) {
|
||||||
|
// Hex dump minimal connu valide
|
||||||
|
hexStr := "160301002f" + // Record : type=22, ver=0x0301, len=47
|
||||||
|
"01000002b" + // Handshake : type=1, len=43 (ajusté)
|
||||||
|
"0303" +
|
||||||
|
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
|
||||||
|
"00" + // session id len = 0
|
||||||
|
"0004" + "13010035" + // cipher suites : 2 suites
|
||||||
|
"0100" + // compression
|
||||||
|
"0000" // no extensions
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(
|
||||||
|
"1603010032" + // Record Layer : type=22, len=50
|
||||||
|
"01" + "00002e" + // HandshakeType=1, len=46
|
||||||
|
"0303" + // version
|
||||||
|
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + // random
|
||||||
|
"00" + // session id len
|
||||||
|
"0004" + "13010035" + // 2 cipher suites
|
||||||
|
"01" + "00" + // compression
|
||||||
|
"0000", // extensions
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("hex decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := parser.ParseClientHello(raw)
|
||||||
|
if err != nil {
|
||||||
|
// Acceptable : le hex dump est peut-être mal formé (longueurs)
|
||||||
|
t.Logf("ParseClientHello (hex dump): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("ClientHello parsé : version=0x%04x, ciphers=%d", ch.HandshakeVersion, len(ch.CipherSuites))
|
||||||
|
_ = hexStr
|
||||||
|
}
|
||||||
219
services/ja4ebpf/internal/writer/clickhouse.go
Normal file
219
services/ja4ebpf/internal/writer/clickhouse.go
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// Package writer gère l'écriture asynchrone par batch des sessions
|
||||||
|
// corrélées dans ClickHouse.
|
||||||
|
package writer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClickHouseWriter écrit les sessions corrélées dans ja4_logs.http_logs_raw
|
||||||
|
// via des insertions batch asynchrones.
|
||||||
|
type ClickHouseWriter struct {
|
||||||
|
conn driver.Conn // connexion ClickHouse native
|
||||||
|
ch chan *correlation.SessionState // canal d'entrée des sessions
|
||||||
|
batchSz int // taille d'un batch d'insertion
|
||||||
|
flush time.Duration // intervalle de flush forcé
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionRecord est la représentation JSON d'une session pour http_logs_raw.
|
||||||
|
type sessionRecord struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
SrcIP string `json:"src_ip"`
|
||||||
|
SrcPort int `json:"src_port"`
|
||||||
|
Correlated int `json:"correlated"`
|
||||||
|
|
||||||
|
// L3/L4
|
||||||
|
TTL *uint8 `json:"ttl,omitempty"`
|
||||||
|
DFBit *bool `json:"df_bit,omitempty"`
|
||||||
|
IPID *uint16 `json:"ip_id,omitempty"`
|
||||||
|
WindowSize *uint16 `json:"window_size,omitempty"`
|
||||||
|
WindowScale *uint8 `json:"window_scale,omitempty"`
|
||||||
|
MSS *uint16 `json:"mss,omitempty"`
|
||||||
|
|
||||||
|
// TLS
|
||||||
|
JA4Hash string `json:"ja4,omitempty"`
|
||||||
|
SNI string `json:"sni,omitempty"`
|
||||||
|
ALPN []string `json:"alpn,omitempty"`
|
||||||
|
TLSVersion *uint16 `json:"tls_version,omitempty"`
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
QueryString string `json:"query_string,omitempty"`
|
||||||
|
StatusCode *int `json:"status_code,omitempty"`
|
||||||
|
ResponseSize *int64 `json:"response_size,omitempty"`
|
||||||
|
DurationMS *float64 `json:"duration_ms,omitempty"`
|
||||||
|
KeepAlives int `json:"keepalives,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClickHouseWriter crée un writer et établit la connexion ClickHouse.
|
||||||
|
func NewClickHouseWriter(dsn string, batchSize int, flushInterval time.Duration) (*ClickHouseWriter, error) {
|
||||||
|
opts, err := clickhouse.ParseDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyse DSN ClickHouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := clickhouse.Open(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connexion ClickHouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la connexion avec un ping limité dans le temps
|
||||||
|
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := conn.Ping(pingCtx); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("ping ClickHouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ClickHouseWriter{
|
||||||
|
conn: conn,
|
||||||
|
ch: make(chan *correlation.SessionState, 8192),
|
||||||
|
batchSz: batchSize,
|
||||||
|
flush: flushInterval,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start lance la goroutine de consommation du canal de sessions.
|
||||||
|
// Se termine proprement à l'annulation du contexte.
|
||||||
|
func (w *ClickHouseWriter) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
batch := make([]*correlation.SessionState, 0, w.batchSz)
|
||||||
|
ticker := time.NewTicker(w.flush)
|
||||||
|
defer ticker.Stop()
|
||||||
|
defer w.conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s, ok := <-w.ch:
|
||||||
|
if !ok {
|
||||||
|
// Canal fermé : vider le batch restant
|
||||||
|
if len(batch) > 0 {
|
||||||
|
if err := w.flushBatch(ctx, batch); err != nil {
|
||||||
|
log.Printf("[writer] erreur flush final: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch = append(batch, s)
|
||||||
|
if len(batch) >= w.batchSz {
|
||||||
|
if err := w.flushBatch(ctx, batch); err != nil {
|
||||||
|
log.Printf("[writer] erreur flush batch: %v", err)
|
||||||
|
}
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
if len(batch) > 0 {
|
||||||
|
if err := w.flushBatch(ctx, batch); err != nil {
|
||||||
|
log.Printf("[writer] erreur flush périodique: %v", err)
|
||||||
|
}
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
if len(batch) > 0 {
|
||||||
|
flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
if err := w.flushBatch(flushCtx, batch); err != nil {
|
||||||
|
log.Printf("[writer] erreur flush arrêt: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write envoie une session dans le canal d'écriture (non-bloquant).
|
||||||
|
// Si le canal est plein, la session est abandonnée avec un log d'avertissement.
|
||||||
|
func (w *ClickHouseWriter) Write(s *correlation.SessionState) {
|
||||||
|
select {
|
||||||
|
case w.ch <- s:
|
||||||
|
default:
|
||||||
|
log.Printf("[writer] canal plein, session abandonnée: src=%v:%d", s.Key.SrcIP, s.Key.SrcPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBatch insère un batch de sessions dans ja4_logs.http_logs_raw.
|
||||||
|
// Chaque session est sérialisée en JSON et insérée dans la colonne raw_json.
|
||||||
|
func (w *ClickHouseWriter) flushBatch(ctx context.Context, batch []*correlation.SessionState) error {
|
||||||
|
b, err := w.conn.PrepareBatch(ctx, "INSERT INTO ja4_logs.http_logs_raw (raw_json)")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("préparation batch ClickHouse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range batch {
|
||||||
|
record := sessionToRecord(s)
|
||||||
|
jsonBytes, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sérialisation session JSON: %w", err)
|
||||||
|
}
|
||||||
|
if err := b.Append(string(jsonBytes)); err != nil {
|
||||||
|
return fmt.Errorf("ajout ligne au batch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Send(); err != nil {
|
||||||
|
return fmt.Errorf("envoi batch ClickHouse (%d lignes): %w", len(batch), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionToRecord convertit une SessionState en enregistrement JSON plat.
|
||||||
|
func sessionToRecord(s *correlation.SessionState) sessionRecord {
|
||||||
|
srcIP := fmt.Sprintf("%d.%d.%d.%d",
|
||||||
|
s.Key.SrcIP[0], s.Key.SrcIP[1], s.Key.SrcIP[2], s.Key.SrcIP[3])
|
||||||
|
|
||||||
|
correlated := 0
|
||||||
|
if s.Correlated {
|
||||||
|
correlated = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := sessionRecord{
|
||||||
|
Timestamp: s.FirstSeen,
|
||||||
|
SrcIP: srcIP,
|
||||||
|
SrcPort: int(s.Key.SrcPort),
|
||||||
|
Correlated: correlated,
|
||||||
|
KeepAlives: len(s.Requests),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs L3/L4
|
||||||
|
if s.L3L4 != nil {
|
||||||
|
rec.TTL = &s.L3L4.TTL
|
||||||
|
rec.DFBit = &s.L3L4.DFBit
|
||||||
|
rec.IPID = &s.L3L4.IPID
|
||||||
|
rec.WindowSize = &s.L3L4.WindowSize
|
||||||
|
rec.WindowScale = &s.L3L4.WindowScale
|
||||||
|
rec.MSS = &s.L3L4.MSS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs TLS
|
||||||
|
if s.TLS != nil {
|
||||||
|
rec.JA4Hash = s.TLS.JA4Hash
|
||||||
|
rec.SNI = s.TLS.SNI
|
||||||
|
rec.ALPN = s.TLS.ALPN
|
||||||
|
rec.TLSVersion = &s.TLS.TLSVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs HTTP (dernière requête)
|
||||||
|
if len(s.Requests) > 0 {
|
||||||
|
last := &s.Requests[len(s.Requests)-1]
|
||||||
|
rec.Method = last.Method
|
||||||
|
rec.Path = last.Path
|
||||||
|
rec.QueryString = last.QueryString
|
||||||
|
rec.StatusCode = &last.StatusCode
|
||||||
|
rec.ResponseSize = &last.ResponseSize
|
||||||
|
rec.DurationMS = &last.DurationMS
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
86
services/ja4ebpf/packaging/rpm/ja4ebpf.spec
Normal file
86
services/ja4ebpf/packaging/rpm/ja4ebpf.spec
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
Name: ja4ebpf
|
||||||
|
Version: %{build_version}
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: JA4 eBPF Network Fingerprint Agent
|
||||||
|
|
||||||
|
License: Proprietary
|
||||||
|
URL: https://github.com/antitbone/ja4-platform
|
||||||
|
Source0: ja4ebpf
|
||||||
|
Source1: ja4ebpf.service
|
||||||
|
Source2: config.yml.example
|
||||||
|
|
||||||
|
# ── Compatibilité : RHEL/CentOS/Rocky/AlmaLinux 8 → 10 ───────────────────
|
||||||
|
# Binaire statique (CGO_ENABLED=0) : aucune dépendance de bibliothèque partagée.
|
||||||
|
# BTF natif disponible sur tous les kernels RHEL 8+ (backport dans 4.18).
|
||||||
|
BuildArch: x86_64
|
||||||
|
|
||||||
|
Requires: systemd
|
||||||
|
|
||||||
|
%description
|
||||||
|
ja4ebpf est un agent de collecte passif basé sur eBPF qui capture les
|
||||||
|
métadonnées réseau (L3/L4/L5/L7) pour le pipeline de détection de bots JA4.
|
||||||
|
|
||||||
|
Il utilise :
|
||||||
|
- Des hooks TC ingress pour les TCP SYN, TLS ClientHello, HTTP clair (80/8080)
|
||||||
|
- Des uprobes sur SSL_read/SSL_write pour le trafic HTTPS déchiffré
|
||||||
|
|
||||||
|
Le binaire est compilé statique et supporte RHEL/CentOS/Rocky/AlmaLinux 8 à 10.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
# Binaire pré-compilé fourni dans Source0 (compilé par Dockerfile.package).
|
||||||
|
|
||||||
|
%build
|
||||||
|
# Compilation déléguée au Dockerfile.package multi-stage.
|
||||||
|
|
||||||
|
%install
|
||||||
|
rm -rf %{buildroot}
|
||||||
|
|
||||||
|
install -D -m 0755 %{SOURCE0} %{buildroot}%{_sbindir}/ja4ebpf
|
||||||
|
install -D -m 0640 %{SOURCE2} %{buildroot}%{_sysconfdir}/ja4ebpf/config.yml.example
|
||||||
|
install -D -m 0644 %{SOURCE1} %{buildroot}%{_unitdir}/ja4ebpf.service
|
||||||
|
install -d -m 0750 %{buildroot}%{_localstatedir}/lib/ja4ebpf
|
||||||
|
install -d -m 0750 %{buildroot}%{_localstatedir}/log/ja4ebpf
|
||||||
|
|
||||||
|
%pre
|
||||||
|
getent group ja4ebpf >/dev/null 2>&1 || \
|
||||||
|
groupadd -r -g 490 ja4ebpf
|
||||||
|
getent passwd ja4ebpf >/dev/null 2>&1 || \
|
||||||
|
useradd -r -u 490 -g ja4ebpf \
|
||||||
|
-d %{_localstatedir}/lib/ja4ebpf \
|
||||||
|
-s /sbin/nologin \
|
||||||
|
-c "JA4 eBPF agent" \
|
||||||
|
ja4ebpf
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
%post
|
||||||
|
%systemd_post ja4ebpf.service
|
||||||
|
|
||||||
|
if [ ! -f %{_sysconfdir}/ja4ebpf/config.yml ]; then
|
||||||
|
cp -p %{_sysconfdir}/ja4ebpf/config.yml.example \
|
||||||
|
%{_sysconfdir}/ja4ebpf/config.yml
|
||||||
|
chown root:ja4ebpf %{_sysconfdir}/ja4ebpf/config.yml
|
||||||
|
chmod 640 %{_sysconfdir}/ja4ebpf/config.yml
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R ja4ebpf:ja4ebpf \
|
||||||
|
%{_localstatedir}/lib/ja4ebpf \
|
||||||
|
%{_localstatedir}/log/ja4ebpf
|
||||||
|
|
||||||
|
%preun
|
||||||
|
%systemd_preun ja4ebpf.service
|
||||||
|
|
||||||
|
%postun
|
||||||
|
%systemd_postun_with_restart ja4ebpf.service
|
||||||
|
|
||||||
|
%files
|
||||||
|
%defattr(-,root,root,-)
|
||||||
|
%attr(0755, root, root) %{_sbindir}/ja4ebpf
|
||||||
|
%dir %attr(0750, root, ja4ebpf) %{_sysconfdir}/ja4ebpf
|
||||||
|
%config(noreplace) %attr(0640, root, ja4ebpf) %{_sysconfdir}/ja4ebpf/config.yml.example
|
||||||
|
%{_unitdir}/ja4ebpf.service
|
||||||
|
%dir %attr(0750, ja4ebpf, ja4ebpf) %{_localstatedir}/lib/ja4ebpf
|
||||||
|
%dir %attr(0750, ja4ebpf, ja4ebpf) %{_localstatedir}/log/ja4ebpf
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* %(date "+%a %b %d %Y") Build System <build@antitbone.local> - %{build_version}-1
|
||||||
|
- Build automatique via Dockerfile.package
|
||||||
80
services/ja4ebpf/packaging/systemd/ja4ebpf.service
Normal file
80
services/ja4ebpf/packaging/systemd/ja4ebpf.service
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ja4ebpf.service — Unité systemd pour l'agent eBPF ja4ebpf
|
||||||
|
#
|
||||||
|
# Installation :
|
||||||
|
# install -m 644 ja4ebpf.service /usr/lib/systemd/system/
|
||||||
|
# systemctl daemon-reload
|
||||||
|
# systemctl enable --now ja4ebpf
|
||||||
|
#
|
||||||
|
# Sécurité :
|
||||||
|
# L'agent fonctionne sous un compte dédié "ja4ebpf" sans shell ni home.
|
||||||
|
# Les capabilities Linux strictement nécessaires sont accordées via
|
||||||
|
# AmbientCapabilities + CapabilityBoundingSet (sans User=root).
|
||||||
|
# Cible : RHEL/CentOS/Rocky/Alma 8+ (kernel ≥ 4.18, BTF natif disponible).
|
||||||
|
# =============================================================================
|
||||||
|
[Unit]
|
||||||
|
Description=JA4 eBPF Network Fingerprint Agent
|
||||||
|
Documentation=https://github.com/antitbone/ja4-platform
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ja4ebpf
|
||||||
|
Group=ja4ebpf
|
||||||
|
|
||||||
|
ExecStart=/usr/sbin/ja4ebpf -config /etc/ja4ebpf/config.yml
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=30s
|
||||||
|
|
||||||
|
# ── Capabilities Linux ─────────────────────────────────────────────────────
|
||||||
|
# CAP_BPF : charger/créer des programmes et maps eBPF (kernel ≥ 5.8)
|
||||||
|
# Sur RHEL 8 (4.18), cilium/ebpf retombe sur CAP_SYS_ADMIN.
|
||||||
|
# CAP_NET_ADMIN : attacher un programme TC sur une interface réseau
|
||||||
|
# CAP_NET_RAW : accès raw socket (fallback sur kernels sans TCX)
|
||||||
|
# CAP_PERFMON : attacher des perf_events / uprobes (kernel ≥ 5.8)
|
||||||
|
# CAP_SYS_ADMIN : requis sur RHEL 8 / kernel < 5.8 pour charger eBPF
|
||||||
|
# CAP_SYS_PTRACE : résoudre les offsets de fonctions pour les uprobes
|
||||||
|
# CAP_DAC_READ_SEARCH : lire /proc/<pid>/maps pour localiser libssl.so
|
||||||
|
CapabilityBoundingSet=CAP_BPF CAP_NET_ADMIN CAP_NET_RAW CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_DAC_READ_SEARCH
|
||||||
|
AmbientCapabilities=CAP_BPF CAP_NET_ADMIN CAP_NET_RAW CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_DAC_READ_SEARCH
|
||||||
|
|
||||||
|
# Ne jamais acquérir de nouveaux privilèges via setuid/setgid
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
|
||||||
|
# ── Isolation du système de fichiers ───────────────────────────────────────
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/lib/ja4ebpf /var/log/ja4ebpf /run/ja4ebpf
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=no
|
||||||
|
|
||||||
|
# ── Isolation réseau ───────────────────────────────────────────────────────
|
||||||
|
PrivateNetwork=no
|
||||||
|
|
||||||
|
# ── Divers ─────────────────────────────────────────────────────────────────
|
||||||
|
ProtectKernelTunables=no
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=no
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=no # JIT eBPF requiert des mappings exécutables kernel-side
|
||||||
|
|
||||||
|
# ── Limites de ressources ──────────────────────────────────────────────────
|
||||||
|
LimitMEMLOCK=infinity
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
# ── Journalisation ─────────────────────────────────────────────────────────
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ja4ebpf
|
||||||
|
|
||||||
|
WorkingDirectory=/var/lib/ja4ebpf
|
||||||
|
RuntimeDirectory=ja4ebpf
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user