fix(ja4ebpf): split bpf2go generate into Ja4Tc + Ja4Ssl, fix RPM systemd-rpm-macros

- Use two separate //go:generate directives (Ja4Tc for tc_capture.c, Ja4Ssl
  for uprobe_ssl.c) to avoid duplicate LICENSE symbol and multi-file clang issue
- Update loader.go to hold tcObjs/sslObjs separately with correct field names:
  UprobeSslSetFd, UprobeSslReadEntry, UretprobeSslReadExit,
  KprobeAccept4Entry, KretprobeAccept4Exit
- Add systemd-rpm-macros to all three RPM build stages (el8/el9/el10)
  so that %{_unitdir} macro resolves correctly
- RPMs now build successfully for el8, el9, el10

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-11 23:21:11 +02:00
parent a1e4c1dad5
commit 3b047b680a
155 changed files with 197011 additions and 599 deletions

View File

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

View File

@ -0,0 +1,7 @@
# sentinel configuration — DO NOT COMMIT real values
# Copy to .env and fill in for local development
JA4SENTINEL_INTERFACE=eth0
JA4SENTINEL_PORTS=443,8443
JA4SENTINEL_BPF_FILTER=
JA4SENTINEL_FLOW_TIMEOUT=30
JA4SENTINEL_PACKET_BUFFER_SIZE=1000

View File

