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:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.aider*
|
||||||
64
Dockerfile
Normal file
64
Dockerfile
Normal 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
30
Dockerfile.dev
Normal 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
105
Dockerfile.test-server
Normal 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
103
Makefile
Normal 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
276
README.md
Normal 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.**
|
||||||
12
api/types.go
12
api/types.go
@ -1,15 +1,14 @@
|
|||||||
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
|
||||||
@ -17,6 +16,7 @@ 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
|
||||||
@ -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
|
||||||
@ -202,6 +204,7 @@ const (
|
|||||||
DefaultInterface = "eth0"
|
DefaultInterface = "eth0"
|
||||||
DefaultPort = 443
|
DefaultPort = 443
|
||||||
DefaultBPFFilter = ""
|
DefaultBPFFilter = ""
|
||||||
|
DefaultFlowTimeout = 30 // seconds
|
||||||
|
|
||||||
// Logging levels
|
// Logging levels
|
||||||
LogLevelDebug = "DEBUG"
|
LogLevelDebug = "DEBUG"
|
||||||
@ -217,6 +220,7 @@ func DefaultConfig() AppConfig {
|
|||||||
Interface: DefaultInterface,
|
Interface: DefaultInterface,
|
||||||
ListenPorts: []uint16{DefaultPort},
|
ListenPorts: []uint16{DefaultPort},
|
||||||
BPFFilter: DefaultBPFFilter,
|
BPFFilter: DefaultBPFFilter,
|
||||||
|
FlowTimeoutSec: DefaultFlowTimeout,
|
||||||
},
|
},
|
||||||
Outputs: []OutputConfig{},
|
Outputs: []OutputConfig{},
|
||||||
}
|
}
|
||||||
|
|||||||
200
architecture.yml
200
architecture.yml
@ -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 d’entrée de l’application (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 l’architecture 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 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: 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 d’une sortie de logs."
|
description: "Configuration d’une 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 d’intégration."
|
- "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"
|
||||||
|
|
||||||
|
|||||||
189
cmd/ja4sentinel/main.go
Normal file
189
cmd/ja4sentinel/main.go
Normal 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
32
config.yml.example
Normal 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
49
docker-compose.test.yml
Normal 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
13
go.mod
Normal 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
22
go.sum
Normal 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=
|
||||||
@ -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{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
176
internal/config/loader.go
Normal 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)
|
||||||
|
}
|
||||||
213
internal/config/loader_test.go
Normal file
213
internal/config/loader_test.go
Normal 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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/fingerprint/engine.go
Normal file
64
internal/fingerprint/engine.go
Normal 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 ""
|
||||||
|
}
|
||||||
47
internal/fingerprint/engine_test.go
Normal file
47
internal/fingerprint/engine_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
250
internal/output/writers.go
Normal 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
|
||||||
|
}
|
||||||
235
internal/output/writers_test.go
Normal file
235
internal/output/writers_test.go
Normal 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
389
internal/tlsparse/parser.go
Normal 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
|
||||||
|
}
|
||||||
253
internal/tlsparse/parser_test.go
Normal file
253
internal/tlsparse/parser_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +0,0 @@
|
|||||||
// entire file content ...
|
|
||||||
// ... goes in between
|
|
||||||
Reference in New Issue
Block a user