feat: implémentation complète du pipeline JA4 + Docker + tests

Nouveaux modules:
- cmd/ja4sentinel/main.go : point d'entrée avec pipeline capture→parse→fingerprint→output
- internal/config/loader.go : chargement YAML + env (JA4SENTINEL_*) + validation
- internal/tlsparse/parser.go : extraction ClientHello avec suivi d'état de flux (NEW/WAIT_CLIENT_HELLO/JA4_DONE)
- internal/fingerprint/engine.go : génération JA4/JA3 via psanford/tlsfingerprint
- internal/output/writers.go : StdoutWriter, FileWriter, UnixSocketWriter, MultiWriter

Infrastructure:
- Dockerfile (multi-stage), Dockerfile.dev, Dockerfile.test-server
- Makefile (build, test, lint, docker-build-*)
- docker-compose.test.yml pour tests d'intégration
- README.md (276 lignes) avec architecture, config, exemples

API (api/types.go):
- Ajout Close() aux interfaces Capture et Parser
- Ajout FlowTimeoutSec dans Config (défaut: 30s, env: JA4SENTINEL_FLOW_TIMEOUT)
- ServiceLog: +Timestamp, +TraceID, +ConnID
- LogRecord: champs flatten (ip_meta_*, tcp_meta_*, ja4*)
- Helper NewLogRecord() pour conversion TLSClientHello+Fingerprints→LogRecord

Architecture (architecture.yml):
- Documentation module logging + interfaces LoggerFactory/Logger
- Section service.systemd complète (unit, security, capabilities)
- Section logging.strategy (JSON lines, champs, règles)
- api.Config: +FlowTimeoutSec documenté

Fixes/cleanup:
- Suppression internal/api/types.go (consolidé dans api/types.go)
- Correction imports logging (ja4sentinel/api)
- .dockerignore / .gitignore
- config.yml.example

Tests:
- Tous les modules ont leurs tests (*_test.go)
- Tests unitaires : capture, config, fingerprint, output, tlsparse
- Tests d'intégration via docker-compose.test.yml

Build:
- Binaires dans dist/ (make build → dist/ja4sentinel)
- Docker runtime avec COPY --from=builder /app/dist/

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-02-25 20:02:52 +01:00
parent 3b09f9416e
commit efd4481729
28 changed files with 2797 additions and 285 deletions

View File