@ -0,0 +1,125 @@
name: Build RPM Package
on:
push:
tags:
- 'v*'
branches:
- main
- master
paths:
- 'go/**'
- 'cmd/**'
- 'internal/**'
- 'api/**'
- 'packaging/**'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- 'Dockerfile.package'
pull_request:
branches:
- main
- master
paths:
- 'go/**'
- 'cmd/**'
- 'internal/**'
- 'api/**'
- 'packaging/**'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- 'Dockerfile.package'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g., 1.0.0)'
required: false
default: '1.0.0-dev'
env:
GO_VERSION: '1.24'
PACKAGE_NAME: ja4sentinel
jobs:
build-rpm:
name: Build RPM Packages (CentOS 7, Rocky 8/9/10)
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name#v }}"
else
VERSION="0.0.0-$(git rev-parse --short HEAD)"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Build RPM packages in Docker
run: |
docker build --no-cache \
-t ${PACKAGE_NAME}-packager \
--build-arg VERSION="${{ steps.version.outputs.version }}" \
-f Dockerfile.package .
# Extract RPM packages from image
mkdir -p build/rpm/el8 build/rpm/el9 build/rpm/el10
docker run --rm -v $(pwd)/build:/output ${PACKAGE_NAME}-packager sh -c \
'cp -r /packages/rpm/el8 /output/rpm/ && \
cp -r /packages/rpm/el9 /output/rpm/ && \
cp -r /packages/rpm/el10 /output/rpm/'
- name: List build artifacts
run: |
echo "=== Build Artifacts ==="
echo "Rocky Linux 8 (el8):"
ls -lah build/rpm/el8/ || echo " (no packages)"
echo "Rocky Linux 9 (el9):"
ls -lah build/rpm/el9/ || echo " (no packages)"
echo "AlmaLinux/Rocky 10 (el10):"
ls -lah build/rpm/el10/ || echo " (no packages)"
# Generate checksums
find build/rpm -name "*.rpm" -exec sha256sum {} \; > build/rpm/checksums.txt
cat build/rpm/checksums.txt
- name: Upload RPM artifacts
uses: actions/upload-artifact@v4
with:
name: ${PACKAGE_NAME}-rpm-x86_64
path: build/rpm/**/*.rpm
retention-days: 30
- name: Upload checksum artifact
uses: actions/upload-artifact@v4
with:
name: ${PACKAGE_NAME}-rpm-checksums
path: build/rpm/checksums.txt
retention-days: 30
- name: Create release and upload assets (on tag)
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
build/rpm/el8/*.rpm
build/rpm/el9/*.rpm
build/rpm/el10/*.rpm
generate_release_notes: true
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

59
old/services/sentinel/.gitignore vendored Normal file
View File

@ -0,0 +1,59 @@
# AIDER
.aider*
.qwen/
.qwenignore
# Build artifacts
dist/
build/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.o
# Go
*.test
*.out
coverage.out
coverage.html
go.work
go.work.sum
# Docker
*.dockerfile.local
docker-compose.override.yml
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Temporary files
tmp/
temp/
*.tmp
*.bak
# Local config (copie de config.yml.example)
config.yml
# Runtime artifacts
*.pid
*.sock
# Integration test artifacts
test-results/
# Test artifacts
packaging/test/*.rpm
# Build artifacts
packages/
# Binary (root level only)
/ja4sentinel
ja4sentinel-linux-amd64

View File

@ -0,0 +1,37 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git make libpcap-dev gcc musl-dev linux-headers
WORKDIR /build
# Copy workspace and shared module first (better caching)
COPY go.work go.work.sum* ./
COPY shared/go/ja4common/ ./shared/go/ja4common/
COPY services/sentinel/go.mod services/sentinel/go.sum* ./services/sentinel/
COPY services/correlator/go.mod services/correlator/go.sum* ./services/correlator/
WORKDIR /build/services/sentinel
RUN go mod download || true
COPY services/sentinel/ /build/services/sentinel/
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG GIT_COMMIT=unknown
RUN mkdir -p dist && \
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
CGO_LDFLAGS="-Wl,-Bstatic -lpcap -Wl,-Bdynamic" \
go build -buildvcs=false \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \
-o dist/sentinel ./cmd/ja4sentinel
FROM alpine:latest
RUN apk add --no-cache ca-certificates
RUN addgroup -S sentinel && adduser -S sentinel -G sentinel
RUN mkdir -p /var/lib/sentinel /var/run /etc/sentinel /var/log/sentinel
COPY --from=builder /build/services/sentinel/dist/sentinel /usr/local/bin/sentinel
RUN chown -R sentinel:sentinel /var/lib/sentinel /var/log/sentinel
USER sentinel
WORKDIR /var/lib/sentinel
ENTRYPOINT ["/usr/local/bin/sentinel"]

View File

@ -0,0 +1,25 @@
# Development and test image for sentinel (was ja4sentinel)
# Build context: monorepo root (ja4-platform/)
# Usage: docker build -f services/sentinel/Dockerfile.dev -t sentinel-dev .
FROM golang:1.24-alpine
RUN apk add --no-cache git make libpcap-dev gcc musl-dev linux-headers
WORKDIR /build
# Copy Go workspace and shared module first for better layer caching
COPY go.work go.work.sum* ./
COPY shared/go/ja4common/ ./shared/go/ja4common/
# Copy service module descriptor then download deps
COPY services/sentinel/go.mod services/sentinel/go.sum* ./services/sentinel/
COPY services/correlator/go.mod services/correlator/go.sum* ./services/correlator/
WORKDIR /build/services/sentinel
RUN go mod download || true
# Copy full service source
COPY services/sentinel/ /build/services/sentinel/
# Default: run tests with race detector
CMD ["go", "test", "-race", "-v", "./..."]

View File

@ -0,0 +1,109 @@
# syntax=docker/dockerfile:1
# =============================================================================
# sentinel — Dockerfile de packaging RPM (Rocky Linux 8/9, AlmaLinux 10)
# Build context: monorepo root (ja4-platform/)
# Méthode: 1 builder Go → 1 rpm-builder (rpmbuild, 3 × dist) → 1 output alpine
# =============================================================================
# =============================================================================
# Stage 1: Builder — compilation du binaire Go sur Rocky Linux 9
# Rocky Linux 9 comme base builder assure la compatibilité binaire sur toutes
# les distros cibles (el8/el9/el10 sont ABI-compatibles pour les libs system).
# =============================================================================
FROM rockylinux:9 AS builder
WORKDIR /build
RUN dnf install -y epel-release && \
dnf config-manager --set-enabled crb && \
dnf install -y golang git libpcap-devel gcc make && \
dnf clean all
# Copie du workspace Go et du module partagé en premier (meilleur cache)
COPY go.work go.work.sum* ./
COPY shared/go/ja4common/ ./shared/go/ja4common/
COPY services/sentinel/go.mod services/sentinel/go.sum* ./services/sentinel/
COPY services/correlator/go.mod services/correlator/go.sum* ./services/correlator/
WORKDIR /build/services/sentinel
RUN go mod download || true
COPY services/sentinel/ /build/services/sentinel/
ARG VERSION=dev
ARG BUILD_TIME=""
ARG GIT_COMMIT=""
RUN mkdir -p dist && \
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -buildvcs=false \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \
-o dist/sentinel \
./cmd/ja4sentinel
# =============================================================================
# Stage 2: rpm-builder — construction des RPMs avec rpmbuild
# Un seul stage, trois appels rpmbuild successifs (el8, el9, el10).
# =============================================================================
FROM rockylinux:9 AS rpm-builder
WORKDIR /package
ARG VERSION=dev
RUN dnf install -y rpm-build rpmdevtools systemd-rpm-macros && dnf clean all
RUN mkdir -p /root/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} && \
mkdir -p /root/rpmbuild/SOURCES/logrotate && \
mkdir -p /packages/rpm/{el8,el9,el10}
# Spec et fichiers sources
COPY services/sentinel/packaging/rpm/ja4sentinel.spec /root/rpmbuild/SPECS/ja4sentinel.spec
COPY --from=builder /build/services/sentinel/dist/sentinel /root/rpmbuild/SOURCES/ja4sentinel
COPY services/sentinel/packaging/systemd/ja4sentinel.service /root/rpmbuild/SOURCES/ja4sentinel.service
COPY services/sentinel/packaging/logrotate/ja4sentinel /root/rpmbuild/SOURCES/logrotate/ja4sentinel
COPY services/sentinel/config.yml.example /root/rpmbuild/SOURCES/config.yml
RUN chmod 755 /root/rpmbuild/SOURCES/ja4sentinel && \
chmod 644 /root/rpmbuild/SOURCES/ja4sentinel.service && \
chmod 644 /root/rpmbuild/SOURCES/logrotate/ja4sentinel && \
chmod 640 /root/rpmbuild/SOURCES/config.yml
# el8
RUN rpmbuild --define "_topdir /root/rpmbuild" \
--define "dist .el8" \
--define "build_version ${VERSION}" \
--target x86_64 \
-bb /root/rpmbuild/SPECS/ja4sentinel.spec && \
cp /root/rpmbuild/RPMS/x86_64/*.el8.x86_64.rpm /packages/rpm/el8/
# el9
RUN rpmbuild --define "_topdir /root/rpmbuild" \
--define "dist .el9" \
--define "build_version ${VERSION}" \
--target x86_64 \
-bb /root/rpmbuild/SPECS/ja4sentinel.spec && \
cp /root/rpmbuild/RPMS/x86_64/*.el9.x86_64.rpm /packages/rpm/el9/
# el10
RUN rpmbuild --define "_topdir /root/rpmbuild" \
--define "dist .el10" \
--define "build_version ${VERSION}" \
--target x86_64 \
-bb /root/rpmbuild/SPECS/ja4sentinel.spec && \
cp /root/rpmbuild/RPMS/x86_64/*.el10.x86_64.rpm /packages/rpm/el10/
# =============================================================================
# Stage 3: output — image finale contenant uniquement les RPMs
# =============================================================================
FROM alpine:latest AS output
WORKDIR /packages
COPY --from=rpm-builder /packages/rpm/el8/*.rpm /packages/rpm/el8/
COPY --from=rpm-builder /packages/rpm/el9/*.rpm /packages/rpm/el9/
COPY --from=rpm-builder /packages/rpm/el10/*.rpm /packages/rpm/el10/
CMD ["sh", "-c", \
"echo '=== RPM el8 ===' && ls -la /packages/rpm/el8/ && \
echo '' && echo '=== RPM el9 ===' && ls -la /packages/rpm/el9/ && \
echo '' && echo '=== RPM el10 ===' && ls -la /packages/rpm/el10/"]

View File

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

View File

@ -0,0 +1,158 @@
.PHONY: build build-docker test test-docker test-integration lint clean help docker-build-dev docker-build-runtime package package-rpm
# 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_DIR=build
# RPM build directory
RPM_DIR=$(DIST_DIR)/rpm
# Package version (extract default from spec file, can be overridden)
PKG_VERSION ?= $(shell grep '^%define spec_version' packaging/rpm/ja4sentinel.spec | tail -1 | awk '{print $$3}')
# Build flags
VERSION=$(PKG_VERSION)
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 build --no-cache
$(DOCKER_COMPOSE) -f docker-compose.test.yml up --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 build --no-cache
$(DOCKER_COMPOSE) -f docker-compose.test.yml up --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 .
## package: Build RPM packages for all target distributions
package: package-rpm
## package-rpm: Build RPM packages for Rocky Linux 8/9/10, AlmaLinux (requires Docker)
package-rpm:
mkdir -p $(RPM_DIR)/el8 $(RPM_DIR)/el9 $(RPM_DIR)/el10
@echo "Building RPM packages for Rocky Linux 8/9, AlmaLinux 10..."
docker build --target output -t ja4sentinel-rpm-packager:latest \
--build-arg VERSION=$(PKG_VERSION) \
-f Dockerfile.package .
@echo "Extracting RPM packages from Docker image..."
@docker run --rm -v $(PWD)/$(RPM_DIR):/output/rpm ja4sentinel-rpm-packager:latest sh -c \
'cp -r /packages/rpm/el8 /output/rpm/ && \
cp -r /packages/rpm/el9 /output/rpm/ && \
cp -r /packages/rpm/el10 /output/rpm/'
@echo "RPM packages created:"
@echo " Rocky Linux 8 (el8):"
ls -la $(RPM_DIR)/el8/ 2>/dev/null || echo " (no packages)"
@echo " Rocky Linux 9 (el9):"
ls -la $(RPM_DIR)/el9/ 2>/dev/null || echo " (no packages)"
@echo " AlmaLinux/Rocky 10 (el10):"
ls -la $(RPM_DIR)/el10/ 2>/dev/null || echo " (no packages)"
## test-package-rpm: Test RPM package installation in Docker
test-package-rpm: package-rpm
./packaging/test/test-rpm.sh
## test-package: Test RPM package installation
test-package: test-package-rpm
## ci: Full CI pipeline (tests, build, packages, package tests)
ci: ci-test ci-build ci-package ci-package-test
## ci-test: Run all tests for CI
ci-test: test lint
## ci-build: Build for CI (production binary)
ci-build: build-linux
## ci-package: Build all packages for CI
ci-package: package
## ci-package-test: Test all packages for CI
ci-package-test: test-package
## clean: Clean build artifacts and Docker images
clean:
rm -rf $(DIST_DIR)/
rm -rf $(BUILD_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/^/ /'

View File

@ -0,0 +1,291 @@
# 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 compilation)
- Docker (pour les tests et le déploiement)
### Note sur libpcap
**Le binaire est compilé sur Rocky Linux 9** pour une compatibilité maximale avec toutes les distributions RHEL/Rocky/AlmaLinux.
libpcap est requis à l'exécution et sera installé automatiquement par le gestionnaire de packages.
### Packages système
#### Rocky Linux / RHEL / AlmaLinux (.rpm)
```bash
# Télécharger le package
wget https://github.com/your-repo/ja4sentinel/releases/latest/download/ja4sentinel.rpm
# Installer
sudo dnf install ./ja4sentinel.rpm
# Activer le service
sudo systemctl enable ja4sentinel
sudo systemctl start ja4sentinel
# Vérifier le statut
sudo systemctl status ja4sentinel
```
#### Distributions supportées
- Rocky Linux 8, 9, 10
- AlmaLinux 8, 9, 10
- RHEL 8, 9, 10
## 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/logcorrelator/network.socket
```
### 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/logcorrelator/network.socket
# log_level: debug # debug, info, warn, error (défaut: error)
```
### Logging fichier + stdout
```yaml
core:
interface: ens192
listen_ports: [443]
flow_timeout_sec: 60
outputs:
- type: stdout
enabled: true
- type: file
enabled: true
params:
path: /var/log/ja4sentinel/ja4.json
```
## Développement
### Linting
```bash
make lint
```
### Formatage
```bash
make fmt
```
### Nettoyage
```bash
# Supprime les binaires et images Docker
make clean
# Supprime aussi les conteneurs et volumes
make clean-all
```
## Licence
À définir.
## Contribuer
1. Fork le projet
2. Créer une branche de feature (`git checkout -b feature/amélioration`)
3. Commit les changements (`git commit -am 'Ajout fonctionnalité'`)
4. Push (`git push origin feature/amélioration`)
5. Ouvrir une Pull Request
---
**Voir `architecture.yml` pour la documentation complète de l'architecture.**

View File

@ -0,0 +1,307 @@
package api
import (
"strings"
"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 `yaml:"interface" json:"interface"`
ListenPorts []uint16 `yaml:"listen_ports" json:"listen_ports"`
BPFFilter string `yaml:"bpf_filter" json:"bpf_filter,omitempty"`
LocalIPs []string `yaml:"local_ips" json:"local_ips,omitempty"` // Local IPs to monitor (empty = auto-detect, excludes loopback)
ExcludeSourceIPs []string `yaml:"exclude_source_ips" json:"exclude_source_ips,omitempty"` // Source IPs or CIDR ranges to exclude (e.g., ["10.0.0.0/8", "192.168.1.1"])
FlowTimeoutSec int `yaml:"flow_timeout_sec" json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30)
PacketBufferSize int `yaml:"packet_buffer_size" json:"packet_buffer_size,omitempty"` // Buffer size for packet channel (default: 1000)
LogLevel string `yaml:"log_level" json:"log_level,omitempty"` // Log level: debug, info, warn, error (default: info)
}
// 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"`
OptionKinds []uint8 `json:"-"` // Raw TCP option kind numbers for JA4T
}
// RawPacket represents a raw packet captured from the network
type RawPacket struct {
Data []byte `json:"-"` // Raw packet data including link-layer header
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
LinkType int `json:"-"` // Link type (1=Ethernet, 101=Linux SLL, etc.)
}
// 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"`
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version supported
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
}
// Fingerprints contains TLS fingerprints for a client flow
// Note: JA4Hash is kept for internal use but not serialized to LogRecord
// as the JA4 format already includes its own hash portions
type Fingerprints struct {
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash,omitempty"` // Internal use, not serialized to LogRecord
JA4T string `json:"ja4t,omitempty"`
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
// Correlation & Triage
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SensorID string `json:"sensor_id,omitempty"` // Sensor/captor identifier
// TLS elements (ClientHello)
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version announced by client
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
// Behavioral detection (Timing)
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
// Fingerprints
// Note: ja4_hash is NOT included - the JA4 format already includes its own hash portions
JA4 string `json:"ja4"`
JA4T string `json:"ja4t,omitempty"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
// Timestamp in nanoseconds since Unix epoch
Timestamp int64 `json:"timestamp"`
}
// OutputConfig defines configuration for a single log output
type OutputConfig struct {
Type string `yaml:"type" json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `yaml:"enabled" json:"enabled"` // whether this output is active
AsyncBuffer int `yaml:"async_buffer" json:"async_buffer"` // queue size for async writes (e.g., 5000)
Params map[string]string `yaml:"params" json:"params"` // specific parameters like socket_path, path, etc.
}
// AppConfig is the complete ja4sentinel configuration
type AppConfig struct {
Core Config `yaml:"core" json:"core"`
Outputs []OutputConfig `yaml:"outputs" json:"outputs"`
}
// Loader defines the interface for loading application configuration.
// Implementations must read configuration from a YAML file, merge with
// environment variables (JA4SENTINEL_*), and validate the final result.
type Loader interface {
Load() (AppConfig, error)
}
// Capture defines the interface for capturing raw network packets.
// Implementations must listen on a configured network interface, apply
// BPF filters for specified ports, and emit RawPacket objects to a channel.
// The Close method must be called to release resources (e.g., pcap handle).
type Capture interface {
Run(cfg Config, out chan<- RawPacket) error
Close() error
GetStats() (received, sent, dropped uint64)
}
// Parser defines the interface for extracting TLS ClientHello messages
// from raw network packets. Implementations must track TCP connection states,
// reassemble fragmented handshakes, and return TLSClientHello objects with
// IP/TCP metadata. Returns nil for non-TLS or non-ClientHello packets.
type Parser interface {
Process(pkt RawPacket) (*TLSClientHello, error)
Close() error
GetMetrics() (retransmit, gapDetected, bufferExceeded, segmentExceeded uint64)
}
// Engine defines the interface for generating TLS fingerprints.
// Implementations must analyze TLS ClientHello payloads and produce
// JA4 (required) and optionally JA3 fingerprint strings.
type Engine interface {
FromClientHello(ch TLSClientHello) (*Fingerprints, error)
}
// Writer defines the generic interface for writing log records.
// Implementations must serialize LogRecord objects and send them to
// a destination (stdout, file, UNIX socket, etc.).
type Writer interface {
Write(rec LogRecord) error
}
// UnixSocketWriter extends Writer with a Close method for UNIX socket cleanup.
// Implementations must connect to a UNIX socket at the specified path and
// write JSON-encoded LogRecord objects. Reconnection logic should be
// implemented for transient socket failures.
type UnixSocketWriter interface {
Writer
Close() error
}
// MultiWriter extends Writer to support multiple output destinations.
// Implementations must write each LogRecord to all registered writers
// and provide methods to add writers and close all connections.
type MultiWriter interface {
Writer
Add(writer Writer)
CloseAll() error
}
// Builder defines the interface for constructing output writers from configuration.
// Implementations must parse AppConfig.Outputs and create appropriate Writer
// instances (StdoutWriter, FileWriter, UnixSocketWriter), combining them
// into a MultiWriter if multiple outputs are configured.
type Builder interface {
NewFromConfig(cfg AppConfig) (Writer, error)
}
// Logger defines the interface for structured service logging.
// Implementations must emit JSON-formatted log entries to stdout/stderr
// with support for multiple log levels (DEBUG, INFO, WARN, ERROR).
// Each log entry includes timestamp, level, component, message, and optional details.
type Logger interface {
Debug(component, message string, details map[string]string)
Info(component, message string, details map[string]string)
Warn(component, message string, details map[string]string)
Error(component, message string, details map[string]string)
}
// Reopenable defines the interface for components that support log file rotation.
// Implementations must reopen their output files when receiving a SIGHUP signal.
// This is used by systemctl reload to switch to new log files after logrotate.
type Reopenable interface {
Reopen() error
}
// Helper functions for creating and converting records
// NewLogRecord creates a flattened LogRecord from TLSClientHello and Fingerprints.
// Converts TCPMeta options to a comma-separated string and creates pointer values
// for optional fields (MSS, WindowScale) to support proper JSON omitempty behavior.
// If fingerprints is nil, the JA4/JA3 fields will be empty strings.
// Note: JA4Hash is intentionally NOT included in LogRecord as the JA4 format
// already includes its own hash portions (the full 38-character JA4 string is sufficient).
func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts := ""
if len(ch.TCPMeta.Options) > 0 {
opts = strings.Join(ch.TCPMeta.Options, ",")
}
// Helper to create pointer from value for optional fields
var mssPtr *uint16
if ch.TCPMeta.MSS != 0 {
mssPtr = &ch.TCPMeta.MSS
}
var wScalePtr *uint8
if ch.TCPMeta.WindowScale != 0 {
wScalePtr = &ch.TCPMeta.WindowScale
}
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: mssPtr,
TCPWScale: wScalePtr,
TCPOptions: opts,
ConnID: ch.ConnID,
SNI: ch.SNI,
ALPN: ch.ALPN,
TLSVersion: ch.TLSVersion,
SynToCHMs: ch.SynToCHMs,
Timestamp: time.Now().UnixNano(),
}
if fp != nil {
rec.JA4 = fp.JA4
rec.JA4T = fp.JA4T
rec.JA3 = fp.JA3
rec.JA3Hash = fp.JA3Hash
}
return rec
}
// Default values and constants
const (
DefaultInterface = "eth0"
DefaultPort = 443
DefaultBPFFilter = ""
DefaultFlowTimeout = 30 // seconds
DefaultPacketBuffer = 1000 // packet channel buffer size
DefaultLogLevel = "info"
)
// DefaultConfig returns an AppConfig with sensible default values.
// Uses eth0 as the default interface, port 443 for monitoring,
// no BPF filter, a 30-second flow timeout, and a 1000-packet
// channel buffer. Returns an empty outputs slice (caller must
// configure outputs explicitly).
func DefaultConfig() AppConfig {
return AppConfig{
Core: Config{
Interface: DefaultInterface,
ListenPorts: []uint16{DefaultPort},
BPFFilter: DefaultBPFFilter,
FlowTimeoutSec: DefaultFlowTimeout,
PacketBufferSize: DefaultPacketBuffer,
LogLevel: DefaultLogLevel,
},
Outputs: []OutputConfig{},
}
}

View File

@ -0,0 +1,340 @@
package api
import (
"testing"
)
func TestNewLogRecord(t *testing.T) {
synToCHMs := uint32(150)
tests := []struct {
name string
clientHello TLSClientHello
fingerprints *Fingerprints
wantNil bool
}{
{
name: "complete record with fingerprints",
clientHello: TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-abc123",
SNI: "example.com",
ALPN: "h2",
TLSVersion: "1.3",
SynToCHMs: &synToCHMs,
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
IPID: 12345,
DF: true,
},
TCPMeta: TCPMeta{
WindowSize: 65535,
MSS: 1460,
WindowScale: 7,
Options: []string{"MSS", "WS", "SACK", "TS"},
},
},
fingerprints: &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Internal use only
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
},
wantNil: false,
},
{
name: "record without fingerprints",
clientHello: TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-xyz789",
SNI: "test.example.com",
ALPN: "http/1.1",
TLSVersion: "1.2",
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
IPID: 12345,
DF: true,
},
TCPMeta: TCPMeta{
WindowSize: 65535,
MSS: 1460,
WindowScale: 7,
Options: []string{"MSS", "WS"},
},
},
fingerprints: nil,
wantNil: false,
},
{
name: "record with zero values for optional fields",
clientHello: TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
IPMeta: IPMeta{
TTL: 0,
TotalLength: 0,
IPID: 0,
DF: false,
},
TCPMeta: TCPMeta{
WindowSize: 0,
MSS: 0,
WindowScale: 0,
Options: []string{},
},
},
fingerprints: nil,
wantNil: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := NewLogRecord(tt.clientHello, tt.fingerprints)
// Verify timestamp is set
if rec.Timestamp == 0 {
t.Error("Timestamp should be set")
}
// Verify basic fields
if rec.SrcIP != tt.clientHello.SrcIP {
t.Errorf("SrcIP = %v, want %v", rec.SrcIP, tt.clientHello.SrcIP)
}
if rec.SrcPort != tt.clientHello.SrcPort {
t.Errorf("SrcPort = %v, want %v", rec.SrcPort, tt.clientHello.SrcPort)
}
if rec.DstIP != tt.clientHello.DstIP {
t.Errorf("DstIP = %v, want %v", rec.DstIP, tt.clientHello.DstIP)
}
if rec.DstPort != tt.clientHello.DstPort {
t.Errorf("DstPort = %v, want %v", rec.DstPort, tt.clientHello.DstPort)
}
// Verify IPMeta fields
if rec.IPTTL != tt.clientHello.IPMeta.TTL {
t.Errorf("IPTTL = %v, want %v", rec.IPTTL, tt.clientHello.IPMeta.TTL)
}
if rec.IPTotalLen != tt.clientHello.IPMeta.TotalLength {
t.Errorf("IPTotalLen = %v, want %v", rec.IPTotalLen, tt.clientHello.IPMeta.TotalLength)
}
if rec.IPID != tt.clientHello.IPMeta.IPID {
t.Errorf("IPID = %v, want %v", rec.IPID, tt.clientHello.IPMeta.IPID)
}
if rec.IPDF != tt.clientHello.IPMeta.DF {
t.Errorf("IPDF = %v, want %v", rec.IPDF, tt.clientHello.IPMeta.DF)
}
// Verify TCPMeta fields
if rec.TCPWindow != tt.clientHello.TCPMeta.WindowSize {
t.Errorf("TCPWindow = %v, want %v", rec.TCPWindow, tt.clientHello.TCPMeta.WindowSize)
}
// Verify optional fields (MSS, WindowScale)
if tt.clientHello.TCPMeta.MSS != 0 {
if rec.TCPMSS == nil {
t.Error("TCPMSS should not be nil when MSS != 0")
} else if *rec.TCPMSS != tt.clientHello.TCPMeta.MSS {
t.Errorf("TCPMSS = %v, want %v", *rec.TCPMSS, tt.clientHello.TCPMeta.MSS)
}
} else {
if rec.TCPMSS != nil {
t.Error("TCPMSS should be nil when MSS == 0")
}
}
if tt.clientHello.TCPMeta.WindowScale != 0 {
if rec.TCPWScale == nil {
t.Error("TCPWScale should not be nil when WindowScale != 0")
} else if *rec.TCPWScale != tt.clientHello.TCPMeta.WindowScale {
t.Errorf("TCPWScale = %v, want %v", *rec.TCPWScale, tt.clientHello.TCPMeta.WindowScale)
}
} else {
if rec.TCPWScale != nil {
t.Error("TCPWScale should be nil when WindowScale == 0")
}
}
// Verify new TLS fields
if rec.ConnID != tt.clientHello.ConnID {
t.Errorf("ConnID = %v, want %v", rec.ConnID, tt.clientHello.ConnID)
}
if rec.SNI != tt.clientHello.SNI {
t.Errorf("SNI = %v, want %v", rec.SNI, tt.clientHello.SNI)
}
if rec.ALPN != tt.clientHello.ALPN {
t.Errorf("ALPN = %v, want %v", rec.ALPN, tt.clientHello.ALPN)
}
if rec.TLSVersion != tt.clientHello.TLSVersion {
t.Errorf("TLSVersion = %v, want %v", rec.TLSVersion, tt.clientHello.TLSVersion)
}
if tt.clientHello.SynToCHMs != nil {
if rec.SynToCHMs == nil {
t.Error("SynToCHMs should not be nil")
} else if *rec.SynToCHMs != *tt.clientHello.SynToCHMs {
t.Errorf("SynToCHMs = %v, want %v", *rec.SynToCHMs, *tt.clientHello.SynToCHMs)
}
}
// Verify fingerprints (note: JA4Hash is NOT in LogRecord per architecture)
if tt.fingerprints != nil {
if rec.JA4 != tt.fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, tt.fingerprints.JA4)
}
// JA4Hash is intentionally NOT in LogRecord (architecture decision)
// JA3Hash is still present as it's the MD5 of JA3 (needed for exploitation)
if rec.JA3 != tt.fingerprints.JA3 {
t.Errorf("JA3 = %v, want %v", rec.JA3, tt.fingerprints.JA3)
}
if rec.JA3Hash != tt.fingerprints.JA3Hash {
t.Errorf("JA3Hash = %v, want %v", rec.JA3Hash, tt.fingerprints.JA3Hash)
}
} else {
if rec.JA4 != "" {
t.Error("JA4 should be empty when fingerprints is nil")
}
}
})
}
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Core.Interface != DefaultInterface {
t.Errorf("Core.Interface = %v, want %v", cfg.Core.Interface, DefaultInterface)
}
if len(cfg.Core.ListenPorts) != 1 {
t.Errorf("Core.ListenPorts length = %v, want 1", len(cfg.Core.ListenPorts))
}
if cfg.Core.ListenPorts[0] != DefaultPort {
t.Errorf("Core.ListenPorts[0] = %v, want %v", cfg.Core.ListenPorts[0], DefaultPort)
}
if cfg.Core.BPFFilter != DefaultBPFFilter {
t.Errorf("Core.BPFFilter = %v, want %v", cfg.Core.BPFFilter, DefaultBPFFilter)
}
if cfg.Core.FlowTimeoutSec != DefaultFlowTimeout {
t.Errorf("Core.FlowTimeoutSec = %v, want %v", cfg.Core.FlowTimeoutSec, DefaultFlowTimeout)
}
if len(cfg.Outputs) != 0 {
t.Errorf("Outputs length = %v, want 0", len(cfg.Outputs))
}
}
func TestLogRecordConversion(t *testing.T) {
// Test that NewLogRecord correctly converts TCPMeta options to comma-separated string
clientHello := TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
TCPMeta: TCPMeta{
WindowSize: 65535,
MSS: 1460,
WindowScale: 7,
Options: []string{"MSS", "WS", "SACK", "TS"},
},
}
rec := NewLogRecord(clientHello, nil)
// Verify options are joined with comma
expectedOpts := "MSS,WS,SACK,TS"
if rec.TCPOptions != expectedOpts {
t.Errorf("TCPOptions = %v, want %v", rec.TCPOptions, expectedOpts)
}
}
func TestLogRecordNoJA4Hash(t *testing.T) {
// Verify that JA4Hash is NOT included in LogRecord per architecture decision
clientHello := TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
}
fingerprints := &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Should NOT appear in LogRecord
JA3: "771,4865-4866-4867,0-23-65281,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
}
rec := NewLogRecord(clientHello, fingerprints)
// JA4Hash is NOT in LogRecord (architecture decision)
// The JA4 format already includes its own hash portions
// But JA4 should be present
if rec.JA4 != fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, fingerprints.JA4)
}
// JA3Hash should still be present (it's the MD5 of JA3, which is needed)
if rec.JA3Hash != fingerprints.JA3Hash {
t.Errorf("JA3Hash = %v, want %v", rec.JA3Hash, fingerprints.JA3Hash)
}
}
func TestOutputConfig(t *testing.T) {
tests := []struct {
name string
config OutputConfig
wantEnabled bool
wantAsyncBuf int
}{
{
name: "stdout output with async buffer",
config: OutputConfig{
Type: "stdout",
Enabled: true,
AsyncBuffer: 5000,
Params: map[string]string{},
},
wantEnabled: true,
wantAsyncBuf: 5000,
},
{
name: "unix_socket output with default async buffer",
config: OutputConfig{
Type: "unix_socket",
Enabled: true,
AsyncBuffer: 0, // Default
Params: map[string]string{"socket_path": "/var/run/test.sock"},
},
wantEnabled: true,
wantAsyncBuf: 0,
},
{
name: "disabled output",
config: OutputConfig{
Type: "file",
Enabled: false,
AsyncBuffer: 1000,
Params: map[string]string{"path": "/var/log/test.log"},
},
wantEnabled: false,
wantAsyncBuf: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.config.Enabled != tt.wantEnabled {
t.Errorf("Enabled = %v, want %v", tt.config.Enabled, tt.wantEnabled)
}
if tt.config.AsyncBuffer != tt.wantAsyncBuf {
t.Errorf("AsyncBuffer = %v, want %v", tt.config.AsyncBuffer, tt.wantAsyncBuf)
}
})
}
}

View File

@ -0,0 +1,524 @@
version: 1
project:
name: ja4sentinel
description: >
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
(via psanford/tlsfingerprint), enrichir avec des métadonnées IP/TCP,
et loguer les résultats (IP, ports, JA4, meta) vers une ou plusieurs
sorties configurables (socket UNIX par défaut, stdout, fichier, ...).
Le service est géré par systemd avec support de rotation des logs via logrotate.
La commande `systemctl reload ja4sentinel` permet de réouvrir les fichiers de log
après rotation (signal SIGHUP).
languages:
- go
goals:
- "Développement bloc par bloc avec interfaces simples et stables."
- "Focalisé sur JA4 client (le serveur est connu/local)."
- "Séparation claire des responsabilités (capture, parsing, fingerprint, output)."
- "Tests unitaires pour chaque fonction publique."
- "Tests dintégration dans des conteneurs Docker."
- "Commentaires standardisés, code évolutif avec changements minimaux."
modules:
- name: config
path: "internal/config"
description: "Chargement et validation de la configuration (fichier, env, CLI)."
responsibilities:
- "Lire le fichier de configuration (YAML par défaut)."
- "Fusionner avec les overrides env/CLI."
- "Construire une api.AppConfig cohérente."
allowed_dependencies: []
forbidden_dependencies:
- "capture"
- "tlsparse"
- "fingerprint"
- "output"
- name: capture
path: "internal/capture"
description: "Capture des paquets réseau (pcap/raw socket) sur Linux."
responsibilities:
- "Ouvrir linterface réseau configurée."
- "Appliquer les filtres (ports, BPF, protocole)."
- "Observer les flux TCP côté client vers les ports dintérêt."
- "Extraire les en-têtes IP/TCP utiles (IPMeta, TCPMeta)."
- "Convertir les paquets en objets RawPacket."
allowed_dependencies:
- "config"
- "api"
forbidden_dependencies:
- "tlsparse"
- "fingerprint"
- "output"
- name: tlsparse
path: "internal/tlsparse"
description: "Extraction des ClientHello TLS côté client à partir des paquets capturés."
responsibilities:
- "Décoder les couches IP/TCP jusqu'au payload TLS."
- "Identifier le ClientHello TLS du client sur les ports configurés."
- "Assembler les segments si nécessaire pour obtenir un ClientHello complet."
- "Produire des TLSClientHello enrichis avec IPMeta et TCPMeta."
- "Filtrer les IPs source exclues via le module ipfilter (avant parsing TLS)."
- "Compter les paquets filtrés pour statistiques (GetFilterStats)."
allowed_dependencies:
- "config"
- "capture"
- "api"
- "ipfilter"
forbidden_dependencies:
- "output"
- name: fingerprint
path: "internal/fingerprint"
description: "Génération des empreintes JA4 à partir des ClientHello TLS."
responsibilities:
- "Utiliser psanford/tlsfingerprint pour analyser le ClientHello."
- "Générer la chaîne JA4 (et éventuellement JA3) côté client."
- "Encapsuler les résultats dans un type Fingerprints."
allowed_dependencies:
- "config"
- "tlsparse"
- "api"
forbidden_dependencies:
- "capture"
- name: output
path: "internal/output"
description: "Sortie asynchrone ultra-rapide des résultats (JA4 + meta)."
responsibilities:
- "Prendre en entrée les Fingerprints et les métadonnées réseau."
- "Formater les données en enregistrements log (JSON ou autre format simple)."
- "Gérer une file d'attente interne (buffer channel) pour rendre l'écriture non-bloquante pour la capture."
- "Sérialiser le JSON le plus rapidement possible (ex: pool d'allocations, librairies optimisées comme goccy/go-json)."
- "Envoyer les enregistrements vers une ou plusieurs sorties (socket UNIX DGRAM, stdout, fichier, ...)."
- "Gérer un MultiWriter pour combiner plusieurs outputs sans modifier le reste du code."
allowed_dependencies:
- "config"
- "api"
forbidden_dependencies:
- "capture"
- "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: ipfilter
path: "internal/ipfilter"
description: "Filtrage des adresses IP source par correspondance IP/CIDR."
responsibilities:
- "Charger une liste d'IPs ou plages CIDR à exclure."
- "Vérifier si une IP source correspond à une entrée de la liste d'exclusion."
- "Supporter IPv4 et IPv6."
- "Validation des formats IP et CIDR lors du chargement de la config."
allowed_dependencies: []
forbidden_dependencies:
- "config"
- "capture"
- "tlsparse"
- "fingerprint"
- "output"
- name: cmd_ja4sentinel
path: "cmd/ja4sentinel"
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, logging)."
- "Brancher les modules entre eux selon l'architecture pipeline."
- "Gérer les signaux système (arrêt propre)."
- "Gérer le signal SIGHUP pour la rotation des logs (systemctl reload)."
- "Logger les statistiques du filtre IP au démarrage et à l'arrêt (debug)."
allowed_dependencies:
- "config"
- "capture"
- "tlsparse"
- "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: ListenPorts, type: "[]uint16", description: "Ports TCP à surveiller (ex: [443, 8443])." }
- { name: BPFFilter, type: "string", description: "Filtre BPF optionnel pour la capture." }
- { name: LocalIPs, type: "[]string", description: "IPs locales à surveiller (vide = auto-détection, exclut loopback)." }
- { name: ExcludeSourceIPs,type: "[]string", description: "IPs sources ou plages CIDR à exclure (ex: [\"10.0.0.0/8\", \"192.168.1.1\"]). Validé par le module config." }
- { name: FlowTimeoutSec, type: "int", description: "Timeout en secondes pour l'extraction du handshake TLS (défaut: 30)." }
- { name: PacketBufferSize,type: "int", description: "Taille du buffer du canal de paquets (défaut: 1000). Pour les environnements à fort trafic." }
- { name: LogLevel, type: "string", description: "Niveau de log : debug, info, warn, error (défaut: info). Configuration via fichier YAML uniquement (pas d'override env dans systemd)." }
- name: "api.IPMeta"
description: "Métadonnées IP pour fingerprinting de stack."
fields:
- { name: TTL, type: "uint8", description: "TTL initial observé." }
- { name: TotalLength, type: "uint16", description: "Taille totale du paquet IP." }
- { name: IPID, type: "uint16", description: "Identifiant IP du paquet." }
- { name: DF, type: "bool", description: "Flag Don't Fragment." }
- name: "api.TCPMeta"
description: "Métadonnées TCP pour fingerprinting de stack."
fields:
- { name: WindowSize, type: "uint16", description: "Fenêtre initiale TCP." }
- { name: MSS, type: "uint16", description: "Maximum Segment Size (option TCP)." }
- { name: WindowScale, type: "uint8", description: "Facteur de scaling (option TCP)." }
- { name: Options, type: "[]string", description: "Liste ordonnée des options TCP (ex: [MSS, SACK, TS])." }
- name: "api.RawPacket"
description: "Paquet brut capturé sur le réseau (vue minimale)."
fields:
- { name: Data, type: "[]byte", description: "Contenu brut du paquet." }
- { name: Timestamp, type: "int64", description: "Timestamp (nanos / epoch) de capture." }
- name: "api.TLSClientHello"
description: "Représentation d'un ClientHello TLS client, avec meta IP/TCP."
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: Payload, type: "[]byte", description: "Bytes correspondant au ClientHello TLS." }
- { name: IPMeta, type: "api.IPMeta", description: "Métadonnées IP observées côté client." }
- { name: TCPMeta, type: "api.TCPMeta", description: "Métadonnées TCP observées côté client." }
- { name: ConnID, type: "string", description: "Identifiant unique du flux TCP (extension pour corrélation)." }
- { name: SNI, type: "string", description: "Server Name Indication extrait du ClientHello (extension)." }
- { name: ALPN, type: "string", description: "ALPN protocols négociés (extension)." }
- { name: TLSVersion,type: "string", description: "Version TLS maximale annoncée (extension)." }
- { name: SynToCHMs,type: "*uint32", description: "Temps SYN->ClientHello en ms (extension pour détection comportementale)." }
- name: "api.Fingerprints"
description: "Empreintes TLS pour un flux client."
fields:
- { name: JA4, type: "string", description: "Signature JA4 client." }
- { name: JA4Hash, type: "string", description: "Hash JA4 client." }
- { name: JA3, type: "string", description: "Signature JA3 (optionnel, si calculée)." }
- { name: JA3Hash, type: "string", description: "Hash JA3 (optionnel)." }
- name: "api.LogRecord"
description: "Enregistrement de log final, sérialisé en JSON objet plat."
json_object: true
fields:
- { 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", optional: true, description: "Pointeur (nil si non présent, 0 si absent)." }
- { name: TCPWScale, type: "*uint8", json_key: "tcp_meta_window_scale", optional: true, description: "Pointeur (nil si non présent, 0 si absent)." }
- { name: TCPOptions, type: "string", json_key: "tcp_meta_options" }
# Fingerprints
- { name: JA4, type: "string", json_key: "ja4", description: "Le format JA4 inclut nativement ses propres hachages (parties b et c), pas besoin de ja4_hash séparé." }
- { name: JA3, type: "string", json_key: "ja3", description: "Chaîne brute JA3 (variable)." }
- { name: JA3Hash, type: "string", json_key: "ja3_hash", description: "Hachage MD5 indispensable pour exploiter la chaîne JA3." }
# --- Corrélation & Triage ---
- { name: ConnID, type: "string", json_key: "conn_id", optional: true, description: "Identifiant unique du flux (ex: hash de src_ip:src_port-dst_ip:dst_port) pour corréler facilement plusieurs événements liés à une même session TCP." }
- { name: SensorID, type: "string", json_key: "sensor_id", optional: true, description: "Nom ou identifiant du serveur/capteur qui a généré le log. Indispensable pour du déploiement à grande échelle." }
# --- Éléments TLS (ClientHello) ---
- { name: TLSVersion, type: "string", json_key: "tls_version", optional: true, description: "Version TLS maximale supportée annoncée par le client (ex: 1.2, 1.3). Utile pour repérer les clients obsolètes." }
- { name: SNI, type: "string", json_key: "tls_sni", optional: true, description: "Server Name Indication en clair. Crucial pour détecter le domaine visé par le client (C2, DGA, etc.)." }
- { name: ALPN, type: "string", json_key: "tls_alpn", optional: true, description: "Application-Layer Protocol Negotiation (ex: h2, http/1.1). Aide à différencier le trafic web légitime d'un tunnel personnalisé." }
# --- Détection comportementale (Timing) ---
- { name: SynToCHMs, type: "*uint32", json_key: "syn_to_clienthello_ms", optional: true, description: "Temps écoulé (en millisecondes) entre l'observation du SYN et l'envoi du ClientHello complet." }
# Timestamp
- { name: Timestamp, type: "int64", json_key: "timestamp", description: "Wall-clock timestamp in nanoseconds since Unix epoch (auto-filled by NewLogRecord)." }
- name: "api.OutputConfig"
description: "Configuration dune sortie de logs."
fields:
- { name: Type, type: "string", description: "Type doutput (unix_socket, stdout, file, ...)." }
- { name: AsyncBuffer, type: "int", description: "Taille de la file d'attente avant envoi asynchrone (ex: 5000)." }
- { name: Enabled, type: "bool", description: "Active ou non cette sortie." }
- { name: Params, type: "map[string]string", description: "Paramètres spécifiques (socket_path, path, ...)." }
- name: "api.AppConfig"
description: "Configuration complète de ja4sentinel."
fields:
- { name: Core, type: "api.Config", description: "Paramètres réseau + TLS." }
- { name: Outputs, type: "[]api.OutputConfig", description: "Liste des outputs configurés." }
interfaces:
- name: "config.Loader"
description: "Charge la configuration (fichier + env + CLI)."
module: "config"
methods:
- name: "Load"
params: []
returns:
- { type: "api.AppConfig" }
- { type: "error" }
- name: "capture.Capture"
description: "Source de paquets réseau bruts côté client."
module: "capture"
methods:
- name: "Run"
params:
- { name: cfg, type: "api.Config" }
- { name: out, type: "chan<- api.RawPacket" }
returns:
- { type: "error" }
notes:
- "Doit respecter les filtres (ports, BPF) définis dans la configuration."
- "Ne connaît pas le format TLS ni JA4."
- name: "Close"
params: []
returns:
- { type: "error" }
notes:
- "Libère les ressources (handle pcap, etc.). Doit être appelé après Run()."
- name: "tlsparse.Parser"
description: "Transforme des RawPacket en TLSClientHello (côté client uniquement)."
module: "tlsparse"
methods:
- name: "Process"
params:
- { name: pkt, type: "api.RawPacket" }
returns:
- { type: "*api.TLSClientHello" }
- { type: "error" }
notes:
- "Retourne nil si le paquet ne contient pas (ou plus) de ClientHello."
- "Pour chaque flux, s'arrête une fois le ClientHello complet obtenu."
- name: "Close"
params: []
returns:
- { type: "error" }
notes:
- "Arrête les goroutines en arrière-plan et nettoie les états de flux."
- name: "fingerprint.Engine"
description: "Génère les empreintes JA4 (et JA3 éventuellement) à partir dun ClientHello."
module: "fingerprint"
methods:
- name: "FromClientHello"
params:
- { name: ch, type: "api.TLSClientHello" }
returns:
- { type: "*api.Fingerprints" }
- { type: "error" }
notes:
- "Utilise github.com/psanford/tlsfingerprint en interne."
- "Focalisé sur le JA4 client (le côté serveur est déjà connu)."
- name: "output.Writer"
description: "Interface générique pour écrire les résultats."
module: "output"
methods:
- name: "Write"
params:
- { name: rec, type: "api.LogRecord" }
returns:
- { type: "error" }
notes:
- "Ne connaît pas la capture ni les détails de parsing TLS."
- name: "output.UnixSocketWriter"
description: "Implémentation de Writer envoyant les logs sur une socket UNIX."
module: "output"
implements: "output.Writer"
config:
- { name: socket_path, type: "string", description: "Chemin de la socket UNIX DGRAM (ex: /var/run/logcorrelator/network.sock)." }
- name: "output.MultiWriter"
description: "Combinaison de plusieurs Writer configurés."
module: "output"
implements: "output.Writer"
config:
- { name: writers, type: "[]output.Writer", description: "Liste de Writers concrets à appeler." }
- name: "output.Builder"
description: "Construit les Writers à partir de api.AppConfig."
module: "output"
methods:
- name: "NewFromConfig"
params:
- { name: cfg, type: "api.AppConfig" }
returns:
- { type: "output.Writer" }
- { type: "error" }
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:
- from: "capture.Capture"
to: "tlsparse.Parser"
via: "api.RawPacket"
- from: "tlsparse.Parser"
to: "fingerprint.Engine"
via: "api.TLSClientHello"
- from: "fingerprint.Engine"
to: "output.Writer"
via: "api.LogRecord"
constraints:
- id: "client_only"
description: "On ne calcule que les empreintes JA4 côté client (pas côté serveur)."
- id: "no_back_dependencies"
description: "Pas de dépendances en arrière (output ne dépend pas de fingerprint, etc.)."
- id: "simple_messages"
description: "Les communications entre blocs utilisent uniquement les types définis dans api.*."
- id: "no_global_state"
description: "Pas de variables globales partagées entre blocs pour la logique principale."
flow_control:
connection_states:
description: "États simplifiés d'un flux TCP pour minimiser la capture."
states:
- name: "NEW"
description: "Observation d'un SYN client sur un port surveillé, création d'un état minimal (IP/TCP meta)."
- name: "WAIT_CLIENT_HELLO"
description: "Accumulation des segments TCP nécessaires pour extraire un ClientHello complet."
- name: "JA4_DONE"
description: "JA4 calculé et logué, on arrête de suivre ce flux."
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_*, ja*."
- "Pas de champ ja4_hash : le format JA4 intègre déjà son propre hachage tronqué, la chaîne complète de 38 caractères suffit."
logrecord_schema:
# Exemple de mapping pour api.LogRecord (résumé)
- "conn_id"
- "sensor_id"
- "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'
- "tls_version"
- "tls_sni"
- "tls_alpn"
- "syn_to_clienthello_ms"
- "ja4"
- "ja3"
- "ja3_hash"
packaging:
rpm:
description: "Package RPM pour déploiement sur serveurs Linux."
files:
- path: "/etc/logrotate.d/ja4sentinel"
description: "Script logrotate pour la rotation des fichiers de log."
note: "Fourni par le RPM, configure la rotation quotidienne avec compression."
- path: "/etc/systemd/system/ja4sentinel.service"
description: "Unité systemd pour la gestion du service."
note: "Doit inclure Type=notify et ExecReload=/bin/kill -HUP $MAINPID pour supporter systemctl reload. PAS de variable Environment=JA4SENTINEL_LOG_LEVEL pour respecter la config fichier."
logrotate:
description: "Configuration logrotate pour la rotation des logs."
behavior:
- "Rotation quotidienne ou selon taille."
- "Compression des logs archivés."
- "Envoi du signal SIGHUP au service après rotation pour réouvrir les fichiers."
reload_mechanism:
- "systemctl reload ja4sentinel déclenche le handler SIGHUP."
- "Le service réouvre ses fichiers de log sans redémarrage complet."
config_loading:
priority:
- "1. Fichier de configuration YAML (config.yml)"
- "2. Variables d'environnement JA4SENTINEL_* (sauf log_level depuis v1.1.11)"
- "3. Arguments CLI (--config)"
notes:
- "Depuis v1.1.11, la variable JA4SENTINEL_LOG_LEVEL n'est plus définie dans le service systemd."
- "Le log_level doit être configuré exclusivement dans le fichier YAML."
- "exclude_source_ips est uniquement chargé depuis le fichier YAML (pas d'override env)."
- "La fusion des configs utilise mergeConfigs() qui préserve les valeurs non-overridées."

View File

@ -0,0 +1,357 @@
// Package main provides the entry point for ja4sentinel
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/coreos/go-systemd/v22/daemon"
"github.com/antitbone/ja4/sentinel/api"
"github.com/antitbone/ja4/sentinel/internal/capture"
"github.com/antitbone/ja4/sentinel/internal/config"
"github.com/antitbone/ja4/sentinel/internal/fingerprint"
"github.com/antitbone/ja4/sentinel/internal/logging"
"github.com/antitbone/ja4/sentinel/internal/output"
"github.com/antitbone/ja4/sentinel/internal/tlsparse"
)
var (
// Version information (set via ldflags)
Version = "1.1.15"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() {
// Parse command-line flags
configPath := flag.String("config", "", "Path to configuration file (YAML)")
version := flag.Bool("version", false, "Show version information")
flag.Parse()
if *version {
fmt.Printf("ja4sentinel version %s (built %s, commit %s)\n", Version, BuildTime, GitCommit)
os.Exit(0)
}
// Load configuration
cfgLoader := config.NewLoader(*configPath)
appConfig, err := cfgLoader.Load()
if err != nil {
// Create logger with default level for error reporting
loggerFactory := &logging.LoggerFactory{}
appLogger := loggerFactory.NewDefaultLogger()
appLogger.Error("main", "Failed to load configuration", map[string]string{
"error": err.Error(),
})
os.Exit(1)
}
// Create logger factory with configured log level
loggerFactory := &logging.LoggerFactory{}
appLogger := loggerFactory.NewLogger(appConfig.Core.LogLevel)
appLogger.Info("main", "Starting ja4sentinel", map[string]string{
"version": Version,
"build_time": BuildTime,
"git_commit": GitCommit,
})
appLogger.Info("main", "Configuration loaded", map[string]string{
"interface": appConfig.Core.Interface,
"listen_ports": formatPorts(appConfig.Core.ListenPorts),
"log_level": appConfig.Core.LogLevel,
})
// Create context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Signal readiness to systemd
if _, err := daemon.SdNotify(false, daemon.SdNotifyReady); err != nil {
appLogger.Warn("main", "Failed to send READY notification to systemd", map[string]string{
"error": err.Error(),
})
}
// Start watchdog goroutine if enabled
watchdogInterval, err := daemon.SdWatchdogEnabled(false)
if err != nil {
appLogger.Warn("main", "Failed to check watchdog status", map[string]string{
"error": err.Error(),
})
}
if watchdogInterval > 0 {
appLogger.Info("main", "systemd watchdog enabled", map[string]string{
"interval": watchdogInterval.String(),
})
go func() {
ticker := time.NewTicker(watchdogInterval / 2)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if _, err := daemon.SdNotify(false, daemon.SdNotifyWatchdog); err != nil {
appLogger.Warn("main", "Failed to send WATCHDOG notification", map[string]string{
"error": err.Error(),
})
}
case <-ctx.Done():
return
}
}
}()
}
// Setup signal handling for shutdown and log rotation
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
// Create pipeline components
captureEngine := capture.New()
parser := tlsparse.NewParserWithTimeoutAndFilter(
time.Duration(appConfig.Core.FlowTimeoutSec)*time.Second,
appConfig.Core.ExcludeSourceIPs,
)
fingerprintEngine := fingerprint.NewEngine()
// Log exclusion configuration with debug details
if len(appConfig.Core.ExcludeSourceIPs) > 0 {
appLogger.Info("main", "Source IP exclusion enabled", map[string]string{
"exclude_count": fmt.Sprintf("%d", len(appConfig.Core.ExcludeSourceIPs)),
"exclude_ips": strings.Join(appConfig.Core.ExcludeSourceIPs, ", "),
})
appLogger.Debug("tlsparse", "IP filter configured", map[string]string{
"filter_entries": strings.Join(appConfig.Core.ExcludeSourceIPs, ", "),
})
} else {
appLogger.Debug("tlsparse", "IP filter disabled (no exclusions configured)", nil)
}
// Log filter stats at startup (debug mode)
filteredCount, hasFilter := parser.GetFilterStats()
if hasFilter {
appLogger.Debug("tlsparse", "IP filter initialized", map[string]string{
"filtered_packets": fmt.Sprintf("%d", filteredCount),
})
}
// Create output builder with error callback for socket connection errors
outputBuilder := output.NewBuilder().WithErrorCallback(func(socketPath string, err error, attempt int) {
appLogger.Error("output", "UNIX socket connection failed", map[string]string{
"socket_path": socketPath,
"error": err.Error(),
"attempt": fmt.Sprintf("%d", attempt),
})
})
outputWriter, err := outputBuilder.NewFromConfig(appConfig)
if err != nil {
appLogger.Error("main", "Failed to create output writer", map[string]string{
"error": err.Error(),
})
os.Exit(1)
}
// Create channel for raw packets (configurable buffer size)
bufferSize := appConfig.Core.PacketBufferSize
if bufferSize <= 0 {
bufferSize = 1000 // Default fallback
}
packetChan := make(chan api.RawPacket, bufferSize)
// Start capture goroutine
captureErrChan := make(chan error, 1)
go func() {
appLogger.Info("capture", "Starting packet capture", map[string]string{
"interface": appConfig.Core.Interface,
})
err := captureEngine.Run(appConfig.Core, packetChan)
close(packetChan) // Close channel to signal packet processor to shut down
captureErrChan <- err
}()
// Log capture diagnostics after a short delay to allow initialization
go func() {
time.Sleep(100 * time.Millisecond)
ifName, localIPs, bpfFilter, linkType := captureEngine.GetDiagnostics()
appLogger.Debug("capture", "Capture initialized", map[string]string{
"interface": ifName,
"link_type": fmt.Sprintf("%d", linkType),
"local_ips": strings.Join(localIPs, ", "),
"bpf_filter": bpfFilter,
})
}()
// Process packets
go func() {
for {
select {
case <-ctx.Done():
appLogger.Info("main", "Packet processor shutting down", nil)
return
case pkt, ok := <-packetChan:
if !ok {
return
}
// Parse TLS ClientHello
clientHello, err := parser.Process(pkt)
if err != nil {
appLogger.Warn("tlsparse", "Failed to parse TLS ClientHello", map[string]string{
"error": err.Error(),
"packet_len": fmt.Sprintf("%d", len(pkt.Data)),
"link_type": fmt.Sprintf("%d", pkt.LinkType),
"timestamp": fmt.Sprintf("%d", pkt.Timestamp),
})
continue
}
if clientHello == nil {
continue // Not a TLS ClientHello packet
}
appLogger.Debug("tlsparse", "ClientHello extracted", map[string]string{
"src_ip": clientHello.SrcIP,
"src_port": fmt.Sprintf("%d", clientHello.SrcPort),
"dst_ip": clientHello.DstIP,
"dst_port": fmt.Sprintf("%d", clientHello.DstPort),
})
// Generate fingerprints
fingerprints, err := fingerprintEngine.FromClientHello(*clientHello)
if err != nil {
appLogger.Warn("fingerprint", "Failed to generate fingerprints", map[string]string{
"error": err.Error(),
"src_ip": clientHello.SrcIP,
"src_port": fmt.Sprintf("%d", clientHello.SrcPort),
"dst_ip": clientHello.DstIP,
"dst_port": fmt.Sprintf("%d", clientHello.DstPort),
"conn_id": clientHello.ConnID,
"payload_len": fmt.Sprintf("%d", len(clientHello.Payload)),
"sni": clientHello.SNI,
"tls_version": clientHello.TLSVersion,
"alpn": clientHello.ALPN,
})
continue
}
appLogger.Debug("fingerprint", "Fingerprints generated", map[string]string{
"src_ip": clientHello.SrcIP,
"ja4": fingerprints.JA4,
})
// Create log record
logRecord := api.NewLogRecord(*clientHello, fingerprints)
// Write output
if err := outputWriter.Write(logRecord); err != nil {
appLogger.Error("output", "Failed to write log record", map[string]string{
"error": err.Error(),
})
}
}
}
}()
// Wait for shutdown signal or capture error
for {
select {
case sig := <-sigChan:
switch sig {
case syscall.SIGHUP:
// Handle log rotation - reopen output files
appLogger.Info("main", "Received SIGHUP, reopening log files", nil)
if mw, ok := outputWriter.(api.Reopenable); ok {
if err := mw.Reopen(); err != nil {
appLogger.Error("main", "Failed to reopen log files", map[string]string{
"error": err.Error(),
})
} else {
appLogger.Info("main", "Log files reopened successfully", nil)
}
} else {
appLogger.Warn("main", "Output writer does not support log rotation", nil)
}
case syscall.SIGINT, syscall.SIGTERM:
appLogger.Info("main", "Received shutdown signal", map[string]string{
"signal": sig.String(),
})
goto shutdown
}
case err := <-captureErrChan:
if err != nil {
appLogger.Error("capture", "Capture engine failed", map[string]string{
"error": err.Error(),
})
}
goto shutdown
}
}
shutdown:
// Graceful shutdown
appLogger.Info("main", "Shutting down...", nil)
// Signal stopping to systemd
if _, err := daemon.SdNotify(false, daemon.SdNotifyStopping); err != nil {
appLogger.Warn("main", "Failed to send STOPPING notification to systemd", map[string]string{
"error": err.Error(),
})
}
cancel()
// Close components
if err := captureEngine.Close(); err != nil {
appLogger.Error("main", "Failed to close capture engine", map[string]string{
"error": err.Error(),
})
}
if err := parser.Close(); err != nil {
appLogger.Error("main", "Failed to close parser", map[string]string{
"error": err.Error(),
})
}
// Log final filter stats
filteredCount, hasFilter = parser.GetFilterStats()
if hasFilter {
appLogger.Info("tlsparse", "IP filter statistics", map[string]string{
"total_filtered_packets": fmt.Sprintf("%d", filteredCount),
})
}
if mw, ok := outputWriter.(interface{ CloseAll() error }); ok {
if err := mw.CloseAll(); err != nil {
appLogger.Error("main", "Failed to close output writers", map[string]string{
"error": err.Error(),
})
}
} else if closer, ok := outputWriter.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
appLogger.Error("main", "Failed to close output writer", map[string]string{
"error": err.Error(),
})
}
}
appLogger.Info("main", "ja4sentinel stopped", nil)
}
// formatPorts formats a slice of ports as a comma-separated string
func formatPorts(ports []uint16) string {
if len(ports) == 0 {
return ""
}
result := fmt.Sprintf("%d", ports[0])
for _, port := range ports[1:] {
result += fmt.Sprintf(",%d", port)
}
return result
}

View File

@ -0,0 +1,221 @@
package main
import (
"flag"
"strings"
"testing"
)
func TestFormatPorts(t *testing.T) {
tests := []struct {
name string
ports []uint16
want string
}{
{
name: "empty slice",
ports: []uint16{},
want: "",
},
{
name: "single port",
ports: []uint16{443},
want: "443",
},
{
name: "multiple ports",
ports: []uint16{443, 8443, 9443},
want: "443,8443,9443",
},
{
name: "two ports",
ports: []uint16{80, 443},
want: "80,443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatPorts(tt.ports)
if got != tt.want {
t.Errorf("formatPorts() = %v, want %v", got, tt.want)
}
})
}
}
// TestMain_VersionFlag_VerifiesOutput tests that the version flag produces correct output
// Note: This test verifies the version variables are set correctly
func TestMain_VersionFlag_VerifiesOutput(t *testing.T) {
// Verify version variables are set
if Version == "" {
t.Error("Version should not be empty")
}
if BuildTime == "" {
t.Error("BuildTime should not be empty")
}
if GitCommit == "" {
t.Error("GitCommit should not be empty")
}
// Verify version format
expectedPrefix := "ja4sentinel version"
got := getVersionString()
if !strings.HasPrefix(got, expectedPrefix) {
t.Errorf("getVersionString() = %v, should start with %v", got, expectedPrefix)
}
}
// getVersionString returns the version string (helper for testing)
func getVersionString() string {
return "ja4sentinel version " + Version + " (built " + BuildTime + ", commit " + GitCommit + ")"
}
func TestFlagParsing(t *testing.T) {
tests := []struct {
name string
args []string
wantConfig string
wantVersion bool
}{
{
name: "config flag",
args: []string{"ja4sentinel", "-config", "/path/to/config.yml"},
wantConfig: "/path/to/config.yml",
wantVersion: false,
},
{
name: "version flag",
args: []string{"ja4sentinel", "-version"},
wantConfig: "",
wantVersion: true,
},
{
name: "no flags",
args: []string{"ja4sentinel"},
wantConfig: "",
wantVersion: false,
},
{
name: "config with long form",
args: []string{"ja4sentinel", "--config", "/etc/ja4sentinel/config.yml"},
wantConfig: "/etc/ja4sentinel/config.yml",
wantVersion: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
configPath := fs.String("config", "", "Path to configuration file (YAML)")
version := fs.Bool("version", false, "Show version information")
err := fs.Parse(tt.args[1:])
if err != nil {
t.Fatalf("Flag parsing failed: %v", err)
}
if *configPath != tt.wantConfig {
t.Errorf("config = %v, want %v", *configPath, tt.wantConfig)
}
if *version != tt.wantVersion {
t.Errorf("version = %v, want %v", *version, tt.wantVersion)
}
})
}
}
// TestMain_WithInvalidConfig tests that main exits gracefully with invalid config
func TestMain_WithInvalidConfig(t *testing.T) {
// This test verifies that the application handles config errors gracefully
// We can't easily test the full main() function, but we can test the
// config loading and error handling paths
t.Log("Note: Full main() testing requires integration tests with mocked dependencies")
}
// TestSignalHandling_VerifiesConstants tests that signal constants are defined
func TestSignalHandling_VerifiesConstants(t *testing.T) {
// Verify that we import the required packages for signal handling
// This test ensures the imports are present
t.Log("syscall and os/signal packages are imported for signal handling")
}
// TestGracefulShutdown_SimulatesSignal tests graceful shutdown behavior
func TestGracefulShutdown_SimulatesSignal(t *testing.T) {
// This test documents the expected shutdown behavior
// Full testing requires integration tests with actual signal sending
expectedBehavior := `
Graceful shutdown sequence:
1. Receive SIGINT or SIGTERM
2. Stop packet capture
3. Close output writers
4. Flush pending logs
5. Exit cleanly
`
t.Log(expectedBehavior)
}
// TestLogRotation_SIGHUP tests SIGHUP handling for log rotation
func TestLogRotation_SIGHUP(t *testing.T) {
// This test documents the expected log rotation behavior
// Full testing requires integration tests with actual SIGHUP signal
expectedBehavior := `
Log rotation sequence (SIGHUP):
1. Receive SIGHUP
2. Reopen all reopenable writers (FileWriter, MultiWriter)
3. Continue operation with new file handles
4. No data loss during rotation
`
t.Log(expectedBehavior)
}
// TestMain_ConfigValidation tests config validation before starting
func TestMain_ConfigValidation(t *testing.T) {
// Test that invalid configs are rejected before starting the pipeline
tests := []struct {
name string
configErr string
}{
{
name: "empty_interface",
configErr: "interface cannot be empty",
},
{
name: "no_listen_ports",
configErr: "at least one listen port required",
},
{
name: "invalid_output_type",
configErr: "unknown output type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Verify that these error conditions are documented
t.Logf("Expected error for %s: %s", tt.name, tt.configErr)
})
}
}
// TestPipelineConstruction verifies the pipeline is built correctly
func TestPipelineConstruction(t *testing.T) {
// This test documents the expected pipeline construction
// Full testing requires integration tests
expectedPipeline := `
Pipeline construction:
1. Load configuration
2. Create logger
3. Create capture engine
4. Create TLS parser
5. Create fingerprint engine
6. Create output writer(s)
7. Connect pipeline: capture -> parser -> fingerprint -> output
8. Start signal handling
9. Run capture loop
`
t.Log(expectedPipeline)
}

View File

@ -0,0 +1,57 @@
# Sample configuration file for ja4sentinel
# Copy to config.yml and adjust as needed
core:
# Network interface to capture traffic from
# "any" captures on all interfaces (default, recommended)
# Or specify a specific interface (e.g., eth0, ens192, etc.)
interface: any
# TCP ports to monitor for TLS handshakes
listen_ports:
- 443
- 8443
# Optional BPF filter (leave empty for auto-generated filter based on listen_ports and local_ips)
bpf_filter: ""
# Local IP addresses to monitor (traffic destined to these IPs will be captured)
# Leave empty for auto-detection (recommended) - excludes loopback addresses
# Or specify manually: ["192.168.1.10", "10.0.0.5", "2001:db8::1"]
local_ips: []
# Source IP addresses or CIDR ranges to exclude from capture
# Useful for filtering out internal traffic, health checks, or monitoring systems
# Examples: ["10.0.0.0/8", "192.168.1.1", "172.16.0.0/12"]
exclude_source_ips: []
# Timeout in seconds for TLS handshake extraction (default: 30)
flow_timeout_sec: 30
# Buffer size for packet channel (default: 1000, increase for high-traffic environments)
packet_buffer_size: 1000
# Log level: debug, info, warn, error (default: info)
# Can be overridden by JA4SENTINEL_LOG_LEVEL environment variable
log_level: info
outputs:
# Output to UNIX socket (for systemd/journald or other consumers)
# Only JSON LogRecord data is sent - no diagnostic logs
- type: unix_socket
enabled: true
params:
socket_path: /var/run/logcorrelator/network.socket
# Output to stdout (JSON lines)
# Diagnostic logs (error, debug, warning) should go here
# - type: stdout
# enabled: false
# params: {}
# Output to file
# Only JSON LogRecord data is sent - no diagnostic logs
# - type: file
# enabled: false
# params:
# path: /var/log/ja4sentinel/ja4.log

View File

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

View File

@ -0,0 +1,21 @@
module github.com/antitbone/ja4/sentinel
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 (
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
golang.org/x/sys v0.1.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
require github.com/antitbone/ja4/ja4common v0.1.0
replace github.com/antitbone/ja4/ja4common => ../../shared/go/ja4common

View File

@ -0,0 +1,27 @@
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
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/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,397 @@
// Package capture provides network packet capture functionality for ja4sentinel
package capture
import (
"fmt"
"log"
"net"
"regexp"
"strings"
"sync"
"sync/atomic"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"github.com/antitbone/ja4/sentinel/api"
)
// Capture configuration constants
const (
// DefaultSnapLen is the default snapshot length for packet capture
// Increased from 1600 to 65535 to capture full packets including large TLS handshakes
DefaultSnapLen = 65535
// DefaultPromiscuous is the default promiscuous mode setting
DefaultPromiscuous = false
// MaxBPFFilterLength is the maximum allowed length for BPF filters
MaxBPFFilterLength = 1024
)
// validBPFPattern checks if a BPF filter contains only valid characters
// This is a basic validation to prevent injection attacks
var validBPFPattern = regexp.MustCompile(`^[a-zA-Z0-9\s\(\)\-\_\.\*\+\?\:\=\!\&\|\<\>\[\]\/\@,]+$`)
// CaptureImpl implements the capture.Capture interface for packet capture
type CaptureImpl struct {
handle *pcap.Handle
mu sync.Mutex
snapLen int
promisc bool
isClosed bool
localIPs []string // Local IPs to filter (dst host)
linkType int // Link type from pcap handle
interfaceName string // Interface name (for diagnostics)
bpfFilter string // Applied BPF filter (for diagnostics)
// Metrics counters (atomic)
packetsReceived uint64 // Total packets received from interface
packetsSent uint64 // Total packets sent to channel
packetsDropped uint64 // Total packets dropped (channel full)
}
// New creates a new capture instance
func New() *CaptureImpl {
return &CaptureImpl{
snapLen: DefaultSnapLen,
promisc: DefaultPromiscuous,
}
}
// NewWithSnapLen creates a new capture instance with custom snapshot length
func NewWithSnapLen(snapLen int) *CaptureImpl {
if snapLen <= 0 || snapLen > 65535 {
snapLen = DefaultSnapLen
}
return &CaptureImpl{
snapLen: snapLen,
promisc: DefaultPromiscuous,
}
}
// Run starts network packet capture according to the configuration
func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
// Validate interface name (basic check)
if cfg.Interface == "" {
return fmt.Errorf("interface cannot be empty")
}
// Find available interfaces to validate the interface exists
ifaces, err := pcap.FindAllDevs()
if err != nil {
return fmt.Errorf("failed to list network interfaces: %w", err)
}
// Special handling for "any" interface
interfaceFound := cfg.Interface == "any"
if !interfaceFound {
for _, iface := range ifaces {
if iface.Name == cfg.Interface {
interfaceFound = true
break
}
}
}
if !interfaceFound {
return fmt.Errorf("interface %s not found (available: %v)", cfg.Interface, getInterfaceNames(ifaces))
}
handle, err := pcap.OpenLive(cfg.Interface, int32(c.snapLen), c.promisc, pcap.BlockForever)
if err != nil {
return fmt.Errorf("failed to open interface %s: %w", cfg.Interface, err)
}
c.mu.Lock()
c.handle = handle
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.handle != nil && !c.isClosed {
c.handle.Close()
c.handle = nil
}
c.mu.Unlock()
}()
// Store interface name for diagnostics
c.interfaceName = cfg.Interface
// Resolve local IPs for filtering (if not manually specified)
localIPs := cfg.LocalIPs
if len(localIPs) == 0 {
localIPs, err = c.detectLocalIPs(cfg.Interface)
if err != nil {
return fmt.Errorf("failed to detect local IPs: %w", err)
}
if len(localIPs) == 0 {
// NAT/VIP: destination IP may not be assigned to this interface.
// Fall back to port-only BPF filter instead of aborting.
log.Printf("WARN capture: no local IPs found on interface %s; using port-only BPF filter (NAT/VIP mode)", cfg.Interface)
}
}
c.localIPs = localIPs
// Build and apply BPF filter
bpfFilter := cfg.BPFFilter
if bpfFilter == "" {
bpfFilter = c.buildBPFFilter(cfg.ListenPorts, localIPs)
}
c.bpfFilter = bpfFilter
// Validate BPF filter before applying
if err := validateBPFFilter(bpfFilter); err != nil {
return fmt.Errorf("invalid BPF filter: %w", err)
}
err = handle.SetBPFFilter(bpfFilter)
if err != nil {
return fmt.Errorf("failed to set BPF filter '%s': %w", bpfFilter, err)
}
// Store link type once, after the handle is fully configured (BPF filter applied).
// A single write avoids the race where packetToRawPacket reads a stale value
// that existed before the BPF filter was set.
c.mu.Lock()
c.linkType = int(handle.LinkType())
c.mu.Unlock()
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Convert packet to RawPacket
rawPkt := c.packetToRawPacket(packet)
if rawPkt != nil {
atomic.AddUint64(&c.packetsReceived, 1)
select {
case out <- *rawPkt:
// Packet sent successfully
atomic.AddUint64(&c.packetsSent, 1)
default:
// Channel full, drop packet
atomic.AddUint64(&c.packetsDropped, 1)
}
}
}
return nil
}
// validateBPFFilter performs basic validation of BPF filter strings
func validateBPFFilter(filter string) error {
if filter == "" {
return nil
}
if len(filter) > MaxBPFFilterLength {
return fmt.Errorf("BPF filter too long (max %d characters)", MaxBPFFilterLength)
}
// Check for potentially dangerous patterns
if !validBPFPattern.MatchString(filter) {
return fmt.Errorf("BPF filter contains invalid characters")
}
// Check for unbalanced parentheses
openParens := 0
for _, ch := range filter {
if ch == '(' {
openParens++
} else if ch == ')' {
openParens--
if openParens < 0 {
return fmt.Errorf("BPF filter has unbalanced parentheses")
}
}
}
if openParens != 0 {
return fmt.Errorf("BPF filter has unbalanced parentheses")
}
return nil
}
// getInterfaceNames extracts interface names from a list of devices
func getInterfaceNames(ifaces []pcap.Interface) []string {
names := make([]string, len(ifaces))
for i, iface := range ifaces {
names[i] = iface.Name
}
return names
}
// detectLocalIPs detects local IP addresses on the specified interface
// Excludes loopback addresses (127.0.0.0/8, ::1) and IPv6 link-local (fe80::)
func (c *CaptureImpl) detectLocalIPs(interfaceName string) ([]string, error) {
var localIPs []string
// Special case: "any" interface - get all non-loopback IPs
if interfaceName == "any" {
ifaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("failed to list interfaces: %w", err)
}
for _, iface := range ifaces {
// Skip loopback interfaces
if iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue // Skip this interface, try others
}
for _, addr := range addrs {
ip := extractIP(addr)
if ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
localIPs = append(localIPs, ip.String())
}
}
}
return localIPs, nil
}
// Specific interface - get IPs from that interface only
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return nil, fmt.Errorf("failed to get interface %s: %w", interfaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("failed to get addresses for %s: %w", interfaceName, err)
}
for _, addr := range addrs {
ip := extractIP(addr)
if ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
localIPs = append(localIPs, ip.String())
}
}
return localIPs, nil
}
// extractIP extracts the IP address from a net.Addr
func extractIP(addr net.Addr) net.IP {
switch v := addr.(type) {
case *net.IPNet:
ip := v.IP
// Return IPv4 as 4-byte, IPv6 as 16-byte
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
case *net.IPAddr:
ip := v.IP
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
return nil
}
// buildBPFFilter builds a BPF filter for the specified ports and local IPs
// Filter: (tcp dst port 443 or tcp dst port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5)
// Uses "tcp dst port" to only capture client→server traffic (not server→client responses)
func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string {
if len(ports) == 0 {
return "tcp"
}
// Build port filter (dst port only to avoid capturing server responses)
portParts := make([]string, len(ports))
for i, port := range ports {
portParts[i] = fmt.Sprintf("tcp dst port %d", port)
}
portFilter := "(" + strings.Join(portParts, ") or (") + ")"
// Build destination host filter
if len(localIPs) == 0 {
return portFilter
}
hostParts := make([]string, len(localIPs))
for i, ip := range localIPs {
// Handle IPv6 addresses
if strings.Contains(ip, ":") {
hostParts[i] = fmt.Sprintf("dst host %s", ip)
} else {
hostParts[i] = fmt.Sprintf("dst host %s", ip)
}
}
hostFilter := "(" + strings.Join(hostParts, ") or (") + ")"
// Combine port and host filters
return portFilter + " and " + hostFilter
}
// joinString joins strings with a separator (kept for backward compatibility)
func joinString(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, part := range parts[1:] {
result += sep + part
}
return result
}
// packetToRawPacket converts a gopacket packet to RawPacket
// Uses the raw packet bytes from the link layer
func (c *CaptureImpl) packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
// Try to get link layer contents + payload for full packet
var data []byte
linkLayer := packet.LinkLayer()
if linkLayer != nil {
// Combine link layer contents with payload to get full packet
data = append(data, linkLayer.LayerContents()...)
data = append(data, linkLayer.LayerPayload()...)
} else {
// Fallback to packet.Data()
data = packet.Data()
}
if len(data) == 0 {
return nil
}
return &api.RawPacket{
Data: data,
Timestamp: packet.Metadata().Timestamp.UnixNano(),
LinkType: c.linkType,
}
}
// Close properly closes the capture handle
func (c *CaptureImpl) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.handle != nil && !c.isClosed {
c.handle.Close()
c.handle = nil
c.isClosed = true
return nil
}
c.isClosed = true
return nil
}
// GetStats returns capture statistics (for monitoring/debugging)
func (c *CaptureImpl) GetStats() (received, sent, dropped uint64) {
return atomic.LoadUint64(&c.packetsReceived),
atomic.LoadUint64(&c.packetsSent),
atomic.LoadUint64(&c.packetsDropped)
}
// GetDiagnostics returns capture diagnostics information (for debugging)
func (c *CaptureImpl) GetDiagnostics() (interfaceName string, localIPs []string, bpfFilter string, linkType int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.interfaceName, c.localIPs, c.bpfFilter, c.linkType
}

View File

@ -0,0 +1,661 @@
package capture
import (
"net"
"strings"
"testing"
"time"
"github.com/antitbone/ja4/sentinel/api"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
func TestCaptureImpl_Run_EmptyInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "",
ListenPorts: []uint16{443},
}
out := make(chan api.RawPacket, 10)
err := c.Run(cfg, out)
if err == nil {
t.Error("Run() with empty interface should return error")
}
if err.Error() != "interface cannot be empty" {
t.Errorf("Run() error = %v, want 'interface cannot be empty'", err)
}
}
func TestCaptureImpl_Run_NonExistentInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "nonexistent_interface_xyz123",
ListenPorts: []uint16{443},
}
out := make(chan api.RawPacket, 10)
err := c.Run(cfg, out)
if err == nil {
t.Error("Run() with non-existent interface should return error")
}
}
func TestCaptureImpl_Run_InvalidBPFFilter(t *testing.T) {
// Get a real interface name
ifaces, err := pcap.FindAllDevs()
if err != nil || len(ifaces) == 0 {
t.Skip("No network interfaces available for testing")
}
c := New()
cfg := api.Config{
Interface: ifaces[0].Name,
ListenPorts: []uint16{443},
BPFFilter: "invalid; rm -rf /", // Invalid characters
}
out := make(chan api.RawPacket, 10)
err = c.Run(cfg, out)
if err == nil {
t.Error("Run() with invalid BPF filter should return error")
}
}
func TestCaptureImpl_Run_ChannelFull_DropsPackets(t *testing.T) {
// This test verifies that when the output channel is full,
// packets are dropped gracefully (non-blocking write)
// We can't easily test the full Run() loop without real interfaces,
// but we can verify the channel behavior with a small buffer
out := make(chan api.RawPacket, 1)
// Fill the channel
out <- api.RawPacket{Data: []byte{1, 2, 3}, Timestamp: time.Now().UnixNano()}
// Channel should be full now, select default should trigger
done := make(chan bool)
go func() {
select {
case out <- api.RawPacket{Data: []byte{4, 5, 6}, Timestamp: time.Now().UnixNano()}:
done <- false // Would block
default:
done <- true // Dropped as expected
}
}()
dropped := <-done
if !dropped {
t.Error("Expected packet to be dropped when channel is full")
}
}
func TestPacketToRawPacket(t *testing.T) {
t.Run("valid_packet", func(t *testing.T) {
// Create a simple TCP packet
eth := layers.Ethernet{
SrcMAC: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55},
DstMAC: []byte{0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB},
EthernetType: layers.EthernetTypeIPv4,
}
ip := layers.IPv4{
Version: 4,
TTL: 64,
Protocol: layers.IPProtocolTCP,
SrcIP: []byte{192, 168, 1, 1},
DstIP: []byte{10, 0, 0, 1},
}
tcp := layers.TCP{
SrcPort: 12345,
DstPort: 443,
}
tcp.SetNetworkLayerForChecksum(&ip)
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{}
gopacket.SerializeLayers(buf, opts, &eth, &ip, &tcp)
packet := gopacket.NewPacket(buf.Bytes(), layers.LinkTypeEthernet, gopacket.Default)
// Create capture instance for method call
c := New()
rawPkt := c.packetToRawPacket(packet)
if rawPkt == nil {
t.Fatal("packetToRawPacket() returned nil for valid packet")
}
if len(rawPkt.Data) == 0 {
t.Error("packetToRawPacket() returned empty data")
}
if rawPkt.Timestamp == 0 {
t.Error("packetToRawPacket() returned zero timestamp")
}
})
t.Run("empty_packet", func(t *testing.T) {
// Create packet with no data
packet := gopacket.NewPacket([]byte{}, layers.LinkTypeEthernet, gopacket.Default)
c := New()
rawPkt := c.packetToRawPacket(packet)
if rawPkt != nil {
t.Error("packetToRawPacket() should return nil for empty packet")
}
})
t.Run("nil_packet", func(t *testing.T) {
// packetToRawPacket will panic with nil packet due to Metadata() call
// This is expected behavior - the function is not designed to handle nil
defer func() {
if r := recover(); r == nil {
t.Error("packetToRawPacket() with nil packet should panic")
}
}()
c := New()
var packet gopacket.Packet
_ = c.packetToRawPacket(packet)
})
}
func TestGetInterfaceNames(t *testing.T) {
t.Run("empty_list", func(t *testing.T) {
names := getInterfaceNames([]pcap.Interface{})
if len(names) != 0 {
t.Errorf("getInterfaceNames() with empty list = %v, want []", names)
}
})
t.Run("single_interface", func(t *testing.T) {
ifaces := []pcap.Interface{
{Name: "eth0"},
}
names := getInterfaceNames(ifaces)
if len(names) != 1 || names[0] != "eth0" {
t.Errorf("getInterfaceNames() = %v, want [eth0]", names)
}
})
t.Run("multiple_interfaces", func(t *testing.T) {
ifaces := []pcap.Interface{
{Name: "eth0"},
{Name: "lo"},
{Name: "docker0"},
}
names := getInterfaceNames(ifaces)
if len(names) != 3 {
t.Errorf("getInterfaceNames() returned %d names, want 3", len(names))
}
expected := []string{"eth0", "lo", "docker0"}
for i, name := range names {
if name != expected[i] {
t.Errorf("getInterfaceNames()[%d] = %s, want %s", i, name, expected[i])
}
}
})
}
func TestValidateBPFFilter(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{
name: "empty filter",
filter: "",
wantErr: false,
},
{
name: "valid simple filter",
filter: "tcp port 443",
wantErr: false,
},
{
name: "valid complex filter",
filter: "(tcp port 443) or (tcp port 8443)",
wantErr: false,
},
{
name: "filter with special chars",
filter: "tcp port 443 and host 192.168.1.1",
wantErr: false,
},
{
name: "too long filter",
filter: string(make([]byte, MaxBPFFilterLength+1)),
wantErr: true,
},
{
name: "unbalanced parentheses - extra open",
filter: "(tcp port 443",
wantErr: true,
},
{
name: "unbalanced parentheses - extra close",
filter: "tcp port 443)",
wantErr: true,
},
{
name: "invalid characters - semicolon",
filter: "tcp port 443; rm -rf /",
wantErr: true,
},
{
name: "invalid characters - backtick",
filter: "tcp port `whoami`",
wantErr: true,
},
{
name: "invalid characters - dollar",
filter: "tcp port $HOME",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBPFFilter(tt.filter)
if (err != nil) != tt.wantErr {
t.Errorf("validateBPFFilter() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestJoinString(t *testing.T) {
tests := []struct {
name string
parts []string
sep string
want string
}{
{
name: "empty slice",
parts: []string{},
sep: ") or (",
want: "",
},
{
name: "single element",
parts: []string{"tcp port 443"},
sep: ") or (",
want: "tcp port 443",
},
{
name: "multiple elements",
parts: []string{"tcp port 443", "tcp port 8443"},
sep: ") or (",
want: "tcp port 443) or (tcp port 8443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := joinString(tt.parts, tt.sep)
if got != tt.want {
t.Errorf("joinString() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewCapture(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
if c.snapLen != DefaultSnapLen {
t.Errorf("snapLen = %d, want %d", c.snapLen, DefaultSnapLen)
}
if c.promisc != DefaultPromiscuous {
t.Errorf("promisc = %v, want %v", c.promisc, DefaultPromiscuous)
}
}
func TestNewWithSnapLen(t *testing.T) {
tests := []struct {
name string
snapLen int
wantSnapLen int
}{
{
name: "valid snapLen",
snapLen: 2048,
wantSnapLen: 2048,
},
{
name: "zero snapLen uses default",
snapLen: 0,
wantSnapLen: DefaultSnapLen,
},
{
name: "negative snapLen uses default",
snapLen: -100,
wantSnapLen: DefaultSnapLen,
},
{
name: "too large snapLen uses default",
snapLen: 100000,
wantSnapLen: DefaultSnapLen,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewWithSnapLen(tt.snapLen)
if c == nil {
t.Fatal("NewWithSnapLen() returned nil")
}
if c.snapLen != tt.wantSnapLen {
t.Errorf("snapLen = %d, want %d", c.snapLen, tt.wantSnapLen)
}
})
}
}
func TestCaptureImpl_Close(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
// Close should not panic on fresh instance
if err := c.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
// Multiple closes should be safe
if err := c.Close(); err != nil {
t.Errorf("Close() second call error = %v", err)
}
}
func TestValidateBPFFilter_BalancedParentheses(t *testing.T) {
// Test various balanced parentheses scenarios
validFilters := []string{
"(tcp port 443)",
"((tcp port 443))",
"(tcp port 443) or (tcp port 8443)",
"((tcp port 443) or (tcp port 8443))",
"(tcp port 443 and host 1.2.3.4) or (tcp port 8443)",
}
for _, filter := range validFilters {
t.Run(filter, func(t *testing.T) {
if err := validateBPFFilter(filter); err != nil {
t.Errorf("validateBPFFilter(%q) unexpected error = %v", filter, err)
}
})
}
}
func TestCaptureImpl_detectLocalIPs(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
t.Run("any_interface", func(t *testing.T) {
ips, err := c.detectLocalIPs("any")
if err != nil {
t.Errorf("detectLocalIPs(any) error = %v", err)
}
// Should return at least one non-loopback IP or empty if none available
for _, ip := range ips {
if ip == "127.0.0.1" || ip == "::1" {
t.Errorf("detectLocalIPs(any) should exclude loopback, got %s", ip)
}
}
})
t.Run("loopback_excluded", func(t *testing.T) {
ips, err := c.detectLocalIPs("any")
if err != nil {
t.Skipf("Skipping loopback test: %v", err)
}
// Verify no loopback addresses are included
for _, ip := range ips {
if ip == "127.0.0.1" {
t.Error("detectLocalIPs should exclude 127.0.0.1")
}
}
})
}
func TestCaptureImpl_detectLocalIPs_SpecificInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
// Test with a non-existent interface
_, err := c.detectLocalIPs("nonexistent_interface_xyz")
if err == nil {
t.Error("detectLocalIPs with non-existent interface should return error")
}
}
func TestCaptureImpl_extractIP(t *testing.T) {
tests := []struct {
name string
addr net.Addr
wantIPv4 bool
wantIPv6 bool
}{
{
name: "IPv4",
addr: &net.IPNet{
IP: net.ParseIP("192.168.1.10"),
Mask: net.CIDRMask(24, 32),
},
wantIPv4: true,
},
{
name: "IPv6",
addr: &net.IPNet{
IP: net.ParseIP("2001:db8::1"),
Mask: net.CIDRMask(64, 128),
},
wantIPv6: true,
},
{
name: "nil",
addr: nil,
wantIPv4: false,
wantIPv6: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractIP(tt.addr)
if tt.wantIPv4 {
if got == nil || got.To4() == nil {
t.Error("extractIP() should return IPv4 address")
}
}
if tt.wantIPv6 {
if got == nil || got.To4() != nil {
t.Error("extractIP() should return IPv6 address")
}
}
if !tt.wantIPv4 && !tt.wantIPv6 {
if got != nil {
t.Error("extractIP() should return nil for nil address")
}
}
})
}
}
func TestCaptureImpl_buildBPFFilter(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
tests := []struct {
name string
ports []uint16
localIPs []string
wantParts []string // Parts that should be in the filter
}{
{
name: "no ports",
ports: []uint16{},
localIPs: []string{},
wantParts: []string{"tcp"},
},
{
name: "single port no IPs",
ports: []uint16{443},
localIPs: []string{},
wantParts: []string{"tcp dst port 443"},
},
{
name: "single port with single IP",
ports: []uint16{443},
localIPs: []string{"192.168.1.10"},
wantParts: []string{"tcp dst port 443", "dst host 192.168.1.10"},
},
{
name: "multiple ports with multiple IPs",
ports: []uint16{443, 8443},
localIPs: []string{"192.168.1.10", "10.0.0.5"},
wantParts: []string{"tcp dst port 443", "tcp dst port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"},
},
{
name: "IPv6 address",
ports: []uint16{443},
localIPs: []string{"2001:db8::1"},
wantParts: []string{"tcp dst port 443", "dst host 2001:db8::1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.buildBPFFilter(tt.ports, tt.localIPs)
for _, part := range tt.wantParts {
if !strings.Contains(got, part) {
t.Errorf("buildBPFFilter() = %q, should contain %q", got, part)
}
}
})
}
}
func TestCaptureImpl_Run_AnyInterface(t *testing.T) {
t.Skip("integration: pcap on 'any' interface blocks until close; run with -run=Integration in a real network env")
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "any",
ListenPorts: []uint16{443},
LocalIPs: []string{"192.168.1.10"},
}
out := make(chan api.RawPacket, 10)
errCh := make(chan error, 1)
go func() { errCh <- c.Run(cfg, out) }()
// Allow up to 300ms for the handle to open (or fail immediately)
select {
case err := <-errCh:
// Immediate error: permission or "not found"
if err != nil && strings.Contains(err.Error(), "not found") {
t.Errorf("Run() with 'any' interface should be valid, got: %v", err)
}
case <-time.After(300 * time.Millisecond):
// Run() started successfully (blocking on packets) — close to stop it
c.Close()
}
}
func TestCaptureImpl_Run_WithManualLocalIPs(t *testing.T) {
t.Skip("integration: pcap on 'any' interface blocks until close; run with -run=Integration in a real network env")
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "any",
ListenPorts: []uint16{443},
LocalIPs: []string{"192.168.1.10", "10.0.0.5"},
}
out := make(chan api.RawPacket, 10)
errCh := make(chan error, 1)
go func() { errCh <- c.Run(cfg, out) }()
select {
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "not found") {
t.Errorf("Run() with manual LocalIPs should be valid, got: %v", err)
}
case <-time.After(300 * time.Millisecond):
c.Close()
}
}
// TestCaptureImpl_LinkTypeInitializedOnce verifies that linkType is set exactly once,
// after the BPF filter is applied (Bug 2 fix: removed the redundant early assignment).
func TestCaptureImpl_LinkTypeInitializedOnce(t *testing.T) {
c := New()
// Fresh instance: linkType must be zero before Run() is called.
if c.linkType != 0 {
t.Errorf("new CaptureImpl should have linkType=0, got %d", c.linkType)
}
// GetDiagnostics reflects linkType correctly.
_, _, _, lt := c.GetDiagnostics()
if lt != 0 {
t.Errorf("GetDiagnostics() linkType before Run() should be 0, got %d", lt)
}
// Simulate what Run() does: set linkType once under the mutex.
c.mu.Lock()
c.linkType = 1 // 1 = Ethernet
c.mu.Unlock()
_, _, _, lt = c.GetDiagnostics()
if lt != 1 {
t.Errorf("GetDiagnostics() linkType after set = %d, want 1", lt)
}
}
// TestBuildBPFFilter_NoLocalIPs verifies Bug 3 fix: when no local IPs are
// available (NAT/VIP), buildBPFFilter returns a port-only filter.
func TestBuildBPFFilter_NoLocalIPs(t *testing.T) {
c := New()
filter := c.buildBPFFilter([]uint16{443}, nil)
if strings.Contains(filter, "dst host") {
t.Errorf("port-only filter expected when localIPs nil, got: %s", filter)
}
if !strings.Contains(filter, "tcp dst port 443") {
t.Errorf("expected tcp dst port 443, got: %s", filter)
}
}
func TestBuildBPFFilter_EmptyLocalIPs(t *testing.T) {
c := New()
filter := c.buildBPFFilter([]uint16{443, 8443}, []string{})
if strings.Contains(filter, "dst host") {
t.Errorf("port-only filter expected when localIPs empty, got: %s", filter)
}
if !strings.Contains(filter, "tcp dst port 443") || !strings.Contains(filter, "tcp dst port 8443") {
t.Errorf("expected both ports in filter, got: %s", filter)
}
}

View File

@ -0,0 +1,295 @@
// Package config provides configuration loading and validation for ja4sentinel
package config
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"github.com/antitbone/ja4/sentinel/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()
path := l.configPath
explicit := path != ""
if !explicit {
path = "config.yml"
}
fileConfig, err := l.loadFromFile(path)
if err == nil {
config = mergeConfigs(config, fileConfig)
} else if !(!explicit && errors.Is(err, os.ErrNotExist)) {
return config, fmt.Errorf("failed to load config file: %w", err)
}
// 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
}
}
// JA4SENTINEL_PACKET_BUFFER_SIZE
if val := os.Getenv("JA4SENTINEL_PACKET_BUFFER_SIZE"); val != "" {
if size, err := strconv.Atoi(val); err == nil && size > 0 {
config.Core.PacketBufferSize = size
}
}
// Note: JA4SENTINEL_LOG_LEVEL is intentionally NOT loaded from env.
// log_level must be configured exclusively via the YAML config file.
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))
seen := make(map[uint16]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
port, err := strconv.ParseUint(part, 10, 16)
if err != nil {
continue
}
p := uint16(port)
if p == 0 {
continue
}
if _, exists := seen[p]; exists {
continue
}
seen[p] = struct{}{}
ports = append(ports, p)
}
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 override.Core.PacketBufferSize > 0 {
result.Core.PacketBufferSize = override.Core.PacketBufferSize
}
if override.Core.LogLevel != "" {
result.Core.LogLevel = override.Core.LogLevel
}
// Merge exclude_source_ips (override takes precedence)
if len(override.Core.ExcludeSourceIPs) > 0 {
result.Core.ExcludeSourceIPs = override.Core.ExcludeSourceIPs
}
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 strings.TrimSpace(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")
}
for _, p := range config.Core.ListenPorts {
if p == 0 {
return fmt.Errorf("listen port 0 is invalid")
}
}
if config.Core.FlowTimeoutSec <= 0 || config.Core.FlowTimeoutSec > 300 {
return fmt.Errorf("flow_timeout_sec must be between 1 and 300")
}
if config.Core.PacketBufferSize <= 0 || config.Core.PacketBufferSize > 1_000_000 {
return fmt.Errorf("packet_buffer_size must be between 1 and 1000000")
}
// Validate log level
validLogLevels := map[string]struct{}{
"debug": {},
"info": {},
"warn": {},
"error": {},
}
if config.Core.LogLevel != "" {
if _, ok := validLogLevels[config.Core.LogLevel]; !ok {
return fmt.Errorf("log_level must be one of: debug, info, warn, error")
}
}
// Validate exclude_source_ips (if provided)
if len(config.Core.ExcludeSourceIPs) > 0 {
for i, ip := range config.Core.ExcludeSourceIPs {
if ip == "" {
return fmt.Errorf("exclude_source_ips[%d]: entry cannot be empty", i)
}
// Basic validation: check if it looks like an IP or CIDR
if !strings.Contains(ip, "/") {
// Single IP - basic check
if !isValidIP(ip) {
return fmt.Errorf("exclude_source_ips[%d]: invalid IP address %q", i, ip)
}
} else {
// CIDR - basic check
if !isValidCIDR(ip) {
return fmt.Errorf("exclude_source_ips[%d]: invalid CIDR %q", i, ip)
}
}
}
}
allowedTypes := map[string]struct{}{
"stdout": {},
"file": {},
"unix_socket": {},
}
// Validate outputs
for i, output := range config.Outputs {
outputType := strings.TrimSpace(output.Type)
if outputType == "" {
return fmt.Errorf("output[%d]: type cannot be empty", i)
}
if _, ok := allowedTypes[outputType]; !ok {
return fmt.Errorf("output[%d]: unknown type %q", i, outputType)
}
switch outputType {
case "file":
if strings.TrimSpace(output.Params["path"]) == "" {
return fmt.Errorf("output[%d]: file output requires non-empty path", i)
}
case "unix_socket":
if strings.TrimSpace(output.Params["socket_path"]) == "" {
return fmt.Errorf("output[%d]: unix_socket output requires non-empty socket_path", 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)
}
// isValidIP checks if a string is a valid IP address using net.ParseIP
func isValidIP(ip string) bool {
return net.ParseIP(ip) != nil
}
// isValidCIDR checks if a string is a valid CIDR notation using net.ParseCIDR
func isValidCIDR(cidr string) bool {
_, _, err := net.ParseCIDR(cidr)
return err == nil
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
// Package fingerprint provides JA4/JA3 fingerprint generation for TLS ClientHello
package fingerprint
import (
"encoding/binary"
"fmt"
"strconv"
"strings"
"github.com/antitbone/ja4/sentinel/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
// Note: JA4 hash portion is extracted for internal use but NOT serialized to LogRecord
// as the JA4 format already includes its own hash portions (per architecture.yml)
func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints, error) {
if len(ch.Payload) == 0 {
return nil, fmt.Errorf("empty ClientHello payload from %s:%d -> %s:%d",
ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort)
}
// Parse the ClientHello using tlsfingerprint
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
if err != nil {
// Try to sanitize truncated extensions and retry
sanitized := sanitizeClientHelloExtensions(ch.Payload)
if sanitized != nil {
fp, err = tlsfingerprint.ParseClientHello(sanitized)
}
if err != nil {
sanitizeStatus := "unavailable"
if sanitized != nil {
sanitizeStatus = "failed"
}
return nil, fmt.Errorf("fingerprint generation failed for %s:%d -> %s:%d (conn_id=%s, payload_len=%d, tls_version=%s, sni=%s, sanitization=%s): %w",
ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort, ch.ConnID, len(ch.Payload), ch.TLSVersion, ch.SNI, sanitizeStatus, 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>
// This is kept for internal use but NOT serialized to LogRecord
ja4Hash := extractJA4Hash(ja4)
// Generate JA4T fingerprint from TCP SYN parameters
ja4t := computeJA4T(ch.TCPMeta)
return &api.Fingerprints{
JA4: ja4,
JA4Hash: ja4Hash, // Internal use only - not serialized to LogRecord
JA4T: ja4t,
JA3: ja3,
JA3Hash: ja3Hash,
}, nil
}
// computeJA4T génère l'empreinte JA4T à partir des métadonnées TCP SYN.
// Format : {WindowSize}_{OptionKinds}_{WindowScale}_{MSS}
func computeJA4T(tcp api.TCPMeta) string {
optStr := ""
if len(tcp.OptionKinds) > 0 {
parts := make([]string, len(tcp.OptionKinds))
for i, k := range tcp.OptionKinds {
parts[i] = strconv.Itoa(int(k))
}
optStr = strings.Join(parts, "-")
}
return fmt.Sprintf("%d_%s_%d_%d", tcp.WindowSize, optStr, tcp.WindowScale, tcp.MSS)
}
// 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 ""
}
// sanitizeClientHelloExtensions fixes ClientHellos with truncated extension data
// by adjusting the extensions length to include only complete extensions.
// Returns a corrected copy, or nil if the payload cannot be fixed.
func sanitizeClientHelloExtensions(data []byte) []byte {
if len(data) < 5 || data[0] != 0x16 {
return nil
}
recordLen := int(data[3])<<8 | int(data[4])
if len(data) < 5+recordLen {
return nil
}
payload := data[5 : 5+recordLen]
if len(payload) < 4 || payload[0] != 0x01 {
return nil
}
helloLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
if len(payload) < 4+helloLen {
return nil
}
hello := payload[4 : 4+helloLen]
// Skip through ClientHello fields to reach extensions
offset := 2 + 32 // version + random
if len(hello) < offset+1 {
return nil
}
offset += 1 + int(hello[offset]) // session ID
if len(hello) < offset+2 {
return nil
}
csLen := int(hello[offset])<<8 | int(hello[offset+1])
offset += 2 + csLen // cipher suites
if len(hello) < offset+1 {
return nil
}
offset += 1 + int(hello[offset]) // compression methods
if len(hello) < offset+2 {
return nil
}
extLenOffset := offset // position of extensions length field
declaredExtLen := int(hello[offset])<<8 | int(hello[offset+1])
offset += 2
extStart := offset
if len(hello) < extStart+declaredExtLen {
return nil
}
extData := hello[extStart : extStart+declaredExtLen]
// Walk extensions, find how many complete ones exist
validLen := 0
pos := 0
for pos < len(extData) {
if pos+4 > len(extData) {
break
}
extBodyLen := int(extData[pos+2])<<8 | int(extData[pos+3])
if pos+4+extBodyLen > len(extData) {
break // this extension is truncated
}
pos += 4 + extBodyLen
validLen = pos
}
if validLen == declaredExtLen {
return nil // no truncation found, nothing to fix
}
// Build a corrected copy with adjusted extensions length
fixed := make([]byte, len(data))
copy(fixed, data)
// Absolute offset of extensions length field within data
extLenAbs := 5 + 4 + extLenOffset
diff := declaredExtLen - validLen
// Update extensions length
binary.BigEndian.PutUint16(fixed[extLenAbs:], uint16(validLen))
// Update ClientHello handshake length
newHelloLen := helloLen - diff
fixed[5+1] = byte(newHelloLen >> 16)
fixed[5+2] = byte(newHelloLen >> 8)
fixed[5+3] = byte(newHelloLen)
// Update TLS record length
newRecordLen := recordLen - diff
binary.BigEndian.PutUint16(fixed[3:5], uint16(newRecordLen))
return fixed[:5+newRecordLen]
}

View File

@ -0,0 +1,585 @@
package fingerprint
import (
"strings"
"testing"
"github.com/antitbone/ja4/sentinel/api"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
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")
}
}
func TestFromClientHello_ValidPayload(t *testing.T) {
// Use a minimal valid TLS 1.2 ClientHello with extensions
// Build a proper ClientHello using the same structure as parser tests
clientHello := buildMinimalClientHelloForTest()
ch := api.TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
Payload: clientHello,
}
engine := NewEngine()
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
if fp == nil {
t.Fatal("FromClientHello() returned nil")
}
// Verify JA4 is populated (format: t13d... or t12d...)
if fp.JA4 == "" {
t.Error("JA4 should not be empty")
}
// JA4Hash is populated for internal use (but not serialized to LogRecord)
// It contains the hash portions of the JA4 string
if fp.JA4Hash == "" {
t.Error("JA4Hash should be populated for internal use")
}
}
// buildMinimalClientHelloForTest creates a minimal valid TLS 1.2 ClientHello
func buildMinimalClientHelloForTest() []byte {
// Cipher suites (minimal set)
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
// Compression methods (null only)
compressionMethods := []byte{0x01, 0x00}
// No extensions
extensions := []byte{}
extLen := len(extensions)
// Build ClientHello handshake body
handshakeBody := []byte{
0x03, 0x03, // Version: TLS 1.2
// Random (32 bytes)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, // Session ID length: 0
}
// Add cipher suites (with length prefix)
cipherSuiteLen := len(cipherSuites)
handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen))
handshakeBody = append(handshakeBody, cipherSuites...)
// Add compression methods (with length prefix)
handshakeBody = append(handshakeBody, compressionMethods...)
// Add extensions (with length prefix)
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
handshakeBody = append(handshakeBody, extensions...)
// Now build full handshake with type and length
handshakeLen := len(handshakeBody)
handshake := append([]byte{
0x01, // Handshake type: ClientHello
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen), // Handshake length
}, handshakeBody...)
// Build TLS record
recordLen := len(handshake)
record := make([]byte, 5+recordLen)
record[0] = 0x16 // Handshake
record[1] = 0x03 // Version: TLS 1.2
record[2] = 0x03
record[3] = byte(recordLen >> 8)
record[4] = byte(recordLen)
copy(record[5:], handshake)
return record
}
// TestExtractJA4Hash tests the extractJA4Hash helper function
func TestExtractJA4Hash(t *testing.T) {
tests := []struct {
name string
ja4 string
want string
}{
{
name: "standard_ja4_format",
ja4: "t13d1516h2_8daaf6152771_02cb136f2775",
want: "8daaf6152771_02cb136f2775",
},
{
name: "ja4_with_single_underscore",
ja4: "t12d1234h1_abcdef123456",
want: "abcdef123456",
},
{
name: "ja4_no_underscore_returns_empty",
ja4: "t13d1516h2",
want: "",
},
{
name: "empty_ja4_returns_empty",
ja4: "",
want: "",
},
{
name: "underscore_at_start",
ja4: "_hash1_hash2",
want: "hash1_hash2",
},
{
name: "multiple_underscores_returns_after_first",
ja4: "base_part1_part2_part3",
want: "part1_part2_part3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractJA4Hash(tt.ja4)
if got != tt.want {
t.Errorf("extractJA4Hash(%q) = %q, want %q", tt.ja4, got, tt.want)
}
})
}
}
// TestFromClientHello_NilPayload tests error handling for nil payload
func TestFromClientHello_NilPayload(t *testing.T) {
engine := NewEngine()
ch := api.TLSClientHello{
Payload: nil,
}
_, err := engine.FromClientHello(ch)
if err == nil {
t.Error("FromClientHello() with nil payload should return error")
}
if !strings.HasPrefix(err.Error(), "empty ClientHello payload") {
t.Errorf("FromClientHello() error = %v, should start with 'empty ClientHello payload'", err)
}
}
// TestFromClientHello_JA3Hash tests that JA3Hash is correctly populated
func TestFromClientHello_JA3Hash(t *testing.T) {
clientHello := buildMinimalClientHelloForTest()
ch := api.TLSClientHello{
Payload: clientHello,
}
engine := NewEngine()
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
// JA3Hash should be populated (MD5 hash of JA3 string)
if fp.JA3Hash == "" {
t.Error("JA3Hash should be populated")
}
// JA3 should also be populated
if fp.JA3 == "" {
t.Error("JA3 should be populated")
}
}
// TestFromClientHello_EmptyJA4Hash tests behavior when JA4 has no underscore
func TestFromClientHello_EmptyJA4Hash(t *testing.T) {
// This test verifies that even if JA4 format changes, the code handles it gracefully
engine := NewEngine()
// Use a valid ClientHello - the library should produce a proper JA4
clientHello := buildMinimalClientHelloForTest()
ch := api.TLSClientHello{
Payload: clientHello,
}
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
// JA4 should always be populated
if fp.JA4 == "" {
t.Error("JA4 should be populated")
}
// JA4Hash may be empty if the JA4 format doesn't include underscores
// This is acceptable behavior
}
// buildClientHelloWithTruncatedExtension creates a ClientHello where the last
// extension declares more data than actually present.
func buildClientHelloWithTruncatedExtension() []byte {
// Build a valid SNI extension first
sniHostname := []byte("example.com")
sniExt := []byte{
0x00, 0x00, // Extension type: server_name
}
sniData := []byte{0x00}
sniListLen := 1 + 2 + len(sniHostname) // type(1) + len(2) + hostname
sniData = append(sniData, byte(sniListLen>>8), byte(sniListLen))
sniData = append(sniData, 0x00) // hostname type
sniData = append(sniData, byte(len(sniHostname)>>8), byte(len(sniHostname)))
sniData = append(sniData, sniHostname...)
sniExt = append(sniExt, byte(len(sniData)>>8), byte(len(sniData)))
sniExt = append(sniExt, sniData...)
// Build a truncated extension: declares 100 bytes but only has 5
truncatedExt := []byte{
0x00, 0x15, // Extension type: padding
0x00, 0x64, // Extension data length: 100 (but we only provide 5)
0x00, 0x00, 0x00, 0x00, 0x00, // Only 5 bytes of padding
}
// Extensions = valid SNI + truncated padding
extensions := append(sniExt, truncatedExt...)
// But the extensions length field claims the full size (including the bad extension)
extLen := len(extensions)
// Cipher suites
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
compressionMethods := []byte{0x01, 0x00}
handshakeBody := []byte{0x03, 0x03}
for i := 0; i < 32; i++ {
handshakeBody = append(handshakeBody, 0x01)
}
handshakeBody = append(handshakeBody, 0x00) // session ID length: 0
handshakeBody = append(handshakeBody, byte(len(cipherSuites)>>8), byte(len(cipherSuites)))
handshakeBody = append(handshakeBody, cipherSuites...)
handshakeBody = append(handshakeBody, compressionMethods...)
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
handshakeBody = append(handshakeBody, extensions...)
handshakeLen := len(handshakeBody)
handshake := append([]byte{
0x01,
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen),
}, handshakeBody...)
recordLen := len(handshake)
record := make([]byte, 5+recordLen)
record[0] = 0x16
record[1] = 0x03
record[2] = 0x03
record[3] = byte(recordLen >> 8)
record[4] = byte(recordLen)
copy(record[5:], handshake)
return record
}
func TestFromClientHello_TruncatedExtension_StillGeneratesFingerprint(t *testing.T) {
payload := buildClientHelloWithTruncatedExtension()
ch := api.TLSClientHello{
SrcIP: "4.251.36.192",
SrcPort: 19346,
DstIP: "212.95.72.88",
DstPort: 443,
Payload: payload,
ConnID: "4.251.36.192:19346->212.95.72.88:443",
}
engine := NewEngine()
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() should succeed after sanitization, got error: %v", err)
}
if fp == nil {
t.Fatal("FromClientHello() returned nil fingerprint")
}
if fp.JA4 == "" {
t.Error("JA4 should be populated even with truncated extension")
}
if fp.JA3 == "" {
t.Error("JA3 should be populated even with truncated extension")
}
}
func TestSanitizeClientHelloExtensions(t *testing.T) {
t.Run("valid payload returns nil", func(t *testing.T) {
valid := buildMinimalClientHelloForTest()
result := sanitizeClientHelloExtensions(valid)
if result != nil {
t.Error("should return nil for valid payload (no fix needed)")
}
})
t.Run("truncated extension is fixed", func(t *testing.T) {
truncated := buildClientHelloWithTruncatedExtension()
result := sanitizeClientHelloExtensions(truncated)
if result == nil {
t.Fatal("should return sanitized payload")
}
// The sanitized payload should be parseable by the library
fp, err := tlsfingerprint.ParseClientHello(result)
if err != nil {
t.Fatalf("sanitized payload should parse without error, got: %v", err)
}
if fp == nil {
t.Fatal("sanitized payload should produce a fingerprint")
}
})
t.Run("too short returns nil", func(t *testing.T) {
if sanitizeClientHelloExtensions([]byte{0x16}) != nil {
t.Error("should return nil for short payload")
}
})
t.Run("non-TLS returns nil", func(t *testing.T) {
if sanitizeClientHelloExtensions([]byte{0x15, 0x03, 0x03, 0x00, 0x01, 0x00}) != nil {
t.Error("should return nil for non-TLS payload")
}
})
}
// TestExtractJA4Hash_Standard tests the hash extraction from a standard JA4 string.
func TestExtractJA4Hash_Standard(t *testing.T) {
ja4 := "t13d1516h2_8daaf6152771_02cb136f2775"
got := extractJA4Hash(ja4)
expected := "8daaf6152771_02cb136f2775"
if got != expected {
t.Errorf("extractJA4Hash(%q) = %q, want %q", ja4, got, expected)
}
}
// TestExtractJA4Hash_NoUnderscore tests that no underscore returns empty string.
func TestExtractJA4Hash_NoUnderscore(t *testing.T) {
got := extractJA4Hash("nounderscore")
if got != "" {
t.Errorf("expected empty string for no underscore, got %q", got)
}
}
// TestExtractJA4Hash_Empty tests that empty string returns empty string.
func TestExtractJA4Hash_Empty(t *testing.T) {
got := extractJA4Hash("")
if got != "" {
t.Errorf("expected empty string for empty input, got %q", got)
}
}
// TestFromClientHello_NilPayloadExplicit tests that nil payload (empty) returns error.
func TestFromClientHello_NilPayloadExplicit(t *testing.T) {
engine := NewEngine()
_, err := engine.FromClientHello(api.TLSClientHello{
SrcIP: "1.2.3.4",
SrcPort: 12345,
DstIP: "5.6.7.8",
DstPort: 443,
Payload: nil,
})
if err == nil {
t.Error("expected error for nil payload")
}
}
// TestFromClientHello_SingleByte tests that single byte payload returns error.
func TestFromClientHello_SingleByte(t *testing.T) {
engine := NewEngine()
_, err := engine.FromClientHello(api.TLSClientHello{
Payload: []byte{0x16},
})
if err == nil {
t.Error("expected error for single-byte payload")
}
}
// TestFromClientHello_ErrorContainsAddresses tests that error message includes addresses.
func TestFromClientHello_ErrorContainsAddresses(t *testing.T) {
engine := NewEngine()
_, err := engine.FromClientHello(api.TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "test-conn-id",
Payload: []byte{0x01, 0x02, 0x03}, // invalid
})
if err == nil {
t.Fatal("expected error for invalid payload")
}
if !strings.Contains(err.Error(), "192.168.1.100") {
t.Errorf("expected error to contain src IP, got: %v", err)
}
}
// TestSanitizeClientHelloExtensions_NilInput tests nil input returns nil.
func TestSanitizeClientHelloExtensions_NilInput(t *testing.T) {
if sanitizeClientHelloExtensions(nil) != nil {
t.Error("nil input should return nil")
}
}
// TestSanitizeClientHelloExtensions_EmptyInput tests empty input returns nil.
func TestSanitizeClientHelloExtensions_EmptyInput(t *testing.T) {
if sanitizeClientHelloExtensions([]byte{}) != nil {
t.Error("empty input should return nil")
}
}
// TestJA4HashExtraction_ConsistentWithFullParse verifies JA4Hash is the tail of JA4 string.
func TestJA4HashExtraction_ConsistentWithFullParse(t *testing.T) {
// Any JA4 string with exactly one underscore should work
ja4 := "t12d4562h0_somehash"
hash := extractJA4Hash(ja4)
if !strings.HasPrefix(ja4, "t12") {
t.Skip("precondition failed")
}
if hash != "somehash" {
t.Errorf("expected 'somehash', got %q", hash)
}
}
// Compile-time check: EngineImpl satisfies api.Engine.
var _ interface {
FromClientHello(api.TLSClientHello) (*api.Fingerprints, error)
} = (*EngineImpl)(nil)
// TestComputeJA4T tests the JA4T fingerprint generation.
func TestComputeJA4T(t *testing.T) {
tests := []struct {
name string
tcp api.TCPMeta
want string
}{
{
name: "linux_5x_typical",
tcp: api.TCPMeta{
WindowSize: 64240,
OptionKinds: []uint8{2, 4, 8, 1, 3},
WindowScale: 7,
MSS: 1460,
},
want: "64240_2-4-8-1-3_7_1460",
},
{
name: "windows_11_typical",
tcp: api.TCPMeta{
WindowSize: 64240,
OptionKinds: []uint8{2, 4, 8, 1, 3},
WindowScale: 8,
MSS: 1460,
},
want: "64240_2-4-8-1-3_8_1460",
},
{
name: "macos_14_typical",
tcp: api.TCPMeta{
WindowSize: 65535,
OptionKinds: []uint8{2, 4, 8, 1, 3},
WindowScale: 6,
MSS: 1460,
},
want: "65535_2-4-8-1-3_6_1460",
},
{
name: "no_options",
tcp: api.TCPMeta{
WindowSize: 8192,
OptionKinds: nil,
WindowScale: 0,
MSS: 0,
},
want: "8192__0_0",
},
{
name: "windows_no_ts",
tcp: api.TCPMeta{
WindowSize: 8192,
OptionKinds: []uint8{2, 4, 1, 3},
WindowScale: 2,
MSS: 1460,
},
want: "8192_2-4-1-3_2_1460",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeJA4T(tt.tcp)
if got != tt.want {
t.Errorf("computeJA4T() = %q, want %q", got, tt.want)
}
})
}
}
// TestFromClientHello_JA4T_Populated tests that JA4T is populated in FromClientHello.
func TestFromClientHello_JA4T_Populated(t *testing.T) {
clientHello := buildMinimalClientHelloForTest()
ch := api.TLSClientHello{
Payload: clientHello,
TCPMeta: api.TCPMeta{
WindowSize: 64240,
MSS: 1460,
WindowScale: 7,
OptionKinds: []uint8{2, 4, 8, 1, 3},
Options: []string{"MSS", "SACK", "TS", "NOP", "WS"},
},
}
engine := NewEngine()
fp, err := engine.FromClientHello(ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
expected := "64240_2-4-8-1-3_7_1460"
if fp.JA4T != expected {
t.Errorf("JA4T = %q, want %q", fp.JA4T, expected)
}
}

View File

@ -0,0 +1,402 @@
// Package integration provides integration tests for the full ja4sentinel pipeline
package integration
import (
"encoding/json"
"os"
"testing"
"time"
"github.com/antitbone/ja4/sentinel/api"
"github.com/antitbone/ja4/sentinel/internal/fingerprint"
"github.com/antitbone/ja4/sentinel/internal/output"
"github.com/antitbone/ja4/sentinel/internal/tlsparse"
)
// TestFullPipeline_TLSClientHelloToFingerprint tests the pipeline from TLS ClientHello to fingerprint
func TestFullPipeline_TLSClientHelloToFingerprint(t *testing.T) {
// Create a minimal TLS 1.2 ClientHello for testing
clientHello := buildMinimalTLSClientHello()
// Step 1: Parse the ClientHello
parser := tlsparse.NewParser()
if parser == nil {
t.Fatal("NewParser() returned nil")
}
defer parser.Close()
// Create a raw packet with the ClientHello
rawPacket := api.RawPacket{
Data: buildEthernetIPPacket(clientHello),
Timestamp: time.Now().UnixNano(),
}
// Process the packet
ch, err := parser.Process(rawPacket)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
if ch == nil {
t.Fatal("Process() returned nil ClientHello")
}
// Step 2: Generate fingerprints
engine := fingerprint.NewEngine()
if engine == nil {
t.Fatal("NewEngine() returned nil")
}
fp, err := engine.FromClientHello(*ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
if fp == nil {
t.Fatal("FromClientHello() returned nil")
}
// Verify fingerprints are populated
if fp.JA4 == "" {
t.Error("JA4 should be populated")
}
if fp.JA3 == "" {
t.Error("JA3 should be populated")
}
if fp.JA3Hash == "" {
t.Error("JA3Hash should be populated")
}
}
// TestFullPipeline_FingerprintToOutput tests the pipeline from fingerprint to output
func TestFullPipeline_FingerprintToOutput(t *testing.T) {
// Create test data
clientHello := api.TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
IPMeta: api.IPMeta{
TTL: 64,
TotalLength: 512,
IPID: 12345,
DF: true,
},
TCPMeta: api.TCPMeta{
WindowSize: 65535,
MSS: 1460,
WindowScale: 7,
Options: []string{"MSS", "SACK", "TS", "WS"},
},
ConnID: "test-flow-123",
SNI: "example.com",
ALPN: "h2",
TLSVersion: "1.3",
SynToCHMs: uint32Ptr(50),
}
// Create fingerprints
fingerprints := &api.Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "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",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
}
// Step 1: Create LogRecord
logRecord := api.NewLogRecord(clientHello, fingerprints)
logRecord.SensorID = "test-sensor"
// Step 2: Write to output (stdout writer for testing)
writer := output.NewStdoutWriter()
if writer == nil {
t.Fatal("NewStdoutWriter() returned nil")
}
// Capture stdout by using a buffer (we can't easily test stdout, so we verify the record)
// Instead, verify the LogRecord is valid JSON
data, err := json.Marshal(logRecord)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
// Verify JSON is valid and contains expected fields
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
// Verify key fields
if result["src_ip"] != "192.168.1.100" {
t.Errorf("src_ip = %v, want 192.168.1.100", result["src_ip"])
}
if result["src_port"] != float64(54321) {
t.Errorf("src_port = %v, want 54321", result["src_port"])
}
if result["ja4"] != "t13d1516h2_8daaf6152771_02cb136f2775" {
t.Errorf("ja4 = %v, want t13d1516h2_8daaf6152771_02cb136f2775", result["ja4"])
}
if result["tls_sni"] != "example.com" {
t.Errorf("tls_sni = %v, want example.com", result["tls_sni"])
}
if result["sensor_id"] != "test-sensor" {
t.Errorf("sensor_id = %v, want test-sensor", result["sensor_id"])
}
}
// TestFullPipeline_EndToEnd tests the complete pipeline with file output
func TestFullPipeline_EndToEnd(t *testing.T) {
tmpDir := t.TempDir()
outputPath := tmpDir + "/output.log"
// Create test ClientHello
clientHello := buildMinimalTLSClientHello()
// Step 1: Parse
parser := tlsparse.NewParser()
defer parser.Close()
rawPacket := api.RawPacket{
Data: buildEthernetIPPacket(clientHello),
Timestamp: time.Now().UnixNano(),
}
ch, err := parser.Process(rawPacket)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
// Step 2: Fingerprint
engine := fingerprint.NewEngine()
fp, err := engine.FromClientHello(*ch)
if err != nil {
t.Fatalf("FromClientHello() error = %v", err)
}
// Step 3: Create LogRecord
logRecord := api.NewLogRecord(*ch, fp)
logRecord.SensorID = "test-sensor-e2e"
// Step 4: Write to file
fileWriter, err := output.NewFileWriter(outputPath)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
defer fileWriter.Close()
err = fileWriter.Write(logRecord)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Verify output file
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if len(data) == 0 {
t.Fatal("Output file is empty")
}
// Parse and verify
var result api.LogRecord
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if result.SensorID != "test-sensor-e2e" {
t.Errorf("SensorID = %v, want test-sensor-e2e", result.SensorID)
}
if result.JA4 == "" {
t.Error("JA4 should be populated")
}
}
// TestFullPipeline_MultiOutput tests writing to multiple outputs simultaneously
func TestFullPipeline_MultiOutput(t *testing.T) {
tmpDir := t.TempDir()
filePath := tmpDir + "/multi.log"
// Create multi-writer
multiWriter := output.NewMultiWriter()
multiWriter.Add(output.NewStdoutWriter())
fileWriter, err := output.NewFileWriter(filePath)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
multiWriter.Add(fileWriter)
// Create test record
logRecord := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
JA4: "test-multi-output",
}
// Write to all outputs
err = multiWriter.Write(logRecord)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Verify file output
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if len(data) == 0 {
t.Fatal("File output is empty")
}
}
// TestFullPipeline_ConfigToOutput tests building output from config
func TestFullPipeline_ConfigToOutput(t *testing.T) {
tmpDir := t.TempDir()
// Create config with multiple outputs
config := api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{
Type: "stdout",
Enabled: true,
AsyncBuffer: 1000,
},
{
Type: "file",
Enabled: true,
AsyncBuffer: 1000,
Params: map[string]string{"path": tmpDir + "/config-output.log"},
},
},
}
// Build writer from config
builder := output.NewBuilder()
writer, err := builder.NewFromConfig(config)
if err != nil {
t.Fatalf("NewFromConfig() error = %v", err)
}
// Verify writer is MultiWriter
_, ok := writer.(*output.MultiWriter)
if !ok {
t.Fatal("Expected MultiWriter")
}
// Test writing
logRecord := api.LogRecord{
SrcIP: "192.168.1.1",
JA4: "test-config-output",
}
err = writer.Write(logRecord)
if err != nil {
t.Errorf("Write() error = %v", err)
}
}
// Helper functions
// buildMinimalTLSClientHello creates a minimal TLS 1.2 ClientHello for testing
func buildMinimalTLSClientHello() []byte {
// Cipher suites
cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f}
compressionMethods := []byte{0x01, 0x00}
extensions := []byte{}
extLen := len(extensions)
handshakeBody := []byte{
0x03, 0x03, // Version: TLS 1.2
}
// Random (32 bytes)
for i := 0; i < 32; i++ {
handshakeBody = append(handshakeBody, 0x00)
}
handshakeBody = append(handshakeBody, 0x00) // Session ID length
// Cipher suites
cipherSuiteLen := len(cipherSuites)
handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen))
handshakeBody = append(handshakeBody, cipherSuites...)
// Compression methods
handshakeBody = append(handshakeBody, compressionMethods...)
// Extensions
handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen))
handshakeBody = append(handshakeBody, extensions...)
// Build handshake
handshakeLen := len(handshakeBody)
handshake := append([]byte{
0x01, // Handshake type: ClientHello
byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen),
}, handshakeBody...)
// Build TLS record
recordLen := len(handshake)
record := make([]byte, 5+recordLen)
record[0] = 0x16 // Handshake
record[1] = 0x03 // Version: TLS 1.2
record[2] = 0x03
record[3] = byte(recordLen >> 8)
record[4] = byte(recordLen)
copy(record[5:], handshake)
return record
}
// buildEthernetIPPacket wraps a TLS payload in Ethernet/IP/TCP headers
func buildEthernetIPPacket(tlsPayload []byte) []byte {
// This is a simplified packet structure for testing
// Real packets would have proper Ethernet, IP, and TCP headers
// Ethernet header (14 bytes)
eth := make([]byte, 14)
eth[12] = 0x08 // EtherType: IPv4
eth[13] = 0x00
// IP header (20 bytes)
ip := make([]byte, 20)
ip[0] = 0x45 // Version 4, IHL 5
ip[1] = 0x00 // DSCP/ECN
ip[2] = byte((20 + 20 + len(tlsPayload)) >> 8) // Total length
ip[3] = byte((20 + 20 + len(tlsPayload)) & 0xFF)
ip[8] = 64 // TTL
ip[9] = 6 // Protocol: TCP
ip[12] = 192
ip[13] = 168
ip[14] = 1
ip[15] = 100 // Src IP: 192.168.1.100
ip[16] = 10
ip[17] = 0
ip[18] = 0
ip[19] = 1 // Dst IP: 10.0.0.1
// TCP header (20 bytes)
tcp := make([]byte, 20)
tcp[0] = byte(54321 >> 8) // Src port high
tcp[1] = byte(54321 & 0xFF) // Src port low
tcp[2] = byte(443 >> 8) // Dst port high
tcp[3] = byte(443 & 0xFF) // Dst port low
tcp[12] = 0x50 // Data offset (5 * 4 = 20 bytes)
tcp[13] = 0x18 // Flags: ACK, PSH
// Combine all headers with payload
packet := make([]byte, len(eth)+len(ip)+len(tcp)+len(tlsPayload))
copy(packet, eth)
copy(packet[len(eth):], ip)
copy(packet[len(eth)+len(ip):], tcp)
copy(packet[len(eth)+len(ip)+len(tcp):], tlsPayload)
return packet
}
func uint32Ptr(v uint32) *uint32 {
return &v
}

View File

@ -0,0 +1,15 @@
// Package ipfilter provides IP address and CIDR range matching for filtering.
// Implementation is delegated to shared/go/ja4common/ipfilter to avoid duplication.
package ipfilter
import jaipfilter "github.com/antitbone/ja4/ja4common/ipfilter"
// Filter is a type alias for ja4common/ipfilter.Filter.
// All methods (New, ShouldExclude, Count) are inherited from the shared module.
type Filter = jaipfilter.Filter
// New creates a new IP filter from a list of IP addresses or CIDR ranges.
// Accepts formats like: "192.168.1.1", "10.0.0.0/8", "2001:db8::/32"
func New(excludeList []string) (*Filter, error) {
return jaipfilter.New(excludeList)
}

View File

@ -0,0 +1,160 @@
package ipfilter
import (
"testing"
)
func TestFilter_New(t *testing.T) {
tests := []struct {
name string
list []string
wantErr bool
}{
{
name: "empty list",
list: []string{},
wantErr: false,
},
{
name: "single IP",
list: []string{"192.168.1.1"},
wantErr: false,
},
{
name: "single CIDR",
list: []string{"10.0.0.0/8"},
wantErr: false,
},
{
name: "mixed IPs and CIDRs",
list: []string{"192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"},
wantErr: false,
},
{
name: "invalid IP",
list: []string{"999.999.999.999"},
wantErr: true,
},
{
name: "invalid CIDR",
list: []string{"10.0.0.0/33"},
wantErr: true,
},
{
name: "IPv6 address",
list: []string{"2001:db8::1"},
wantErr: false,
},
{
name: "IPv6 CIDR",
list: []string{"2001:db8::/32"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := New(tt.list)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && f == nil {
t.Error("New() should return non-nil filter on success")
}
})
}
}
func TestFilter_ShouldExclude(t *testing.T) {
f, err := New([]string{
"192.168.1.1",
"10.0.0.0/8",
"172.16.0.0/12",
"2001:db8::1",
"fc00::/7",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
tests := []struct {
name string
ip string
want bool
}{
// Exact IP matches
{"exact match", "192.168.1.1", true},
{"exact IPv6 match", "2001:db8::1", true},
// CIDR matches
{"CIDR match 10.0.0.1", "10.0.0.1", true},
{"CIDR match 10.255.255.255", "10.255.255.255", true},
{"CIDR match 172.16.0.1", "172.16.0.1", true},
{"CIDR match 172.31.255.255", "172.31.255.255", true},
{"CIDR IPv6 match", "fc00::1", true},
// No matches
{"no match 192.168.2.1", "192.168.2.1", false},
{"no match 11.0.0.1", "11.0.0.1", false},
{"no match 172.32.0.1", "172.32.0.1", false},
{"no match 8.8.8.8", "8.8.8.8", false},
// Invalid IP
{"invalid IP", "invalid", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := f.ShouldExclude(tt.ip); got != tt.want {
t.Errorf("ShouldExclude(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}
func TestFilter_ShouldExclude_NilFilter(t *testing.T) {
var f *Filter
if f.ShouldExclude("192.168.1.1") {
t.Error("ShouldExclude on nil filter should return false")
}
}
func TestFilter_Count(t *testing.T) {
f, err := New([]string{
"192.168.1.1",
"10.0.0.1",
"10.0.0.0/8",
"172.16.0.0/12",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ips, networks := f.Count()
if ips != 2 {
t.Errorf("Count() ips = %d, want 2", ips)
}
if networks != 2 {
t.Errorf("Count() networks = %d, want 2", networks)
}
}
func TestFilter_EmptyEntries(t *testing.T) {
f, err := New([]string{"", "192.168.1.1", ""})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ips, _ := f.Count()
if ips != 1 {
t.Errorf("Count() ips = %d, want 1 (empty entries should be skipped)", ips)
}
if !f.ShouldExclude("192.168.1.1") {
t.Error("Should exclude 192.168.1.1")
}
if f.ShouldExclude("192.168.1.2") {
t.Error("Should not exclude 192.168.1.2")
}
}

View File

@ -0,0 +1,19 @@
// Package logging provides a factory for creating loggers
package logging
import (
"github.com/antitbone/ja4/sentinel/api"
)
// LoggerFactory creates logger instances
type LoggerFactory struct{}
// NewLogger creates a new logger based on configuration
func (f *LoggerFactory) NewLogger(level string) api.Logger {
return NewServiceLogger(level)
}
// NewDefaultLogger creates a logger with default settings
func (f *LoggerFactory) NewDefaultLogger() api.Logger {
return NewServiceLogger("info")
}

View File

@ -0,0 +1,47 @@
// Package logging provides structured logging for the sentinel service.
// Implementation is delegated to shared/go/ja4common/logger to avoid duplication.
package logging
import (
jalogger "github.com/antitbone/ja4/ja4common/logger"
"github.com/antitbone/ja4/sentinel/api"
)
// ServiceLogger satisfies api.Logger using ja4common/logger.ComponentLogger.
// This avoids duplicating logging logic that is now shared across all ja4-platform services.
type ServiceLogger struct {
inner *jalogger.ComponentLogger
}
// NewServiceLogger creates a new ServiceLogger backed by ja4common.
func NewServiceLogger(level string) *ServiceLogger {
return &ServiceLogger{inner: jalogger.NewComponentLogger(level)}
}
// Log emits a structured log entry for the given component.
func (l *ServiceLogger) Log(component, level, message string, details map[string]string) {
l.inner.Log(component, level, message, details)
}
// Debug logs a debug entry for the given component.
func (l *ServiceLogger) Debug(component, message string, details map[string]string) {
l.inner.Debug(component, message, details)
}
// Info logs an info entry for the given component.
func (l *ServiceLogger) Info(component, message string, details map[string]string) {
l.inner.Info(component, message, details)
}
// Warn logs a warning entry for the given component.
func (l *ServiceLogger) Warn(component, message string, details map[string]string) {
l.inner.Warn(component, message, details)
}
// Error logs an error entry for the given component.
func (l *ServiceLogger) Error(component, message string, details map[string]string) {
l.inner.Error(component, message, details)
}
// compile-time check: ServiceLogger must satisfy api.Logger
var _ api.Logger = (*ServiceLogger)(nil)

View File

@ -0,0 +1,79 @@
// Package logging tests — behavioral tests for ServiceLogger.
// Since ServiceLogger delegates to ja4common/logger.ComponentLogger,
// we test behavior (no-panic, interface satisfaction, level filtering)
// rather than internal output buffering.
package logging_test
import (
"testing"
"github.com/antitbone/ja4/sentinel/api"
"github.com/antitbone/ja4/sentinel/internal/logging"
)
func TestNewServiceLogger_NonNil(t *testing.T) {
logger := logging.NewServiceLogger("info")
if logger == nil {
t.Fatal("expected non-nil logger")
}
}
func TestServiceLogger_ImplementsApiLogger(t *testing.T) {
logger := logging.NewServiceLogger("debug")
var _ api.Logger = logger // compile-time check
}
func TestServiceLogger_AllLevels_NoPanic(t *testing.T) {
levels := []string{"debug", "info", "warn", "error", "invalid"}
for _, level := range levels {
t.Run(level, func(t *testing.T) {
logger := logging.NewServiceLogger(level)
logger.Debug("comp", "debug msg", map[string]string{"k": "v"})
logger.Info("comp", "info msg", nil)
logger.Warn("comp", "warn msg", map[string]string{"x": "y"})
logger.Error("comp", "error msg", nil)
})
}
}
func TestServiceLogger_WithDetails(t *testing.T) {
logger := logging.NewServiceLogger("debug")
details := map[string]string{"error": "test error", "trace_id": "abc123"}
logger.Info("service", "test message", details)
}
func TestServiceLogger_NilDetails(t *testing.T) {
logger := logging.NewServiceLogger("debug")
logger.Info("service", "test message", nil)
}
func TestServiceLogger_ConcurrentLogging(t *testing.T) {
logger := logging.NewServiceLogger("debug")
done := make(chan bool)
for i := 0; i < 10; i++ {
go func(id int) {
logger.Info("service", "concurrent message", map[string]string{"id": string(rune('0'+id))})
done <- true
}(i)
}
for i := 0; i < 10; i++ {
<-done
}
}
func TestLoggerFactory(t *testing.T) {
factory := &logging.LoggerFactory{}
levels := []string{"debug", "info", "warn", "error"}
for _, level := range levels {
t.Run(level, func(t *testing.T) {
logger := factory.NewLogger(level)
if logger == nil {
t.Fatalf("NewLogger(%q) returned nil", level)
}
})
}
logger := factory.NewDefaultLogger()
if logger == nil {
t.Fatal("NewDefaultLogger() returned nil")
}
}

View File

@ -0,0 +1,644 @@
// Package output provides writers for ja4sentinel log records
package output
import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/antitbone/ja4/sentinel/api"
)
// Socket configuration constants
const (
// DefaultDialTimeout is the default timeout for socket connections
DefaultDialTimeout = 5 * time.Second
// DefaultWriteTimeout is the default timeout for socket writes
DefaultWriteTimeout = 5 * time.Second
// DefaultMaxReconnectAttempts is the maximum number of reconnection attempts
DefaultMaxReconnectAttempts = 3
// DefaultReconnectBackoff is the initial backoff duration for reconnection
DefaultReconnectBackoff = 100 * time.Millisecond
// DefaultMaxReconnectBackoff is the maximum backoff duration
DefaultMaxReconnectBackoff = 2 * time.Second
// DefaultQueueSize is the size of the write queue for async writes
DefaultQueueSize = 1000
// DefaultMaxFileSize is the default maximum file size in bytes before rotation (100MB)
DefaultMaxFileSize = 100 * 1024 * 1024
// DefaultMaxBackups is the default number of backup files to keep
DefaultMaxBackups = 3
)
// 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 with rotation support
type FileWriter struct {
file *os.File
encoder *json.Encoder
mutex sync.Mutex
path string
maxSize int64
maxBackups int
currentSize int64
errorCallback ErrorCallback
failuresMu sync.Mutex
failures int
}
// FileWriterOption is a function type for configuring FileWriter
type FileWriterOption func(*FileWriter)
// WithFileErrorCallback sets an error callback for file write errors
func WithFileErrorCallback(cb ErrorCallback) FileWriterOption {
return func(w *FileWriter) {
w.errorCallback = cb
}
}
// NewFileWriter creates a new file writer with rotation
func NewFileWriter(path string) (*FileWriter, error) {
return NewFileWriterWithConfig(path, DefaultMaxFileSize, DefaultMaxBackups)
}
// NewFileWriterWithConfig creates a new file writer with custom rotation config
func NewFileWriterWithConfig(path string, maxSize int64, maxBackups int, opts ...FileWriterOption) (*FileWriter, error) {
// Create directory if it doesn't exist
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
}
// Open file with secure permissions (owner read/write only)
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}
// Get current file size
info, err := file.Stat()
if err != nil {
file.Close()
return nil, fmt.Errorf("failed to stat file: %w", err)
}
w := &FileWriter{
file: file,
encoder: json.NewEncoder(file),
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
currentSize: info.Size(),
}
// Apply options (for error callback)
for _, opt := range opts {
opt(w)
}
return w, nil
}
// rotate rotates the log file if it exceeds the max size
func (w *FileWriter) rotate() error {
if err := w.file.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
// Rotate existing backups
for i := w.maxBackups; i > 1; i-- {
oldPath := fmt.Sprintf("%s.%d", w.path, i-1)
newPath := fmt.Sprintf("%s.%d", w.path, i)
os.Rename(oldPath, newPath) // Ignore errors - file may not exist
}
// Move current file to .1
backupPath := fmt.Sprintf("%s.1", w.path)
if err := os.Rename(w.path, backupPath); err != nil {
// If rename fails, just truncate
if err := os.Truncate(w.path, 0); err != nil {
return fmt.Errorf("failed to truncate file: %w", err)
}
}
// Open new file
newFile, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open new file: %w", err)
}
w.file = newFile
w.encoder = json.NewEncoder(newFile)
w.currentSize = 0
return nil
}
// Write writes a log record to the file
func (w *FileWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
// Check if rotation is needed
if w.currentSize >= w.maxSize {
if err := w.rotate(); err != nil {
w.reportError(fmt.Errorf("failed to rotate file: %w", err))
return fmt.Errorf("failed to rotate file: %w", err)
}
}
// Encode to buffer first to get size
data, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
data = append(data, '\n')
// Write to file
n, err := w.file.Write(data)
if err != nil {
w.reportError(fmt.Errorf("failed to write to file: %w", err))
return fmt.Errorf("failed to write to file: %w", err)
}
w.currentSize += int64(n)
return nil
}
// reportError reports a file write error via the configured callback
func (w *FileWriter) reportError(err error) {
if w.errorCallback != nil {
w.failuresMu.Lock()
w.failures++
failures := w.failures
w.failuresMu.Unlock()
w.errorCallback(w.path, err, failures)
}
}
// 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
}
// Reopen reopens the log file (for logrotate support)
func (w *FileWriter) Reopen() error {
w.mutex.Lock()
defer w.mutex.Unlock()
if err := w.file.Close(); err != nil {
return fmt.Errorf("failed to close file during reopen: %w", err)
}
// Open new file
newFile, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to reopen file %s: %w", w.path, err)
}
w.file = newFile
w.encoder = json.NewEncoder(newFile)
w.currentSize = 0
return nil
}
// ErrorCallback is a function type for reporting socket connection errors
type ErrorCallback func(socketPath string, err error, attempt int)
// UnixSocketWriter writes log records to a UNIX socket with reconnection logic
// No internal logging - only LogRecord JSON data is sent to the socket
type UnixSocketWriter struct {
socketPath string
conn net.Conn
mutex sync.Mutex
dialTimeout time.Duration
writeTimeout time.Duration
maxReconnects int
reconnectBackoff time.Duration
maxBackoff time.Duration
queue chan []byte
queueClose chan struct{}
queueDone chan struct{}
closeOnce sync.Once
isClosed bool
pendingWrites [][]byte
pendingMu sync.Mutex
errorCallback ErrorCallback
consecutiveFailures int
failuresMu sync.Mutex
networkType string // "unix" for STREAM, "unixgram" for DGRAM
}
// NewUnixSocketWriter creates a new UNIX socket writer with reconnection logic
func NewUnixSocketWriter(socketPath string) (*UnixSocketWriter, error) {
return NewUnixSocketWriterWithConfig(socketPath, DefaultDialTimeout, DefaultWriteTimeout, DefaultQueueSize)
}
// UnixSocketWriterOption is a function type for configuring UnixSocketWriter
type UnixSocketWriterOption func(*UnixSocketWriter)
// WithErrorCallback sets an error callback for socket connection errors
func WithErrorCallback(cb ErrorCallback) UnixSocketWriterOption {
return func(w *UnixSocketWriter) {
w.errorCallback = cb
}
}
// NewUnixSocketWriterWithConfig creates a new UNIX socket writer with custom configuration
func NewUnixSocketWriterWithConfig(socketPath string, dialTimeout, writeTimeout time.Duration, queueSize int, opts ...UnixSocketWriterOption) (*UnixSocketWriter, error) {
w := &UnixSocketWriter{
socketPath: socketPath,
dialTimeout: dialTimeout,
writeTimeout: writeTimeout,
maxReconnects: DefaultMaxReconnectAttempts,
reconnectBackoff: DefaultReconnectBackoff,
maxBackoff: DefaultMaxReconnectBackoff,
queue: make(chan []byte, queueSize),
queueClose: make(chan struct{}),
queueDone: make(chan struct{}),
pendingWrites: make([][]byte, 0),
}
// Apply options
for _, opt := range opts {
opt(w)
}
// Start the queue processor
go w.processQueue()
// Try initial connection silently (socket may not exist yet - that's okay)
// Use unixgram (DGRAM) for connectionless UDP-like socket communication
conn, err := net.DialTimeout("unixgram", socketPath, w.dialTimeout)
if err == nil {
w.conn = conn
}
return w, nil
}
// processQueue handles queued writes with reconnection logic
func (w *UnixSocketWriter) processQueue() {
defer close(w.queueDone)
backoff := w.reconnectBackoff
for {
select {
case data, ok := <-w.queue:
if !ok {
// Channel closed, drain remaining data
w.flushPendingData()
return
}
if err := w.writeWithReconnect(data); err != nil {
w.failuresMu.Lock()
w.consecutiveFailures++
failures := w.consecutiveFailures
w.failuresMu.Unlock()
// Report error via callback if configured
w.reportError(err, failures)
// Queue for retry
w.pendingMu.Lock()
if len(w.pendingWrites) < DefaultQueueSize {
w.pendingWrites = append(w.pendingWrites, data)
}
w.pendingMu.Unlock()
// Exponential backoff
if failures > w.maxReconnects {
time.Sleep(backoff)
backoff *= 2
if backoff > w.maxBackoff {
backoff = w.maxBackoff
}
}
} else {
w.failuresMu.Lock()
w.consecutiveFailures = 0
w.failuresMu.Unlock()
backoff = w.reconnectBackoff
// Try to flush pending data
w.flushPendingData()
}
case <-w.queueClose:
w.flushPendingData()
return
}
}
}
// reportError reports a socket connection error via the configured callback
func (w *UnixSocketWriter) reportError(err error, attempt int) {
if w.errorCallback != nil {
w.errorCallback(w.socketPath, err, attempt)
}
}
// flushPendingData attempts to write any pending data
func (w *UnixSocketWriter) flushPendingData() {
w.pendingMu.Lock()
pending := w.pendingWrites
w.pendingWrites = make([][]byte, 0)
w.pendingMu.Unlock()
for _, data := range pending {
if err := w.writeWithReconnect(data); err != nil {
// Put it back for next flush attempt
w.pendingMu.Lock()
if len(w.pendingWrites) < DefaultQueueSize {
w.pendingWrites = append(w.pendingWrites, data)
}
w.pendingMu.Unlock()
break
}
}
}
// writeWithReconnect attempts to write data with reconnection logic
func (w *UnixSocketWriter) writeWithReconnect(data []byte) error {
w.mutex.Lock()
defer w.mutex.Unlock()
ensureConn := func() error {
if w.conn != nil {
return nil
}
// Use unixgram (DGRAM) for connectionless UDP-like socket communication
conn, err := net.DialTimeout("unixgram", w.socketPath, w.dialTimeout)
if err != nil {
return fmt.Errorf("failed to connect to socket %s: %w", w.socketPath, err)
}
w.conn = conn
return nil
}
if err := ensureConn(); err != nil {
return err
}
if err := w.conn.SetWriteDeadline(time.Now().Add(w.writeTimeout)); err != nil {
return fmt.Errorf("failed to set write deadline: %w", err)
}
if _, err := w.conn.Write(data); err == nil {
return nil
}
// Connection failed, try to reconnect
_ = w.conn.Close()
w.conn = nil
if err := ensureConn(); err != nil {
return fmt.Errorf("failed to reconnect: %w", err)
}
if err := w.conn.SetWriteDeadline(time.Now().Add(w.writeTimeout)); err != nil {
_ = w.conn.Close()
w.conn = nil
return fmt.Errorf("failed to set write deadline after reconnect: %w", err)
}
if _, err := w.conn.Write(data); err != nil {
_ = w.conn.Close()
w.conn = nil
return fmt.Errorf("failed to write after reconnect: %w", err)
}
return nil
}
// Write writes a log record to the UNIX socket (non-blocking with queue).
// Bug 12 fix: marshal JSON outside the lock, then hold mutex through both the
// isClosed check AND the non-blocking channel send so Close() cannot close the
// channel between those two operations.
func (w *UnixSocketWriter) Write(rec api.LogRecord) error {
data, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
data = append(data, '\n')
w.mutex.Lock()
defer w.mutex.Unlock()
if w.isClosed {
return fmt.Errorf("writer is closed")
}
select {
case w.queue <- data:
return nil
default:
return fmt.Errorf("write queue is full, dropping message")
}
}
// Close closes the UNIX socket connection and stops the queue processor.
// Bug 12 fix: set isClosed=true under mutex BEFORE closing the channel so a
// concurrent Write() sees the flag and returns early instead of panicking on
// a send-on-closed-channel.
func (w *UnixSocketWriter) Close() error {
w.closeOnce.Do(func() {
w.mutex.Lock()
w.isClosed = true
w.mutex.Unlock()
close(w.queueClose)
<-w.queueDone
close(w.queue)
w.mutex.Lock()
if w.conn != nil {
w.conn.Close()
w.conn = nil
}
w.mutex.Unlock()
})
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
}
// Reopen reopens all writers that support log rotation
func (mw *MultiWriter) Reopen() error {
mw.mutex.Lock()
defer mw.mutex.Unlock()
var lastErr error
for _, w := range mw.writers {
if reopenable, ok := w.(api.Reopenable); ok {
if err := reopenable.Reopen(); err != nil {
lastErr = err
}
}
}
return lastErr
}
// BuilderImpl implements the api.Builder interface
type BuilderImpl struct {
errorCallback ErrorCallback
}
// NewBuilder creates a new output builder
func NewBuilder() *BuilderImpl {
return &BuilderImpl{}
}
// WithErrorCallback sets an error callback for all unix_socket and file writers created by this builder
func (b *BuilderImpl) WithErrorCallback(cb ErrorCallback) *BuilderImpl {
b.errorCallback = cb
return b
}
// NewFromConfig constructs writers from AppConfig
// Uses AsyncBuffer from OutputConfig if specified, otherwise uses DefaultQueueSize
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
// Determine queue size: use AsyncBuffer if specified, otherwise default
queueSize := DefaultQueueSize
if outputCfg.AsyncBuffer > 0 {
queueSize = outputCfg.AsyncBuffer
}
switch outputCfg.Type {
case "stdout":
writer = NewStdoutWriter()
case "file":
path := outputCfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("file output requires 'path' parameter")
}
// Build options list for file writer
var fileOpts []FileWriterOption
if b.errorCallback != nil {
fileOpts = append(fileOpts, WithFileErrorCallback(b.errorCallback))
}
writer, err = NewFileWriterWithConfig(path, DefaultMaxFileSize, DefaultMaxBackups, fileOpts...)
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")
}
// Build options list
var opts []UnixSocketWriterOption
if b.errorCallback != nil {
opts = append(opts, WithErrorCallback(b.errorCallback))
}
writer, err = NewUnixSocketWriterWithConfig(socketPath, DefaultDialTimeout, DefaultWriteTimeout, queueSize, opts...)
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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
# Logrotate configuration for ja4sentinel
# Install to: /etc/logrotate.d/ja4sentinel
/var/log/ja4sentinel/*.log {
daily
missingok
rotate 7
compress
delaycompress
notifempty
create 0600 root root
sharedscripts
postrotate
# Send SIGHUP to ja4sentinel to reopen log files
/bin/systemctl reload ja4sentinel 2>/dev/null || true
endscript
}

View File

@ -0,0 +1,328 @@
# Version macro must be defined BEFORE it's used in Version: field
# Override from command line: rpmbuild --define "build_version X.Y.Z"
%if %{defined build_version}
%define spec_version %{build_version}
%else
%define spec_version 1.1.18
%endif
Name: ja4sentinel
Version: %{spec_version}
Release: 1%{?dist}
Summary: JA4 TLS fingerprinting daemon for network monitoring
License: MIT
URL: https://github.com/your-repo/ja4sentinel
BuildArch: x86_64
BuildRequires: systemd-rpm-macros
# Distribution-agnostic dependencies
Requires: systemd
Requires(post): systemd
Requires(preun): systemd
Requires(postun): systemd
# libpcap is required for packet capture (dynamically linked)
# Version varies by distro: Rocky 8/9/10 (1.9.0+)
Requires: libpcap >= 1.9.0
%description
JA4Sentinel is a Go-based tool for capturing network traffic on Linux servers,
extracting client-side TLS handshakes, generating JA4 signatures, enriching
with IP/TCP metadata, and logging results to configurable outputs.
Features:
- Network packet capture with BPF filters
- TLS ClientHello extraction
- JA4/JA3 fingerprint generation
- IP/TCP metadata enrichment
- Multiple output formats (UNIX socket by default, stdout, file)
- Structured JSON logging for systemd/journald
- Compatible with Rocky Linux 8/9/10, RHEL, AlmaLinux
%prep
# No source to unpack, binary is pre-built
%build
# No build needed, binary is pre-built
%install
mkdir -p %{buildroot}/usr/bin
mkdir -p %{buildroot}/etc/ja4sentinel
mkdir -p %{buildroot}/etc/logrotate.d
mkdir -p %{buildroot}/var/lib/ja4sentinel
mkdir -p %{buildroot}/var/log/ja4sentinel
mkdir -p %{buildroot}/var/run/logcorrelator
mkdir -p %{buildroot}/usr/lib/systemd/system
mkdir -p %{buildroot}/usr/share/ja4sentinel
# Install binary
install -m 755 %{_sourcedir}/ja4sentinel %{buildroot}/usr/bin/ja4sentinel
# Install systemd service
install -m 644 %{_sourcedir}/ja4sentinel.service %{buildroot}%{_unitdir}/ja4sentinel.service
# Install logrotate configuration
install -m 644 %{_sourcedir}/logrotate/ja4sentinel %{buildroot}/etc/logrotate.d/ja4sentinel
# Install default config
install -m 640 %{_sourcedir}/config.yml %{buildroot}/etc/ja4sentinel/config.yml.default
install -m 640 %{_sourcedir}/config.yml %{buildroot}/usr/share/ja4sentinel/config.yml
%pre
# No user creation needed - service runs as root for packet capture
exit 0
%post
# Use standard systemd RPM macros (handles daemon-reload, preset, no-op in containers)
%systemd_post ja4sentinel.service
# Explicitly enable+start on fresh install — this is a security daemon, auto-start is expected
if [ $1 -eq 1 ] && [ -x /usr/bin/systemctl ] && [ -d /run/systemd/system ]; then
/usr/bin/systemctl enable ja4sentinel.service 2>/dev/null || :
/usr/bin/systemctl start ja4sentinel.service 2>/dev/null || :
fi
# Set proper ownership (root:root for packet capture)
chown -R root:root /var/lib/ja4sentinel 2>/dev/null || true
chown -R root:root /var/log/ja4sentinel 2>/dev/null || true
chown -R root:root /etc/ja4sentinel 2>/dev/null || true
# Set proper permissions
chmod 750 /var/lib/ja4sentinel 2>/dev/null || true
chmod 750 /var/log/ja4sentinel 2>/dev/null || true
chmod 750 /etc/ja4sentinel 2>/dev/null || true
# Install config if not exists
if [ ! -f /etc/ja4sentinel/config.yml ]; then
cp /usr/share/ja4sentinel/config.yml /etc/ja4sentinel/config.yml
chmod 640 /etc/ja4sentinel/config.yml
fi
%preun
%systemd_preun ja4sentinel.service
%postun
%systemd_postun_with_restart ja4sentinel.service
%files
/usr/bin/ja4sentinel
%{_unitdir}/ja4sentinel.service
/etc/logrotate.d/ja4sentinel
/usr/share/ja4sentinel/config.yml
%config(noreplace) /etc/ja4sentinel/config.yml.default
%dir /etc/ja4sentinel
%dir /var/lib/ja4sentinel
%dir /var/log/ja4sentinel
%changelog
* Mon Mar 09 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.18-1
- FEATURE: Add comprehensive metrics for capture and TLS parser monitoring
- Capture metrics: packets_received, packets_sent, packets_dropped (atomic counters)
- Parser metrics: retransmit_count, gap_detected_count, buffer_exceeded_count, segment_exceeded_count
- New GetStats() method on Capture interface for capture statistics
- New GetMetrics() method on Parser interface for parser statistics
- Add DefaultMaxHelloSegments constant (100) to prevent memory leaks from fragmented handshakes
- Add Segments field to ConnectionFlow for per-flow segment tracking
- Increase DefaultMaxTrackedFlows from 50000 to 100000 for high-traffic scenarios
- Improve TCP reassembly: better handling of retransmissions and sequence gaps
- Memory leak prevention: limit segments per flow and buffer size
- Aggressive flow cleanup: clean up JA4_DONE flows when approaching flow limit
- Lock ordering fix: release flow.mu before acquiring p.mu to avoid deadlocks
- Exclude IPv6 link-local addresses (fe80::) from local IP detection
- Improve error logging with detailed connection and TLS extension information
- Add capture diagnostics logging (interface, link_type, local_ips, bpf_filter)
- Fix false positive retransmission counter when SYN packet is missed
- Fix gap handling: reset sequence tracking instead of dropping flow
- Fix extractTLSExtensions: return error details with basic TLS info for debugging
* Mon Mar 09 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.17-1
- FEATURE: Default network interface set to "any" for automatic multi-interface capture
- No manual configuration required - captures on all interfaces out of the box
- Supports physical (ens18, eth0), virtual, Docker, VPN interfaces automatically
- Linux SLL (cooked capture) used for interface "any" - already implemented and tested
* Mon Mar 09 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.16-1
- FEATURE: Add comprehensive metrics for capture and TLS parser monitoring
- Capture: packets_received, packets_sent, packets_dropped counters (atomic)
- Parser: retransmit_count, gap_detected_count, buffer_exceeded_count, segment_exceeded_count
- New GetStats() method on Capture interface for capture statistics
- New GetMetrics() method on Parser interface for parser statistics
- Add DefaultMaxHelloSegments constant (100) to prevent memory leaks from fragmented handshakes
- Add Segments field to ConnectionFlow for per-flow segment tracking
- Improve TCP reassembly: better handling of retransmissions and sequence gaps
- Memory leak prevention: limit segments per flow and buffer size
- All counters use sync/atomic for thread-safe access without locks
- Metrics designed for monitoring/debugging (can be exposed via future endpoints)
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.15-1
- FIX: ALPN not appearing in logs for packets with truncated/malformed TLS extensions
- Add sanitization fallback in extractTLSExtensions (same as fingerprint engine)
- ALPN (tls_alpn) now correctly extracted even when ParseClientHello fails on raw payload
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.14-1
- FIX: Handle ClientHellos with truncated extension data (extension data truncated)
- Sanitize malformed extensions by trimming to last complete extension before retry
- Fingerprints (JA4/JA3) now generated even for slightly malformed ClientHellos
- Added unit tests for extension sanitization and truncated extension handling
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.13-1
- FIX: BPF filter uses 'tcp dst port' instead of 'tcp port' to capture client-to-server traffic only
- FIX: SYN packet handling detect SYN before payload-length check, create flow with IP/TCP metadata
- FIX: SynToCHMs timing now uses SYN timestamp instead of first data packet timestamp
- FIX: Fragmented ClientHello uses flow metadata from SYN instead of last fragment's packet metadata
- FIX: TCP reassembly sequence tracking detect retransmissions (skip) and gaps (drop flow)
- Added TLS 1.3 supported_versions test coverage (verified library already handles it correctly)
- 9 new unit tests for SYN handling, TCP reassembly, TLS 1.3, and fragmentation metadata
* Thu Mar 05 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.12-1
- FIX: Remove JA4SENTINEL_LOG_LEVEL env override (architecture violation, log_level YAML-only)
- FIX: Add yaml struct tags to Config/AppConfig/OutputConfig (yaml.v3 does not fall back to json tags)
- FIX: SLL packet parsing rewritten to use protoType from SLL header for IPv4/IPv6 selection
- FIX: Replace isValidIP/isValidCIDR with net.ParseIP/net.ParseCIDR for proper validation
- FIX: Various pre-existing unit test bugs (TestLoadFromFile_ExcludeSourceIPs, TestFromClientHello_NilPayload, TestValidate_ExcludeSourceIPs)
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.11-1
- FIX: Remove JA4SENTINEL_LOG_LEVEL environment variable from systemd service
- Config file log_level now respected (no env var override)
- FIX: Add exclude_source_ips to config merge function (mergeConfigs)
- FIX: Add validation for exclude_source_ips entries (IP/CIDR)
- New isValidIP() and isValidCIDR() helper functions
- Config file exclude_source_ips now properly loaded and validated
- DEBUG: Add IP filter debug logging for troubleshooting
- Log filter entries at startup in debug mode
- Track filtered packet count with atomic counter
- Display filter statistics at shutdown
- New GetFilterStats() method on parser for monitoring
- Added unit tests for exclude_source_ips and log_level config loading
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.10-1
- DEBUG: Add IP filter debug logging for troubleshooting
- Log filter entries at startup in debug mode
- Track filtered packet count with atomic counter
- Display filter statistics at shutdown
- New GetFilterStats() method on parser for monitoring
- FIX: Add exclude_source_ips to config merge function
- FIX: Add validation for exclude_source_ips entries (IP/CIDR)
- Helps diagnose exclude_source_ips filtering issues
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.9-1
- FEATURE: Add source IP exclusion with CIDR support
- New exclude_source_ips configuration option
- Support single IPs (192.168.1.1) and CIDR ranges (10.0.0.0/8)
- Filter packets before TLS processing to reduce load
- IPv4 and IPv6 support
- New ipfilter package with unit tests
- Log exclusion configuration at startup
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.8-1
- CRITICAL FIX: Resolve crash in TLS parser with nil decode context
- Use gopacket.NewPacket with LinkTypeIPv4/IPv6 instead of DecodeFromBytes
- Fixes panic: runtime error: invalid memory address or nil pointer dereference
- Properly handles raw IP packets after SLL header stripping
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.7-1
- FIX: Improve error logging with source/destination details
- Add src_ip, src_port, dst_ip, dst_port to tlsparse error logs
- Add connection details to fingerprint error logs (conn_id, payload_len)
- Include 'unknown' placeholders for packets that fail before parsing
- Helps debug truncated ClientHello payloads and identify problematic connections
* Wed Mar 04 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.6-1
- FEATURE: Add support for capturing traffic to local machine IPs only
- Add local_ips configuration option (auto-detect or manual list)
- Auto-detection excludes loopback addresses (127.x.x.x, ::1)
- Support interface "any" for capturing on all network interfaces
- Add Linux SLL (cooked capture) support for interface "any"
- Generate BPF filter with "dst host" for local IP filtering
- Add LinkType field to RawPacket for proper packet parsing
- Add unit tests for local IP detection and SLL packet parsing
- Update version to 1.1.6
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.5-1
- Fix: Use unixgram (DGRAM) instead of unix (STREAM) for socket output
- Fixes "protocol wrong type for socket" error
- DGRAM sockets are connectionless, better suited for log shipping
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.4-1
- Add error callback for file output writer
- File write errors (permission, disk space, rotation) now logged
- Same error reporting mechanism as UNIX socket writer
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.2-1
- Add error callback mechanism for UNIX socket connection failures
- Add ErrorCallback type and WithErrorCallback option for UnixSocketWriter
- Add BuilderImpl.WithErrorCallback() for propagating error callbacks
- Add processQueue error reporting with consecutive failure tracking
- Add 50+ new unit tests across all modules (capture, config, fingerprint, tlsparse, output, cmd)
- Add integration tests for full pipeline (TLS ClientHello -> fingerprint -> output)
- Add tests for FileWriter.rotate() and FileWriter.Reopen() log rotation
- Add tests for cleanupExpiredFlows() and cleanupLoop() in TLS parser
- Add tests for extractSNIFromPayload() and extractJA4Hash() helpers
- Add tests for config load error paths (invalid YAML, permission denied)
- Update architecture.yml with new fields (LogLevel, TLSClientHello extensions)
- Update architecture.yml with Close() methods for Capture and Parser interfaces
- Remove empty internal/api/ directory
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.1.0-1
- Add logrotate configuration for automatic log file rotation
- Add SIGHUP signal handling for log file reopening (systemctl reload)
- Add ExecReload to systemd service for graceful log rotation
- Add Reopenable interface for output writers supporting log rotation
- Add FileWriter.Reopen() method for log file rotation support
- Add MultiWriter.Reopen() method to propagate rotation to all writers
- Update main.go to handle SIGHUP signal for log rotation
- Add packaging/logrotate/ja4sentinel configuration file
- Update architecture.yml with logrotate and reload documentation
- Update Dockerfile.package to include logrotate file in RPM build
* Mon Mar 02 2026 Jacquin Antoine <rpm@arkel.fr> - 1.0.9-1
- Add SNI (Server Name Indication) extraction from TLS ClientHello
- Add ALPN (Application-Layer Protocol Negotiation) extraction
- Add TLS version detection from ClientHello
- Add ConnID field for flow correlation
- Add SensorID field for multi-sensor deployments
- Add SynToCHMs timing field for behavioral detection
- Add AsyncBuffer configuration for output queue sizing
- Remove JA4Hash from LogRecord (JA4 format includes its own hash)
- Use tlsfingerprint library for ALPN and TLS version parsing
- Update architecture.yml compliance for all new fields
- Add unit tests for TLS extension extraction
* Sun Mar 01 2026 Jacquin Antoine <rpm@arkel.fr> - 1.0.8-1
- Add configurable log level (debug, info, warn, error) via config.yml
- Add JA4SENTINEL_LOG_LEVEL environment variable support
- Set TimeoutStopSec=2 for immediate service stop on restart/stop
- Consolidate config files into single example (config.yml.example)
* Sat Feb 28 2026 Jacquin Antoine <rpm@arkel.fr> - 1.0.4-1
- Add systemd sdnotify support (READY, WATCHDOG, STOPPING signals)
- Enable systemd watchdog with 30s timeout
- Update service unit to Type=notify
- Document sdnotify integration in architecture.yml
* Sat Feb 28 2026 JA4Sentinel Team <team@example.com> - 1.0.2-1
- BREAKING: Drop CentOS 7 support (EOL June 2024), minimum Rocky Linux 8
- Fix race condition in TLS parser with per-ConnectionFlow mutex
- Fix memory leak in fragmented ClientHello buffer accumulation
- Add log file rotation (100MB, 3 backups)
- Improve UNIX socket reconnection with async queue and exponential backoff
- Add BPF filter validation (characters, length, balanced parentheses)
- Secure file permissions (0600 instead of 0644)
- Add 46 unit tests (capture, output, logging)
- Enable race detection in test pipeline (go test -race)
- Increase pcap snaplen from 1600 to 65535 bytes for large TLS handshakes
- Increase socket timeouts (2s to 5s) with configurable backoff
- Add named constants for configuration values
* Sat Feb 28 2026 JA4Sentinel Team <team@example.com> - 1.0.1-1
- Add configurable packet channel buffer size for high-throughput capture
- Add timestamp field to LogRecord for precise event tracking
- Fix race condition: close packetChan after capture goroutine finishes
- Strengthen TLS limits and socket timeouts for robustness
- Improve configuration validation with stricter checks
- Include systemd service file in RPM packages
- Unified Docker-based packaging for CentOS 7, Rocky Linux 8/9/10
- Add comprehensive unit tests for API and cmd packages
- Add Godoc documentation for all public interfaces
* Wed Feb 25 2026 JA4Sentinel Team <team@example.com> - 1.0.0-1
- Initial package release for CentOS 7, Rocky Linux 8/9/10

View File

@ -0,0 +1,41 @@
[Unit]
Description=JA4 client fingerprinting daemon
Documentation=https://github.com/your-repo/ja4sentinel
After=network.target
Wants=network-online.target
[Service]
Type=notify
User=root
Group=root
WorkingDirectory=/var/lib/ja4sentinel
ExecStart=/usr/bin/ja4sentinel --config /etc/ja4sentinel/config.yml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
WatchdogSec=30
TimeoutStopSec=2
NotifyAccess=main
# Security hardening (compatible with root for packet capture)
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/var/lib/ja4sentinel /var/log/ja4sentinel
# Capabilities for packet capture (inherited by root)
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
# Resource limits
LimitNOFILE=65536
LimitNPROC=64
[Install]
WantedBy=multi-user.target