feat: version 1.0.0 avec corrections critiques et nommage de packages

Ajout du point d'entrée principal :
- cmd/ja4sentinel/main.go : pipeline complet avec gestion des signaux
- Intégration des modules (capture, tlsparse, fingerprint, output)
- Shutdown propre avec context.Context

Corrections du parsing TLS :
- Flow key unidirectionnel (client → serveur uniquement)
- Timeout de flux configurable via FlowTimeoutSec
- Structure ConnectionFlow simplifiée

Améliorations de l'API :
- Champs TCPMSS et TCPWScale en pointeurs (omitempty correct)
- NewLogRecord mis à jour pour les champs optionnels

Mise à jour de l'architecture :
- architecture.yml : documentation des champs optionnels
- Règles de flux unidirectionnel documentées

Système de packages :
- Version par défaut : 1.0.0
- Nommage cohérent : ja4sentinel_1.0.0_amd64.deb
- Scripts build-deb.sh et build-rpm.sh simplifiés
- Extraction correcte des checksums

Tests :
- TestFlowKey mis à jour pour le format unidirectionnel
- Tous les tests passent (go test ./...)
- go vet clean

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-02-26 23:24:42 +01:00
parent 410467f099
commit 9280cb545c
9 changed files with 201 additions and 179 deletions

View File

