diff --git a/Makefile b/Makefile index 27299bc..49393c1 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,10 @@ DIST_DIR=dist BUILD_DIR=build # 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 VERSION=$(PKG_VERSION) @@ -97,10 +100,15 @@ package-deb: build-linux --build-arg VERSION=$(PKG_VERSION) \ --build-arg ARCH=amd64 \ -f packaging/Dockerfile.deb . - @echo "Extracting DEB from Docker image..." - docker run --rm ja4sentinel-packager-deb sh -c 'cat /packages/*.deb' > build/deb/ja4sentinel.deb - @echo "DEB package created: build/deb/ja4sentinel.deb" - ls -la build/deb/*.deb + @echo "Extracting DEB packages from Docker image..." + @for f in $$(docker run --rm ja4sentinel-packager-deb sh -c 'ls /packages/*.deb 2>/dev/null'); do \ + docker run --rm ja4sentinel-packager-deb sh -c "cat $$f" > build/deb/$$(basename $$f); \ + 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-linux diff --git a/api/types.go b/api/types.go index 70337db..d2f4103 100644 --- a/api/types.go +++ b/api/types.go @@ -75,8 +75,8 @@ type LogRecord struct { // 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"` + TCPMSS *uint16 `json:"tcp_meta_mss,omitempty"` + TCPWScale *uint8 `json:"tcp_meta_window_scale,omitempty"` TCPOptions string `json:"tcp_meta_options"` // comma-separated list // Fingerprints @@ -161,6 +161,17 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord { 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{ SrcIP: ch.SrcIP, SrcPort: ch.SrcPort, @@ -171,8 +182,8 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord { IPID: ch.IPMeta.IPID, IPDF: ch.IPMeta.DF, TCPWindow: ch.TCPMeta.WindowSize, - TCPMSS: ch.TCPMeta.MSS, - TCPWScale: ch.TCPMeta.WindowScale, + TCPMSS: mssPtr, + TCPWScale: wScalePtr, TCPOptions: opts, } diff --git a/architecture.yml b/architecture.yml index e605b7d..d20f8c0 100644 --- a/architecture.yml +++ b/architecture.yml @@ -209,8 +209,8 @@ api: # TCPMeta flatten - { name: TCPWindow, type: "uint16", json_key: "tcp_meta_window_size" } - - { name: TCPMSS, type: "uint16", json_key: "tcp_meta_mss" } - - { name: TCPWScale, type: "uint8", json_key: "tcp_meta_window_scale" } + - { name: 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 @@ -401,8 +401,10 @@ flow_control: - name: "JA4_DONE" description: "JA4 calculé et logué, on arrête de suivre ce flux." rules: - - "Pour chaque flux (srcIP, srcPort, dstIP, dstPort), 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." + - "Suivi unidirectionnel : uniquement le flux entrant du client vers la machine locale (srcIP:srcPort -> dstIP:dstPort)." + - "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: policy: diff --git a/cmd/ja4sentinel/main.go b/cmd/ja4sentinel/main.go index 6a7737a..e3caba1 100644 --- a/cmd/ja4sentinel/main.go +++ b/cmd/ja4sentinel/main.go @@ -2,11 +2,11 @@ package main import ( + "context" "flag" "fmt" "os" "os/signal" - "sync" "syscall" "time" @@ -19,190 +19,192 @@ import ( "ja4sentinel/internal/tlsparse" ) -// Version information (set via ldflags) var ( - Version = "dev" + // Version information (set via ldflags) + Version = "1.0.0" BuildTime = "unknown" GitCommit = "unknown" ) func main() { - // Parse command line flags + // Parse command-line flags 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() if *version { - fmt.Printf("ja4sentinel version %s\n", Version) - fmt.Printf("Build time: %s\n", BuildTime) - fmt.Printf("Git commit: %s\n", GitCommit) + fmt.Printf("ja4sentinel version %s (built %s, commit %s)\n", Version, BuildTime, GitCommit) os.Exit(0) } - // Initialize logger factory + // Create logger factory loggerFactory := &logging.LoggerFactory{} - logger := loggerFactory.NewDefaultLogger() + appLogger := loggerFactory.NewDefaultLogger() - logger.Info("service", "Starting ja4sentinel", map[string]string{ - "version": Version, + appLogger.Info("main", "Starting ja4sentinel", map[string]string{ + "version": Version, + "build_time": BuildTime, + "git_commit": GitCommit, }) // Load configuration - configLoader := config.NewLoader(*configPath) - cfg, err := configLoader.Load() + cfgLoader := config.NewLoader(*configPath) + appConfig, err := cfgLoader.Load() 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(), }) os.Exit(1) } - logger.Info("config", "Configuration loaded", map[string]string{ - "interface": cfg.Core.Interface, - "ports": fmt.Sprintf("%v", cfg.Core.ListenPorts), - "bpf_filter": cfg.Core.BPFFilter, - "num_outputs": fmt.Sprintf("%d", len(cfg.Outputs)), + appLogger.Info("main", "Configuration loaded", map[string]string{ + "interface": appConfig.Core.Interface, + "listen_ports": formatPorts(appConfig.Core.ListenPorts), }) - // Build output writer - outputBuilder := output.NewBuilder() - writer, err := outputBuilder.NewFromConfig(cfg) - if err != nil { - logger.Error("output", "Failed to create output writer", map[string]string{ - "error": err.Error(), - }) - os.Exit(1) - } + // Create context with cancellation for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - // Create pipeline components - captureImpl := capture.New() - parser := tlsparse.NewParserWithTimeout(time.Duration(cfg.Core.FlowTimeoutSec) * time.Second) - engine := fingerprint.NewEngine() - - // Create channels for pipeline - packetChan := make(chan api.RawPacket, 1000) - helloChan := make(chan api.TLSClientHello, 1000) - errorChan := make(chan error, 100) - - var wg sync.WaitGroup - - // Setup signal handling for graceful shutdown + // Setup signal handling sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - // Start capture goroutine - wg.Add(1) - go func() { - defer wg.Done() - defer close(packetChan) + // Create pipeline components + captureEngine := capture.New() + parser := tlsparse.NewParserWithTimeout(time.Duration(appConfig.Core.FlowTimeoutSec) * time.Second) + fingerprintEngine := fingerprint.NewEngine() + outputBuilder := output.NewBuilder() - logger.Info("capture", "Starting packet capture", map[string]string{ - "interface": cfg.Core.Interface, + 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) + } - err := captureImpl.Run(cfg.Core, packetChan) - if err != nil { + // Create channel for raw packets + 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 { - case errorChan <- fmt.Errorf("capture error: %w", err): - default: + 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(), + }) + 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 - 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 + // Wait for shutdown signal or capture error select { case sig := <-sigChan: - logger.Info("service", "Received shutdown signal", map[string]string{ + appLogger.Info("main", "Received shutdown signal", map[string]string{ "signal": sig.String(), }) - case err := <-errorChan: - logger.Error("service", "Pipeline error", map[string]string{ - "error": err.Error(), - }) - } - - // Graceful shutdown - logger.Info("service", "Shutting down", nil) - - 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{ + case err := <-captureErrChan: + if err != nil { + appLogger.Error("capture", "Capture engine failed", map[string]string{ "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 } diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go index 8ee11f4..ba4f2de 100644 --- a/internal/tlsparse/parser.go +++ b/internal/tlsparse/parser.go @@ -26,14 +26,15 @@ const ( ) // ConnectionFlow tracks a single TCP flow for TLS handshake extraction +// Only tracks incoming traffic from client to the local machine type ConnectionFlow struct { State ConnectionState CreatedAt time.Time LastSeen time.Time - SrcIP string - SrcPort uint16 - DstIP string - DstPort uint16 + SrcIP string // Client IP + SrcPort uint16 // Client port + DstIP string // Server IP (local machine) + DstPort uint16 // Server port (local machine) IPMeta api.IPMeta TCPMeta api.TCPMeta HelloBuffer []byte @@ -66,7 +67,8 @@ func NewParserWithTimeout(timeout time.Duration) *ParserImpl { 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 { 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 +// 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 { p.mu.Lock() defer p.mu.Unlock() @@ -247,10 +250,10 @@ func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, d State: NEW, CreatedAt: time.Now(), LastSeen: time.Now(), - SrcIP: srcIP, - SrcPort: srcPort, - DstIP: dstIP, - DstPort: dstPort, + SrcIP: srcIP, // Client IP + SrcPort: srcPort, // Client port + DstIP: dstIP, // Server IP (local machine) + DstPort: dstPort, // Server port (local machine) IPMeta: ipMeta, TCPMeta: tcpMeta, HelloBuffer: make([]byte, 0), diff --git a/internal/tlsparse/parser_test.go b/internal/tlsparse/parser_test.go index 7244390..ababcb9 100644 --- a/internal/tlsparse/parser_test.go +++ b/internal/tlsparse/parser_test.go @@ -223,6 +223,7 @@ func TestParserClose(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) expected := "192.168.1.1:12345->10.0.0.1:443" if key != expected { diff --git a/packaging/Dockerfile.deb b/packaging/Dockerfile.deb index b9638cd..9b7fffe 100644 --- a/packaging/Dockerfile.deb +++ b/packaging/Dockerfile.deb @@ -25,11 +25,14 @@ RUN mkdir -p dist && \ ARG ARCH=amd64 RUN mkdir -p /app/packages && \ ./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 +WORKDIR /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"] diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 0dc1301..db47c7e 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -12,13 +12,9 @@ DIST="${3:-debian}" PACKAGE_NAME="ja4sentinel" # Convert git version to Debian-compatible format -if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then - DEB_VERSION="$VERSION" -elif [[ "$VERSION" =~ ^v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - DEB_VERSION="${BASH_REMATCH[1]}" -else - DEB_VERSION="0.0.0+${VERSION//[^a-zA-Z0-9+.-]/_}" -fi +# Remove 'v' prefix if present, replace invalid chars with '-' +DEB_VERSION="${VERSION#v}" +DEB_VERSION="${DEB_VERSION//+/-}" echo "=== Building ${PACKAGE_NAME} ${DEB_VERSION} for ${DIST} (${ARCH}) ===" diff --git a/packaging/build-rpm.sh b/packaging/build-rpm.sh index c41c833..521dcf5 100755 --- a/packaging/build-rpm.sh +++ b/packaging/build-rpm.sh @@ -12,13 +12,9 @@ DIST="${3:-rocky}" PACKAGE_NAME="ja4sentinel" # Convert git version to RPM-compatible format -if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then - RPM_VERSION="$VERSION" -elif [[ "$VERSION" =~ ^v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - RPM_VERSION="${BASH_REMATCH[1]}" -else - RPM_VERSION="0.0.0.${VERSION//[^a-zA-Z0-9.]/_}" -fi +# Remove 'v' prefix if present, replace invalid chars with '-' +RPM_VERSION="${VERSION#v}" +RPM_VERSION="${RPM_VERSION//+/-}" echo "=== Building ${PACKAGE_NAME} ${RPM_VERSION} for ${DIST} (${ARCH}) ==="