From efd44817298e14169103d5031a7baa3f6db0e0c6 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Wed, 25 Feb 2026 20:02:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20impl=C3=A9mentation=20compl=C3=A8te=20d?= =?UTF-8?q?u=20pipeline=20JA4=20+=20Docker=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 37 +++ .gitignore | 1 + Dockerfile | 64 +++++ Dockerfile.dev | 30 +++ Dockerfile.test-server | 105 ++++++++ Makefile | 103 ++++++++ README.md | 276 ++++++++++++++++++++ api/types.go | 80 +++--- architecture.yml | 200 +++++++++++++- cmd/ja4sentinel/main.go | 189 ++++++++++++++ config.yml.example | 32 +++ docker-compose.test.yml | 49 ++++ go.mod | 13 + go.sum | 22 ++ internal/api/types.go | 209 --------------- internal/capture/capture.go | 36 ++- internal/capture/capture_test.go | 3 - internal/config/loader.go | 176 +++++++++++++ internal/config/loader_test.go | 213 +++++++++++++++ internal/fingerprint/engine.go | 64 +++++ internal/fingerprint/engine_test.go | 47 ++++ internal/logging/logger_factory.go | 2 +- internal/logging/service_logger.go | 2 +- internal/output/writers.go | 250 ++++++++++++++++++ internal/output/writers_test.go | 235 +++++++++++++++++ internal/tlsparse/parser.go | 389 ++++++++++++++++++++++++++++ internal/tlsparse/parser_test.go | 253 ++++++++++++++++++ path/to/filename.js | 2 - 28 files changed, 2797 insertions(+), 285 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.test-server create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/ja4sentinel/main.go create mode 100644 config.yml.example create mode 100644 docker-compose.test.yml create mode 100644 go.mod create mode 100644 go.sum delete mode 100644 internal/api/types.go create mode 100644 internal/config/loader.go create mode 100644 internal/config/loader_test.go create mode 100644 internal/fingerprint/engine.go create mode 100644 internal/fingerprint/engine_test.go create mode 100644 internal/output/writers.go create mode 100644 internal/output/writers_test.go create mode 100644 internal/tlsparse/parser.go create mode 100644 internal/tlsparse/parser_test.go delete mode 100644 path/to/filename.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4d3c438 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0ac3ed --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.aider* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae8569d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..d283d34 --- /dev/null +++ b/Dockerfile.dev @@ -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"] diff --git a/Dockerfile.test-server b/Dockerfile.test-server new file mode 100644 index 0000000..276ca83 --- /dev/null +++ b/Dockerfile.test-server @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8736f85 --- /dev/null +++ b/Makefile @@ -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/^/ /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3f8e1e --- /dev/null +++ b/README.md @@ -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.** diff --git a/api/types.go b/api/types.go index 6530f24..70337db 100644 --- a/api/types.go +++ b/api/types.go @@ -1,22 +1,22 @@ 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"` + 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 type Config struct { - Interface string `json:"interface"` - ListenPorts []uint16 `json:"listen_ports"` - BPFFilter string `json:"bpf_filter,omitempty"` + Interface string `json:"interface"` + ListenPorts []uint16 `json:"listen_ports"` + 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 @@ -37,18 +37,18 @@ type TCPMeta struct { // RawPacket represents a raw packet captured from the network type RawPacket struct { - Data []byte `json:"-"` // Not serialized + 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"` + 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"` } @@ -62,10 +62,10 @@ type Fingerprints struct { // 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"` + 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"` @@ -107,11 +107,13 @@ type Loader interface { // Capture interface provides raw network packets type Capture interface { Run(cfg Config, out chan<- RawPacket) error + Close() error } // Parser converts RawPacket to TLSClientHello type Parser interface { Process(pkt RawPacket) (*TLSClientHello, error) + Close() error } // Engine generates JA4 fingerprints from TLS ClientHello @@ -160,20 +162,20 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord { } 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, + 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 @@ -199,10 +201,11 @@ func joinStringSlice(slice []string, sep string) string { // Default values and constants const ( - DefaultInterface = "eth0" - DefaultPort = 443 - DefaultBPFFilter = "" - + DefaultInterface = "eth0" + DefaultPort = 443 + DefaultBPFFilter = "" + DefaultFlowTimeout = 30 // seconds + // Logging levels LogLevelDebug = "DEBUG" LogLevelInfo = "INFO" @@ -214,9 +217,10 @@ const ( func DefaultConfig() AppConfig { return AppConfig{ Core: Config{ - Interface: DefaultInterface, - ListenPorts: []uint16{DefaultPort}, - BPFFilter: DefaultBPFFilter, + Interface: DefaultInterface, + ListenPorts: []uint16{DefaultPort}, + BPFFilter: DefaultBPFFilter, + FlowTimeoutSec: DefaultFlowTimeout, }, Outputs: []OutputConfig{}, } diff --git a/architecture.yml b/architecture.yml index 0a8d96d..e605b7d 100644 --- a/architecture.yml +++ b/architecture.yml @@ -95,13 +95,30 @@ modules: - "tlsparse" - "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 path: "cmd/ja4sentinel" - description: "Point d’entrée de l’application (main)." + description: "Point d'entrée de l'application (main)." responsibilities: - "Charger la configuration via le module config." - - "Construire les instances des modules (capture, tlsparse, fingerprint, output)." - - "Brancher les modules entre eux selon l’architecture pipeline." + - "Construire les instances des modules (capture, tlsparse, fingerprint, output, logging)." + - "Brancher les modules entre eux selon l'architecture pipeline." - "Gérer les signaux système (arrêt propre)." allowed_dependencies: - "config" @@ -110,16 +127,29 @@ modules: - "fingerprint" - "output" - "api" + - "logging" forbidden_dependencies: [] api: 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" description: "Configuration réseau et TLS de base." fields: - - { name: Interface, type: "string", description: "Nom de l’interface 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: 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" description: "Métadonnées IP pour fingerprinting de stack." @@ -163,15 +193,32 @@ api: - { name: JA3Hash, type: "string", description: "Hash JA3 (optionnel)." } - 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: - - { name: SrcIP, type: "string", description: "Adresse IP source (client)." } - - { name: SrcPort, type: "uint16", description: "Port source (client)." } - - { name: DstIP, type: "string", description: "Adresse IP destination (serveur)." } - - { name: DstPort, type: "uint16", description: "Port destination (serveur)." } - - { name: IPMeta, type: "api.IPMeta", description: "Métadonnées IP." } - - { name: TCPMeta, type: "api.TCPMeta", description: "Métadonnées TCP." } - - { name: Fingerprints, type: "api.Fingerprints", description: "Empreintes JA4/JA3 associées." } + - { name: SrcIP, type: "string", json_key: "src_ip" } + - { name: SrcPort, type: "uint16", json_key: "src_port" } + - { name: DstIP, type: "string", json_key: "dst_ip" } + - { name: DstPort, type: "uint16", json_key: "dst_port" } + + # IPMeta flatten + - { 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" description: "Configuration d’une sortie de logs." @@ -278,6 +325,49 @@ api: notes: - "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: style: "pipeline" flow: @@ -479,3 +569,89 @@ dev_tools: - "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 d’inté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 s’arrê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 d’erreur 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 d’objets 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" + diff --git a/cmd/ja4sentinel/main.go b/cmd/ja4sentinel/main.go new file mode 100644 index 0000000..6e6fd61 --- /dev/null +++ b/cmd/ja4sentinel/main.go @@ -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) +} diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..ee609df --- /dev/null +++ b/config.yml.example @@ -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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..2f45efe --- /dev/null +++ b/docker-compose.test.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..55473f4 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f4b1070 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/types.go b/internal/api/types.go deleted file mode 100644 index 7a14391..0000000 --- a/internal/api/types.go +++ /dev/null @@ -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{}, - } -} diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 28b72ee..cb3ac8e 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -1,28 +1,26 @@ +// Package capture provides network packet capture functionality for ja4sentinel package capture import ( "fmt" - "net" - "time" "github.com/google/gopacket" - "github.com/google/gopacket/layers" "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 { handle *pcap.Handle } -// New crée une nouvelle instance de capture +// New creates a new capture instance func New() *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 { var err error 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() - // Appliquer le filtre BPF s'il est fourni + // Apply BPF filter if provided if cfg.BPFFilter != "" { err = c.handle.SetBPFFilter(cfg.BPFFilter) if err != nil { return fmt.Errorf("failed to set BPF filter: %w", err) } } else { - // Créer un filtre par défaut pour les ports surveillés + // Create default filter for monitored ports defaultFilter := buildBPFForPorts(cfg.ListenPorts) err = c.handle.SetBPFFilter(defaultFilter) if err != nil { @@ -47,29 +45,29 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { } packetSource := gopacket.NewPacketSource(c.handle, c.handle.LinkType()) - + for packet := range packetSource.Packets() { - // Convertir le paquet en RawPacket + // Convert packet to RawPacket rawPkt := packetToRawPacket(packet) if rawPkt != nil { select { case out <- *rawPkt: - // Paquet envoyé avec succès + // Packet sent successfully default: - // Canal plein, ignorer le paquet + // Channel full, drop packet } } } - + 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 { if len(ports) == 0 { return "tcp" } - + filterParts := make([]string, len(ports)) for i, port := range ports { filterParts[i] = fmt.Sprintf("tcp port %d", port) @@ -77,7 +75,7 @@ func buildBPFForPorts(ports []uint16) string { 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 { if len(parts) == 0 { return "" @@ -89,7 +87,7 @@ func joinString(parts []string, sep string) string { return result } -// packetToRawPacket convertit un paquet gopacket en RawPacket +// packetToRawPacket converts a gopacket packet to RawPacket func packetToRawPacket(packet gopacket.Packet) *api.RawPacket { data := packet.Data() 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 { if c.handle != nil { c.handle.Close() diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index ddaf857..b95ff3d 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -2,9 +2,6 @@ package capture import ( "testing" - "time" - - "ja4sentinel/internal/api" ) func TestBuildBPFForPorts(t *testing.T) { diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..b40f143 --- /dev/null +++ b/internal/config/loader.go @@ -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) +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..a65241f --- /dev/null +++ b/internal/config/loader_test.go @@ -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'") + } +} diff --git a/internal/fingerprint/engine.go b/internal/fingerprint/engine.go new file mode 100644 index 0000000..0330b0b --- /dev/null +++ b/internal/fingerprint/engine.go @@ -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: __ + 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: __ -> returns "_" +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 "" +} diff --git a/internal/fingerprint/engine_test.go b/internal/fingerprint/engine_test.go new file mode 100644 index 0000000..af3e84f --- /dev/null +++ b/internal/fingerprint/engine_test.go @@ -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") + } +} diff --git a/internal/logging/logger_factory.go b/internal/logging/logger_factory.go index 2377749..25121e7 100644 --- a/internal/logging/logger_factory.go +++ b/internal/logging/logger_factory.go @@ -2,7 +2,7 @@ package logging import ( - "github.com/your-repo/ja4sentinel/api" + "ja4sentinel/api" ) // LoggerFactory creates logger instances diff --git a/internal/logging/service_logger.go b/internal/logging/service_logger.go index edd1e32..3a38951 100644 --- a/internal/logging/service_logger.go +++ b/internal/logging/service_logger.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/your-repo/ja4sentinel/api" + "ja4sentinel/api" ) // ServiceLogger handles structured logging for the ja4sentinel service diff --git a/internal/output/writers.go b/internal/output/writers.go new file mode 100644 index 0000000..d17c6f7 --- /dev/null +++ b/internal/output/writers.go @@ -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 +} diff --git a/internal/output/writers_test.go b/internal/output/writers_test.go new file mode 100644 index 0000000..f19458f --- /dev/null +++ b/internal/output/writers_test.go @@ -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() +} diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go new file mode 100644 index 0000000..3355ab2 --- /dev/null +++ b/internal/tlsparse/parser.go @@ -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 +} diff --git a/internal/tlsparse/parser_test.go b/internal/tlsparse/parser_test.go new file mode 100644 index 0000000..ce0bd68 --- /dev/null +++ b/internal/tlsparse/parser_test.go @@ -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") + } +} diff --git a/path/to/filename.js b/path/to/filename.js deleted file mode 100644 index 7435937..0000000 --- a/path/to/filename.js +++ /dev/null @@ -1,2 +0,0 @@ -// entire file content ... -// ... goes in between