@ -18,7 +18,10 @@ DIST_DIR=dist
BUILD_DIR=build BUILD_DIR=build
# Package version (strip 'v' prefix from git tags) # Package version (strip 'v' prefix from git tags)
PKG_VERSION=$(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//') # Set to explicit version for release builds, or use git-based version for dev builds
PKG_VERSION ?= 1.0.0
# Alternative git-based version (uncomment for dev builds):
# PKG_VERSION=$(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//')
# Build flags # Build flags
VERSION=$(PKG_VERSION) VERSION=$(PKG_VERSION)
@ -97,10 +100,15 @@ package-deb: build-linux
--build-arg VERSION=$(PKG_VERSION) \ --build-arg VERSION=$(PKG_VERSION) \
--build-arg ARCH=amd64 \ --build-arg ARCH=amd64 \
-f packaging/Dockerfile.deb . -f packaging/Dockerfile.deb .
@echo "Extracting DEB from Docker image..." @echo "Extracting DEB packages from Docker image..."
docker run --rm ja4sentinel-packager-deb sh -c 'cat /packages/*.deb' > build/deb/ja4sentinel.deb @for f in $$(docker run --rm ja4sentinel-packager-deb sh -c 'ls /packages/*.deb 2>/dev/null'); do \
@echo "DEB package created: build/deb/ja4sentinel.deb" docker run --rm ja4sentinel-packager-deb sh -c "cat $$f" > build/deb/$$(basename $$f); \
ls -la build/deb/*.deb done
@for f in $$(docker run --rm ja4sentinel-packager-deb sh -c 'ls /packages/*.sha256 2>/dev/null'); do \
docker run --rm ja4sentinel-packager-deb sh -c "cat $$f" > build/deb/$$(basename $$f); \
done || true
@echo "DEB packages created:"
ls -la build/deb/
## package-rpm: Build RPM package (requires Docker) ## package-rpm: Build RPM package (requires Docker)
package-rpm: build-linux package-rpm: build-linux

View File

@ -75,8 +75,8 @@ type LogRecord struct {
// Flattened TCPMeta fields // Flattened TCPMeta fields
TCPWindow uint16 `json:"tcp_meta_window_size"` TCPWindow uint16 `json:"tcp_meta_window_size"`
TCPMSS uint16 `json:"tcp_meta_mss,omitempty"` TCPMSS *uint16 `json:"tcp_meta_mss,omitempty"`
TCPWScale uint8 `json:"tcp_meta_window_scale,omitempty"` TCPWScale *uint8 `json:"tcp_meta_window_scale,omitempty"`
TCPOptions string `json:"tcp_meta_options"` // comma-separated list TCPOptions string `json:"tcp_meta_options"` // comma-separated list
// Fingerprints // Fingerprints
@ -161,6 +161,17 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts = joinStringSlice(ch.TCPMeta.Options, ",") opts = joinStringSlice(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{ rec := LogRecord{
SrcIP: ch.SrcIP, SrcIP: ch.SrcIP,
SrcPort: ch.SrcPort, SrcPort: ch.SrcPort,
@ -171,8 +182,8 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
IPID: ch.IPMeta.IPID, IPID: ch.IPMeta.IPID,
IPDF: ch.IPMeta.DF, IPDF: ch.IPMeta.DF,
TCPWindow: ch.TCPMeta.WindowSize, TCPWindow: ch.TCPMeta.WindowSize,
TCPMSS: ch.TCPMeta.MSS, TCPMSS: mssPtr,
TCPWScale: ch.TCPMeta.WindowScale, TCPWScale: wScalePtr,
TCPOptions: opts, TCPOptions: opts,
} }

View File

@ -209,8 +209,8 @@ api:
# TCPMeta flatten # TCPMeta flatten
- { name: TCPWindow, type: "uint16", json_key: "tcp_meta_window_size" } - { name: TCPWindow, type: "uint16", json_key: "tcp_meta_window_size" }
- { name: TCPMSS, type: "uint16", json_key: "tcp_meta_mss" } - { 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" } - { 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" } - { name: TCPOptions, type: "string", json_key: "tcp_meta_options" }
# Fingerprints # Fingerprints
@ -401,8 +401,10 @@ flow_control:
- name: "JA4_DONE" - name: "JA4_DONE"
description: "JA4 calculé et logué, on arrête de suivre ce flux." description: "JA4 calculé et logué, on arrête de suivre ce flux."
rules: rules:
- "Pour chaque flux (srcIP, srcPort, dstIP, dstPort), on sarrête dès que JA4 + IPMeta + TCPMeta sont obtenus et logués." - "Suivi unidirectionnel : uniquement le flux entrant du client vers la machine locale (srcIP:srcPort -> dstIP:dstPort)."
- "Un timeout par flux doit être défini pour éviter de garder un état si le ClientHello narrive jamais." - "Pour chaque flux, on s'arrête dès que JA4 + IPMeta + TCPMeta sont obtenus et logués."
- "Un timeout par flux doit être défini pour éviter de garder un état si le ClientHello n'arrive jamais."
- "Le flow key est au format : `srcIP:srcPort->dstIP:dstPort` (pas de suivi bidirectionnel)."
testing: testing:
policy: policy:

View File

@ -2,11 +2,11 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"time" "time"
@ -19,190 +19,192 @@ import (
"ja4sentinel/internal/tlsparse" "ja4sentinel/internal/tlsparse"
) )
// Version information (set via ldflags)
var ( var (
Version = "dev" // Version information (set via ldflags)
Version = "1.0.0"
BuildTime = "unknown" BuildTime = "unknown"
GitCommit = "unknown" GitCommit = "unknown"
) )
func main() { func main() {
// Parse command line flags // Parse command-line flags
configPath := flag.String("config", "", "Path to configuration file (YAML)") configPath := flag.String("config", "", "Path to configuration file (YAML)")
version := flag.Bool("version", false, "Print version information") version := flag.Bool("version", false, "Show version information")
flag.Parse() flag.Parse()
if *version { if *version {
fmt.Printf("ja4sentinel version %s\n", Version) fmt.Printf("ja4sentinel version %s (built %s, commit %s)\n", Version, BuildTime, GitCommit)
fmt.Printf("Build time: %s\n", BuildTime)
fmt.Printf("Git commit: %s\n", GitCommit)
os.Exit(0) os.Exit(0)
} }
// Initialize logger factory // Create logger factory
loggerFactory := &logging.LoggerFactory{} loggerFactory := &logging.LoggerFactory{}
logger := loggerFactory.NewDefaultLogger() appLogger := loggerFactory.NewDefaultLogger()
logger.Info("service", "Starting ja4sentinel", map[string]string{ appLogger.Info("main", "Starting ja4sentinel", map[string]string{
"version": Version, "version": Version,
"build_time": BuildTime,
"git_commit": GitCommit,
}) })
// Load configuration // Load configuration
configLoader := config.NewLoader(*configPath) cfgLoader := config.NewLoader(*configPath)
cfg, err := configLoader.Load() appConfig, err := cfgLoader.Load()
if err != nil { if err != nil {
logger.Error("service", "Failed to load configuration", map[string]string{ appLogger.Error("main", "Failed to load configuration", map[string]string{
"error": err.Error(), "error": err.Error(),
}) })
os.Exit(1) os.Exit(1)
} }
logger.Info("config", "Configuration loaded", map[string]string{ appLogger.Info("main", "Configuration loaded", map[string]string{
"interface": cfg.Core.Interface, "interface": appConfig.Core.Interface,
"ports": fmt.Sprintf("%v", cfg.Core.ListenPorts), "listen_ports": formatPorts(appConfig.Core.ListenPorts),
"bpf_filter": cfg.Core.BPFFilter,
"num_outputs": fmt.Sprintf("%d", len(cfg.Outputs)),
}) })
// Build output writer // Create context with cancellation for graceful shutdown
outputBuilder := output.NewBuilder() ctx, cancel := context.WithCancel(context.Background())
writer, err := outputBuilder.NewFromConfig(cfg) defer cancel()
if err != nil {
logger.Error("output", "Failed to create output writer", map[string]string{
"error": err.Error(),
})
os.Exit(1)
}
// Create pipeline components // Setup signal handling
captureImpl := capture.New()
parser := tlsparse.NewParserWithTimeout(time.Duration(cfg.Core.FlowTimeoutSec) * time.Second)
engine := fingerprint.NewEngine()
// Create channels for pipeline
packetChan := make(chan api.RawPacket, 1000)
helloChan := make(chan api.TLSClientHello, 1000)
errorChan := make(chan error, 100)
var wg sync.WaitGroup
// Setup signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start capture goroutine // Create pipeline components
wg.Add(1) captureEngine := capture.New()
go func() { parser := tlsparse.NewParserWithTimeout(time.Duration(appConfig.Core.FlowTimeoutSec) * time.Second)
defer wg.Done() fingerprintEngine := fingerprint.NewEngine()
defer close(packetChan) outputBuilder := output.NewBuilder()
logger.Info("capture", "Starting packet capture", map[string]string{ outputWriter, err := outputBuilder.NewFromConfig(appConfig)
"interface": cfg.Core.Interface, if err != nil {
appLogger.Error("main", "Failed to create output writer", map[string]string{
"error": err.Error(),
}) })
os.Exit(1)
}
err := captureImpl.Run(cfg.Core, packetChan) // Create channel for raw packets
if err != nil { packetChan := make(chan api.RawPacket, 1000)
// 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)
captureErrChan <- err
}()
// Process packets
go func() {
for {
select { select {
case errorChan <- fmt.Errorf("capture error: %w", err): case <-ctx.Done():
default: 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(),
})
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(),
})
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(),
})
}
} }
} }
}() }()
// Start TLS parsing goroutine // Wait for shutdown signal or capture error
wg.Add(1)
go func() {
defer wg.Done()
defer close(helloChan)
for pkt := range packetChan {
hello, err := parser.Process(pkt)
if err != nil {
logger.Warn("tlsparse", "Failed to parse packet", map[string]string{
"error": err.Error(),
})
continue
}
if hello != nil {
logger.Debug("tlsparse", "ClientHello extracted", map[string]string{
"src_ip": hello.SrcIP,
"src_port": fmt.Sprintf("%d", hello.SrcPort),
"dst_ip": hello.DstIP,
"dst_port": fmt.Sprintf("%d", hello.DstPort),
})
helloChan <- *hello
}
}
}()
// Start fingerprinting and output goroutine
wg.Add(1)
go func() {
defer wg.Done()
for hello := range helloChan {
fingerprints, err := engine.FromClientHello(hello)
if err != nil {
logger.Warn("fingerprint", "Failed to generate fingerprints", map[string]string{
"error": err.Error(),
"src_ip": hello.SrcIP,
})
continue
}
logger.Debug("fingerprint", "JA4 generated", map[string]string{
"src_ip": hello.SrcIP,
"ja4": fingerprints.JA4,
})
// Create log record and write
rec := api.NewLogRecord(hello, fingerprints)
if err := writer.Write(rec); err != nil {
logger.Error("output", "Failed to write log record", map[string]string{
"error": err.Error(),
})
}
}
}()
// Wait for shutdown signal or error
select { select {
case sig := <-sigChan: case sig := <-sigChan:
logger.Info("service", "Received shutdown signal", map[string]string{ appLogger.Info("main", "Received shutdown signal", map[string]string{
"signal": sig.String(), "signal": sig.String(),
}) })
case err := <-errorChan: case err := <-captureErrChan:
logger.Error("service", "Pipeline error", map[string]string{ if err != nil {
"error": err.Error(), appLogger.Error("capture", "Capture engine failed", map[string]string{
})
}
// Graceful shutdown
logger.Info("service", "Shutting down", nil)
if err := captureImpl.Close(); err != nil {
logger.Error("capture", "Error closing capture", map[string]string{
"error": err.Error(),
})
}
wg.Wait()
// Close parser (stops cleanup goroutine)
if err := parser.Close(); err != nil {
logger.Error("tlsparse", "Error closing parser", map[string]string{
"error": err.Error(),
})
}
// Close output writer
if closer, ok := writer.(interface{ CloseAll() error }); ok {
if err := closer.CloseAll(); err != nil {
logger.Error("output", "Error closing writer", map[string]string{
"error": err.Error(), "error": err.Error(),
}) })
} }
} }
logger.Info("service", "ja4sentinel stopped", nil) // Graceful shutdown
appLogger.Info("main", "Shutting down...", nil)
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(),
})
}
if closer, ok := outputWriter.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
appLogger.Error("main", "Failed to close output writer", map[string]string{
"error": err.Error(),
})
}
}
appLogger.Info("main", "ja4sentinel stopped", nil)
}
// formatPorts formats a slice of ports as a comma-separated string
func formatPorts(ports []uint16) string {
if len(ports) == 0 {
return ""
}
result := fmt.Sprintf("%d", ports[0])
for _, port := range ports[1:] {
result += fmt.Sprintf(",%d", port)
}
return result
} }

View File

@ -26,14 +26,15 @@ const (
) )
// ConnectionFlow tracks a single TCP flow for TLS handshake extraction // ConnectionFlow tracks a single TCP flow for TLS handshake extraction
// Only tracks incoming traffic from client to the local machine
type ConnectionFlow struct { type ConnectionFlow struct {
State ConnectionState State ConnectionState
CreatedAt time.Time CreatedAt time.Time
LastSeen time.Time LastSeen time.Time
SrcIP string SrcIP string // Client IP
SrcPort uint16 SrcPort uint16 // Client port
DstIP string DstIP string // Server IP (local machine)
DstPort uint16 DstPort uint16 // Server port (local machine)
IPMeta api.IPMeta IPMeta api.IPMeta
TCPMeta api.TCPMeta TCPMeta api.TCPMeta
HelloBuffer []byte HelloBuffer []byte
@ -66,7 +67,8 @@ func NewParserWithTimeout(timeout time.Duration) *ParserImpl {
return p return p
} }
// flowKey generates a unique key for a TCP flow // flowKey generates a unique key for a TCP flow (client -> server only)
// Only tracks incoming traffic from client to the local machine
func flowKey(srcIP string, srcPort uint16, dstIP string, dstPort uint16) string { func flowKey(srcIP string, srcPort uint16, dstIP string, dstPort uint16) string {
return fmt.Sprintf("%s:%d->%s:%d", srcIP, srcPort, dstIP, dstPort) return fmt.Sprintf("%s:%d->%s:%d", srcIP, srcPort, dstIP, dstPort)
} }
@ -234,6 +236,7 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
} }
// getOrCreateFlow gets existing flow or creates a new one // getOrCreateFlow gets existing flow or creates a new one
// Only tracks incoming traffic from client to the local machine
func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, dstIP string, dstPort uint16, ipMeta api.IPMeta, tcpMeta api.TCPMeta) *ConnectionFlow { func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, dstIP string, dstPort uint16, ipMeta api.IPMeta, tcpMeta api.TCPMeta) *ConnectionFlow {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@ -247,10 +250,10 @@ func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, d
State: NEW, State: NEW,
CreatedAt: time.Now(), CreatedAt: time.Now(),
LastSeen: time.Now(), LastSeen: time.Now(),
SrcIP: srcIP, SrcIP: srcIP, // Client IP
SrcPort: srcPort, SrcPort: srcPort, // Client port
DstIP: dstIP, DstIP: dstIP, // Server IP (local machine)
DstPort: dstPort, DstPort: dstPort, // Server port (local machine)
IPMeta: ipMeta, IPMeta: ipMeta,
TCPMeta: tcpMeta, TCPMeta: tcpMeta,
HelloBuffer: make([]byte, 0), HelloBuffer: make([]byte, 0),

View File

@ -223,6 +223,7 @@ func TestParserClose(t *testing.T) {
} }
func TestFlowKey(t *testing.T) { func TestFlowKey(t *testing.T) {
// Test unidirectional flow key (client -> server only)
key := flowKey("192.168.1.1", 12345, "10.0.0.1", 443) key := flowKey("192.168.1.1", 12345, "10.0.0.1", 443)
expected := "192.168.1.1:12345->10.0.0.1:443" expected := "192.168.1.1:12345->10.0.0.1:443"
if key != expected { if key != expected {

View File

@ -25,11 +25,14 @@ RUN mkdir -p dist && \
ARG ARCH=amd64 ARG ARCH=amd64
RUN mkdir -p /app/packages && \ RUN mkdir -p /app/packages && \
./packaging/build-deb.sh "${VERSION}" "${ARCH}" "debian" && \ ./packaging/build-deb.sh "${VERSION}" "${ARCH}" "debian" && \
cp /app/build/deb/*.deb /app/packages/ cp /app/build/deb/*.deb /app/packages/ && \
cp /app/build/deb/*.sha256 /app/packages/ 2>/dev/null || true
# Final stage - minimal image with just the DEB # Final stage - minimal image with just the packages
FROM alpine:latest FROM alpine:latest
WORKDIR /packages
COPY --from=builder /app/packages/ /packages/ COPY --from=builder /app/packages/ /packages/
CMD ["ls", "-la", "/packages/"] # Output list of packages
CMD ["sh", "-c", "ls -la /packages/ && echo '---' && cat /packages/*.sha256 2>/dev/null || true"]

View File

@ -12,13 +12,9 @@ DIST="${3:-debian}"
PACKAGE_NAME="ja4sentinel" PACKAGE_NAME="ja4sentinel"
# Convert git version to Debian-compatible format # Convert git version to Debian-compatible format
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then # Remove 'v' prefix if present, replace invalid chars with '-'
DEB_VERSION="$VERSION" DEB_VERSION="${VERSION#v}"
elif [[ "$VERSION" =~ ^v([0-9]+\.[0-9]+\.[0-9]+) ]]; then DEB_VERSION="${DEB_VERSION//+/-}"
DEB_VERSION="${BASH_REMATCH[1]}"
else
DEB_VERSION="0.0.0+${VERSION//[^a-zA-Z0-9+.-]/_}"
fi
echo "=== Building ${PACKAGE_NAME} ${DEB_VERSION} for ${DIST} (${ARCH}) ===" echo "=== Building ${PACKAGE_NAME} ${DEB_VERSION} for ${DIST} (${ARCH}) ==="

View File

@ -12,13 +12,9 @@ DIST="${3:-rocky}"
PACKAGE_NAME="ja4sentinel" PACKAGE_NAME="ja4sentinel"
# Convert git version to RPM-compatible format # Convert git version to RPM-compatible format
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then # Remove 'v' prefix if present, replace invalid chars with '-'
RPM_VERSION="$VERSION" RPM_VERSION="${VERSION#v}"
elif [[ "$VERSION" =~ ^v([0-9]+\.[0-9]+\.[0-9]+) ]]; then RPM_VERSION="${RPM_VERSION//+/-}"
RPM_VERSION="${BASH_REMATCH[1]}"
else
RPM_VERSION="0.0.0.${VERSION//[^a-zA-Z0-9.]/_}"
fi
echo "=== Building ${PACKAGE_NAME} ${RPM_VERSION} for ${DIST} (${ARCH}) ===" echo "=== Building ${PACKAGE_NAME} ${RPM_VERSION} for ${DIST} (${ARCH}) ==="