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:
@ -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{},
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -2,9 +2,6 @@ package capture
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ja4sentinel/internal/api"
|
||||
)
|
||||
|
||||
func TestBuildBPFForPorts(t *testing.T) {
|
||||
|
||||
176
internal/config/loader.go
Normal file
176
internal/config/loader.go
Normal 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)
|
||||
}
|
||||
213
internal/config/loader_test.go
Normal file
213
internal/config/loader_test.go
Normal 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'")
|
||||
}
|
||||
}
|
||||
64
internal/fingerprint/engine.go
Normal file
64
internal/fingerprint/engine.go
Normal 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 ""
|
||||
}
|
||||
47
internal/fingerprint/engine_test.go
Normal file
47
internal/fingerprint/engine_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"github.com/your-repo/ja4sentinel/api"
|
||||
"ja4sentinel/api"
|
||||
)
|
||||
|
||||
// LoggerFactory creates logger instances
|
||||
|
||||
@ -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
250
internal/output/writers.go
Normal 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
|
||||
}
|
||||
235
internal/output/writers_test.go
Normal file
235
internal/output/writers_test.go
Normal 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
389
internal/tlsparse/parser.go
Normal 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
|
||||
}
|
||||
253
internal/tlsparse/parser_test.go
Normal file
253
internal/tlsparse/parser_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user