feat: pipeline L7 HTTP complet + infrastructure tests VM
Correctifs pipeline L7 (uprobe SSL_read) :
- uprobe_ssl.c : ssl_set_fd ne retourne plus tôt quand fd_conn_map est
vide (accept4 non disponible en Docker). Sauvegarde ssl_ptr→{fd,0,0}
pour permettre le fallback /proc côté Go.
- main.go : consumeSSLEvents reécrit avec routeur magic-bytes complet :
* HTTP/2 preface → extraction SETTINGS + conversion correlation.HTTP2Settings
* HTTP/1.x requête → method, path, query, headers, header_order_sig
* HTTP/1.x réponse → status_code
* Fallback /proc/<tgid>/fd/<fd> quand src_ip=0 (accept4 absent)
- writer/clickhouse.go : export header_order_signature ajouté
Nouveaux packages :
- internal/parser/http1.go : parseur HTTP/1.x (IsHTTP1Request,
ParseHTTP1Request, IsHTTP1Response, ParseHTTP1Response)
- internal/parser/http1_test.go : 11 tests unitaires (28 total passent)
- internal/procutil/proc_lookup.go : résolution fd→IP via /proc avec cache
TTL 5s (FDCache). Supporte /proc/PID/net/tcp et tcp6, IPv4-mappé IPv6.
Infrastructure tests VM (tests/vm/) :
- Vagrantfile : VM Rocky Linux 9 KVM, 4 CPU / 4 GB RAM
- provision.sh : installation toolchain eBPF + Go + Docker + nginx
- run-tests-vm.sh : suite de test complète dans la VM (L3/L4+TLS+L7)
- README.md : guide d'installation et d'utilisation
- Makefile : cibles vm-up, vm-down, vm-ssh, test-vm-nginx, test-vm-all,
vm-rebuild-ja4ebpf
Corrections stack Docker :
- Dockerfiles nginx/apache/nginx-varnish/hitch-varnish : suppression des
références à shared/go/ja4common/ (répertoire supprimé)
- clickhouse-init.sh : restauré depuis git, seed anubis_ua_rules obsolète
supprimé (table REGEXP_TREE supprimée du schéma)
- traffic-gen : ajout HTTP/1.0 (http.client) et HTTP/2 (httpx)
- verify_db.py : script de vérification 35 checks (L3/L4/TLS/L7/corrélation)
- run-stack-tests.sh : phase 6 verify_db ajoutée
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -183,19 +183,23 @@ func (l *Loader) AttachUprobes(sslLibPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachAcceptProbe attache les kprobes sur l'appel système accept4.
|
||||
// AttachAcceptProbe attache les tracepoints syscalls/sys_{enter,exit}_accept4.
|
||||
// Les tracepoints sont préférés aux kprobes car ils ne dépendent pas du nom
|
||||
// manglé __x64_sys_accept4 qui varie entre les versions du kernel (5.1+).
|
||||
func (l *Loader) AttachAcceptProbe() error {
|
||||
// Kprobe à l'entrée d'accept4
|
||||
kpEntry, err := link.Kprobe("accept4", l.sslObjs.KprobeAccept4Entry, nil)
|
||||
// Tracepoint à l'entrée de accept4
|
||||
kpEntry, err := link.Tracepoint("syscalls", "sys_enter_accept4",
|
||||
l.sslObjs.KprobeAccept4Entry, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachement kprobe accept4 (entry): %w", err)
|
||||
return fmt.Errorf("attachement tracepoint sys_enter_accept4: %w", err)
|
||||
}
|
||||
l.uprobeLinks = append(l.uprobeLinks, kpEntry)
|
||||
|
||||
// Kretprobe à la sortie d'accept4
|
||||
kpExit, err := link.Kretprobe("accept4", l.sslObjs.KretprobeAccept4Exit, nil)
|
||||
// Tracepoint à la sortie de accept4
|
||||
kpExit, err := link.Tracepoint("syscalls", "sys_exit_accept4",
|
||||
l.sslObjs.KretprobeAccept4Exit, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachement kretprobe accept4 (exit): %w", err)
|
||||
return fmt.Errorf("attachement tracepoint sys_exit_accept4: %w", err)
|
||||
}
|
||||
l.uprobeLinks = append(l.uprobeLinks, kpExit)
|
||||
|
||||
|
||||
146
services/ja4ebpf/internal/parser/http1.go
Normal file
146
services/ja4ebpf/internal/parser/http1.go
Normal file
@ -0,0 +1,146 @@
|
||||
// Package parser fournit les parseurs pour les protocoles HTTP/1.x, HTTP/2 et TLS.
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTTP1Request représente une requête HTTP/1.x parsée depuis le flux déchiffré.
|
||||
type HTTP1Request struct {
|
||||
Method string // méthode HTTP (GET, POST, …)
|
||||
Path string // chemin (sans query string)
|
||||
Query string // query string (sans le '?')
|
||||
Protocol string // "HTTP/1.0" ou "HTTP/1.1"
|
||||
Headers []string // noms des en-têtes dans l'ordre exact d'arrivée
|
||||
HeaderSig string // signature : noms joints par ";"
|
||||
}
|
||||
|
||||
// HTTP1Response représente le début d'une réponse HTTP/1.x (status line).
|
||||
type HTTP1Response struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// knownMethods est la liste des méthodes HTTP/1.x reconnues.
|
||||
var knownMethods = []string{
|
||||
"GET", "POST", "PUT", "DELETE", "HEAD",
|
||||
"OPTIONS", "PATCH", "CONNECT", "TRACE",
|
||||
}
|
||||
|
||||
// IsHTTP1Request retourne true si les premiers octets ressemblent à une
|
||||
// requête HTTP/1.x (commence par une méthode reconnue suivi d'un espace).
|
||||
func IsHTTP1Request(data []byte) bool {
|
||||
for _, m := range knownMethods {
|
||||
if bytes.HasPrefix(data, []byte(m+" ")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsHTTP1Response retourne true si les premiers octets ressemblent à une
|
||||
// réponse HTTP/1.x ("HTTP/1.").
|
||||
func IsHTTP1Response(data []byte) bool {
|
||||
return bytes.HasPrefix(data, []byte("HTTP/1."))
|
||||
}
|
||||
|
||||
// ParseHTTP1Request extrait les champs d'une requête HTTP/1.x depuis un buffer brut.
|
||||
// Retourne nil sans erreur si le buffer ne contient pas de requête complète.
|
||||
func ParseHTTP1Request(data []byte) *HTTP1Request {
|
||||
// Localiser la fin de la request-line + headers (double CRLF)
|
||||
headerEnd := bytes.Index(data, []byte("\r\n\r\n"))
|
||||
if headerEnd < 0 {
|
||||
headerEnd = len(data)
|
||||
}
|
||||
text := string(data[:headerEnd])
|
||||
lines := strings.Split(text, "\r\n")
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parser la request-line : "METHOD path HTTP/x.y"
|
||||
requestLine := lines[0]
|
||||
parts := strings.SplitN(requestLine, " ", 3)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
method := parts[0]
|
||||
rawPath := parts[1]
|
||||
protocol := "HTTP/1.1"
|
||||
if len(parts) == 3 {
|
||||
protocol = parts[2]
|
||||
}
|
||||
|
||||
// Valider la méthode
|
||||
validMethod := false
|
||||
for _, m := range knownMethods {
|
||||
if method == m {
|
||||
validMethod = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validMethod {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Séparer path et query string
|
||||
path := rawPath
|
||||
query := ""
|
||||
if idx := strings.Index(rawPath, "?"); idx >= 0 {
|
||||
path = rawPath[:idx]
|
||||
query = rawPath[idx+1:]
|
||||
}
|
||||
|
||||
// Extraire les noms d'en-têtes dans l'ordre
|
||||
headers := make([]string, 0, len(lines)-1)
|
||||
for _, line := range lines[1:] {
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if colon := strings.Index(line, ":"); colon > 0 {
|
||||
name := strings.TrimSpace(line[:colon])
|
||||
if name != "" {
|
||||
headers = append(headers, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sig := strings.Join(headers, ";")
|
||||
|
||||
return &HTTP1Request{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Protocol: protocol,
|
||||
Headers: headers,
|
||||
HeaderSig: sig,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseHTTP1Response extrait le code de statut d'une réponse HTTP/1.x.
|
||||
// Retourne nil si le buffer n'est pas une réponse HTTP/1.x reconnaissable.
|
||||
func ParseHTTP1Response(data []byte) *HTTP1Response {
|
||||
if !IsHTTP1Response(data) {
|
||||
return nil
|
||||
}
|
||||
// Status-line : "HTTP/1.1 200 OK\r\n..."
|
||||
line := data
|
||||
if idx := bytes.IndexByte(data, '\n'); idx >= 0 {
|
||||
line = data[:idx]
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimRight(string(line), "\r\n"), " ", 3)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
code := 0
|
||||
for _, c := range parts[1] {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
code = code*10 + int(c-'0')
|
||||
}
|
||||
if code < 100 || code > 599 {
|
||||
return nil
|
||||
}
|
||||
return &HTTP1Response{StatusCode: code}
|
||||
}
|
||||
201
services/ja4ebpf/internal/parser/http1_test.go
Normal file
201
services/ja4ebpf/internal/parser/http1_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsHTTP1RequestTrue(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
[]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"),
|
||||
[]byte("POST /api/data HTTP/1.1\r\n"),
|
||||
[]byte("PUT /resource HTTP/1.0\r\n"),
|
||||
[]byte("HEAD /ping HTTP/1.1\r\n"),
|
||||
[]byte("DELETE /item/1 HTTP/1.1\r\n"),
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !IsHTTP1Request(c) {
|
||||
t.Errorf("attendu true pour %q", c[:20])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTP1RequestFalse(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
[]byte("HTTP/1.1 200 OK\r\n"),
|
||||
[]byte(H2Magic),
|
||||
[]byte("INVALID /path HTTP/1.1\r\n"),
|
||||
[]byte{0x16, 0x03, 0x01}, // TLS handshake
|
||||
}
|
||||
for _, c := range cases {
|
||||
if IsHTTP1Request(c) {
|
||||
t.Errorf("attendu false pour %q", c[:min(20, len(c))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1RequestBasic(t *testing.T) {
|
||||
raw := "GET /path/to/resource HTTP/1.1\r\n" +
|
||||
"Host: example.com\r\n" +
|
||||
"User-Agent: Go-http-client/1.1\r\n" +
|
||||
"Accept: */*\r\n" +
|
||||
"\r\n"
|
||||
req := ParseHTTP1Request([]byte(raw))
|
||||
if req == nil {
|
||||
t.Fatal("attendu non-nil")
|
||||
}
|
||||
if req.Method != "GET" {
|
||||
t.Errorf("method: attendu GET, obtenu %q", req.Method)
|
||||
}
|
||||
if req.Path != "/path/to/resource" {
|
||||
t.Errorf("path: %q", req.Path)
|
||||
}
|
||||
if req.Protocol != "HTTP/1.1" {
|
||||
t.Errorf("protocol: %q", req.Protocol)
|
||||
}
|
||||
if len(req.Headers) != 3 {
|
||||
t.Errorf("nb headers: attendu 3, obtenu %d", len(req.Headers))
|
||||
}
|
||||
if req.Headers[0] != "Host" {
|
||||
t.Errorf("premier header: attendu Host, obtenu %q", req.Headers[0])
|
||||
}
|
||||
expected := "Host;User-Agent;Accept"
|
||||
if req.HeaderSig != expected {
|
||||
t.Errorf("HeaderSig: attendu %q, obtenu %q", expected, req.HeaderSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1RequestWithQueryString(t *testing.T) {
|
||||
raw := "GET /search?q=ebpf&page=2 HTTP/1.1\r\nHost: test.com\r\n\r\n"
|
||||
req := ParseHTTP1Request([]byte(raw))
|
||||
if req == nil {
|
||||
t.Fatal("attendu non-nil")
|
||||
}
|
||||
if req.Path != "/search" {
|
||||
t.Errorf("path: attendu /search, obtenu %q", req.Path)
|
||||
}
|
||||
if req.Query != "q=ebpf&page=2" {
|
||||
t.Errorf("query: %q", req.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1RequestHTTP10(t *testing.T) {
|
||||
raw := "GET / HTTP/1.0\r\nHost: legacy.com\r\n\r\n"
|
||||
req := ParseHTTP1Request([]byte(raw))
|
||||
if req == nil {
|
||||
t.Fatal("attendu non-nil")
|
||||
}
|
||||
if req.Protocol != "HTTP/1.0" {
|
||||
t.Errorf("protocol: %q", req.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1RequestPostBody(t *testing.T) {
|
||||
raw := "POST /api/submit HTTP/1.1\r\n" +
|
||||
"Host: api.example.com\r\n" +
|
||||
"Content-Type: application/json\r\n" +
|
||||
"Content-Length: 42\r\n" +
|
||||
"\r\n" +
|
||||
`{"key":"value"}`
|
||||
req := ParseHTTP1Request([]byte(raw))
|
||||
if req == nil {
|
||||
t.Fatal("attendu non-nil")
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Errorf("method: %q", req.Method)
|
||||
}
|
||||
if len(req.Headers) != 3 {
|
||||
t.Errorf("nb headers: attendu 3, obtenu %d", len(req.Headers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1RequestInvalid(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
[]byte("HTTP/1.1 200 OK\r\n"),
|
||||
[]byte("NOTAMETHOD /path HTTP/1.1\r\n"),
|
||||
[]byte(""),
|
||||
{0xFF, 0xFE, 0xFD},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := ParseHTTP1Request(c)
|
||||
if req != nil {
|
||||
t.Errorf("attendu nil pour %q, obtenu non-nil", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTP1Response(t *testing.T) {
|
||||
if !IsHTTP1Response([]byte("HTTP/1.1 200 OK\r\n")) {
|
||||
t.Error("attendu true pour HTTP/1.1 200")
|
||||
}
|
||||
if !IsHTTP1Response([]byte("HTTP/1.0 404 Not Found\r\n")) {
|
||||
t.Error("attendu true pour HTTP/1.0 404")
|
||||
}
|
||||
if IsHTTP1Response([]byte("GET / HTTP/1.1\r\n")) {
|
||||
t.Error("attendu false pour une requête")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1Response(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
code int
|
||||
}{
|
||||
{"HTTP/1.1 200 OK\r\n", 200},
|
||||
{"HTTP/1.0 404 Not Found\r\n", 404},
|
||||
{"HTTP/1.1 301 Moved Permanently\r\n", 301},
|
||||
{"HTTP/1.1 500 Internal Server Error\r\n", 500},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp := ParseHTTP1Response([]byte(tc.raw))
|
||||
if resp == nil {
|
||||
t.Errorf("attendu non-nil pour %q", tc.raw)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != tc.code {
|
||||
t.Errorf("code attendu %d, obtenu %d pour %q", tc.code, resp.StatusCode, tc.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTP1ResponseInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"GET / HTTP/1.1",
|
||||
"HTTP/1.1 99 Continue", // hors plage 100-599
|
||||
"",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp := ParseHTTP1Response([]byte(tc))
|
||||
if resp != nil && (resp.StatusCode < 100 || resp.StatusCode > 599) {
|
||||
t.Errorf("code invalide %d pour %q", resp.StatusCode, tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// min retourne le minimum de deux entiers (helper pour les tests).
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestHTTP1HeaderOrderSignature(t *testing.T) {
|
||||
raw := "GET / HTTP/1.1\r\n" +
|
||||
"Accept: text/html\r\n" +
|
||||
"Accept-Encoding: gzip\r\n" +
|
||||
"Connection: keep-alive\r\n" +
|
||||
"Host: example.com\r\n" +
|
||||
"\r\n"
|
||||
req := ParseHTTP1Request([]byte(raw))
|
||||
if req == nil {
|
||||
t.Fatal("attendu non-nil")
|
||||
}
|
||||
parts := strings.Split(req.HeaderSig, ";")
|
||||
if len(parts) != 4 {
|
||||
t.Errorf("attendu 4 headers dans la signature, obtenu %d: %q", len(parts), req.HeaderSig)
|
||||
}
|
||||
if parts[0] != "Accept" {
|
||||
t.Errorf("premier header sig: attendu Accept, obtenu %q", parts[0])
|
||||
}
|
||||
}
|
||||
247
services/ja4ebpf/internal/procutil/proc_lookup.go
Normal file
247
services/ja4ebpf/internal/procutil/proc_lookup.go
Normal file
@ -0,0 +1,247 @@
|
||||
// Package procutil fournit des utilitaires pour résoudre les informations de
|
||||
// connexion réseau depuis le système de fichiers /proc.
|
||||
// Utilisé comme fallback quand la sonde accept4 n'est pas disponible (ex: Docker).
|
||||
package procutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cacheEntry est une entrée du cache de résolution fd→IP.
|
||||
type cacheEntry struct {
|
||||
IP net.IP
|
||||
Port uint16
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// FDCache résout un descripteur de fichier socket en adresse IP:port du client
|
||||
// en interrogeant /proc. Les résultats sont mis en cache pour limiter les I/O.
|
||||
type FDCache struct {
|
||||
mu sync.Mutex
|
||||
cache map[fdKey]*cacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// fdKey est la clé du cache : TGID (PID du groupe de threads) + fd.
|
||||
type fdKey struct {
|
||||
tgid uint32
|
||||
fd uint32
|
||||
}
|
||||
|
||||
// NewFDCache crée un nouveau cache avec la durée de vie d'entrée spécifiée.
|
||||
func NewFDCache(ttl time.Duration) *FDCache {
|
||||
c := &FDCache{
|
||||
cache: make(map[fdKey]*cacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
// Purge périodique des entrées expirées
|
||||
go c.purgeLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
// Lookup retourne l'IP et le port du client pour un socket identifié par (tgid, fd).
|
||||
// Consulte d'abord le cache, puis /proc si nécessaire.
|
||||
func (c *FDCache) Lookup(tgid, fd uint32) (net.IP, uint16, error) {
|
||||
key := fdKey{tgid: tgid, fd: fd}
|
||||
|
||||
c.mu.Lock()
|
||||
if e, ok := c.cache[key]; ok && time.Now().Before(e.expiresAt) {
|
||||
ip, port := e.IP, e.Port
|
||||
c.mu.Unlock()
|
||||
return ip, port, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Résoudre depuis /proc
|
||||
ip, port, err := lookupFDPeer(tgid, fd)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cache[key] = &cacheEntry{
|
||||
IP: ip,
|
||||
Port: port,
|
||||
expiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// lookupFDPeer résout l'adresse du pair (client) pour un fd donné via /proc.
|
||||
func lookupFDPeer(tgid, fd uint32) (net.IP, uint16, error) {
|
||||
// Lire le lien symbolique /proc/<tgid>/fd/<fd> → "socket:[inode]"
|
||||
linkPath := fmt.Sprintf("/proc/%d/fd/%d", tgid, fd)
|
||||
dest, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("readlink %s: %w", linkPath, err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(dest, "socket:[") || !strings.HasSuffix(dest, "]") {
|
||||
return nil, 0, fmt.Errorf("fd %d n'est pas un socket: %s", fd, dest)
|
||||
}
|
||||
|
||||
inodeStr := dest[8 : len(dest)-1]
|
||||
inode, err := strconv.ParseUint(inodeStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("inode invalide '%s': %w", inodeStr, err)
|
||||
}
|
||||
|
||||
// Chercher dans /proc/<tgid>/net/tcp (IPv4)
|
||||
ip, port, err := searchTCPTable(fmt.Sprintf("/proc/%d/net/tcp", tgid), inode, false)
|
||||
if err == nil {
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// Fallback sur /proc/<tgid>/net/tcp6 (IPv6 et IPv4-mappé)
|
||||
ip, port, err = searchTCPTable(fmt.Sprintf("/proc/%d/net/tcp6", tgid), inode, true)
|
||||
if err == nil {
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// Dernier recours : /proc/net/tcp (namespace réseau global)
|
||||
ip, port, err = searchTCPTable("/proc/net/tcp", inode, false)
|
||||
if err == nil {
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
return nil, 0, fmt.Errorf("inode %d introuvable dans les tables TCP", inode)
|
||||
}
|
||||
|
||||
// searchTCPTable recherche un inode dans /proc/.../net/tcp ou tcp6.
|
||||
// Retourne l'adresse du pair (remote = client) et son port.
|
||||
func searchTCPTable(path string, inode uint64, isIPv6 bool) (net.IP, uint16, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Scan() // sauter la ligne d'en-tête
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Le champ d'inode est en position 9
|
||||
lineInode, err := strconv.ParseUint(fields[9], 10, 64)
|
||||
if err != nil || lineInode != inode {
|
||||
continue
|
||||
}
|
||||
|
||||
// Le champ remote_address est en position 2 : "AABBCCDD:PPPP"
|
||||
remAddr := fields[2]
|
||||
colonIdx := strings.Index(remAddr, ":")
|
||||
if colonIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hexIP := remAddr[:colonIdx]
|
||||
hexPort := remAddr[colonIdx+1:]
|
||||
|
||||
var ip net.IP
|
||||
if isIPv6 {
|
||||
ip, err = parseHexIPv6(hexIP)
|
||||
} else {
|
||||
ip, err = parseHexIPv4(hexIP)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
portVal, err := strconv.ParseUint(hexPort, 16, 16)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return ip, uint16(portVal), nil
|
||||
}
|
||||
|
||||
return nil, 0, fmt.Errorf("inode %d non trouvé dans %s", inode, path)
|
||||
}
|
||||
|
||||
// parseHexIPv4 décode une adresse IPv4 hex 8 caractères depuis /proc/net/tcp.
|
||||
// Sur x86 little-endian, le noyau écrit l'adresse en ordre little-endian.
|
||||
// Exemple : "0201010A" → 10.1.1.2
|
||||
func parseHexIPv4(hexStr string) (net.IP, error) {
|
||||
if len(hexStr) != 8 {
|
||||
return nil, fmt.Errorf("adresse IPv4 hex invalide: %s", hexStr)
|
||||
}
|
||||
val, err := strconv.ParseUint(hexStr, 16, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ip := make(net.IP, 4)
|
||||
// Le noyau stocke en little-endian sur x86 → PutUint32 en little-endian reconstitue les octets
|
||||
binary.LittleEndian.PutUint32(ip, uint32(val))
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// parseHexIPv6 décode une adresse IPv6 hex 32 caractères depuis /proc/net/tcp6.
|
||||
// Gère aussi les adresses IPv4-mappées (::ffff:x.x.x.x).
|
||||
func parseHexIPv6(hexStr string) (net.IP, error) {
|
||||
if len(hexStr) != 32 {
|
||||
return nil, fmt.Errorf("adresse IPv6 hex invalide: %s", hexStr)
|
||||
}
|
||||
|
||||
// Les 32 caractères hex représentent 4 groupes de 4 octets en little-endian
|
||||
rawIP := make(net.IP, 16)
|
||||
for i := 0; i < 4; i++ {
|
||||
chunk := hexStr[i*8 : i*8+8]
|
||||
val, err := strconv.ParseUint(chunk, 16, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary.LittleEndian.PutUint32(rawIP[i*4:], uint32(val))
|
||||
}
|
||||
|
||||
// Détecter IPv4-mappé ::ffff:x.x.x.x
|
||||
if isIPv4MappedIPv6(rawIP) {
|
||||
return rawIP[12:].To4(), nil
|
||||
}
|
||||
|
||||
return rawIP, nil
|
||||
}
|
||||
|
||||
// isIPv4MappedIPv6 retourne true si l'adresse est une IPv4-mappée dans IPv6.
|
||||
func isIPv4MappedIPv6(ip net.IP) bool {
|
||||
if len(ip) != 16 {
|
||||
return false
|
||||
}
|
||||
// ::ffff:x.x.x.x : les 10 premiers octets sont 0, puis FF FF, puis 4 octets IPv4
|
||||
for i := 0; i < 10; i++ {
|
||||
if ip[i] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return ip[10] == 0xff && ip[11] == 0xff
|
||||
}
|
||||
|
||||
// purgeLoop nettoie périodiquement le cache des entrées expirées.
|
||||
func (c *FDCache) purgeLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, e := range c.cache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(c.cache, k)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@ -53,13 +53,14 @@ type sessionRecord struct {
|
||||
TLSVersion string `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"`
|
||||
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"`
|
||||
HeaderOrderSig string `json:"header_order_signature,omitempty"`
|
||||
}
|
||||
|
||||
// NewClickHouseWriter crée un writer et établit la connexion ClickHouse.
|
||||
@ -217,12 +218,13 @@ func sessionToRecord(s *correlation.SessionState) sessionRecord {
|
||||
// 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
|
||||
rec.Method = last.Method
|
||||
rec.Path = last.Path
|
||||
rec.QueryString = last.QueryString
|
||||
rec.StatusCode = &last.StatusCode
|
||||
rec.ResponseSize = &last.ResponseSize
|
||||
rec.DurationMS = &last.DurationMS
|
||||
rec.HeaderOrderSig = last.HeaderOrderSig
|
||||
}
|
||||
|
||||
return rec
|
||||
|
||||
Reference in New Issue
Block a user