feat: implémentation complète du pipeline JA4 + Docker + tests

Nouveaux modules:
- cmd/ja4sentinel/main.go : point d'entrée avec pipeline capture→parse→fingerprint→output
- internal/config/loader.go : chargement YAML + env (JA4SENTINEL_*) + validation
- internal/tlsparse/parser.go : extraction ClientHello avec suivi d'état de flux (NEW/WAIT_CLIENT_HELLO/JA4_DONE)
- internal/fingerprint/engine.go : génération JA4/JA3 via psanford/tlsfingerprint
- internal/output/writers.go : StdoutWriter, FileWriter, UnixSocketWriter, MultiWriter

Infrastructure:
- Dockerfile (multi-stage), Dockerfile.dev, Dockerfile.test-server
- Makefile (build, test, lint, docker-build-*)
- docker-compose.test.yml pour tests d'intégration
- README.md (276 lignes) avec architecture, config, exemples

API (api/types.go):
- Ajout Close() aux interfaces Capture et Parser
- Ajout FlowTimeoutSec dans Config (défaut: 30s, env: JA4SENTINEL_FLOW_TIMEOUT)
- ServiceLog: +Timestamp, +TraceID, +ConnID
- LogRecord: champs flatten (ip_meta_*, tcp_meta_*, ja4*)
- Helper NewLogRecord() pour conversion TLSClientHello+Fingerprints→LogRecord

Architecture (architecture.yml):
- Documentation module logging + interfaces LoggerFactory/Logger
- Section service.systemd complète (unit, security, capabilities)
- Section logging.strategy (JSON lines, champs, règles)
- api.Config: +FlowTimeoutSec documenté

Fixes/cleanup:
- Suppression internal/api/types.go (consolidé dans api/types.go)
- Correction imports logging (ja4sentinel/api)
- .dockerignore / .gitignore
- config.yml.example

Tests:
- Tous les modules ont leurs tests (*_test.go)
- Tests unitaires : capture, config, fingerprint, output, tlsparse
- Tests d'intégration via docker-compose.test.yml

Build:
- Binaires dans dist/ (make build → dist/ja4sentinel)
- Docker runtime avec COPY --from=builder /app/dist/

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-02-25 20:02:52 +01:00
parent 3b09f9416e
commit efd4481729
28 changed files with 2797 additions and 285 deletions

37
.dockerignore Normal file
View File

@ -0,0 +1,37 @@
# Git files
.git
.gitignore
.gitattributes
# Qwen
.qwen
.qwenignore
# Build artifacts
dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test results
test-results/
coverage.out
coverage.html
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Temporary files
tmp/
temp/
*.tmp
*.bak
# Docker compose override
docker-compose.override.yml

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.aider*

64
Dockerfile Normal file
View File

@ -0,0 +1,64 @@
# Production runtime image for ja4sentinel
# Based on architecture.yml ci_cd.docker.images.ja4sentinel-runtime
# Build stage
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
git \
make \
libpcap-dev \
gcc \
musl-dev \
linux-headers
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum* ./
# Download dependencies
RUN go mod download || true
# Copy source code
COPY . .
# Build binary
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG GIT_COMMIT=unknown
RUN mkdir -p dist && \
CGO_ENABLED=1 GOOS=linux go build -buildvcs=false \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \
-o dist/ja4sentinel ./cmd/ja4sentinel
# Runtime stage
FROM alpine:latest
# Install runtime dependencies (libpcap for packet capture)
RUN apk add --no-cache \
libpcap \
ca-certificates
# Create non-root user for security
RUN addgroup -S ja4sentinel && adduser -S ja4sentinel -G ja4sentinel
# Create necessary directories
RUN mkdir -p /var/lib/ja4sentinel /var/run /etc/ja4sentinel /var/log/ja4sentinel
# Copy binary from build stage
COPY --from=builder /app/dist/ja4sentinel /usr/local/bin/ja4sentinel
# Set ownership
RUN chown -R ja4sentinel:ja4sentinel /var/lib/ja4sentinel /var/log/ja4sentinel
# Switch to non-root user
USER ja4sentinel
# Working directory
WORKDIR /var/lib/ja4sentinel
# Default command
ENTRYPOINT ["/usr/local/bin/ja4sentinel"]

30
Dockerfile.dev Normal file
View File

@ -0,0 +1,30 @@
# Development and test image for ja4sentinel
# Based on architecture.yml ci_cd.docker.images.ja4sentinel-dev
FROM golang:1.24-alpine
# Install build dependencies including libpcap for packet capture
RUN apk add --no-cache \
git \
make \
libpcap-dev \
gcc \
musl-dev \
linux-headers
# Set working directory
WORKDIR /app
# Copy go mod files first for better caching
COPY go.mod go.sum* ./
# Download dependencies
RUN go mod download || true
# Copy source code
COPY . .
# Download dependencies again to ensure all are present
RUN go mod tidy
# Default command: run tests
CMD ["make", "test"]

105
Dockerfile.test-server Normal file
View File

@ -0,0 +1,105 @@
# Test server for generating TLS traffic in integration tests
FROM golang:1.23-alpine
WORKDIR /app
# Create a simple TLS server for testing
RUN cat > main.go << 'EOF'
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"fmt"
"log"
"math/big"
"net"
"net/http"
"time"
)
func main() {
port := flag.String("port", "8443", "Port to listen on")
flag.Parse()
// Generate self-signed certificate
cert, err := generateSelfSignedCert()
if err != nil {
log.Fatalf("Failed to generate certificate: %v", err)
}
config := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
listener, err := tls.Listen("tcp", ":"+*port, config)
if err != nil {
log.Fatalf("Failed to start TLS listener: %v", err)
}
defer listener.Close()
log.Printf("TLS test server listening on port %s", *port)
http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello from TLS test server"))
}))
}
func generateSelfSignedCert() (tls.Certificate, error) {
// Generate private key
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"JA4Sentinel Test"},
CommonName: "localhost",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
DNSNames: []string{"localhost"},
}
// Create certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
// Encode certificate
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
// Encode private key
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(priv),
})
// Load certificate
return tls.X509KeyPair(certPEM, keyPEM)
}
EOF
RUN go mod init test-server && go mod tidy
EXPOSE 8443
CMD ["go", "run", "main.go", "-port", "8443"]

103
Makefile Normal file
View File

@ -0,0 +1,103 @@
.PHONY: build build-docker test test-docker test-integration lint clean help docker-build-dev docker-build-runtime
# Docker parameters
DOCKER=docker
DOCKER_BUILD=$(DOCKER) build
DOCKER_RUN=$(DOCKER) run
DOCKER_COMPOSE=docker compose
# Image names
DEV_IMAGE=ja4sentinel-dev:latest
RUNTIME_IMAGE=ja4sentinel-runtime:latest
TEST_SERVER_IMAGE=ja4sentinel-test-server:latest
# Binary name
BINARY_NAME=ja4sentinel
BINARY_PATH=./cmd/ja4sentinel
DIST_DIR=dist
# Build flags
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)"
# Default target
all: docker-build-dev test-docker
## build: Build the ja4sentinel binary locally
build:
mkdir -p $(DIST_DIR)
go build -buildvcs=false $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(BINARY_PATH)
## build-linux: Build for Linux (amd64)
build-linux:
mkdir -p $(DIST_DIR)
GOOS=linux GOARCH=amd64 go build -buildvcs=false $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
## docker-build-dev: Build the development Docker image
docker-build-dev:
$(DOCKER_BUILD) -t $(DEV_IMAGE) -f Dockerfile.dev .
## docker-build-runtime: Build the runtime Docker image (multi-stage build)
docker-build-runtime:
$(DOCKER_BUILD) -t $(RUNTIME_IMAGE) -f Dockerfile .
## docker-build-test-server: Build the test server image
docker-build-test-server:
$(DOCKER_BUILD) -t $(TEST_SERVER_IMAGE) -f Dockerfile.test-server .
## test: Run unit tests locally
test:
go test -v ./...
## test-docker: Run unit tests inside Docker container
test-docker: docker-build-dev
$(DOCKER_RUN) --rm -v $(PWD):/app -w /app $(DEV_IMAGE) go test -v ./...
## test-race: Run tests with race detector in Docker
test-race: docker-build-dev
$(DOCKER_RUN) --rm -v $(PWD):/app -w /app $(DEV_IMAGE) go test -race -v ./...
## test-coverage: Run tests with coverage report in Docker
test-coverage: docker-build-dev
$(DOCKER_RUN) --rm -v $(PWD):/app -w /app $(DEV_IMAGE) sh -c \
"go test -v -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html"
## test-integration: Run integration tests in Docker
test-integration: docker-build-dev docker-build-test-server
$(DOCKER_COMPOSE) -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from ja4sentinel-test
## test-integration-clean: Run integration tests and clean up afterward
test-integration-clean: docker-build-dev docker-build-test-server
$(DOCKER_COMPOSE) -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from ja4sentinel-test
$(DOCKER_COMPOSE) -f docker-compose.test.yml down -v
## lint: Run linters in Docker
lint: docker-build-dev
$(DOCKER_RUN) --rm -v $(PWD):/app -w /app $(DEV_IMAGE) sh -c \
"go vet ./... && echo 'Running gofmt check...' && gofmt -l . | grep -v '^vendor/' | grep -v '^path/' || true"
## fmt: Format all Go files
fmt:
gofmt -w .
## clean: Clean build artifacts and Docker images
clean:
rm -rf $(DIST_DIR)/
rm -f coverage.out coverage.html
$(DOCKER) rmi $(DEV_IMAGE) 2>/dev/null || true
$(DOCKER) rmi $(RUNTIME_IMAGE) 2>/dev/null || true
$(DOCKER) rmi $(TEST_SERVER_IMAGE) 2>/dev/null || true
## clean-all: Clean everything including containers and volumes
clean-all: clean
$(DOCKER_COMPOSE) -f docker-compose.test.yml down -v --remove-orphans
## help: Show this help message
help:
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'

276
README.md Normal file
View File

