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:
37
old/services/sentinel/.dockerignore
Normal file
37
old/services/sentinel/.dockerignore
Normal file
@ -0,0 +1,37 @@
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Qwen
|
||||
.qwen
|
||||
.qwenignore
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test results
|
||||
test-results/
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Docker compose override
|
||||
docker-compose.override.yml
|
||||
7
old/services/sentinel/.env.example
Normal file
7
old/services/sentinel/.env.example
Normal 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
|
||||
125
old/services/sentinel/.github/workflows/build-rpm.yml
vendored
Normal file
125
old/services/sentinel/.github/workflows/build-rpm.yml
vendored
Normal 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
59
old/services/sentinel/.gitignore
vendored
Normal 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
|
||||
37
old/services/sentinel/Dockerfile
Normal file
37
old/services/sentinel/Dockerfile
Normal 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"]
|
||||
25
old/services/sentinel/Dockerfile.dev
Normal file
25
old/services/sentinel/Dockerfile.dev
Normal 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", "./..."]
|
||||
109
old/services/sentinel/Dockerfile.package
Normal file
109
old/services/sentinel/Dockerfile.package
Normal 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/"]
|
||||
105
old/services/sentinel/Dockerfile.test-server
Normal file
105
old/services/sentinel/Dockerfile.test-server
Normal file
@ -0,0 +1,105 @@
|
||||
# Test server for generating TLS traffic in integration tests
|
||||
FROM golang:1.23-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create a simple TLS server for testing
|
||||
RUN cat > main.go << 'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8443", "Port to listen on")
|
||||
flag.Parse()
|
||||
|
||||
// Generate self-signed certificate
|
||||
cert, err := generateSelfSignedCert()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate certificate: %v", err)
|
||||
}
|
||||
|
||||
config := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
listener, err := tls.Listen("tcp", ":"+*port, config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start TLS listener: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Printf("TLS test server listening on port %s", *port)
|
||||
|
||||
http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Hello from TLS test server"))
|
||||
}))
|
||||
}
|
||||
|
||||
func generateSelfSignedCert() (tls.Certificate, error) {
|
||||
// Generate private key
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
// Create certificate template
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"JA4Sentinel Test"},
|
||||
CommonName: "localhost",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
|
||||
// Create certificate
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
// Encode certificate
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
})
|
||||
|
||||
// Encode private key
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(priv),
|
||||
})
|
||||
|
||||
// Load certificate
|
||||
return tls.X509KeyPair(certPEM, keyPEM)
|
||||
}
|
||||
EOF
|
||||
|
||||
RUN go mod init test-server && go mod tidy
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
CMD ["go", "run", "main.go", "-port", "8443"]
|
||||
158
old/services/sentinel/Makefile
Normal file
158
old/services/sentinel/Makefile
Normal 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/^/ /'
|
||||
291
old/services/sentinel/README.md
Normal file
291
old/services/sentinel/README.md
Normal 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.**
|
||||
307
old/services/sentinel/api/types.go
Normal file
307
old/services/sentinel/api/types.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
340
old/services/sentinel/api/types_test.go
Normal file
340
old/services/sentinel/api/types_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
524
old/services/sentinel/architecture.yml
Normal file
524
old/services/sentinel/architecture.yml
Normal 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 d’inté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 l’interface réseau configurée."
|
||||
- "Appliquer les filtres (ports, BPF, protocole)."
|
||||
- "Observer les flux TCP côté client vers les ports d’inté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 d’une sortie de logs."
|
||||
fields:
|
||||
- { name: Type, type: "string", description: "Type d’output (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 d’un 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."
|
||||
|
||||
357
old/services/sentinel/cmd/ja4sentinel/main.go
Normal file
357
old/services/sentinel/cmd/ja4sentinel/main.go
Normal 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
|
||||
}
|
||||
221
old/services/sentinel/cmd/ja4sentinel/main_test.go
Normal file
221
old/services/sentinel/cmd/ja4sentinel/main_test.go
Normal 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)
|
||||
}
|
||||
57
old/services/sentinel/config.yml.example
Normal file
57
old/services/sentinel/config.yml.example
Normal 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
|
||||
49
old/services/sentinel/docker-compose.test.yml
Normal file
49
old/services/sentinel/docker-compose.test.yml
Normal file
@ -0,0 +1,49 @@
|
||||
# Docker Compose for integration testing
|
||||
# Based on architecture.yml testing.levels.integration
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# TLS test server for generating test traffic
|
||||
tls-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test-server
|
||||
image: ja4sentinel-test-server:latest
|
||||
networks:
|
||||
- test-network
|
||||
ports:
|
||||
- "8443:8443"
|
||||
command: ["-port", "8443"]
|
||||
|
||||
# ja4sentinel integration test runner
|
||||
ja4sentinel-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
image: ja4sentinel-dev:latest
|
||||
networks:
|
||||
- test-network
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- ./test-results:/app/test-results
|
||||
environment:
|
||||
- JA4SENTINEL_INTERFACE=eth0
|
||||
- JA4SENTINEL_PORTS=8443
|
||||
depends_on:
|
||||
- tls-server
|
||||
command: ["make", "test-integration"]
|
||||
|
||||
# Test client that generates TLS traffic
|
||||
tls-client:
|
||||
image: curlimages/curl:latest
|
||||
networks:
|
||||
- test-network
|
||||
depends_on:
|
||||
- tls-server
|
||||
command: ["curl", "-kv", "https://tls-server:8443/"]
|
||||
|
||||
networks:
|
||||
test-network:
|
||||
driver: bridge
|
||||
21
old/services/sentinel/go.mod
Normal file
21
old/services/sentinel/go.mod
Normal 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
|
||||
27
old/services/sentinel/go.sum
Normal file
27
old/services/sentinel/go.sum
Normal 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=
|
||||
397
old/services/sentinel/internal/capture/capture.go
Normal file
397
old/services/sentinel/internal/capture/capture.go
Normal 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
|
||||
}
|
||||
661
old/services/sentinel/internal/capture/capture_test.go
Normal file
661
old/services/sentinel/internal/capture/capture_test.go
Normal 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, ð, &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)
|
||||
}
|
||||
}
|
||||
295
old/services/sentinel/internal/config/loader.go
Normal file
295
old/services/sentinel/internal/config/loader.go
Normal 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
|
||||
}
|
||||
1008
old/services/sentinel/internal/config/loader_test.go
Normal file
1008
old/services/sentinel/internal/config/loader_test.go
Normal file
File diff suppressed because it is too large
Load Diff
192
old/services/sentinel/internal/fingerprint/engine.go
Normal file
192
old/services/sentinel/internal/fingerprint/engine.go
Normal 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]
|
||||
}
|
||||
585
old/services/sentinel/internal/fingerprint/engine_test.go
Normal file
585
old/services/sentinel/internal/fingerprint/engine_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
402
old/services/sentinel/internal/integration/pipeline_test.go
Normal file
402
old/services/sentinel/internal/integration/pipeline_test.go
Normal 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
|
||||
}
|
||||
15
old/services/sentinel/internal/ipfilter/ipfilter.go
Normal file
15
old/services/sentinel/internal/ipfilter/ipfilter.go
Normal 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)
|
||||
}
|
||||
160
old/services/sentinel/internal/ipfilter/ipfilter_test.go
Normal file
160
old/services/sentinel/internal/ipfilter/ipfilter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
19
old/services/sentinel/internal/logging/logger_factory.go
Normal file
19
old/services/sentinel/internal/logging/logger_factory.go
Normal 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")
|
||||
}
|
||||
47
old/services/sentinel/internal/logging/service_logger.go
Normal file
47
old/services/sentinel/internal/logging/service_logger.go
Normal 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)
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
644
old/services/sentinel/internal/output/writers.go
Normal file
644
old/services/sentinel/internal/output/writers.go
Normal 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
|
||||
}
|
||||
1110
old/services/sentinel/internal/output/writers_test.go
Normal file
1110
old/services/sentinel/internal/output/writers_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1015
old/services/sentinel/internal/tlsparse/parser.go
Normal file
1015
old/services/sentinel/internal/tlsparse/parser.go
Normal file
File diff suppressed because it is too large
Load Diff
1824
old/services/sentinel/internal/tlsparse/parser_test.go
Normal file
1824
old/services/sentinel/internal/tlsparse/parser_test.go
Normal file
File diff suppressed because it is too large
Load Diff
17
old/services/sentinel/packaging/logrotate/ja4sentinel
Normal file
17
old/services/sentinel/packaging/logrotate/ja4sentinel
Normal 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
|
||||
}
|
||||
328
old/services/sentinel/packaging/rpm/ja4sentinel.spec
Normal file
328
old/services/sentinel/packaging/rpm/ja4sentinel.spec
Normal 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
|
||||
41
old/services/sentinel/packaging/systemd/ja4sentinel.service
Normal file
41
old/services/sentinel/packaging/systemd/ja4sentinel.service
Normal 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
|
||||
Reference in New Issue
Block a user