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.**
|
||||
76
api/types.go
76
api/types.go
@ -1,22 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServiceLog represents internal service logging for diagnostics
|
||||
type ServiceLog struct {
|
||||
Level string `json:"level"`
|
||||
Component string `json:"component"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"` // Unix nanoseconds (auto-set by logger)
|
||||
TraceID string `json:"trace_id,omitempty"` // Optional distributed tracing ID
|
||||
ConnID string `json:"conn_id,omitempty"` // Optional TCP flow identifier
|
||||
}
|
||||
|
||||
// Config holds basic network and TLS configuration
|
||||
type Config struct {
|
||||
Interface string `json:"interface"`
|
||||
ListenPorts []uint16 `json:"listen_ports"`
|
||||
BPFFilter string `json:"bpf_filter,omitempty"`
|
||||
Interface string `json:"interface"`
|
||||
ListenPorts []uint16 `json:"listen_ports"`
|
||||
BPFFilter string `json:"bpf_filter,omitempty"`
|
||||
FlowTimeoutSec int `json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30)
|
||||
}
|
||||
|
||||
// IPMeta contains IP metadata for stack fingerprinting
|
||||
@ -37,18 +37,18 @@ type TCPMeta struct {
|
||||
|
||||
// RawPacket represents a raw packet captured from the network
|
||||
type RawPacket struct {
|
||||
Data []byte `json:"-"` // Not serialized
|
||||
Data []byte `json:"-"` // Not serialized
|
||||
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
|
||||
}
|
||||
|
||||
// TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata
|
||||
type TLSClientHello struct {
|
||||
SrcIP string `json:"src_ip"`
|
||||
SrcPort uint16 `json:"src_port"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
Payload []byte `json:"-"` // Not serialized
|
||||
IPMeta IPMeta `json:"ip_meta"`
|
||||
SrcIP string `json:"src_ip"`
|
||||
SrcPort uint16 `json:"src_port"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
Payload []byte `json:"-"` // Not serialized
|
||||
IPMeta IPMeta `json:"ip_meta"`
|
||||
TCPMeta TCPMeta `json:"tcp_meta"`
|
||||
}
|
||||
|
||||
@ -62,10 +62,10 @@ type Fingerprints struct {
|
||||
|
||||
// LogRecord is the final log record, serialized as a flat JSON object
|
||||
type LogRecord struct {
|
||||
SrcIP string `json:"src_ip"`
|
||||
SrcPort uint16 `json:"src_port"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
SrcIP string `json:"src_ip"`
|
||||
SrcPort uint16 `json:"src_port"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
|
||||
// Flattened IPMeta fields
|
||||
IPTTL uint8 `json:"ip_meta_ttl"`
|
||||
@ -107,11 +107,13 @@ type Loader interface {
|
||||
// Capture interface provides raw network packets
|
||||
type Capture interface {
|
||||
Run(cfg Config, out chan<- RawPacket) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Parser converts RawPacket to TLSClientHello
|
||||
type Parser interface {
|
||||
Process(pkt RawPacket) (*TLSClientHello, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Engine generates JA4 fingerprints from TLS ClientHello
|
||||
@ -160,18 +162,18 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
|
||||
}
|
||||
|
||||
rec := LogRecord{
|
||||
SrcIP: ch.SrcIP,
|
||||
SrcPort: ch.SrcPort,
|
||||
DstIP: ch.DstIP,
|
||||
DstPort: ch.DstPort,
|
||||
IPTTL: ch.IPMeta.TTL,
|
||||
IPTotalLen: ch.IPMeta.TotalLength,
|
||||
IPID: ch.IPMeta.IPID,
|
||||
IPDF: ch.IPMeta.DF,
|
||||
TCPWindow: ch.TCPMeta.WindowSize,
|
||||
TCPMSS: ch.TCPMeta.MSS,
|
||||
TCPWScale: ch.TCPMeta.WindowScale,
|
||||
TCPOptions: opts,
|
||||
SrcIP: ch.SrcIP,
|
||||
SrcPort: ch.SrcPort,
|
||||
DstIP: ch.DstIP,
|
||||
DstPort: ch.DstPort,
|
||||
IPTTL: ch.IPMeta.TTL,
|
||||
IPTotalLen: ch.IPMeta.TotalLength,
|
||||
IPID: ch.IPMeta.IPID,
|
||||
IPDF: ch.IPMeta.DF,
|
||||
TCPWindow: ch.TCPMeta.WindowSize,
|
||||
TCPMSS: ch.TCPMeta.MSS,
|
||||
TCPWScale: ch.TCPMeta.WindowScale,
|
||||
TCPOptions: opts,
|
||||
}
|
||||
|
||||
if fp != nil {
|
||||
@ -199,9 +201,10 @@ func joinStringSlice(slice []string, sep string) string {
|
||||
// Default values and constants
|
||||
|
||||
const (
|
||||
DefaultInterface = "eth0"
|
||||
DefaultPort = 443
|
||||
DefaultBPFFilter = ""
|
||||
DefaultInterface = "eth0"
|
||||
DefaultPort = 443
|
||||
DefaultBPFFilter = ""
|
||||
DefaultFlowTimeout = 30 // seconds
|
||||
|
||||
// Logging levels
|
||||
LogLevelDebug = "DEBUG"
|
||||
@ -214,9 +217,10 @@ const (
|
||||
func DefaultConfig() AppConfig {
|
||||
return AppConfig{
|
||||
Core: Config{
|
||||
Interface: DefaultInterface,
|
||||
ListenPorts: []uint16{DefaultPort},
|
||||
BPFFilter: DefaultBPFFilter,
|
||||
Interface: DefaultInterface,
|
||||
ListenPorts: []uint16{DefaultPort},
|
||||
BPFFilter: DefaultBPFFilter,
|
||||
FlowTimeoutSec: DefaultFlowTimeout,
|
||||
},
|
||||
Outputs: []OutputConfig{},
|
||||
}
|
||||
|
||||
200
architecture.yml
200
architecture.yml
@ -95,13 +95,30 @@ modules:
|
||||
- "tlsparse"
|
||||
- "fingerprint"
|
||||
|
||||
- name: logging
|
||||
path: "internal/logging"
|
||||
description: "Logs structurés JSON pour le service (stdout/stderr)."
|
||||
responsibilities:
|
||||
- "Fournir une fabrique de loggers (LoggerFactory)."
|
||||
- "Émettre des logs au format JSON lines sur stdout."
|
||||
- "Supporter les niveaux : debug, info, warn, error."
|
||||
- "Inclure timestamp, niveau, composant, message et détails optionnels."
|
||||
allowed_dependencies:
|
||||
- "api"
|
||||
forbidden_dependencies:
|
||||
- "config"
|
||||
- "capture"
|
||||
- "tlsparse"
|
||||
- "fingerprint"
|
||||
- "output"
|
||||
|
||||
- name: cmd_ja4sentinel
|
||||
path: "cmd/ja4sentinel"
|
||||
description: "Point d’entrée de l’application (main)."
|
||||
description: "Point d'entrée de l'application (main)."
|
||||
responsibilities:
|
||||
- "Charger la configuration via le module config."
|
||||
- "Construire les instances des modules (capture, tlsparse, fingerprint, output)."
|
||||
- "Brancher les modules entre eux selon l’architecture pipeline."
|
||||
- "Construire les instances des modules (capture, tlsparse, fingerprint, output, logging)."
|
||||
- "Brancher les modules entre eux selon l'architecture pipeline."
|
||||
- "Gérer les signaux système (arrêt propre)."
|
||||
allowed_dependencies:
|
||||
- "config"
|
||||
@ -110,16 +127,29 @@ modules:
|
||||
- "fingerprint"
|
||||
- "output"
|
||||
- "api"
|
||||
- "logging"
|
||||
forbidden_dependencies: []
|
||||
|
||||
api:
|
||||
types:
|
||||
- name: "api.ServiceLog"
|
||||
description: "Log interne du service ja4sentinel (diagnostic)."
|
||||
fields:
|
||||
- { name: Level, type: "string", description: "niveau: debug, info, warn, error." }
|
||||
- { name: Component, type: "string", description: "module concerné (capture, tlsparse, ...)." }
|
||||
- { name: Message, type: "string", description: "texte du log." }
|
||||
- { name: Details, type: "map[string]string", description: "infos additionnelles (erreurs, IDs...)." }
|
||||
- { name: Timestamp, type: "int64", description: "Timestamp en nanosecondes (auto-rempli par le logger)." }
|
||||
- { name: TraceID, type: "string", description: "ID de tracing distribué (optionnel)." }
|
||||
- { name: ConnID, type: "string", description: "Identifiant de flux TCP (optionnel)." }
|
||||
|
||||
- name: "api.Config"
|
||||
description: "Configuration réseau et TLS de base."
|
||||
fields:
|
||||
- { name: Interface, type: "string", description: "Nom de l’interface réseau (ex: eth0)." }
|
||||
- { name: Interface, type: "string", description: "Nom de l'interface réseau (ex: eth0)." }
|
||||
- { name: ListenPorts, type: "[]uint16", description: "Ports TCP à surveiller (ex: [443, 8443])." }
|
||||
- { name: BPFFilter, type: "string", description: "Filtre BPF optionnel pour la capture." }
|
||||
- { name: FlowTimeoutSec, type: "int", description: "Timeout en secondes pour l'extraction du handshake TLS (défaut: 30)." }
|
||||
|
||||
- name: "api.IPMeta"
|
||||
description: "Métadonnées IP pour fingerprinting de stack."
|
||||
@ -163,15 +193,32 @@ api:
|
||||
- { name: JA3Hash, type: "string", description: "Hash JA3 (optionnel)." }
|
||||
|
||||
- name: "api.LogRecord"
|
||||
description: "Enregistrement de log final envoyé vers les outputs."
|
||||
description: "Enregistrement de log final, sérialisé en JSON objet plat."
|
||||
json_object: true
|
||||
fields:
|
||||
- { name: SrcIP, type: "string", description: "Adresse IP source (client)." }
|
||||
- { name: SrcPort, type: "uint16", description: "Port source (client)." }
|
||||
- { name: DstIP, type: "string", description: "Adresse IP destination (serveur)." }
|
||||
- { name: DstPort, type: "uint16", description: "Port destination (serveur)." }
|
||||
- { name: IPMeta, type: "api.IPMeta", description: "Métadonnées IP." }
|
||||
- { name: TCPMeta, type: "api.TCPMeta", description: "Métadonnées TCP." }
|
||||
- { name: Fingerprints, type: "api.Fingerprints", description: "Empreintes JA4/JA3 associées." }
|
||||
- { name: SrcIP, type: "string", json_key: "src_ip" }
|
||||
- { name: SrcPort, type: "uint16", json_key: "src_port" }
|
||||
- { name: DstIP, type: "string", json_key: "dst_ip" }
|
||||
- { name: DstPort, type: "uint16", json_key: "dst_port" }
|
||||
|
||||
# IPMeta flatten
|
||||
- { name: IPTTL, type: "uint8", json_key: "ip_meta_ttl" }
|
||||
- { name: IPTotalLen, type: "uint16", json_key: "ip_meta_total_length" }
|
||||
- { name: IPID, type: "uint16", json_key: "ip_meta_id" }
|
||||
- { name: IPDF, type: "bool", json_key: "ip_meta_df" }
|
||||
|
||||
# TCPMeta flatten
|
||||
- { name: TCPWindow, type: "uint16", json_key: "tcp_meta_window_size" }
|
||||
- { name: TCPMSS, type: "uint16", json_key: "tcp_meta_mss" }
|
||||
- { name: TCPWScale, type: "uint8", json_key: "tcp_meta_window_scale" }
|
||||
- { name: TCPOptions, type: "string", json_key: "tcp_meta_options" }
|
||||
|
||||
# Fingerprints
|
||||
- { name: JA4, type: "string", json_key: "ja4" }
|
||||
- { name: JA4Hash, type: "string", json_key: "ja4_hash" }
|
||||
- { name: JA3, type: "string", json_key: "ja3" }
|
||||
- { name: JA3Hash, type: "string", json_key: "ja3_hash" }
|
||||
|
||||
|
||||
- name: "api.OutputConfig"
|
||||
description: "Configuration d’une sortie de logs."
|
||||
@ -278,6 +325,49 @@ api:
|
||||
notes:
|
||||
- "Doit supporter plusieurs outputs simultanés via un MultiWriter."
|
||||
|
||||
- name: "logging.LoggerFactory"
|
||||
description: "Fabrique de loggers structurés JSON."
|
||||
module: "logging"
|
||||
methods:
|
||||
- name: "NewLogger"
|
||||
params:
|
||||
- { name: level, type: "string" }
|
||||
returns:
|
||||
- { type: "api.Logger" }
|
||||
- name: "NewDefaultLogger"
|
||||
params: []
|
||||
returns:
|
||||
- { type: "api.Logger" }
|
||||
notes:
|
||||
- "Les logs sont émis en JSON lines sur stdout pour systemd/journald."
|
||||
|
||||
- name: "api.Logger"
|
||||
description: "Interface de logging pour tous les modules."
|
||||
module: "logging"
|
||||
methods:
|
||||
- name: "Debug"
|
||||
params:
|
||||
- { name: component, type: "string" }
|
||||
- { name: message, type: "string" }
|
||||
- { name: details, type: "map[string]string" }
|
||||
- name: "Info"
|
||||
params:
|
||||
- { name: component, type: "string" }
|
||||
- { name: message, type: "string" }
|
||||
- { name: details, type: "map[string]string" }
|
||||
- name: "Warn"
|
||||
params:
|
||||
- { name: component, type: "string" }
|
||||
- { name: message, type: "string" }
|
||||
- { name: details, type: "map[string]string" }
|
||||
- name: "Error"
|
||||
params:
|
||||
- { name: component, type: "string" }
|
||||
- { name: message, type: "string" }
|
||||
- { name: details, type: "map[string]string" }
|
||||
notes:
|
||||
- "Tous les logs passent par stdout/stderr (pas de fichiers directs)."
|
||||
|
||||
architecture:
|
||||
style: "pipeline"
|
||||
flow:
|
||||
@ -479,3 +569,89 @@ dev_tools:
|
||||
- "Génération automatique de Dockerfile.dev et Dockerfile à partir de cette section."
|
||||
- "Génération de fichiers docker-compose.test.yml pour les scénarios d’intégration."
|
||||
|
||||
service:
|
||||
systemd:
|
||||
unit_name: "ja4sentinel.service"
|
||||
description: "JA4 client fingerprinting daemon"
|
||||
wanted_by: "multi-user.target"
|
||||
exec:
|
||||
binary_path: "/usr/local/bin/ja4sentinel"
|
||||
args:
|
||||
- "--config"
|
||||
- "/etc/ja4sentinel/config.yml"
|
||||
user_group:
|
||||
user: "ja4sentinel"
|
||||
group: "ja4sentinel"
|
||||
runtime:
|
||||
working_directory: "/var/lib/ja4sentinel"
|
||||
pid_file: "/run/ja4sentinel.pid"
|
||||
restart: "on-failure"
|
||||
restart_sec: 5
|
||||
environment_prefix: "JA4SENTINEL_"
|
||||
logging:
|
||||
type: "journald"
|
||||
journal_identifier: "ja4sentinel"
|
||||
expectations:
|
||||
- "Le binaire écrit les logs de service sur stdout/stderr."
|
||||
- "Les messages doivent inclure au minimum un niveau (INFO/ERROR) et un composant."
|
||||
security:
|
||||
capabilities:
|
||||
- "CAP_NET_RAW"
|
||||
- "CAP_NET_ADMIN"
|
||||
sandboxing:
|
||||
- "NoNewPrivileges=yes"
|
||||
- "ProtectSystem=full"
|
||||
- "ProtectHome=true"
|
||||
- "PrivateTmp=true"
|
||||
integration_rules:
|
||||
- "Le binaire doit s’arrêter proprement sur SIGTERM (systemd stop)."
|
||||
- "Le module cmd_ja4sentinel gère les signaux et termine la capture proprement."
|
||||
- "Les chemins (config, socket UNIX, logs) doivent être compatibles avec FHS (/etc, /var/run, /var/log)."
|
||||
- "Le module cmd_ja4sentinel capture SIGTERM/SIGINT et déclenche un arrêt propre (stop capture, flush outputs, fermer socket UNIX)."
|
||||
- "Le processus doit retourner un code de sortie non nul en cas d’erreur fatale au démarrage."
|
||||
|
||||
logging:
|
||||
strategy:
|
||||
description: >
|
||||
ja4sentinel écrit ses logs techniques sur stdout/stderr au format JSON lines,
|
||||
afin que systemd/journald puissent les collecter et les filtrer.
|
||||
format: "json_lines"
|
||||
fields:
|
||||
- "timestamp"
|
||||
- "level"
|
||||
- "component" # capture, tlsparse, fingerprint, output, service, ...
|
||||
- "message"
|
||||
- "trace_id" # optionnel
|
||||
- "conn_id" # optionnel (identifiant de flux TCP)
|
||||
rules:
|
||||
- "Pas d’écriture directe dans des fichiers de log techniques depuis le code (stdout/stderr uniquement)."
|
||||
- "Les logs techniques du daemon passent par stdout/stderr (systemd/journald)."
|
||||
- "Les outputs métiers (LogRecord JA4) sont gérés par le module output, vers socket UNIX et/ou fichier JSON."
|
||||
|
||||
json_format:
|
||||
description: >
|
||||
Les LogRecord métiers (JA4 + métadonnées) sont sérialisés en JSON objet plat,
|
||||
avec des champs nommés explicitement pour ingestion dans ClickHouse.
|
||||
rules:
|
||||
- "Pas de tableaux imbriqués ni d’objets deeply nested."
|
||||
- "Toutes les métadonnées IP/TCP sont flatten sous forme de champs scalaires nommés."
|
||||
- "Les noms de champs suivent la convention: ip_meta_*, tcp_meta_*, ja4*."
|
||||
logrecord_schema:
|
||||
# Exemple de mapping pour api.LogRecord (résumé)
|
||||
- "src_ip"
|
||||
- "src_port"
|
||||
- "dst_ip"
|
||||
- "dst_port"
|
||||
- "ip_meta_ttl"
|
||||
- "ip_meta_total_length"
|
||||
- "ip_meta_id"
|
||||
- "ip_meta_df"
|
||||
- "tcp_meta_window_size"
|
||||
- "tcp_meta_mss"
|
||||
- "tcp_meta_window_scale"
|
||||
- "tcp_meta_options" # string joinée, ex: 'MSS,SACK,TS,NOP,WS'
|
||||
- "ja4"
|
||||
- "ja4_hash"
|
||||
- "ja3"
|
||||
- "ja3_hash"
|
||||
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/google/gopacket/pcap"
|
||||
|
||||
"ja4sentinel/internal/api"
|
||||
"ja4sentinel/api"
|
||||
)
|
||||
|
||||
// CaptureImpl implémente l'interface capture.Capture
|
||||
// CaptureImpl implements the capture.Capture interface for packet capture
|
||||
type CaptureImpl struct {
|
||||
handle *pcap.Handle
|
||||
}
|
||||
|
||||
// New crée une nouvelle instance de capture
|
||||
// New creates a new capture instance
|
||||
func New() *CaptureImpl {
|
||||
return &CaptureImpl{}
|
||||
}
|
||||
|
||||
// Run démarre la capture des paquets réseau selon la configuration
|
||||
// Run starts network packet capture according to the configuration
|
||||
func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
|
||||
var err error
|
||||
c.handle, err = pcap.OpenLive(cfg.Interface, 1600, true, pcap.BlockForever)
|
||||
@ -31,14 +29,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
|
||||
}
|
||||
defer c.handle.Close()
|
||||
|
||||
// Appliquer le filtre BPF s'il est fourni
|
||||
// Apply BPF filter if provided
|
||||
if cfg.BPFFilter != "" {
|
||||
err = c.handle.SetBPFFilter(cfg.BPFFilter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set BPF filter: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Créer un filtre par défaut pour les ports surveillés
|
||||
// Create default filter for monitored ports
|
||||
defaultFilter := buildBPFForPorts(cfg.ListenPorts)
|
||||
err = c.handle.SetBPFFilter(defaultFilter)
|
||||
if err != nil {
|
||||
@ -49,14 +47,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
|
||||
packetSource := gopacket.NewPacketSource(c.handle, c.handle.LinkType())
|
||||
|
||||
for packet := range packetSource.Packets() {
|
||||
// Convertir le paquet en RawPacket
|
||||
// Convert packet to RawPacket
|
||||
rawPkt := packetToRawPacket(packet)
|
||||
if rawPkt != nil {
|
||||
select {
|
||||
case out <- *rawPkt:
|
||||
// Paquet envoyé avec succès
|
||||
// Packet sent successfully
|
||||
default:
|
||||
// Canal plein, ignorer le paquet
|
||||
// Channel full, drop packet
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -64,7 +62,7 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBPFForPorts construit un filtre BPF pour les ports TCP spécifiés
|
||||
// buildBPFForPorts builds a BPF filter for the specified TCP ports
|
||||
func buildBPFForPorts(ports []uint16) string {
|
||||
if len(ports) == 0 {
|
||||
return "tcp"
|
||||
@ -77,7 +75,7 @@ func buildBPFForPorts(ports []uint16) string {
|
||||
return "(" + joinString(filterParts, ") or (") + ")"
|
||||
}
|
||||
|
||||
// joinString joint des chaînes avec un séparateur
|
||||
// joinString joins strings with a separator
|
||||
func joinString(parts []string, sep string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
@ -89,7 +87,7 @@ func joinString(parts []string, sep string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// packetToRawPacket convertit un paquet gopacket en RawPacket
|
||||
// packetToRawPacket converts a gopacket packet to RawPacket
|
||||
func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
|
||||
data := packet.Data()
|
||||
if len(data) == 0 {
|
||||
@ -102,7 +100,7 @@ func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
|
||||
}
|
||||
}
|
||||
|
||||
// Close ferme correctement la capture
|
||||
// Close properly closes the capture handle
|
||||
func (c *CaptureImpl) Close() error {
|
||||
if c.handle != nil {
|
||||
c.handle.Close()
|
||||
|
||||
@ -2,9 +2,6 @@ package capture
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ja4sentinel/internal/api"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"github.com/your-repo/ja4sentinel/api"
|
||||
"ja4sentinel/api"
|
||||
)
|
||||
|
||||
// LoggerFactory creates logger instances
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/your-repo/ja4sentinel/api"
|
||||
"ja4sentinel/api"
|
||||
)
|
||||
|
||||
// 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