@ -0,0 +1,276 @@
# JA4Sentinel
Outil Go pour capturer le trafic réseau sur un serveur Linux, extraire les handshakes TLS côté client, générer les signatures JA4, enrichir avec des métadonnées IP/TCP, et loguer les résultats vers une ou plusieurs sorties configurables.
## Fonctionnalités
- **Capture réseau** : Écoute sur une interface réseau avec filtres BPF configurables
- **Parsing TLS** : Extraction des ClientHello TLS depuis les flux TCP
- **Fingerprinting** : Génération des empreintes JA4 et JA3 pour chaque client
- **Métadonnées** : Enrichissement avec IPMeta (TTL, IP ID, DF) et TCPMeta (window, MSS, options)
- **Sorties multiples** : stdout, fichier JSON, socket UNIX (combinables via MultiWriter)
- **Logging structuré** : Logs JSON sur stdout/stderr pour intégration avec systemd/journald
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Capture │ ──▶ │ TLSParse │ ──▶ │ Fingerprint │ ──▶ │ Output │
│ (pcap) │ │ (ClientHello)│ │ (JA4) │ │ (JSON logs) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
api.RawPacket api.TLSClientHello api.Fingerprints api.LogRecord
```
### Modules
| Module | Responsabilités |
|--------|-----------------|
| `config` | Chargement et validation de la configuration (YAML, env, CLI) |
| `capture` | Capture des paquets réseau via libpcap |
| `tlsparse` | Extraction des ClientHello TLS avec suivi d'état de flux |
| `fingerprint` | Génération JA4/JA3 via `psanford/tlsfingerprint` |
| `output` | Écriture des logs vers stdout, fichier, socket UNIX |
| `logging` | Logs structurés JSON pour le diagnostic du service |
## Installation
### Prérequis
- Go 1.24+
- libpcap-dev (pour la capture réseau)
- Docker (pour les tests et le déploiement)
### Build local
```bash
make build
```
### Build Docker
```bash
# Image de développement
make docker-build-dev
# Image runtime (production)
make docker-build-runtime
```
## Configuration
### Fichier de configuration (YAML)
```yaml
core:
interface: eth0
listen_ports: [443, 8443]
bpf_filter: ""
flow_timeout_sec: 30
outputs:
- type: stdout
enabled: true
- type: file
enabled: true
params:
path: /var/log/ja4sentinel/ja4.log
- type: unix_socket
enabled: true
params:
socket_path: /var/run/ja4sentinel.sock
```
### Variables d'environnement
| Variable | Description |
|----------|-------------|
| `JA4SENTINEL_INTERFACE` | Interface réseau (ex: `eth0`) |
| `JA4SENTINEL_PORTS` | Ports à surveiller (ex: `443,8443`) |
| `JA4SENTINEL_BPF_FILTER` | Filtre BPF personnalisé |
| `JA4SENTINEL_FLOW_TIMEOUT` | Timeout de flux en secondes (défaut: 30) |
### Ligne de commande
```bash
ja4sentinel --config /etc/ja4sentinel/config.yml
ja4sentinel --version
```
## Format des logs
### Logs de service (stdout/stderr)
```json
{
"timestamp": 1708876543210000000,
"level": "INFO",
"component": "capture",
"message": "Starting packet capture",
"interface": "eth0"
}
```
### Logs métier (JA4)
```json
{
"src_ip": "192.168.1.100",
"src_port": 54321,
"dst_ip": "10.0.0.1",
"dst_port": 443,
"ip_meta_ttl": 64,
"ip_meta_total_length": 512,
"ip_meta_id": 12345,
"ip_meta_df": true,
"tcp_meta_window_size": 65535,
"tcp_meta_mss": 1460,
"tcp_meta_window_scale": 7,
"tcp_meta_options": "MSS,WS,SACK,TS",
"ja4": "t13d1516h2_8daaf6152771_02cb136f2775",
"ja4_hash": "8daaf6152771_02cb136f2775",
"ja3": "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
"ja3_hash": "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d"
}
```
## Tests
### Tests unitaires
```bash
# En local
make test
# Dans Docker
make test-docker
# Avec détection de race conditions
make test-race
# Avec rapport de couverture
make test-coverage
```
### Tests d'intégration
```bash
# Lance les tests bout-à-bout dans Docker
make test-integration
# Nettoyage après tests
make test-integration-clean
```
## Déploiement systemd
Exemple de fichier de service `/etc/systemd/system/ja4sentinel.service` :
```ini
[Unit]
Description=JA4 client fingerprinting daemon
After=network.target
[Service]
Type=simple
User=ja4sentinel
Group=ja4sentinel
ExecStart=/usr/local/bin/ja4sentinel --config /etc/ja4sentinel/config.yml
Restart=on-failure
RestartSec=5
Environment=JA4SENTINEL_LOG_LEVEL=info
# Security
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
```
## Exemples d'utilisation
### Surveillance du trafic HTTPS
```yaml
core:
interface: eth0
listen_ports: [443]
outputs:
- type: stdout
enabled: true
```
### Export vers socket UNIX pour traitement externe
```yaml
core:
interface: eth0
listen_ports: [443, 8443]
outputs:
- type: unix_socket
enabled: true
params:
socket_path: /var/run/ja4sentinel.sock
```
### Logging fichier + stdout
```yaml
core:
interface: ens192
listen_ports: [443]
flow_timeout_sec: 60
outputs:
- type: stdout
enabled: true
- type: file
enabled: true
params:
path: /var/log/ja4sentinel/ja4.json
```
## Développement
### Linting
```bash
make lint
```
### Formatage
```bash
make fmt
```
### Nettoyage
```bash
# Supprime les binaires et images Docker
make clean
# Supprime aussi les conteneurs et volumes
make clean-all
```
## Licence
À définir.
## Contribuer
1. Fork le projet
2. Créer une branche de feature (`git checkout -b feature/amélioration`)
3. Commit les changements (`git commit -am 'Ajout fonctionnalité'`)
4. Push (`git push origin feature/amélioration`)
5. Ouvrir une Pull Request
---
**Voir `architecture.yml` pour la documentation complète de l'architecture.**

View File

@ -1,22 +1,22 @@
package api package api
import (
"time"
)
// ServiceLog represents internal service logging for diagnostics // ServiceLog represents internal service logging for diagnostics
type ServiceLog struct { type ServiceLog struct {
Level string `json:"level"` Level string `json:"level"`
Component string `json:"component"` Component string `json:"component"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]string `json:"details,omitempty"` Details map[string]string `json:"details,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"` // Unix nanoseconds (auto-set by logger)
TraceID string `json:"trace_id,omitempty"` // Optional distributed tracing ID
ConnID string `json:"conn_id,omitempty"` // Optional TCP flow identifier
} }
// Config holds basic network and TLS configuration // Config holds basic network and TLS configuration
type Config struct { type Config struct {
Interface string `json:"interface"` Interface string `json:"interface"`
ListenPorts []uint16 `json:"listen_ports"` ListenPorts []uint16 `json:"listen_ports"`
BPFFilter string `json:"bpf_filter,omitempty"` BPFFilter string `json:"bpf_filter,omitempty"`
FlowTimeoutSec int `json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30)
} }
// IPMeta contains IP metadata for stack fingerprinting // IPMeta contains IP metadata for stack fingerprinting
@ -37,18 +37,18 @@ type TCPMeta struct {
// RawPacket represents a raw packet captured from the network // RawPacket represents a raw packet captured from the network
type RawPacket struct { type RawPacket struct {
Data []byte `json:"-"` // Not serialized Data []byte `json:"-"` // Not serialized
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
} }
// TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata // TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata
type TLSClientHello struct { type TLSClientHello struct {
SrcIP string `json:"src_ip"` SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"` SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"` DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"` DstPort uint16 `json:"dst_port"`
Payload []byte `json:"-"` // Not serialized Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"` IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"` TCPMeta TCPMeta `json:"tcp_meta"`
} }
@ -62,10 +62,10 @@ type Fingerprints struct {
// LogRecord is the final log record, serialized as a flat JSON object // LogRecord is the final log record, serialized as a flat JSON object
type LogRecord struct { type LogRecord struct {
SrcIP string `json:"src_ip"` SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"` SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"` DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"` DstPort uint16 `json:"dst_port"`
// Flattened IPMeta fields // Flattened IPMeta fields
IPTTL uint8 `json:"ip_meta_ttl"` IPTTL uint8 `json:"ip_meta_ttl"`
@ -107,11 +107,13 @@ type Loader interface {
// Capture interface provides raw network packets // Capture interface provides raw network packets
type Capture interface { type Capture interface {
Run(cfg Config, out chan<- RawPacket) error Run(cfg Config, out chan<- RawPacket) error
Close() error
} }
// Parser converts RawPacket to TLSClientHello // Parser converts RawPacket to TLSClientHello
type Parser interface { type Parser interface {
Process(pkt RawPacket) (*TLSClientHello, error) Process(pkt RawPacket) (*TLSClientHello, error)
Close() error
} }
// Engine generates JA4 fingerprints from TLS ClientHello // Engine generates JA4 fingerprints from TLS ClientHello
@ -160,18 +162,18 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
} }
rec := LogRecord{ rec := LogRecord{
SrcIP: ch.SrcIP, SrcIP: ch.SrcIP,
SrcPort: ch.SrcPort, SrcPort: ch.SrcPort,
DstIP: ch.DstIP, DstIP: ch.DstIP,
DstPort: ch.DstPort, DstPort: ch.DstPort,
IPTTL: ch.IPMeta.TTL, IPTTL: ch.IPMeta.TTL,
IPTotalLen: ch.IPMeta.TotalLength, IPTotalLen: ch.IPMeta.TotalLength,
IPID: ch.IPMeta.IPID, IPID: ch.IPMeta.IPID,
IPDF: ch.IPMeta.DF, IPDF: ch.IPMeta.DF,
TCPWindow: ch.TCPMeta.WindowSize, TCPWindow: ch.TCPMeta.WindowSize,
TCPMSS: ch.TCPMeta.MSS, TCPMSS: ch.TCPMeta.MSS,
TCPWScale: ch.TCPMeta.WindowScale, TCPWScale: ch.TCPMeta.WindowScale,
TCPOptions: opts, TCPOptions: opts,
} }
if fp != nil { if fp != nil {
@ -199,9 +201,10 @@ func joinStringSlice(slice []string, sep string) string {
// Default values and constants // Default values and constants
const ( const (
DefaultInterface = "eth0" DefaultInterface = "eth0"
DefaultPort = 443 DefaultPort = 443
DefaultBPFFilter = "" DefaultBPFFilter = ""
DefaultFlowTimeout = 30 // seconds
// Logging levels // Logging levels
LogLevelDebug = "DEBUG" LogLevelDebug = "DEBUG"
@ -214,9 +217,10 @@ const (
func DefaultConfig() AppConfig { func DefaultConfig() AppConfig {
return AppConfig{ return AppConfig{
Core: Config{ Core: Config{
Interface: DefaultInterface, Interface: DefaultInterface,
ListenPorts: []uint16{DefaultPort}, ListenPorts: []uint16{DefaultPort},
BPFFilter: DefaultBPFFilter, BPFFilter: DefaultBPFFilter,
FlowTimeoutSec: DefaultFlowTimeout,
}, },
Outputs: []OutputConfig{}, Outputs: []OutputConfig{},
} }

View File

@ -95,13 +95,30 @@ modules:
- "tlsparse" - "tlsparse"
- "fingerprint" - "fingerprint"
- name: logging
path: "internal/logging"
description: "Logs structurés JSON pour le service (stdout/stderr)."
responsibilities:
- "Fournir une fabrique de loggers (LoggerFactory)."
- "Émettre des logs au format JSON lines sur stdout."
- "Supporter les niveaux : debug, info, warn, error."
- "Inclure timestamp, niveau, composant, message et détails optionnels."
allowed_dependencies:
- "api"
forbidden_dependencies:
- "config"
- "capture"
- "tlsparse"
- "fingerprint"
- "output"
- name: cmd_ja4sentinel - name: cmd_ja4sentinel
path: "cmd/ja4sentinel" path: "cmd/ja4sentinel"
description: "Point dentrée de lapplication (main)." description: "Point d'entrée de l'application (main)."
responsibilities: responsibilities:
- "Charger la configuration via le module config." - "Charger la configuration via le module config."
- "Construire les instances des modules (capture, tlsparse, fingerprint, output)." - "Construire les instances des modules (capture, tlsparse, fingerprint, output, logging)."
- "Brancher les modules entre eux selon larchitecture pipeline." - "Brancher les modules entre eux selon l'architecture pipeline."
- "Gérer les signaux système (arrêt propre)." - "Gérer les signaux système (arrêt propre)."
allowed_dependencies: allowed_dependencies:
- "config" - "config"
@ -110,16 +127,29 @@ modules:
- "fingerprint" - "fingerprint"
- "output" - "output"
- "api" - "api"
- "logging"
forbidden_dependencies: [] forbidden_dependencies: []
api: api:
types: types:
- name: "api.ServiceLog"
description: "Log interne du service ja4sentinel (diagnostic)."
fields:
- { name: Level, type: "string", description: "niveau: debug, info, warn, error." }
- { name: Component, type: "string", description: "module concerné (capture, tlsparse, ...)." }
- { name: Message, type: "string", description: "texte du log." }
- { name: Details, type: "map[string]string", description: "infos additionnelles (erreurs, IDs...)." }
- { name: Timestamp, type: "int64", description: "Timestamp en nanosecondes (auto-rempli par le logger)." }
- { name: TraceID, type: "string", description: "ID de tracing distribué (optionnel)." }
- { name: ConnID, type: "string", description: "Identifiant de flux TCP (optionnel)." }
- name: "api.Config" - name: "api.Config"
description: "Configuration réseau et TLS de base." description: "Configuration réseau et TLS de base."
fields: fields:
- { name: Interface, type: "string", description: "Nom de linterface réseau (ex: eth0)." } - { name: Interface, type: "string", description: "Nom de l'interface réseau (ex: eth0)." }
- { name: ListenPorts, type: "[]uint16", description: "Ports TCP à surveiller (ex: [443, 8443])." } - { name: ListenPorts, type: "[]uint16", description: "Ports TCP à surveiller (ex: [443, 8443])." }
- { name: BPFFilter, type: "string", description: "Filtre BPF optionnel pour la capture." } - { name: BPFFilter, type: "string", description: "Filtre BPF optionnel pour la capture." }
- { name: FlowTimeoutSec, type: "int", description: "Timeout en secondes pour l'extraction du handshake TLS (défaut: 30)." }
- name: "api.IPMeta" - name: "api.IPMeta"
description: "Métadonnées IP pour fingerprinting de stack." description: "Métadonnées IP pour fingerprinting de stack."
@ -163,15 +193,32 @@ api:
- { name: JA3Hash, type: "string", description: "Hash JA3 (optionnel)." } - { name: JA3Hash, type: "string", description: "Hash JA3 (optionnel)." }
- name: "api.LogRecord" - name: "api.LogRecord"
description: "Enregistrement de log final envoyé vers les outputs." description: "Enregistrement de log final, sérialisé en JSON objet plat."
json_object: true
fields: fields:
- { name: SrcIP, type: "string", description: "Adresse IP source (client)." } - { name: SrcIP, type: "string", json_key: "src_ip" }
- { name: SrcPort, type: "uint16", description: "Port source (client)." } - { name: SrcPort, type: "uint16", json_key: "src_port" }
- { name: DstIP, type: "string", description: "Adresse IP destination (serveur)." } - { name: DstIP, type: "string", json_key: "dst_ip" }
- { name: DstPort, type: "uint16", description: "Port destination (serveur)." } - { name: DstPort, type: "uint16", json_key: "dst_port" }
- { name: IPMeta, type: "api.IPMeta", description: "Métadonnées IP." }
- { name: TCPMeta, type: "api.TCPMeta", description: "Métadonnées TCP." } # IPMeta flatten
- { name: Fingerprints, type: "api.Fingerprints", description: "Empreintes JA4/JA3 associées." } - { name: IPTTL, type: "uint8", json_key: "ip_meta_ttl" }
- { name: IPTotalLen, type: "uint16", json_key: "ip_meta_total_length" }
- { name: IPID, type: "uint16", json_key: "ip_meta_id" }
- { name: IPDF, type: "bool", json_key: "ip_meta_df" }
# TCPMeta flatten
- { name: TCPWindow, type: "uint16", json_key: "tcp_meta_window_size" }
- { name: TCPMSS, type: "uint16", json_key: "tcp_meta_mss" }
- { name: TCPWScale, type: "uint8", json_key: "tcp_meta_window_scale" }
- { name: TCPOptions, type: "string", json_key: "tcp_meta_options" }
# Fingerprints
- { name: JA4, type: "string", json_key: "ja4" }
- { name: JA4Hash, type: "string", json_key: "ja4_hash" }
- { name: JA3, type: "string", json_key: "ja3" }
- { name: JA3Hash, type: "string", json_key: "ja3_hash" }
- name: "api.OutputConfig" - name: "api.OutputConfig"
description: "Configuration dune sortie de logs." description: "Configuration dune sortie de logs."
@ -278,6 +325,49 @@ api:
notes: notes:
- "Doit supporter plusieurs outputs simultanés via un MultiWriter." - "Doit supporter plusieurs outputs simultanés via un MultiWriter."
- name: "logging.LoggerFactory"
description: "Fabrique de loggers structurés JSON."
module: "logging"
methods:
- name: "NewLogger"
params:
- { name: level, type: "string" }
returns:
- { type: "api.Logger" }
- name: "NewDefaultLogger"
params: []
returns:
- { type: "api.Logger" }
notes:
- "Les logs sont émis en JSON lines sur stdout pour systemd/journald."
- name: "api.Logger"
description: "Interface de logging pour tous les modules."
module: "logging"
methods:
- name: "Debug"
params:
- { name: component, type: "string" }
- { name: message, type: "string" }
- { name: details, type: "map[string]string" }
- name: "Info"
params:
- { name: component, type: "string" }
- { name: message, type: "string" }
- { name: details, type: "map[string]string" }
- name: "Warn"
params:
- { name: component, type: "string" }
- { name: message, type: "string" }
- { name: details, type: "map[string]string" }
- name: "Error"
params:
- { name: component, type: "string" }
- { name: message, type: "string" }
- { name: details, type: "map[string]string" }
notes:
- "Tous les logs passent par stdout/stderr (pas de fichiers directs)."
architecture: architecture:
style: "pipeline" style: "pipeline"
flow: flow:
@ -479,3 +569,89 @@ dev_tools:
- "Génération automatique de Dockerfile.dev et Dockerfile à partir de cette section." - "Génération automatique de Dockerfile.dev et Dockerfile à partir de cette section."
- "Génération de fichiers docker-compose.test.yml pour les scénarios dintégration." - "Génération de fichiers docker-compose.test.yml pour les scénarios dintégration."
service:
systemd:
unit_name: "ja4sentinel.service"
description: "JA4 client fingerprinting daemon"
wanted_by: "multi-user.target"
exec:
binary_path: "/usr/local/bin/ja4sentinel"
args:
- "--config"
- "/etc/ja4sentinel/config.yml"
user_group:
user: "ja4sentinel"
group: "ja4sentinel"
runtime:
working_directory: "/var/lib/ja4sentinel"
pid_file: "/run/ja4sentinel.pid"
restart: "on-failure"
restart_sec: 5
environment_prefix: "JA4SENTINEL_"
logging:
type: "journald"
journal_identifier: "ja4sentinel"
expectations:
- "Le binaire écrit les logs de service sur stdout/stderr."
- "Les messages doivent inclure au minimum un niveau (INFO/ERROR) et un composant."
security:
capabilities:
- "CAP_NET_RAW"
- "CAP_NET_ADMIN"
sandboxing:
- "NoNewPrivileges=yes"
- "ProtectSystem=full"
- "ProtectHome=true"
- "PrivateTmp=true"
integration_rules:
- "Le binaire doit sarrêter proprement sur SIGTERM (systemd stop)."
- "Le module cmd_ja4sentinel gère les signaux et termine la capture proprement."
- "Les chemins (config, socket UNIX, logs) doivent être compatibles avec FHS (/etc, /var/run, /var/log)."
- "Le module cmd_ja4sentinel capture SIGTERM/SIGINT et déclenche un arrêt propre (stop capture, flush outputs, fermer socket UNIX)."
- "Le processus doit retourner un code de sortie non nul en cas derreur fatale au démarrage."
logging:
strategy:
description: >
ja4sentinel écrit ses logs techniques sur stdout/stderr au format JSON lines,
afin que systemd/journald puissent les collecter et les filtrer.
format: "json_lines"
fields:
- "timestamp"
- "level"
- "component" # capture, tlsparse, fingerprint, output, service, ...
- "message"
- "trace_id" # optionnel
- "conn_id" # optionnel (identifiant de flux TCP)
rules:
- "Pas décriture directe dans des fichiers de log techniques depuis le code (stdout/stderr uniquement)."
- "Les logs techniques du daemon passent par stdout/stderr (systemd/journald)."
- "Les outputs métiers (LogRecord JA4) sont gérés par le module output, vers socket UNIX et/ou fichier JSON."
json_format:
description: >
Les LogRecord métiers (JA4 + métadonnées) sont sérialisés en JSON objet plat,
avec des champs nommés explicitement pour ingestion dans ClickHouse.
rules:
- "Pas de tableaux imbriqués ni dobjets deeply nested."
- "Toutes les métadonnées IP/TCP sont flatten sous forme de champs scalaires nommés."
- "Les noms de champs suivent la convention: ip_meta_*, tcp_meta_*, ja4*."
logrecord_schema:
# Exemple de mapping pour api.LogRecord (résumé)
- "src_ip"
- "src_port"
- "dst_ip"
- "dst_port"
- "ip_meta_ttl"
- "ip_meta_total_length"
- "ip_meta_id"
- "ip_meta_df"
- "tcp_meta_window_size"
- "tcp_meta_mss"
- "tcp_meta_window_scale"
- "tcp_meta_options" # string joinée, ex: 'MSS,SACK,TS,NOP,WS'
- "ja4"
- "ja4_hash"
- "ja3"
- "ja3_hash"

189
cmd/ja4sentinel/main.go Normal file
View File

@ -0,0 +1,189 @@
// Package main provides the entry point for ja4sentinel
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"ja4sentinel/api"
"ja4sentinel/internal/capture"
"ja4sentinel/internal/config"
"ja4sentinel/internal/fingerprint"
"ja4sentinel/internal/logging"
"ja4sentinel/internal/output"
"ja4sentinel/internal/tlsparse"
)
// Version information (set via ldflags)
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() {
// Parse command line flags
configPath := flag.String("config", "", "Path to configuration file (YAML)")
version := flag.Bool("version", false, "Print version information")
flag.Parse()
if *version {
fmt.Printf("ja4sentinel version %s\n", Version)
fmt.Printf("Build time: %s\n", BuildTime)
fmt.Printf("Git commit: %s\n", GitCommit)
os.Exit(0)
}
// Initialize logger factory
loggerFactory := &logging.LoggerFactory{}
logger := loggerFactory.NewDefaultLogger()
logger.Info("service", "Starting ja4sentinel", map[string]string{
"version": Version,
})
// Load configuration
configLoader := config.NewLoader(*configPath)
cfg, err := configLoader.Load()
if err != nil {
logger.Error("service", "Failed to load configuration", map[string]string{
"error": err.Error(),
})
os.Exit(1)
}
logger.Info("config", "Configuration loaded", map[string]string{
"interface": cfg.Core.Interface,
"ports": fmt.Sprintf("%v", cfg.Core.ListenPorts),
"bpf_filter": cfg.Core.BPFFilter,
"num_outputs": fmt.Sprintf("%d", len(cfg.Outputs)),
})
// Build output writer
outputBuilder := output.NewBuilder()
writer, err := outputBuilder.NewFromConfig(cfg)
if err != nil {
logger.Error("output", "Failed to create output writer", map[string]string{
"error": err.Error(),
})
os.Exit(1)
}
// Create pipeline components
captureImpl := capture.New()
parser := tlsparse.NewParserWithTimeout(time.Duration(cfg.Core.FlowTimeoutSec) * time.Second)
engine := fingerprint.NewEngine()
// Create channels for pipeline
packetChan := make(chan api.RawPacket, 1000)
helloChan := make(chan api.TLSClientHello, 1000)
errorChan := make(chan error, 100)
// Setup signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start capture goroutine
go func() {
logger.Info("capture", "Starting packet capture", map[string]string{
"interface": cfg.Core.Interface,
})
err := captureImpl.Run(cfg.Core, packetChan)
if err != nil {
errorChan <- fmt.Errorf("capture error: %w", err)
}
}()
// Start TLS parsing goroutine
go func() {
for pkt := range packetChan {
hello, err := parser.Process(pkt)
if err != nil {
logger.Warn("tlsparse", "Failed to parse packet", map[string]string{
"error": err.Error(),
})
continue
}
if hello != nil {
logger.Debug("tlsparse", "ClientHello extracted", map[string]string{
"src_ip": hello.SrcIP,
"src_port": fmt.Sprintf("%d", hello.SrcPort),
"dst_ip": hello.DstIP,
"dst_port": fmt.Sprintf("%d", hello.DstPort),
})
helloChan <- *hello
}
}
}()
// Start fingerprinting and output goroutine
go func() {
for hello := range helloChan {
fingerprints, err := engine.FromClientHello(hello)
if err != nil {
logger.Warn("fingerprint", "Failed to generate fingerprints", map[string]string{
"error": err.Error(),
"src_ip": hello.SrcIP,
})
continue
}
logger.Debug("fingerprint", "JA4 generated", map[string]string{
"src_ip": hello.SrcIP,
"ja4": fingerprints.JA4,
})
// Create log record and write
rec := api.NewLogRecord(hello, fingerprints)
if err := writer.Write(rec); err != nil {
logger.Error("output", "Failed to write log record", map[string]string{
"error": err.Error(),
})
}
}
}()
// Wait for shutdown signal or error
select {
case sig := <-sigChan:
logger.Info("service", "Received shutdown signal", map[string]string{
"signal": sig.String(),
})
case err := <-errorChan:
logger.Error("service", "Pipeline error", map[string]string{
"error": err.Error(),
})
}
// Graceful shutdown
logger.Info("service", "Shutting down", nil)
// Close output writer
if closer, ok := writer.(interface{ CloseAll() error }); ok {
if err := closer.CloseAll(); err != nil {
logger.Error("output", "Error closing writer", map[string]string{
"error": err.Error(),
})
}
}
// Close parser (stops cleanup goroutine)
if err := parser.Close(); err != nil {
logger.Error("tlsparse", "Error closing parser", map[string]string{
"error": err.Error(),
})
}
// Close capture
if err := captureImpl.Close(); err != nil {
logger.Error("capture", "Error closing capture", map[string]string{
"error": err.Error(),
})
}
logger.Info("service", "ja4sentinel stopped", nil)
}

32
config.yml.example Normal file
View File

@ -0,0 +1,32 @@
# Sample configuration file for ja4sentinel
# Copy to config.yml and adjust as needed
core:
# Network interface to capture traffic from
interface: eth0
# TCP ports to monitor for TLS handshakes
listen_ports:
- 443
- 8443
# Optional BPF filter (leave empty for auto-generated filter based on listen_ports)
bpf_filter: ""
outputs:
# Output to stdout (JSON lines)
- type: stdout
enabled: true
params: {}
# Output to file
# - type: file
# enabled: false
# params:
# path: /var/log/ja4sentinel/ja4.log
# Output to UNIX socket (for systemd/journald or other consumers)
# - type: unix_socket
# enabled: false
# params:
# socket_path: /var/run/ja4sentinel.sock

49
docker-compose.test.yml Normal file
View File

@ -0,0 +1,49 @@
# Docker Compose for integration testing
# Based on architecture.yml testing.levels.integration
version: '3.8'
services:
# TLS test server for generating test traffic
tls-server:
build:
context: .
dockerfile: Dockerfile.test-server
image: ja4sentinel-test-server:latest
networks:
- test-network
ports:
- "8443:8443"
command: ["-port", "8443"]
# ja4sentinel integration test runner
ja4sentinel-test:
build:
context: .
dockerfile: Dockerfile.dev
image: ja4sentinel-dev:latest
networks:
- test-network
cap_add:
- NET_RAW
- NET_ADMIN
volumes:
- ./test-results:/app/test-results
environment:
- JA4SENTINEL_INTERFACE=eth0
- JA4SENTINEL_PORTS=8443
depends_on:
- tls-server
command: ["make", "test-integration"]
# Test client that generates TLS traffic
tls-client:
image: curlimages/curl:latest
networks:
- test-network
depends_on:
- tls-server
command: ["curl", "-kv", "https://tls-server:8443/"]
networks:
test-network:
driver: bridge

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module ja4sentinel
go 1.24.6
toolchain go1.24.13
require (
github.com/google/gopacket v1.1.19
github.com/psanford/tlsfingerprint v0.0.0-20251111180026-c742e470de9b
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.0.0-20190412213103-97732733099d // indirect

22
go.sum Normal file
View File

@ -0,0 +1,22 @@
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/psanford/tlsfingerprint v0.0.0-20251111180026-c742e470de9b h1:fsP7F1zLHZ4ozxhesg4j8qSsaJxK7Ev9fA2cUtbThec=
github.com/psanford/tlsfingerprint v0.0.0-20251111180026-c742e470de9b/go.mod h1:F7HlBxc/I5XX6syuwpDtffw/6J+d0Q2xcUhYSbx/0Uw=
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/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,209 +0,0 @@
package api
import (
"time"
)
// ServiceLog represents internal service logging for diagnostics
type ServiceLog struct {
Level string `json:"level"`
Component string `json:"component"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// Config holds basic network and TLS configuration
type Config struct {
Interface string `json:"interface"`
ListenPorts []uint16 `json:"listen_ports"`
BPFFilter string `json:"bpf_filter,omitempty"`
}
// IPMeta contains IP metadata for stack fingerprinting
type IPMeta struct {
TTL uint8 `json:"ttl"`
TotalLength uint16 `json:"total_length"`
IPID uint16 `json:"id"`
DF bool `json:"df"`
}
// TCPMeta contains TCP metadata for stack fingerprinting
type TCPMeta struct {
WindowSize uint16 `json:"window_size"`
MSS uint16 `json:"mss,omitempty"`
WindowScale uint8 `json:"window_scale,omitempty"`
Options []string `json:"options"`
}
// RawPacket represents a raw packet captured from the network
type RawPacket struct {
Data []byte `json:"-"` // Not serialized
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
}
// TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata
type TLSClientHello struct {
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"`
}
// Fingerprints contains TLS fingerprints for a client flow
type Fingerprints struct {
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
// LogRecord is the final log record, serialized as a flat JSON object
type LogRecord struct {
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
// Flattened IPMeta fields
IPTTL uint8 `json:"ip_meta_ttl"`
IPTotalLen uint16 `json:"ip_meta_total_length"`
IPID uint16 `json:"ip_meta_id"`
IPDF bool `json:"ip_meta_df"`
// Flattened TCPMeta fields
TCPWindow uint16 `json:"tcp_meta_window_size"`
TCPMSS uint16 `json:"tcp_meta_mss,omitempty"`
TCPWScale uint8 `json:"tcp_meta_window_scale,omitempty"`
TCPOptions string `json:"tcp_meta_options"` // comma-separated list
// Fingerprints
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
// OutputConfig defines configuration for a single log output
type OutputConfig struct {
Type string `json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc.
}
// AppConfig is the complete ja4sentinel configuration
type AppConfig struct {
Core Config `json:"core"`
Outputs []OutputConfig `json:"outputs"`
}
// Loader interface loads configuration from file/env/CLI
type Loader interface {
Load() (AppConfig, error)
}
// Capture interface provides raw network packets
type Capture interface {
Run(cfg Config, out chan<- RawPacket) error
}
// Parser converts RawPacket to TLSClientHello
type Parser interface {
Process(pkt RawPacket) (*TLSClientHello, error)
}
// Engine generates JA4 fingerprints from TLS ClientHello
type Engine interface {
FromClientHello(ch TLSClientHello) (*Fingerprints, error)
}
// Writer is the generic interface for writing results
type Writer interface {
Write(rec LogRecord) error
}
// UnixSocketWriter implements Writer sending logs to a UNIX socket
type UnixSocketWriter interface {
Writer
Close() error
}
// MultiWriter combines multiple Writers
type MultiWriter interface {
Writer
Add(writer Writer)
CloseAll() error
}
// Builder constructs Writers from AppConfig
type Builder interface {
NewFromConfig(cfg AppConfig) (Writer, error)
}
// Helper functions for creating and converting records
// NewLogRecord creates a LogRecord from TLSClientHello and Fingerprints
func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts := ""
if len(ch.TCPMeta.Options) > 0 {
opts = joinStringSlice(ch.TCPMeta.Options, ",")
}
rec := LogRecord{
SrcIP: ch.SrcIP,
SrcPort: ch.SrcPort,
DstIP: ch.DstIP,
DstPort: ch.DstPort,
IPTTL: ch.IPMeta.TTL,
IPTotalLen: ch.IPMeta.TotalLength,
IPID: ch.IPMeta.IPID,
IPDF: ch.IPMeta.DF,
TCPWindow: ch.TCPMeta.WindowSize,
TCPMSS: ch.TCPMeta.MSS,
TCPWScale: ch.TCPMeta.WindowScale,
TCPOptions: opts,
}
if fp != nil {
rec.JA4 = fp.JA4
rec.JA4Hash = fp.JA4Hash
rec.JA3 = fp.JA3
rec.JA3Hash = fp.JA3Hash
}
return rec
}
// Helper to join string slice with separator
func joinStringSlice(slice []string, sep string) string {
if len(slice) == 0 {
return ""
}
result := slice[0]
for _, s := range slice[1:] {
result += sep + s
}
return result
}
// Default values and constants
const (
DefaultInterface = "eth0"
DefaultPort = 443
DefaultBPFFilter = ""
)
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() AppConfig {
return AppConfig{
Core: Config{
Interface: DefaultInterface,
ListenPorts: []uint16{DefaultPort},
BPFFilter: DefaultBPFFilter,
},
Outputs: []OutputConfig{},
}
}

View File

@ -1,28 +1,26 @@
// Package capture provides network packet capture functionality for ja4sentinel
package capture package capture
import ( import (
"fmt" "fmt"
"net"
"time"
"github.com/google/gopacket" "github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap" "github.com/google/gopacket/pcap"
"ja4sentinel/internal/api" "ja4sentinel/api"
) )
// CaptureImpl implémente l'interface capture.Capture // CaptureImpl implements the capture.Capture interface for packet capture
type CaptureImpl struct { type CaptureImpl struct {
handle *pcap.Handle handle *pcap.Handle
} }
// New crée une nouvelle instance de capture // New creates a new capture instance
func New() *CaptureImpl { func New() *CaptureImpl {
return &CaptureImpl{} return &CaptureImpl{}
} }
// Run démarre la capture des paquets réseau selon la configuration // Run starts network packet capture according to the configuration
func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
var err error var err error
c.handle, err = pcap.OpenLive(cfg.Interface, 1600, true, pcap.BlockForever) c.handle, err = pcap.OpenLive(cfg.Interface, 1600, true, pcap.BlockForever)
@ -31,14 +29,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
} }
defer c.handle.Close() defer c.handle.Close()
// Appliquer le filtre BPF s'il est fourni // Apply BPF filter if provided
if cfg.BPFFilter != "" { if cfg.BPFFilter != "" {
err = c.handle.SetBPFFilter(cfg.BPFFilter) err = c.handle.SetBPFFilter(cfg.BPFFilter)
if err != nil { if err != nil {
return fmt.Errorf("failed to set BPF filter: %w", err) return fmt.Errorf("failed to set BPF filter: %w", err)
} }
} else { } else {
// Créer un filtre par défaut pour les ports surveillés // Create default filter for monitored ports
defaultFilter := buildBPFForPorts(cfg.ListenPorts) defaultFilter := buildBPFForPorts(cfg.ListenPorts)
err = c.handle.SetBPFFilter(defaultFilter) err = c.handle.SetBPFFilter(defaultFilter)
if err != nil { if err != nil {
@ -49,14 +47,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
packetSource := gopacket.NewPacketSource(c.handle, c.handle.LinkType()) packetSource := gopacket.NewPacketSource(c.handle, c.handle.LinkType())
for packet := range packetSource.Packets() { for packet := range packetSource.Packets() {
// Convertir le paquet en RawPacket // Convert packet to RawPacket
rawPkt := packetToRawPacket(packet) rawPkt := packetToRawPacket(packet)
if rawPkt != nil { if rawPkt != nil {
select { select {
case out <- *rawPkt: case out <- *rawPkt:
// Paquet envoyé avec succès // Packet sent successfully
default: default:
// Canal plein, ignorer le paquet // Channel full, drop packet
} }
} }
} }
@ -64,7 +62,7 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
return nil return nil
} }
// buildBPFForPorts construit un filtre BPF pour les ports TCP spécifiés // buildBPFForPorts builds a BPF filter for the specified TCP ports
func buildBPFForPorts(ports []uint16) string { func buildBPFForPorts(ports []uint16) string {
if len(ports) == 0 { if len(ports) == 0 {
return "tcp" return "tcp"
@ -77,7 +75,7 @@ func buildBPFForPorts(ports []uint16) string {
return "(" + joinString(filterParts, ") or (") + ")" return "(" + joinString(filterParts, ") or (") + ")"
} }
// joinString joint des chaînes avec un séparateur // joinString joins strings with a separator
func joinString(parts []string, sep string) string { func joinString(parts []string, sep string) string {
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
@ -89,7 +87,7 @@ func joinString(parts []string, sep string) string {
return result return result
} }
// packetToRawPacket convertit un paquet gopacket en RawPacket // packetToRawPacket converts a gopacket packet to RawPacket
func packetToRawPacket(packet gopacket.Packet) *api.RawPacket { func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
data := packet.Data() data := packet.Data()
if len(data) == 0 { if len(data) == 0 {
@ -102,7 +100,7 @@ func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
} }
} }
// Close ferme correctement la capture // Close properly closes the capture handle
func (c *CaptureImpl) Close() error { func (c *CaptureImpl) Close() error {
if c.handle != nil { if c.handle != nil {
c.handle.Close() c.handle.Close()

View File

@ -2,9 +2,6 @@ package capture
import ( import (
"testing" "testing"
"time"
"ja4sentinel/internal/api"
) )
func TestBuildBPFForPorts(t *testing.T) { func TestBuildBPFForPorts(t *testing.T) {

176
internal/config/loader.go Normal file
View File

@ -0,0 +1,176 @@
// Package config provides configuration loading and validation for ja4sentinel
package config
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"ja4sentinel/api"
)
// LoaderImpl implements the api.Loader interface for configuration loading
type LoaderImpl struct {
configPath string
}
// NewLoader creates a new configuration loader
func NewLoader(configPath string) *LoaderImpl {
return &LoaderImpl{
configPath: configPath,
}
}
// Load reads and merges configuration from file, environment variables, and CLI
func (l *LoaderImpl) Load() (api.AppConfig, error) {
config := api.DefaultConfig()
// Load from YAML file if path is provided
if l.configPath != "" {
fileConfig, err := l.loadFromFile(l.configPath)
if err != nil {
return config, fmt.Errorf("failed to load config file: %w", err)
}
config = mergeConfigs(config, fileConfig)
}
// Override with environment variables
config = l.loadFromEnv(config)
// Validate the final configuration
if err := l.validate(config); err != nil {
return config, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// loadFromFile reads configuration from a YAML file
func (l *LoaderImpl) loadFromFile(path string) (api.AppConfig, error) {
config := api.AppConfig{}
data, err := os.ReadFile(path)
if err != nil {
return config, fmt.Errorf("failed to read config file: %w", err)
}
err = yaml.Unmarshal(data, &config)
if err != nil {
return config, fmt.Errorf("failed to parse config file: %w", err)
}
return config, nil
}
// loadFromEnv overrides configuration with environment variables
func (l *LoaderImpl) loadFromEnv(config api.AppConfig) api.AppConfig {
// JA4SENTINEL_INTERFACE
if val := os.Getenv("JA4SENTINEL_INTERFACE"); val != "" {
config.Core.Interface = val
}
// JA4SENTINEL_PORTS (comma-separated list)
if val := os.Getenv("JA4SENTINEL_PORTS"); val != "" {
ports := parsePorts(val)
if len(ports) > 0 {
config.Core.ListenPorts = ports
}
}
// JA4SENTINEL_BPF_FILTER
if val := os.Getenv("JA4SENTINEL_BPF_FILTER"); val != "" {
config.Core.BPFFilter = val
}
// JA4SENTINEL_FLOW_TIMEOUT (in seconds)
if val := os.Getenv("JA4SENTINEL_FLOW_TIMEOUT"); val != "" {
if timeout, err := strconv.Atoi(val); err == nil && timeout > 0 {
config.Core.FlowTimeoutSec = timeout
}
}
return config
}
// parsePorts parses a comma-separated list of ports
func parsePorts(s string) []uint16 {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
ports := make([]uint16, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
port, err := strconv.ParseUint(part, 10, 16)
if err == nil {
ports = append(ports, uint16(port))
}
}
return ports
}
// mergeConfigs merges two configs, with override taking precedence
func mergeConfigs(base, override api.AppConfig) api.AppConfig {
result := base
if override.Core.Interface != "" {
result.Core.Interface = override.Core.Interface
}
if len(override.Core.ListenPorts) > 0 {
result.Core.ListenPorts = override.Core.ListenPorts
}
if override.Core.BPFFilter != "" {
result.Core.BPFFilter = override.Core.BPFFilter
}
if override.Core.FlowTimeoutSec > 0 {
result.Core.FlowTimeoutSec = override.Core.FlowTimeoutSec
}
if len(override.Outputs) > 0 {
result.Outputs = override.Outputs
}
return result
}
// validate checks if the configuration is valid
func (l *LoaderImpl) validate(config api.AppConfig) error {
if config.Core.Interface == "" {
return fmt.Errorf("interface cannot be empty")
}
if len(config.Core.ListenPorts) == 0 {
return fmt.Errorf("at least one listen port is required")
}
// Validate outputs
for i, output := range config.Outputs {
if output.Type == "" {
return fmt.Errorf("output[%d]: type cannot be empty", i)
}
}
return nil
}
// ToJSON converts config to JSON string for debugging
func ToJSON(config api.AppConfig) string {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Sprintf("error marshaling config: %v", err)
}
return string(data)
}

View File

@ -0,0 +1,213 @@
package config
import (
"os"
"strings"
"testing"
"ja4sentinel/api"
)
func TestParsePorts(t *testing.T) {
tests := []struct {
name string
input string
want []uint16
}{
{
name: "single port",
input: "443",
want: []uint16{443},
},
{
name: "multiple ports",
input: "443, 8443, 9443",
want: []uint16{443, 8443, 9443},
},
{
name: "empty string",
input: "",
want: nil,
},
{
name: "with spaces",
input: " 443 , 8443 ",
want: []uint16{443, 8443},
},
{
name: "invalid port ignored",
input: "443, invalid, 8443",
want: []uint16{443, 8443},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePorts(tt.input)
if len(got) != len(tt.want) {
t.Errorf("parsePorts() length = %v, want %v", len(got), len(tt.want))
return
}
for i, v := range got {
if v != tt.want[i] {
t.Errorf("parsePorts()[%d] = %v, want %v", i, v, tt.want[i])
}
}
})
}
}
func TestMergeConfigs(t *testing.T) {
base := api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
BPFFilter: "",
},
Outputs: []api.OutputConfig{},
}
override := api.AppConfig{
Core: api.Config{
Interface: "lo",
ListenPorts: []uint16{8443},
BPFFilter: "tcp",
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
}
result := mergeConfigs(base, override)
if result.Core.Interface != "lo" {
t.Errorf("Interface = %v, want lo", result.Core.Interface)
}
if len(result.Core.ListenPorts) != 1 || result.Core.ListenPorts[0] != 8443 {
t.Errorf("ListenPorts = %v, want [8443]", result.Core.ListenPorts)
}
if result.Core.BPFFilter != "tcp" {
t.Errorf("BPFFilter = %v, want tcp", result.Core.BPFFilter)
}
if len(result.Outputs) != 1 {
t.Errorf("Outputs length = %v, want 1", len(result.Outputs))
}
}
func TestValidate(t *testing.T) {
loader := &LoaderImpl{}
tests := []struct {
name string
config api.AppConfig
wantErr bool
}{
{
name: "valid config",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
},
wantErr: false,
},
{
name: "empty interface",
config: api.AppConfig{
Core: api.Config{
Interface: "",
ListenPorts: []uint16{443},
},
},
wantErr: true,
},
{
name: "no listen ports",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{},
},
},
wantErr: true,
},
{
name: "output with empty type",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{Type: "", Enabled: true},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := loader.validate(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadFromEnv(t *testing.T) {
// Save original env vars
origInterface := os.Getenv("JA4SENTINEL_INTERFACE")
origPorts := os.Getenv("JA4SENTINEL_PORTS")
origFilter := os.Getenv("JA4SENTINEL_BPF_FILTER")
defer func() {
os.Setenv("JA4SENTINEL_INTERFACE", origInterface)
os.Setenv("JA4SENTINEL_PORTS", origPorts)
os.Setenv("JA4SENTINEL_BPF_FILTER", origFilter)
}()
// Set test env vars
os.Setenv("JA4SENTINEL_INTERFACE", "lo")
os.Setenv("JA4SENTINEL_PORTS", "8443,9443")
os.Setenv("JA4SENTINEL_BPF_FILTER", "tcp port 8443")
loader := &LoaderImpl{}
config := api.DefaultConfig()
result := loader.loadFromEnv(config)
if result.Core.Interface != "lo" {
t.Errorf("Interface = %v, want lo", result.Core.Interface)
}
if len(result.Core.ListenPorts) != 2 {
t.Errorf("ListenPorts length = %v, want 2", len(result.Core.ListenPorts))
}
if result.Core.BPFFilter != "tcp port 8443" {
t.Errorf("BPFFilter = %v, want 'tcp port 8443'", result.Core.BPFFilter)
}
}
func TestToJSON(t *testing.T) {
config := api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443, 8443},
BPFFilter: "tcp",
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true, Params: map[string]string{}},
},
}
jsonStr := ToJSON(config)
if jsonStr == "" {
t.Error("ToJSON() returned empty string")
}
if !strings.Contains(jsonStr, "eth0") {
t.Error("ToJSON() result doesn't contain 'eth0'")
}
}

View File

@ -0,0 +1,64 @@
// Package fingerprint provides JA4/JA3 fingerprint generation for TLS ClientHello
package fingerprint
import (
"fmt"
"ja4sentinel/api"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
// EngineImpl implements the api.Engine interface for fingerprint generation
type EngineImpl struct{}
// NewEngine creates a new fingerprint engine
func NewEngine() *EngineImpl {
return &EngineImpl{}
}
// FromClientHello generates JA4 (and optionally JA3) fingerprints from a TLS ClientHello
func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints, error) {
if len(ch.Payload) == 0 {
return nil, fmt.Errorf("empty ClientHello payload")
}
// Parse the ClientHello using tlsfingerprint
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
if err != nil {
return nil, fmt.Errorf("failed to parse ClientHello: %w", err)
}
// Generate JA4 fingerprint
// Note: JA4 string format already includes the hash portion
// e.g., "t13d1516h2_8daaf6152771_02cb136f2775" where the last part is the SHA256 hash
ja4 := fp.JA4String()
// Generate JA3 fingerprint and its MD5 hash
ja3 := fp.JA3String()
ja3Hash := fp.JA3Hash()
// Extract JA4 hash portion (last segment after underscore)
// JA4 format: <tls_ver><ciphers><extensions>_<sni_hash>_<cipher_extension_hash>
ja4Hash := extractJA4Hash(ja4)
return &api.Fingerprints{
JA4: ja4,
JA4Hash: ja4Hash,
JA3: ja3,
JA3Hash: ja3Hash,
}, nil
}
// extractJA4Hash extracts the hash portion from a JA4 string
// JA4 format: <base>_<sni_hash>_<cipher_hash> -> returns "<sni_hash>_<cipher_hash>"
func extractJA4Hash(ja4 string) string {
// JA4 string format: t13d1516h2_8daaf6152771_02cb136f2775
// We extract everything after the first underscore as the "hash" portion
for i, c := range ja4 {
if c == '_' {
return ja4[i+1:]
}
}
return ""
}

View File

@ -0,0 +1,47 @@
package fingerprint
import (
"testing"
"ja4sentinel/api"
)
func TestFromClientHello(t *testing.T) {
tests := []struct {
name string
ch api.TLSClientHello
wantErr bool
}{
{
name: "empty payload",
ch: api.TLSClientHello{
Payload: []byte{},
},
wantErr: true,
},
{
name: "invalid payload",
ch: api.TLSClientHello{
Payload: []byte{0x00, 0x01, 0x02},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine := NewEngine()
_, err := engine.FromClientHello(tt.ch)
if (err != nil) != tt.wantErr {
t.Errorf("FromClientHello() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestNewEngine(t *testing.T) {
engine := NewEngine()
if engine == nil {
t.Error("NewEngine() returned nil")
}
}

View File

@ -2,7 +2,7 @@
package logging package logging
import ( import (
"github.com/your-repo/ja4sentinel/api" "ja4sentinel/api"
) )
// LoggerFactory creates logger instances // LoggerFactory creates logger instances

View File

@ -10,7 +10,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/your-repo/ja4sentinel/api" "ja4sentinel/api"
) )
// ServiceLogger handles structured logging for the ja4sentinel service // ServiceLogger handles structured logging for the ja4sentinel service

250
internal/output/writers.go Normal file
View File

@ -0,0 +1,250 @@
// Package output provides writers for ja4sentinel log records
package output
import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"sync"
"ja4sentinel/api"
)
// StdoutWriter writes log records to stdout
type StdoutWriter struct {
encoder *json.Encoder
mutex sync.Mutex
}
// NewStdoutWriter creates a new stdout writer
func NewStdoutWriter() *StdoutWriter {
return &StdoutWriter{
encoder: json.NewEncoder(os.Stdout),
}
}
// Write writes a log record to stdout
func (w *StdoutWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.encoder.Encode(rec)
}
// Close closes the writer (no-op for stdout)
func (w *StdoutWriter) Close() error {
return nil
}
// FileWriter writes log records to a file
type FileWriter struct {
file *os.File
encoder *json.Encoder
mutex sync.Mutex
}
// NewFileWriter creates a new file writer
func NewFileWriter(path string) (*FileWriter, error) {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}
return &FileWriter{
file: file,
encoder: json.NewEncoder(file),
}, nil
}
// Write writes a log record to the file
func (w *FileWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.encoder.Encode(rec)
}
// Close closes the file
func (w *FileWriter) Close() error {
w.mutex.Lock()
defer w.mutex.Unlock()
if w.file != nil {
return w.file.Close()
}
return nil
}
// UnixSocketWriter writes log records to a UNIX socket
type UnixSocketWriter struct {
socketPath string
conn net.Conn
mutex sync.Mutex
}
// NewUnixSocketWriter creates a new UNIX socket writer
func NewUnixSocketWriter(socketPath string) (*UnixSocketWriter, error) {
w := &UnixSocketWriter{
socketPath: socketPath,
}
// Try to connect (socket may not exist yet)
conn, err := net.Dial("unix", socketPath)
if err != nil {
// Socket doesn't exist yet, we'll try to connect on first write
return w, nil
}
w.conn = conn
return w, nil
}
// Write writes a log record to the UNIX socket
func (w *UnixSocketWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
// Connect if not already connected
if w.conn == nil {
conn, err := net.Dial("unix", w.socketPath)
if err != nil {
return fmt.Errorf("failed to connect to socket %s: %w", w.socketPath, err)
}
w.conn = conn
}
data, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
// Add newline for line-based protocols
data = append(data, '\n')
_, err = w.conn.Write(data)
if err != nil {
// Connection failed, try to reconnect
w.conn.Close()
w.conn = nil
return fmt.Errorf("failed to write to socket: %w", err)
}
return nil
}
// Close closes the UNIX socket connection
func (w *UnixSocketWriter) Close() error {
w.mutex.Lock()
defer w.mutex.Unlock()
if w.conn != nil {
return w.conn.Close()
}
return nil
}
// MultiWriter combines multiple writers
type MultiWriter struct {
writers []api.Writer
mutex sync.Mutex
}
// NewMultiWriter creates a new multi-writer
func NewMultiWriter() *MultiWriter {
return &MultiWriter{
writers: make([]api.Writer, 0),
}
}
// Write writes a log record to all writers
func (mw *MultiWriter) Write(rec api.LogRecord) error {
mw.mutex.Lock()
defer mw.mutex.Unlock()
var lastErr error
for _, w := range mw.writers {
if err := w.Write(rec); err != nil {
lastErr = err
}
}
return lastErr
}
// Add adds a writer to the multi-writer
func (mw *MultiWriter) Add(writer api.Writer) {
mw.mutex.Lock()
defer mw.mutex.Unlock()
mw.writers = append(mw.writers, writer)
}
// CloseAll closes all writers
func (mw *MultiWriter) CloseAll() error {
mw.mutex.Lock()
defer mw.mutex.Unlock()
var lastErr error
for _, w := range mw.writers {
if closer, ok := w.(io.Closer); ok {
if err := closer.Close(); err != nil {
lastErr = err
}
}
}
return lastErr
}
// BuilderImpl implements the api.Builder interface
type BuilderImpl struct{}
// NewBuilder creates a new output builder
func NewBuilder() *BuilderImpl {
return &BuilderImpl{}
}
// NewFromConfig constructs writers from AppConfig
func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) {
multiWriter := NewMultiWriter()
for _, outputCfg := range cfg.Outputs {
if !outputCfg.Enabled {
continue
}
var writer api.Writer
var err error
switch outputCfg.Type {
case "stdout":
writer = NewStdoutWriter()
case "file":
path := outputCfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("file output requires 'path' parameter")
}
writer, err = NewFileWriter(path)
if err != nil {
return nil, err
}
case "unix_socket":
socketPath := outputCfg.Params["socket_path"]
if socketPath == "" {
return nil, fmt.Errorf("unix_socket output requires 'socket_path' parameter")
}
writer, err = NewUnixSocketWriter(socketPath)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown output type: %s", outputCfg.Type)
}
multiWriter.Add(writer)
}
// If no outputs configured, default to stdout
if len(multiWriter.writers) == 0 {
multiWriter.Add(NewStdoutWriter())
}
return multiWriter, nil
}

View File

@ -0,0 +1,235 @@
package output
import (
"bytes"
"encoding/json"
"os"
"testing"
"ja4sentinel/api"
)
func TestStdoutWriter(t *testing.T) {
// Capture stdout by replacing it temporarily
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
writer := NewStdoutWriter()
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err := writer.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if output == "" {
t.Error("Write() produced no output")
}
// Verify it's valid JSON
var result api.LogRecord
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Errorf("Output is not valid JSON: %v", err)
}
}
func TestFileWriter(t *testing.T) {
// Create a temporary file
tmpFile := "/tmp/ja4sentinel_test.log"
defer os.Remove(tmpFile)
writer, err := NewFileWriter(tmpFile)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
defer writer.Close()
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err = writer.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Read the file and verify
data, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if len(data) == 0 {
t.Error("Write() produced no output")
}
// Verify it's valid JSON
var result api.LogRecord
if err := json.Unmarshal(data, &result); err != nil {
t.Errorf("Output is not valid JSON: %v", err)
}
}
func TestMultiWriter(t *testing.T) {
multiWriter := NewMultiWriter()
// Create a temporary file writer
tmpFile := "/tmp/ja4sentinel_multi_test.log"
defer os.Remove(tmpFile)
fileWriter, err := NewFileWriter(tmpFile)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
defer fileWriter.Close()
multiWriter.Add(fileWriter)
multiWriter.Add(NewStdoutWriter())
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err = multiWriter.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Verify file output
data, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if len(data) == 0 {
t.Error("MultiWriter.Write() produced no file output")
}
}
func TestBuilderNewFromConfig(t *testing.T) {
builder := NewBuilder()
tests := []struct {
name string
cfg api.AppConfig
wantErr bool
}{
{
name: "stdout output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
},
wantErr: false,
},
{
name: "file output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{
Type: "file",
Enabled: true,
Params: map[string]string{"path": "/tmp/ja4sentinel_builder_test.log"},
},
},
},
wantErr: false,
},
{
name: "file output without path",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "file", Enabled: true},
},
},
wantErr: true,
},
{
name: "unix socket output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{
Type: "unix_socket",
Enabled: true,
Params: map[string]string{"socket_path": "/tmp/ja4sentinel_test.sock"},
},
},
},
wantErr: false,
},
{
name: "unknown output type",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "unknown", Enabled: true},
},
},
wantErr: true,
},
{
name: "no outputs (should default to stdout)",
cfg: api.AppConfig{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer, err := builder.NewFromConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && writer == nil {
t.Error("NewFromConfig() returned nil writer")
}
})
}
}
func TestUnixSocketWriter(t *testing.T) {
// Test creation without socket (should not fail)
socketPath := "/tmp/ja4sentinel_nonexistent.sock"
writer, err := NewUnixSocketWriter(socketPath)
if err != nil {
t.Fatalf("NewUnixSocketWriter() error = %v", err)
}
// Write should fail since socket doesn't exist
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
}
err = writer.Write(rec)
if err == nil {
t.Error("Write() should fail for non-existent socket")
}
writer.Close()
}

389
internal/tlsparse/parser.go Normal file
View File

@ -0,0 +1,389 @@
// Package tlsparse provides TLS ClientHello extraction from captured packets
package tlsparse
import (
"encoding/binary"
"fmt"
"sync"
"time"
"ja4sentinel/api"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// ConnectionState represents the state of a TCP connection for TLS parsing
type ConnectionState int
const (
// NEW: Observed SYN from client on a monitored port
NEW ConnectionState = iota
// WAIT_CLIENT_HELLO: Accumulating segments for complete ClientHello
WAIT_CLIENT_HELLO
// JA4_DONE: JA4 computed and logged, stop tracking this flow
JA4_DONE
)
// ConnectionFlow tracks a single TCP flow for TLS handshake extraction
type ConnectionFlow struct {
State ConnectionState
CreatedAt time.Time
LastSeen time.Time
SrcIP string
SrcPort uint16
DstIP string
DstPort uint16
IPMeta api.IPMeta
TCPMeta api.TCPMeta
HelloBuffer []byte
}
// ParserImpl implements the api.Parser interface for TLS parsing
type ParserImpl struct {
mu sync.RWMutex
flows map[string]*ConnectionFlow
flowTimeout time.Duration
cleanupDone chan struct{}
cleanupClose chan struct{}
}
// NewParser creates a new TLS parser with connection state tracking
func NewParser() *ParserImpl {
return NewParserWithTimeout(30 * time.Second)
}
// NewParserWithTimeout creates a new TLS parser with a custom flow timeout
func NewParserWithTimeout(timeout time.Duration) *ParserImpl {
p := &ParserImpl{
flows: make(map[string]*ConnectionFlow),
flowTimeout: timeout,
cleanupDone: make(chan struct{}),
cleanupClose: make(chan struct{}),
}
go p.cleanupLoop()
return p
}
// flowKey generates a unique key for a TCP flow
func flowKey(srcIP string, srcPort uint16, dstIP string, dstPort uint16) string {
return fmt.Sprintf("%s:%d->%s:%d", srcIP, srcPort, dstIP, dstPort)
}
// cleanupLoop periodically removes expired flows
func (p *ParserImpl) cleanupLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.cleanupExpiredFlows()
case <-p.cleanupClose:
close(p.cleanupDone)
return
}
}
}
// cleanupExpiredFlows removes flows that have timed out or are done
func (p *ParserImpl) cleanupExpiredFlows() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for key, flow := range p.flows {
if flow.State == JA4_DONE || now.Sub(flow.LastSeen) > p.flowTimeout {
delete(p.flows, key)
}
}
}
// Process extracts TLS ClientHello from a raw packet
func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
if len(pkt.Data) == 0 {
return nil, fmt.Errorf("empty packet data")
}
// Parse packet layers
packet := gopacket.NewPacket(pkt.Data, layers.LinkTypeEthernet, gopacket.Default)
// Get IP layer
ipLayer := packet.Layer(layers.LayerTypeIPv4)
if ipLayer == nil {
ipLayer = packet.Layer(layers.LayerTypeIPv6)
}
if ipLayer == nil {
return nil, nil // Not an IP packet
}
// Get TCP layer
tcpLayer := packet.Layer(layers.LayerTypeTCP)
if tcpLayer == nil {
return nil, nil // Not a TCP packet
}
ip, ok := ipLayer.(gopacket.Layer)
if !ok {
return nil, fmt.Errorf("failed to cast IP layer")
}
tcp, ok := tcpLayer.(*layers.TCP)
if !ok {
return nil, fmt.Errorf("failed to cast TCP layer")
}
// Extract IP metadata
ipMeta := extractIPMeta(ip)
// Extract TCP metadata
tcpMeta := extractTCPMeta(tcp)
// Get source/destination info
var srcIP, dstIP string
var srcPort, dstPort uint16
switch v := ip.(type) {
case *layers.IPv4:
srcIP = v.SrcIP.String()
dstIP = v.DstIP.String()
case *layers.IPv6:
srcIP = v.SrcIP.String()
dstIP = v.DstIP.String()
}
srcPort = uint16(tcp.SrcPort)
dstPort = uint16(tcp.DstPort)
// Get TCP payload (TLS data)
payload := tcp.Payload
if len(payload) == 0 {
return nil, nil // No payload
}
// Get or create connection flow
key := flowKey(srcIP, srcPort, dstIP, dstPort)
flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta)
// Check if flow is already done
p.mu.RLock()
isDone := flow.State == JA4_DONE
p.mu.RUnlock()
if isDone {
return nil, nil // Already processed this flow
}
// Check if this is a TLS ClientHello
clientHello, err := parseClientHello(payload)
if err != nil {
return nil, err
}
if clientHello != nil {
// Found ClientHello, mark flow as done
p.mu.Lock()
flow.State = JA4_DONE
flow.HelloBuffer = clientHello
p.mu.Unlock()
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
}
// Check for fragmented ClientHello (accumulate segments)
if flow.State == WAIT_CLIENT_HELLO || flow.State == NEW {
p.mu.Lock()
flow.State = WAIT_CLIENT_HELLO
flow.HelloBuffer = append(flow.HelloBuffer, payload...)
bufferCopy := make([]byte, len(flow.HelloBuffer))
copy(bufferCopy, flow.HelloBuffer)
p.mu.Unlock()
// Try to parse accumulated buffer
clientHello, err := parseClientHello(bufferCopy)
if err != nil {
return nil, err
}
if clientHello != nil {
// Complete ClientHello found
p.mu.Lock()
flow.State = JA4_DONE
p.mu.Unlock()
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
}
}
return nil, nil // No ClientHello found yet
}
// getOrCreateFlow gets existing flow or creates a new one
func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, dstIP string, dstPort uint16, ipMeta api.IPMeta, tcpMeta api.TCPMeta) *ConnectionFlow {
p.mu.Lock()
defer p.mu.Unlock()
if flow, exists := p.flows[key]; exists {
flow.LastSeen = time.Now()
return flow
}
flow := &ConnectionFlow{
State: NEW,
CreatedAt: time.Now(),
LastSeen: time.Now(),
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
HelloBuffer: make([]byte, 0),
}
p.flows[key] = flow
return flow
}
// Close cleans up the parser and stops background goroutines
func (p *ParserImpl) Close() error {
close(p.cleanupClose)
<-p.cleanupDone
return nil
}
// extractIPMeta extracts IP metadata from the IP layer
func extractIPMeta(ipLayer gopacket.Layer) api.IPMeta {
meta := api.IPMeta{}
switch v := ipLayer.(type) {
case *layers.IPv4:
meta.TTL = v.TTL
meta.TotalLength = v.Length
meta.IPID = v.Id
meta.DF = v.Flags&layers.IPv4DontFragment != 0
case *layers.IPv6:
meta.TTL = v.HopLimit
meta.TotalLength = uint16(v.Length)
meta.IPID = 0 // IPv6 doesn't have IP ID
meta.DF = true // IPv6 doesn't fragment at source
}
return meta
}
// extractTCPMeta extracts TCP metadata from the TCP layer
func extractTCPMeta(tcp *layers.TCP) api.TCPMeta {
meta := api.TCPMeta{
WindowSize: tcp.Window,
Options: make([]string, 0),
}
// Parse TCP options
for _, opt := range tcp.Options {
switch opt.OptionType {
case layers.TCPOptionKindMSS:
meta.MSS = binary.BigEndian.Uint16(opt.OptionData)
meta.Options = append(meta.Options, "MSS")
case layers.TCPOptionKindWindowScale:
if len(opt.OptionData) > 0 {
meta.WindowScale = opt.OptionData[0]
}
meta.Options = append(meta.Options, "WS")
case layers.TCPOptionKindSACKPermitted:
meta.Options = append(meta.Options, "SACK")
case layers.TCPOptionKindTimestamps:
meta.Options = append(meta.Options, "TS")
default:
meta.Options = append(meta.Options, fmt.Sprintf("OPT%d", opt.OptionType))
}
}
return meta
}
// parseClientHello checks if the payload contains a TLS ClientHello and returns it
func parseClientHello(payload []byte) ([]byte, error) {
if len(payload) < 5 {
return nil, nil // Too short for TLS record
}
// TLS record layer: Content Type (1 byte), Version (2 bytes), Length (2 bytes)
contentType := payload[0]
// Check for TLS handshake (content type 22)
if contentType != 22 {
return nil, nil // Not a TLS handshake
}
// Check TLS version (TLS 1.0 = 0x0301, TLS 1.1 = 0x0302, TLS 1.2 = 0x0303, TLS 1.3 = 0x0304)
version := binary.BigEndian.Uint16(payload[1:3])
if version < 0x0301 || version > 0x0304 {
return nil, nil // Unknown TLS version
}
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return nil, nil // Incomplete TLS record
}
// Parse handshake protocol
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return nil, nil // Too short for handshake type
}
handshakeType := handshakePayload[0]
// Check for ClientHello (handshake type 1)
if handshakeType != 1 {
return nil, nil // Not a ClientHello
}
// Return the full TLS record (header + payload) for fingerprinting
return payload[:5+recordLength], nil
}
// IsClientHello checks if a payload contains a TLS ClientHello
func IsClientHello(payload []byte) bool {
if len(payload) < 6 {
return false
}
// TLS handshake record
if payload[0] != 22 {
return false
}
// Check version
version := binary.BigEndian.Uint16(payload[1:3])
if version < 0x0301 || version > 0x0304 {
return false
}
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return false
}
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return false
}
// ClientHello type
return handshakePayload[0] == 1
}

View File

@ -0,0 +1,253 @@
package tlsparse
import (
"testing"
"github.com/google/gopacket/layers"
)
func TestIsClientHello(t *testing.T) {
tests := []struct {
name string
payload []byte
want bool
}{
{
name: "empty payload",
payload: []byte{},
want: false,
},
{
name: "too short",
payload: []byte{0x16, 0x03, 0x03},
want: false,
},
{
name: "valid TLS 1.2 ClientHello",
payload: createTLSClientHello(0x0303),
want: true,
},
{
name: "valid TLS 1.3 ClientHello",
payload: createTLSClientHello(0x0304),
want: true,
},
{
name: "not a handshake",
payload: []byte{0x17, 0x03, 0x03, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
want: false,
},
{
name: "ServerHello (type 2)",
payload: createTLSServerHello(0x0303),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsClientHello(tt.payload)
if got != tt.want {
t.Errorf("IsClientHello() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseClientHello(t *testing.T) {
tests := []struct {
name string
payload []byte
wantErr bool
wantNil bool
}{
{
name: "empty payload",
payload: []byte{},
wantErr: false,
wantNil: true,
},
{
name: "valid ClientHello",
payload: createTLSClientHello(0x0303),
wantErr: false,
wantNil: false,
},
{
name: "incomplete record",
payload: []byte{0x16, 0x03, 0x03, 0x01, 0x00, 0x01},
wantErr: false,
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseClientHello(tt.payload)
if (err != nil) != tt.wantErr {
t.Errorf("parseClientHello() error = %v, wantErr %v", err, tt.wantErr)
return
}
if (got == nil) != tt.wantNil {
t.Errorf("parseClientHello() = %v, wantNil %v", got == nil, tt.wantNil)
}
})
}
}
func TestExtractIPMeta(t *testing.T) {
ipLayer := &layers.IPv4{
TTL: 64,
Length: 1500,
Id: 12345,
Flags: layers.IPv4DontFragment,
SrcIP: []byte{192, 168, 1, 1},
DstIP: []byte{10, 0, 0, 1},
}
meta := extractIPMeta(ipLayer)
if meta.TTL != 64 {
t.Errorf("TTL = %v, want 64", meta.TTL)
}
if meta.TotalLength != 1500 {
t.Errorf("TotalLength = %v, want 1500", meta.TotalLength)
}
if meta.IPID != 12345 {
t.Errorf("IPID = %v, want 12345", meta.IPID)
}
if !meta.DF {
t.Error("DF = false, want true")
}
}
func TestExtractTCPMeta(t *testing.T) {
tcp := &layers.TCP{
SrcPort: 12345,
DstPort: 443,
Window: 65535,
Options: []layers.TCPOption{
{
OptionType: layers.TCPOptionKindMSS,
OptionData: []byte{0x05, 0xb4}, // 1460
},
{
OptionType: layers.TCPOptionKindWindowScale,
OptionData: []byte{0x07}, // scale 7
},
{
OptionType: layers.TCPOptionKindSACKPermitted,
OptionData: []byte{},
},
},
}
meta := extractTCPMeta(tcp)
if meta.WindowSize != 65535 {
t.Errorf("WindowSize = %v, want 65535", meta.WindowSize)
}
if meta.MSS != 1460 {
t.Errorf("MSS = %v, want 1460", meta.MSS)
}
if meta.WindowScale != 7 {
t.Errorf("WindowScale = %v, want 7", meta.WindowScale)
}
if len(meta.Options) != 3 {
t.Errorf("Options length = %v, want 3", len(meta.Options))
}
}
// Helper functions to create test TLS records
func createTLSClientHello(version uint16) []byte {
// Minimal TLS ClientHello record
handshake := []byte{
0x01, // Handshake type: ClientHello
0x00, 0x00, 0x00, 0x10, // Handshake length (16 bytes)
// ClientHello body (simplified)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
record := make([]byte, 5+len(handshake))
record[0] = 0x16 // Handshake
record[1] = byte(version >> 8)
record[2] = byte(version)
record[3] = byte(len(handshake) >> 8)
record[4] = byte(len(handshake))
copy(record[5:], handshake)
return record
}
func createTLSServerHello(version uint16) []byte {
// Minimal TLS ServerHello record
handshake := []byte{
0x02, // Handshake type: ServerHello
0x00, 0x00, 0x00, 0x10, // Handshake length
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
record := make([]byte, 5+len(handshake))
record[0] = 0x16 // Handshake
record[1] = byte(version >> 8)
record[2] = byte(version)
record[3] = byte(len(handshake) >> 8)
record[4] = byte(len(handshake))
copy(record[5:], handshake)
return record
}
func TestNewParser(t *testing.T) {
parser := NewParser()
if parser == nil {
t.Error("NewParser() returned nil")
}
if parser.flows == nil {
t.Error("NewParser() flows map not initialized")
}
if parser.flowTimeout == 0 {
t.Error("NewParser() flowTimeout not set")
}
}
func TestParserClose(t *testing.T) {
parser := NewParser()
err := parser.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestFlowKey(t *testing.T) {
key := flowKey("192.168.1.1", 12345, "10.0.0.1", 443)
expected := "192.168.1.1:12345->10.0.0.1:443"
if key != expected {
t.Errorf("flowKey() = %v, want %v", key, expected)
}
}
func TestParserConnectionStateTracking(t *testing.T) {
parser := NewParser()
defer parser.Close()
// Create a valid ClientHello payload
clientHello := createTLSClientHello(0x0303)
// Test parseClientHello directly (lower-level test)
result, err := parseClientHello(clientHello)
if err != nil {
t.Errorf("parseClientHello() error = %v", err)
}
if result == nil {
t.Error("parseClientHello() should return ClientHello")
}
// Test IsClientHello helper
if !IsClientHello(clientHello) {
t.Error("IsClientHello() should return true for valid ClientHello")
}
}

View File

@ -1,2 +0,0 @@
// entire file content ...
// ... goes in between