@ -1,209 +0,0 @@
package api
import (
"time"
)
// ServiceLog represents internal service logging for diagnostics
type ServiceLog struct {
Level string `json:"level"`
Component string `json:"component"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// Config holds basic network and TLS configuration
type Config struct {
Interface string `json:"interface"`
ListenPorts []uint16 `json:"listen_ports"`
BPFFilter string `json:"bpf_filter,omitempty"`
}
// IPMeta contains IP metadata for stack fingerprinting
type IPMeta struct {
TTL uint8 `json:"ttl"`
TotalLength uint16 `json:"total_length"`
IPID uint16 `json:"id"`
DF bool `json:"df"`
}
// TCPMeta contains TCP metadata for stack fingerprinting
type TCPMeta struct {
WindowSize uint16 `json:"window_size"`
MSS uint16 `json:"mss,omitempty"`
WindowScale uint8 `json:"window_scale,omitempty"`
Options []string `json:"options"`
}
// RawPacket represents a raw packet captured from the network
type RawPacket struct {
Data []byte `json:"-"` // Not serialized
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
}
// TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata
type TLSClientHello struct {
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"`
}
// Fingerprints contains TLS fingerprints for a client flow
type Fingerprints struct {
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
// LogRecord is the final log record, serialized as a flat JSON object
type LogRecord struct {
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
// Flattened IPMeta fields
IPTTL uint8 `json:"ip_meta_ttl"`
IPTotalLen uint16 `json:"ip_meta_total_length"`
IPID uint16 `json:"ip_meta_id"`
IPDF bool `json:"ip_meta_df"`
// Flattened TCPMeta fields
TCPWindow uint16 `json:"tcp_meta_window_size"`
TCPMSS uint16 `json:"tcp_meta_mss,omitempty"`
TCPWScale uint8 `json:"tcp_meta_window_scale,omitempty"`
TCPOptions string `json:"tcp_meta_options"` // comma-separated list
// Fingerprints
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
// OutputConfig defines configuration for a single log output
type OutputConfig struct {
Type string `json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc.
}
// AppConfig is the complete ja4sentinel configuration
type AppConfig struct {
Core Config `json:"core"`
Outputs []OutputConfig `json:"outputs"`
}
// Loader interface loads configuration from file/env/CLI
type Loader interface {
Load() (AppConfig, error)
}
// Capture interface provides raw network packets
type Capture interface {
Run(cfg Config, out chan<- RawPacket) error
}
// Parser converts RawPacket to TLSClientHello
type Parser interface {
Process(pkt RawPacket) (*TLSClientHello, error)
}
// Engine generates JA4 fingerprints from TLS ClientHello
type Engine interface {
FromClientHello(ch TLSClientHello) (*Fingerprints, error)
}
// Writer is the generic interface for writing results
type Writer interface {
Write(rec LogRecord) error
}
// UnixSocketWriter implements Writer sending logs to a UNIX socket
type UnixSocketWriter interface {
Writer
Close() error
}
// MultiWriter combines multiple Writers
type MultiWriter interface {
Writer
Add(writer Writer)
CloseAll() error
}
// Builder constructs Writers from AppConfig
type Builder interface {
NewFromConfig(cfg AppConfig) (Writer, error)
}
// Helper functions for creating and converting records
// NewLogRecord creates a LogRecord from TLSClientHello and Fingerprints
func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts := ""
if len(ch.TCPMeta.Options) > 0 {
opts = joinStringSlice(ch.TCPMeta.Options, ",")
}
rec := LogRecord{
SrcIP: ch.SrcIP,
SrcPort: ch.SrcPort,
DstIP: ch.DstIP,
DstPort: ch.DstPort,
IPTTL: ch.IPMeta.TTL,
IPTotalLen: ch.IPMeta.TotalLength,
IPID: ch.IPMeta.IPID,
IPDF: ch.IPMeta.DF,
TCPWindow: ch.TCPMeta.WindowSize,
TCPMSS: ch.TCPMeta.MSS,
TCPWScale: ch.TCPMeta.WindowScale,
TCPOptions: opts,
}
if fp != nil {
rec.JA4 = fp.JA4
rec.JA4Hash = fp.JA4Hash
rec.JA3 = fp.JA3
rec.JA3Hash = fp.JA3Hash
}
return rec
}
// Helper to join string slice with separator
func joinStringSlice(slice []string, sep string) string {
if len(slice) == 0 {
return ""
}
result := slice[0]
for _, s := range slice[1:] {
result += sep + s
}
return result
}
// Default values and constants
const (
DefaultInterface = "eth0"
DefaultPort = 443
DefaultBPFFilter = ""
)
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() AppConfig {
return AppConfig{
Core: Config{
Interface: DefaultInterface,
ListenPorts: []uint16{DefaultPort},
BPFFilter: DefaultBPFFilter,
},
Outputs: []OutputConfig{},
}
}

View File

@ -1,28 +1,26 @@
// Package capture provides network packet capture functionality for ja4sentinel
package capture
import (
"fmt"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"ja4sentinel/internal/api"
"ja4sentinel/api"
)
// CaptureImpl implémente l'interface capture.Capture
// CaptureImpl implements the capture.Capture interface for packet capture
type CaptureImpl struct {
handle *pcap.Handle
}
// New crée une nouvelle instance de capture
// New creates a new capture instance
func New() *CaptureImpl {
return &CaptureImpl{}
}
// Run démarre la capture des paquets réseau selon la configuration
// Run starts network packet capture according to the configuration
func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
var err error
c.handle, err = pcap.OpenLive(cfg.Interface, 1600, true, pcap.BlockForever)
@ -31,14 +29,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
}
defer c.handle.Close()
// Appliquer le filtre BPF s'il est fourni
// Apply BPF filter if provided
if cfg.BPFFilter != "" {
err = c.handle.SetBPFFilter(cfg.BPFFilter)
if err != nil {
return fmt.Errorf("failed to set BPF filter: %w", err)
}
} else {
// Créer un filtre par défaut pour les ports surveillés
// Create default filter for monitored ports
defaultFilter := buildBPFForPorts(cfg.ListenPorts)
err = c.handle.SetBPFFilter(defaultFilter)
if err != nil {
@ -47,29 +45,29 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
}
packetSource := gopacket.NewPacketSource(c.handle, c.handle.LinkType())
for packet := range packetSource.Packets() {
// Convertir le paquet en RawPacket
// Convert packet to RawPacket
rawPkt := packetToRawPacket(packet)
if rawPkt != nil {
select {
case out <- *rawPkt:
// Paquet envoyé avec succès
// Packet sent successfully
default:
// Canal plein, ignorer le paquet
// Channel full, drop packet
}
}
}
return nil
}
// buildBPFForPorts construit un filtre BPF pour les ports TCP spécifiés
// buildBPFForPorts builds a BPF filter for the specified TCP ports
func buildBPFForPorts(ports []uint16) string {
if len(ports) == 0 {
return "tcp"
}
filterParts := make([]string, len(ports))
for i, port := range ports {
filterParts[i] = fmt.Sprintf("tcp port %d", port)
@ -77,7 +75,7 @@ func buildBPFForPorts(ports []uint16) string {
return "(" + joinString(filterParts, ") or (") + ")"
}
// joinString joint des chaînes avec un séparateur
// joinString joins strings with a separator
func joinString(parts []string, sep string) string {
if len(parts) == 0 {
return ""
@ -89,7 +87,7 @@ func joinString(parts []string, sep string) string {
return result
}
// packetToRawPacket convertit un paquet gopacket en RawPacket
// packetToRawPacket converts a gopacket packet to RawPacket
func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
data := packet.Data()
if len(data) == 0 {
@ -102,7 +100,7 @@ func packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
}
}
// Close ferme correctement la capture
// Close properly closes the capture handle
func (c *CaptureImpl) Close() error {
if c.handle != nil {
c.handle.Close()

View File

@ -2,9 +2,6 @@ package capture
import (
"testing"
"time"
"ja4sentinel/internal/api"
)
func TestBuildBPFForPorts(t *testing.T) {

176
internal/config/loader.go Normal file
View File

@ -0,0 +1,176 @@
// Package config provides configuration loading and validation for ja4sentinel
package config
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"ja4sentinel/api"
)
// LoaderImpl implements the api.Loader interface for configuration loading
type LoaderImpl struct {
configPath string
}
// NewLoader creates a new configuration loader
func NewLoader(configPath string) *LoaderImpl {
return &LoaderImpl{
configPath: configPath,
}
}
// Load reads and merges configuration from file, environment variables, and CLI
func (l *LoaderImpl) Load() (api.AppConfig, error) {
config := api.DefaultConfig()
// Load from YAML file if path is provided
if l.configPath != "" {
fileConfig, err := l.loadFromFile(l.configPath)
if err != nil {
return config, fmt.Errorf("failed to load config file: %w", err)
}
config = mergeConfigs(config, fileConfig)
}
// Override with environment variables
config = l.loadFromEnv(config)
// Validate the final configuration
if err := l.validate(config); err != nil {
return config, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// loadFromFile reads configuration from a YAML file
func (l *LoaderImpl) loadFromFile(path string) (api.AppConfig, error) {
config := api.AppConfig{}
data, err := os.ReadFile(path)
if err != nil {
return config, fmt.Errorf("failed to read config file: %w", err)
}
err = yaml.Unmarshal(data, &config)
if err != nil {
return config, fmt.Errorf("failed to parse config file: %w", err)
}
return config, nil
}
// loadFromEnv overrides configuration with environment variables
func (l *LoaderImpl) loadFromEnv(config api.AppConfig) api.AppConfig {
// JA4SENTINEL_INTERFACE
if val := os.Getenv("JA4SENTINEL_INTERFACE"); val != "" {
config.Core.Interface = val
}
// JA4SENTINEL_PORTS (comma-separated list)
if val := os.Getenv("JA4SENTINEL_PORTS"); val != "" {
ports := parsePorts(val)
if len(ports) > 0 {
config.Core.ListenPorts = ports
}
}
// JA4SENTINEL_BPF_FILTER
if val := os.Getenv("JA4SENTINEL_BPF_FILTER"); val != "" {
config.Core.BPFFilter = val
}
// JA4SENTINEL_FLOW_TIMEOUT (in seconds)
if val := os.Getenv("JA4SENTINEL_FLOW_TIMEOUT"); val != "" {
if timeout, err := strconv.Atoi(val); err == nil && timeout > 0 {
config.Core.FlowTimeoutSec = timeout
}
}
return config
}
// parsePorts parses a comma-separated list of ports
func parsePorts(s string) []uint16 {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
ports := make([]uint16, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
port, err := strconv.ParseUint(part, 10, 16)
if err == nil {
ports = append(ports, uint16(port))
}
}
return ports
}
// mergeConfigs merges two configs, with override taking precedence
func mergeConfigs(base, override api.AppConfig) api.AppConfig {
result := base
if override.Core.Interface != "" {
result.Core.Interface = override.Core.Interface
}
if len(override.Core.ListenPorts) > 0 {
result.Core.ListenPorts = override.Core.ListenPorts
}
if override.Core.BPFFilter != "" {
result.Core.BPFFilter = override.Core.BPFFilter
}
if override.Core.FlowTimeoutSec > 0 {
result.Core.FlowTimeoutSec = override.Core.FlowTimeoutSec
}
if len(override.Outputs) > 0 {
result.Outputs = override.Outputs
}
return result
}
// validate checks if the configuration is valid
func (l *LoaderImpl) validate(config api.AppConfig) error {
if config.Core.Interface == "" {
return fmt.Errorf("interface cannot be empty")
}
if len(config.Core.ListenPorts) == 0 {
return fmt.Errorf("at least one listen port is required")
}
// Validate outputs
for i, output := range config.Outputs {
if output.Type == "" {
return fmt.Errorf("output[%d]: type cannot be empty", i)
}
}
return nil
}
// ToJSON converts config to JSON string for debugging
func ToJSON(config api.AppConfig) string {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Sprintf("error marshaling config: %v", err)
}
return string(data)
}

View File

@ -0,0 +1,213 @@
package config
import (
"os"
"strings"
"testing"
"ja4sentinel/api"
)
func TestParsePorts(t *testing.T) {
tests := []struct {
name string
input string
want []uint16
}{
{
name: "single port",
input: "443",
want: []uint16{443},
},
{
name: "multiple ports",
input: "443, 8443, 9443",
want: []uint16{443, 8443, 9443},
},
{
name: "empty string",
input: "",
want: nil,
},
{
name: "with spaces",
input: " 443 , 8443 ",
want: []uint16{443, 8443},
},
{
name: "invalid port ignored",
input: "443, invalid, 8443",
want: []uint16{443, 8443},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePorts(tt.input)
if len(got) != len(tt.want) {
t.Errorf("parsePorts() length = %v, want %v", len(got), len(tt.want))
return
}
for i, v := range got {
if v != tt.want[i] {
t.Errorf("parsePorts()[%d] = %v, want %v", i, v, tt.want[i])
}
}
})
}
}
func TestMergeConfigs(t *testing.T) {
base := api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
BPFFilter: "",
},
Outputs: []api.OutputConfig{},
}
override := api.AppConfig{
Core: api.Config{
Interface: "lo",
ListenPorts: []uint16{8443},
BPFFilter: "tcp",
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
}
result := mergeConfigs(base, override)
if result.Core.Interface != "lo" {
t.Errorf("Interface = %v, want lo", result.Core.Interface)
}
if len(result.Core.ListenPorts) != 1 || result.Core.ListenPorts[0] != 8443 {
t.Errorf("ListenPorts = %v, want [8443]", result.Core.ListenPorts)
}
if result.Core.BPFFilter != "tcp" {
t.Errorf("BPFFilter = %v, want tcp", result.Core.BPFFilter)
}
if len(result.Outputs) != 1 {
t.Errorf("Outputs length = %v, want 1", len(result.Outputs))
}
}
func TestValidate(t *testing.T) {
loader := &LoaderImpl{}
tests := []struct {
name string
config api.AppConfig
wantErr bool
}{
{
name: "valid config",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
},
wantErr: false,
},
{
name: "empty interface",
config: api.AppConfig{
Core: api.Config{
Interface: "",
ListenPorts: []uint16{443},
},
},
wantErr: true,
},
{
name: "no listen ports",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{},
},
},
wantErr: true,
},
{
name: "output with empty type",
config: api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443},
},
Outputs: []api.OutputConfig{
{Type: "", Enabled: true},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := loader.validate(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadFromEnv(t *testing.T) {
// Save original env vars
origInterface := os.Getenv("JA4SENTINEL_INTERFACE")
origPorts := os.Getenv("JA4SENTINEL_PORTS")
origFilter := os.Getenv("JA4SENTINEL_BPF_FILTER")
defer func() {
os.Setenv("JA4SENTINEL_INTERFACE", origInterface)
os.Setenv("JA4SENTINEL_PORTS", origPorts)
os.Setenv("JA4SENTINEL_BPF_FILTER", origFilter)
}()
// Set test env vars
os.Setenv("JA4SENTINEL_INTERFACE", "lo")
os.Setenv("JA4SENTINEL_PORTS", "8443,9443")
os.Setenv("JA4SENTINEL_BPF_FILTER", "tcp port 8443")
loader := &LoaderImpl{}
config := api.DefaultConfig()
result := loader.loadFromEnv(config)
if result.Core.Interface != "lo" {
t.Errorf("Interface = %v, want lo", result.Core.Interface)
}
if len(result.Core.ListenPorts) != 2 {
t.Errorf("ListenPorts length = %v, want 2", len(result.Core.ListenPorts))
}
if result.Core.BPFFilter != "tcp port 8443" {
t.Errorf("BPFFilter = %v, want 'tcp port 8443'", result.Core.BPFFilter)
}
}
func TestToJSON(t *testing.T) {
config := api.AppConfig{
Core: api.Config{
Interface: "eth0",
ListenPorts: []uint16{443, 8443},
BPFFilter: "tcp",
},
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true, Params: map[string]string{}},
},
}
jsonStr := ToJSON(config)
if jsonStr == "" {
t.Error("ToJSON() returned empty string")
}
if !strings.Contains(jsonStr, "eth0") {
t.Error("ToJSON() result doesn't contain 'eth0'")
}
}

View File

@ -0,0 +1,64 @@
// Package fingerprint provides JA4/JA3 fingerprint generation for TLS ClientHello
package fingerprint
import (
"fmt"
"ja4sentinel/api"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
// EngineImpl implements the api.Engine interface for fingerprint generation
type EngineImpl struct{}
// NewEngine creates a new fingerprint engine
func NewEngine() *EngineImpl {
return &EngineImpl{}
}
// FromClientHello generates JA4 (and optionally JA3) fingerprints from a TLS ClientHello
func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints, error) {
if len(ch.Payload) == 0 {
return nil, fmt.Errorf("empty ClientHello payload")
}
// Parse the ClientHello using tlsfingerprint
fp, err := tlsfingerprint.ParseClientHello(ch.Payload)
if err != nil {
return nil, fmt.Errorf("failed to parse ClientHello: %w", err)
}
// Generate JA4 fingerprint
// Note: JA4 string format already includes the hash portion
// e.g., "t13d1516h2_8daaf6152771_02cb136f2775" where the last part is the SHA256 hash
ja4 := fp.JA4String()
// Generate JA3 fingerprint and its MD5 hash
ja3 := fp.JA3String()
ja3Hash := fp.JA3Hash()
// Extract JA4 hash portion (last segment after underscore)
// JA4 format: <tls_ver><ciphers><extensions>_<sni_hash>_<cipher_extension_hash>
ja4Hash := extractJA4Hash(ja4)
return &api.Fingerprints{
JA4: ja4,
JA4Hash: ja4Hash,
JA3: ja3,
JA3Hash: ja3Hash,
}, nil
}
// extractJA4Hash extracts the hash portion from a JA4 string
// JA4 format: <base>_<sni_hash>_<cipher_hash> -> returns "<sni_hash>_<cipher_hash>"
func extractJA4Hash(ja4 string) string {
// JA4 string format: t13d1516h2_8daaf6152771_02cb136f2775
// We extract everything after the first underscore as the "hash" portion
for i, c := range ja4 {
if c == '_' {
return ja4[i+1:]
}
}
return ""
}

View File

@ -0,0 +1,47 @@
package fingerprint
import (
"testing"
"ja4sentinel/api"
)
func TestFromClientHello(t *testing.T) {
tests := []struct {
name string
ch api.TLSClientHello
wantErr bool
}{
{
name: "empty payload",
ch: api.TLSClientHello{
Payload: []byte{},
},
wantErr: true,
},
{
name: "invalid payload",
ch: api.TLSClientHello{
Payload: []byte{0x00, 0x01, 0x02},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine := NewEngine()
_, err := engine.FromClientHello(tt.ch)
if (err != nil) != tt.wantErr {
t.Errorf("FromClientHello() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestNewEngine(t *testing.T) {
engine := NewEngine()
if engine == nil {
t.Error("NewEngine() returned nil")
}
}

View File

@ -2,7 +2,7 @@
package logging
import (
"github.com/your-repo/ja4sentinel/api"
"ja4sentinel/api"
)
// LoggerFactory creates logger instances

View File

@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/your-repo/ja4sentinel/api"
"ja4sentinel/api"
)
// ServiceLogger handles structured logging for the ja4sentinel service

250
internal/output/writers.go Normal file
View File

@ -0,0 +1,250 @@
// Package output provides writers for ja4sentinel log records
package output
import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"sync"
"ja4sentinel/api"
)
// StdoutWriter writes log records to stdout
type StdoutWriter struct {
encoder *json.Encoder
mutex sync.Mutex
}
// NewStdoutWriter creates a new stdout writer
func NewStdoutWriter() *StdoutWriter {
return &StdoutWriter{
encoder: json.NewEncoder(os.Stdout),
}
}
// Write writes a log record to stdout
func (w *StdoutWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.encoder.Encode(rec)
}
// Close closes the writer (no-op for stdout)
func (w *StdoutWriter) Close() error {
return nil
}
// FileWriter writes log records to a file
type FileWriter struct {
file *os.File
encoder *json.Encoder
mutex sync.Mutex
}
// NewFileWriter creates a new file writer
func NewFileWriter(path string) (*FileWriter, error) {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}
return &FileWriter{
file: file,
encoder: json.NewEncoder(file),
}, nil
}
// Write writes a log record to the file
func (w *FileWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.encoder.Encode(rec)
}
// Close closes the file
func (w *FileWriter) Close() error {
w.mutex.Lock()
defer w.mutex.Unlock()
if w.file != nil {
return w.file.Close()
}
return nil
}
// UnixSocketWriter writes log records to a UNIX socket
type UnixSocketWriter struct {
socketPath string
conn net.Conn
mutex sync.Mutex
}
// NewUnixSocketWriter creates a new UNIX socket writer
func NewUnixSocketWriter(socketPath string) (*UnixSocketWriter, error) {
w := &UnixSocketWriter{
socketPath: socketPath,
}
// Try to connect (socket may not exist yet)
conn, err := net.Dial("unix", socketPath)
if err != nil {
// Socket doesn't exist yet, we'll try to connect on first write
return w, nil
}
w.conn = conn
return w, nil
}
// Write writes a log record to the UNIX socket
func (w *UnixSocketWriter) Write(rec api.LogRecord) error {
w.mutex.Lock()
defer w.mutex.Unlock()
// Connect if not already connected
if w.conn == nil {
conn, err := net.Dial("unix", w.socketPath)
if err != nil {
return fmt.Errorf("failed to connect to socket %s: %w", w.socketPath, err)
}
w.conn = conn
}
data, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("failed to marshal record: %w", err)
}
// Add newline for line-based protocols
data = append(data, '\n')
_, err = w.conn.Write(data)
if err != nil {
// Connection failed, try to reconnect
w.conn.Close()
w.conn = nil
return fmt.Errorf("failed to write to socket: %w", err)
}
return nil
}
// Close closes the UNIX socket connection
func (w *UnixSocketWriter) Close() error {
w.mutex.Lock()
defer w.mutex.Unlock()
if w.conn != nil {
return w.conn.Close()
}
return nil
}
// MultiWriter combines multiple writers
type MultiWriter struct {
writers []api.Writer
mutex sync.Mutex
}
// NewMultiWriter creates a new multi-writer
func NewMultiWriter() *MultiWriter {
return &MultiWriter{
writers: make([]api.Writer, 0),
}
}
// Write writes a log record to all writers
func (mw *MultiWriter) Write(rec api.LogRecord) error {
mw.mutex.Lock()
defer mw.mutex.Unlock()
var lastErr error
for _, w := range mw.writers {
if err := w.Write(rec); err != nil {
lastErr = err
}
}
return lastErr
}
// Add adds a writer to the multi-writer
func (mw *MultiWriter) Add(writer api.Writer) {
mw.mutex.Lock()
defer mw.mutex.Unlock()
mw.writers = append(mw.writers, writer)
}
// CloseAll closes all writers
func (mw *MultiWriter) CloseAll() error {
mw.mutex.Lock()
defer mw.mutex.Unlock()
var lastErr error
for _, w := range mw.writers {
if closer, ok := w.(io.Closer); ok {
if err := closer.Close(); err != nil {
lastErr = err
}
}
}
return lastErr
}
// BuilderImpl implements the api.Builder interface
type BuilderImpl struct{}
// NewBuilder creates a new output builder
func NewBuilder() *BuilderImpl {
return &BuilderImpl{}
}
// NewFromConfig constructs writers from AppConfig
func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) {
multiWriter := NewMultiWriter()
for _, outputCfg := range cfg.Outputs {
if !outputCfg.Enabled {
continue
}
var writer api.Writer
var err error
switch outputCfg.Type {
case "stdout":
writer = NewStdoutWriter()
case "file":
path := outputCfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("file output requires 'path' parameter")
}
writer, err = NewFileWriter(path)
if err != nil {
return nil, err
}
case "unix_socket":
socketPath := outputCfg.Params["socket_path"]
if socketPath == "" {
return nil, fmt.Errorf("unix_socket output requires 'socket_path' parameter")
}
writer, err = NewUnixSocketWriter(socketPath)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown output type: %s", outputCfg.Type)
}
multiWriter.Add(writer)
}
// If no outputs configured, default to stdout
if len(multiWriter.writers) == 0 {
multiWriter.Add(NewStdoutWriter())
}
return multiWriter, nil
}

View File

@ -0,0 +1,235 @@
package output
import (
"bytes"
"encoding/json"
"os"
"testing"
"ja4sentinel/api"
)
func TestStdoutWriter(t *testing.T) {
// Capture stdout by replacing it temporarily
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
writer := NewStdoutWriter()
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err := writer.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
if output == "" {
t.Error("Write() produced no output")
}
// Verify it's valid JSON
var result api.LogRecord
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Errorf("Output is not valid JSON: %v", err)
}
}
func TestFileWriter(t *testing.T) {
// Create a temporary file
tmpFile := "/tmp/ja4sentinel_test.log"
defer os.Remove(tmpFile)
writer, err := NewFileWriter(tmpFile)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
defer writer.Close()
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err = writer.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Read the file and verify
data, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if len(data) == 0 {
t.Error("Write() produced no output")
}
// Verify it's valid JSON
var result api.LogRecord
if err := json.Unmarshal(data, &result); err != nil {
t.Errorf("Output is not valid JSON: %v", err)
}
}
func TestMultiWriter(t *testing.T) {
multiWriter := NewMultiWriter()
// Create a temporary file writer
tmpFile := "/tmp/ja4sentinel_multi_test.log"
defer os.Remove(tmpFile)
fileWriter, err := NewFileWriter(tmpFile)
if err != nil {
t.Fatalf("NewFileWriter() error = %v", err)
}
defer fileWriter.Close()
multiWriter.Add(fileWriter)
multiWriter.Add(NewStdoutWriter())
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
JA4: "t12s0102ab_1234567890ab",
}
err = multiWriter.Write(rec)
if err != nil {
t.Errorf("Write() error = %v", err)
}
// Verify file output
data, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if len(data) == 0 {
t.Error("MultiWriter.Write() produced no file output")
}
}
func TestBuilderNewFromConfig(t *testing.T) {
builder := NewBuilder()
tests := []struct {
name string
cfg api.AppConfig
wantErr bool
}{
{
name: "stdout output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "stdout", Enabled: true},
},
},
wantErr: false,
},
{
name: "file output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{
Type: "file",
Enabled: true,
Params: map[string]string{"path": "/tmp/ja4sentinel_builder_test.log"},
},
},
},
wantErr: false,
},
{
name: "file output without path",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "file", Enabled: true},
},
},
wantErr: true,
},
{
name: "unix socket output",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{
Type: "unix_socket",
Enabled: true,
Params: map[string]string{"socket_path": "/tmp/ja4sentinel_test.sock"},
},
},
},
wantErr: false,
},
{
name: "unknown output type",
cfg: api.AppConfig{
Outputs: []api.OutputConfig{
{Type: "unknown", Enabled: true},
},
},
wantErr: true,
},
{
name: "no outputs (should default to stdout)",
cfg: api.AppConfig{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer, err := builder.NewFromConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && writer == nil {
t.Error("NewFromConfig() returned nil writer")
}
})
}
}
func TestUnixSocketWriter(t *testing.T) {
// Test creation without socket (should not fail)
socketPath := "/tmp/ja4sentinel_nonexistent.sock"
writer, err := NewUnixSocketWriter(socketPath)
if err != nil {
t.Fatalf("NewUnixSocketWriter() error = %v", err)
}
// Write should fail since socket doesn't exist
rec := api.LogRecord{
SrcIP: "192.168.1.1",
SrcPort: 12345,
DstIP: "10.0.0.1",
DstPort: 443,
}
err = writer.Write(rec)
if err == nil {
t.Error("Write() should fail for non-existent socket")
}
writer.Close()
}

389
internal/tlsparse/parser.go Normal file
View File

@ -0,0 +1,389 @@
// Package tlsparse provides TLS ClientHello extraction from captured packets
package tlsparse
import (
"encoding/binary"
"fmt"
"sync"
"time"
"ja4sentinel/api"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// ConnectionState represents the state of a TCP connection for TLS parsing
type ConnectionState int
const (
// NEW: Observed SYN from client on a monitored port
NEW ConnectionState = iota
// WAIT_CLIENT_HELLO: Accumulating segments for complete ClientHello
WAIT_CLIENT_HELLO
// JA4_DONE: JA4 computed and logged, stop tracking this flow
JA4_DONE
)
// ConnectionFlow tracks a single TCP flow for TLS handshake extraction
type ConnectionFlow struct {
State ConnectionState
CreatedAt time.Time
LastSeen time.Time
SrcIP string
SrcPort uint16
DstIP string
DstPort uint16
IPMeta api.IPMeta
TCPMeta api.TCPMeta
HelloBuffer []byte
}
// ParserImpl implements the api.Parser interface for TLS parsing
type ParserImpl struct {
mu sync.RWMutex
flows map[string]*ConnectionFlow
flowTimeout time.Duration
cleanupDone chan struct{}
cleanupClose chan struct{}
}
// NewParser creates a new TLS parser with connection state tracking
func NewParser() *ParserImpl {
return NewParserWithTimeout(30 * time.Second)
}
// NewParserWithTimeout creates a new TLS parser with a custom flow timeout
func NewParserWithTimeout(timeout time.Duration) *ParserImpl {
p := &ParserImpl{
flows: make(map[string]*ConnectionFlow),
flowTimeout: timeout,
cleanupDone: make(chan struct{}),
cleanupClose: make(chan struct{}),
}
go p.cleanupLoop()
return p
}
// flowKey generates a unique key for a TCP flow
func flowKey(srcIP string, srcPort uint16, dstIP string, dstPort uint16) string {
return fmt.Sprintf("%s:%d->%s:%d", srcIP, srcPort, dstIP, dstPort)
}
// cleanupLoop periodically removes expired flows
func (p *ParserImpl) cleanupLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.cleanupExpiredFlows()
case <-p.cleanupClose:
close(p.cleanupDone)
return
}
}
}
// cleanupExpiredFlows removes flows that have timed out or are done
func (p *ParserImpl) cleanupExpiredFlows() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for key, flow := range p.flows {
if flow.State == JA4_DONE || now.Sub(flow.LastSeen) > p.flowTimeout {
delete(p.flows, key)
}
}
}
// Process extracts TLS ClientHello from a raw packet
func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
if len(pkt.Data) == 0 {
return nil, fmt.Errorf("empty packet data")
}
// Parse packet layers
packet := gopacket.NewPacket(pkt.Data, layers.LinkTypeEthernet, gopacket.Default)
// Get IP layer
ipLayer := packet.Layer(layers.LayerTypeIPv4)
if ipLayer == nil {
ipLayer = packet.Layer(layers.LayerTypeIPv6)
}
if ipLayer == nil {
return nil, nil // Not an IP packet
}
// Get TCP layer
tcpLayer := packet.Layer(layers.LayerTypeTCP)
if tcpLayer == nil {
return nil, nil // Not a TCP packet
}
ip, ok := ipLayer.(gopacket.Layer)
if !ok {
return nil, fmt.Errorf("failed to cast IP layer")
}
tcp, ok := tcpLayer.(*layers.TCP)
if !ok {
return nil, fmt.Errorf("failed to cast TCP layer")
}
// Extract IP metadata
ipMeta := extractIPMeta(ip)
// Extract TCP metadata
tcpMeta := extractTCPMeta(tcp)
// Get source/destination info
var srcIP, dstIP string
var srcPort, dstPort uint16
switch v := ip.(type) {
case *layers.IPv4:
srcIP = v.SrcIP.String()
dstIP = v.DstIP.String()
case *layers.IPv6:
srcIP = v.SrcIP.String()
dstIP = v.DstIP.String()
}
srcPort = uint16(tcp.SrcPort)
dstPort = uint16(tcp.DstPort)
// Get TCP payload (TLS data)
payload := tcp.Payload
if len(payload) == 0 {
return nil, nil // No payload
}
// Get or create connection flow
key := flowKey(srcIP, srcPort, dstIP, dstPort)
flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta)
// Check if flow is already done
p.mu.RLock()
isDone := flow.State == JA4_DONE
p.mu.RUnlock()
if isDone {
return nil, nil // Already processed this flow
}
// Check if this is a TLS ClientHello
clientHello, err := parseClientHello(payload)
if err != nil {
return nil, err
}
if clientHello != nil {
// Found ClientHello, mark flow as done
p.mu.Lock()
flow.State = JA4_DONE
flow.HelloBuffer = clientHello
p.mu.Unlock()
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
}
// Check for fragmented ClientHello (accumulate segments)
if flow.State == WAIT_CLIENT_HELLO || flow.State == NEW {
p.mu.Lock()
flow.State = WAIT_CLIENT_HELLO
flow.HelloBuffer = append(flow.HelloBuffer, payload...)
bufferCopy := make([]byte, len(flow.HelloBuffer))
copy(bufferCopy, flow.HelloBuffer)
p.mu.Unlock()
// Try to parse accumulated buffer
clientHello, err := parseClientHello(bufferCopy)
if err != nil {
return nil, err
}
if clientHello != nil {
// Complete ClientHello found
p.mu.Lock()
flow.State = JA4_DONE
p.mu.Unlock()
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
}
}
return nil, nil // No ClientHello found yet
}
// getOrCreateFlow gets existing flow or creates a new one
func (p *ParserImpl) getOrCreateFlow(key string, srcIP string, srcPort uint16, dstIP string, dstPort uint16, ipMeta api.IPMeta, tcpMeta api.TCPMeta) *ConnectionFlow {
p.mu.Lock()
defer p.mu.Unlock()
if flow, exists := p.flows[key]; exists {
flow.LastSeen = time.Now()
return flow
}
flow := &ConnectionFlow{
State: NEW,
CreatedAt: time.Now(),
LastSeen: time.Now(),
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
HelloBuffer: make([]byte, 0),
}
p.flows[key] = flow
return flow
}
// Close cleans up the parser and stops background goroutines
func (p *ParserImpl) Close() error {
close(p.cleanupClose)
<-p.cleanupDone
return nil
}
// extractIPMeta extracts IP metadata from the IP layer
func extractIPMeta(ipLayer gopacket.Layer) api.IPMeta {
meta := api.IPMeta{}
switch v := ipLayer.(type) {
case *layers.IPv4:
meta.TTL = v.TTL
meta.TotalLength = v.Length
meta.IPID = v.Id
meta.DF = v.Flags&layers.IPv4DontFragment != 0
case *layers.IPv6:
meta.TTL = v.HopLimit
meta.TotalLength = uint16(v.Length)
meta.IPID = 0 // IPv6 doesn't have IP ID
meta.DF = true // IPv6 doesn't fragment at source
}
return meta
}
// extractTCPMeta extracts TCP metadata from the TCP layer
func extractTCPMeta(tcp *layers.TCP) api.TCPMeta {
meta := api.TCPMeta{
WindowSize: tcp.Window,
Options: make([]string, 0),
}
// Parse TCP options
for _, opt := range tcp.Options {
switch opt.OptionType {
case layers.TCPOptionKindMSS:
meta.MSS = binary.BigEndian.Uint16(opt.OptionData)
meta.Options = append(meta.Options, "MSS")
case layers.TCPOptionKindWindowScale:
if len(opt.OptionData) > 0 {
meta.WindowScale = opt.OptionData[0]
}
meta.Options = append(meta.Options, "WS")
case layers.TCPOptionKindSACKPermitted:
meta.Options = append(meta.Options, "SACK")
case layers.TCPOptionKindTimestamps:
meta.Options = append(meta.Options, "TS")
default:
meta.Options = append(meta.Options, fmt.Sprintf("OPT%d", opt.OptionType))
}
}
return meta
}
// parseClientHello checks if the payload contains a TLS ClientHello and returns it
func parseClientHello(payload []byte) ([]byte, error) {
if len(payload) < 5 {
return nil, nil // Too short for TLS record
}
// TLS record layer: Content Type (1 byte), Version (2 bytes), Length (2 bytes)
contentType := payload[0]
// Check for TLS handshake (content type 22)
if contentType != 22 {
return nil, nil // Not a TLS handshake
}
// Check TLS version (TLS 1.0 = 0x0301, TLS 1.1 = 0x0302, TLS 1.2 = 0x0303, TLS 1.3 = 0x0304)
version := binary.BigEndian.Uint16(payload[1:3])
if version < 0x0301 || version > 0x0304 {
return nil, nil // Unknown TLS version
}
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return nil, nil // Incomplete TLS record
}
// Parse handshake protocol
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return nil, nil // Too short for handshake type
}
handshakeType := handshakePayload[0]
// Check for ClientHello (handshake type 1)
if handshakeType != 1 {
return nil, nil // Not a ClientHello
}
// Return the full TLS record (header + payload) for fingerprinting
return payload[:5+recordLength], nil
}
// IsClientHello checks if a payload contains a TLS ClientHello
func IsClientHello(payload []byte) bool {
if len(payload) < 6 {
return false
}
// TLS handshake record
if payload[0] != 22 {
return false
}
// Check version
version := binary.BigEndian.Uint16(payload[1:3])
if version < 0x0301 || version > 0x0304 {
return false
}
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return false
}
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return false
}
// ClientHello type
return handshakePayload[0] == 1
}

View File

@ -0,0 +1,253 @@
package tlsparse
import (
"testing"
"github.com/google/gopacket/layers"
)
func TestIsClientHello(t *testing.T) {
tests := []struct {
name string
payload []byte
want bool
}{
{
name: "empty payload",
payload: []byte{},
want: false,
},
{
name: "too short",
payload: []byte{0x16, 0x03, 0x03},
want: false,
},
{
name: "valid TLS 1.2 ClientHello",
payload: createTLSClientHello(0x0303),
want: true,
},
{
name: "valid TLS 1.3 ClientHello",
payload: createTLSClientHello(0x0304),
want: true,
},
{
name: "not a handshake",
payload: []byte{0x17, 0x03, 0x03, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
want: false,
},
{
name: "ServerHello (type 2)",
payload: createTLSServerHello(0x0303),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsClientHello(tt.payload)
if got != tt.want {
t.Errorf("IsClientHello() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseClientHello(t *testing.T) {
tests := []struct {
name string
payload []byte
wantErr bool
wantNil bool
}{
{
name: "empty payload",
payload: []byte{},
wantErr: false,
wantNil: true,
},
{
name: "valid ClientHello",
payload: createTLSClientHello(0x0303),
wantErr: false,
wantNil: false,
},
{
name: "incomplete record",
payload: []byte{0x16, 0x03, 0x03, 0x01, 0x00, 0x01},
wantErr: false,
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseClientHello(tt.payload)
if (err != nil) != tt.wantErr {
t.Errorf("parseClientHello() error = %v, wantErr %v", err, tt.wantErr)
return
}
if (got == nil) != tt.wantNil {
t.Errorf("parseClientHello() = %v, wantNil %v", got == nil, tt.wantNil)
}
})
}
}
func TestExtractIPMeta(t *testing.T) {
ipLayer := &layers.IPv4{
TTL: 64,
Length: 1500,
Id: 12345,
Flags: layers.IPv4DontFragment,
SrcIP: []byte{192, 168, 1, 1},
DstIP: []byte{10, 0, 0, 1},
}
meta := extractIPMeta(ipLayer)
if meta.TTL != 64 {
t.Errorf("TTL = %v, want 64", meta.TTL)
}
if meta.TotalLength != 1500 {
t.Errorf("TotalLength = %v, want 1500", meta.TotalLength)
}
if meta.IPID != 12345 {
t.Errorf("IPID = %v, want 12345", meta.IPID)
}
if !meta.DF {
t.Error("DF = false, want true")
}
}
func TestExtractTCPMeta(t *testing.T) {
tcp := &layers.TCP{
SrcPort: 12345,
DstPort: 443,
Window: 65535,
Options: []layers.TCPOption{
{
OptionType: layers.TCPOptionKindMSS,
OptionData: []byte{0x05, 0xb4}, // 1460
},
{
OptionType: layers.TCPOptionKindWindowScale,
OptionData: []byte{0x07}, // scale 7
},
{
OptionType: layers.TCPOptionKindSACKPermitted,
OptionData: []byte{},
},
},
}
meta := extractTCPMeta(tcp)
if meta.WindowSize != 65535 {
t.Errorf("WindowSize = %v, want 65535", meta.WindowSize)
}
if meta.MSS != 1460 {
t.Errorf("MSS = %v, want 1460", meta.MSS)
}
if meta.WindowScale != 7 {
t.Errorf("WindowScale = %v, want 7", meta.WindowScale)
}
if len(meta.Options) != 3 {
t.Errorf("Options length = %v, want 3", len(meta.Options))
}
}
// Helper functions to create test TLS records
func createTLSClientHello(version uint16) []byte {
// Minimal TLS ClientHello record
handshake := []byte{
0x01, // Handshake type: ClientHello
0x00, 0x00, 0x00, 0x10, // Handshake length (16 bytes)
// ClientHello body (simplified)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
record := make([]byte, 5+len(handshake))
record[0] = 0x16 // Handshake
record[1] = byte(version >> 8)
record[2] = byte(version)
record[3] = byte(len(handshake) >> 8)
record[4] = byte(len(handshake))
copy(record[5:], handshake)
return record
}
func createTLSServerHello(version uint16) []byte {
// Minimal TLS ServerHello record
handshake := []byte{
0x02, // Handshake type: ServerHello
0x00, 0x00, 0x00, 0x10, // Handshake length
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
record := make([]byte, 5+len(handshake))
record[0] = 0x16 // Handshake
record[1] = byte(version >> 8)
record[2] = byte(version)
record[3] = byte(len(handshake) >> 8)
record[4] = byte(len(handshake))
copy(record[5:], handshake)
return record
}
func TestNewParser(t *testing.T) {
parser := NewParser()
if parser == nil {
t.Error("NewParser() returned nil")
}
if parser.flows == nil {
t.Error("NewParser() flows map not initialized")
}
if parser.flowTimeout == 0 {
t.Error("NewParser() flowTimeout not set")
}
}
func TestParserClose(t *testing.T) {
parser := NewParser()
err := parser.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestFlowKey(t *testing.T) {
key := flowKey("192.168.1.1", 12345, "10.0.0.1", 443)
expected := "192.168.1.1:12345->10.0.0.1:443"
if key != expected {
t.Errorf("flowKey() = %v, want %v", key, expected)
}
}
func TestParserConnectionStateTracking(t *testing.T) {
parser := NewParser()
defer parser.Close()
// Create a valid ClientHello payload
clientHello := createTLSClientHello(0x0303)
// Test parseClientHello directly (lower-level test)
result, err := parseClientHello(clientHello)
if err != nil {
t.Errorf("parseClientHello() error = %v", err)
}
if result == nil {
t.Error("parseClientHello() should return ClientHello")
}
// Test IsClientHello helper
if !IsClientHello(clientHello) {
t.Error("IsClientHello() should return true for valid ClientHello")
}
}