feat: ja4-platform monorepo — 5 services unified, tests & RPM builds standardized

Services:
- ja4sentinel: TLS/JA4 fingerprint capture daemon (Go, libpcap)
- logcorrelator: JA4 log correlation engine (Go, ClickHouse)
- mod_reqin_log: Apache module (C, JSON request logging)
- bot_detector: ML bot detection pipeline (Python)
- dashboard: FastAPI/Streamlit analytics UI (Python)

Shared libraries:
- shared/go/ja4common: logger, config, shutdown, ipfilter (Go module)
- shared/python/ja4_common: ClickHouseClient, ClickHouseSettings (Python package)
- shared/clickhouse/: canonical SQL migrations (10 files)

Build & packaging:
- Unified 3-stage Dockerfile.package for Go RPMs (el8/el9/el10)
- go.work workspace linking sentinel, correlator, ja4common
- Makefile with test-all, build-all, rpm-* targets

Fixes applied:
- go.work: 1.21 → 1.24.6 (required by sentinel)
- correlator Dockerfiles: golang:1.21 → golang:1.24
- replace directives in go.mod for ja4common local path
- pyproject.toml: setuptools.backends → setuptools.build_meta
- Removed static libpcap linking (unavailable on Rocky 9)
- Fixed data races in output/writers_test.go (sync.Mutex + atomic.Int32)
- Rewrote corrupted test files (logger_test.go × 2)

Test coverage:
- correlator: 67.1% total (unixsocket 80.5%, config 91.7%, app 83.3%, multi 87.7%, stdout 100%)
- sentinel: all 10 packages pass (api, capture, config, fingerprint, ipfilter, logging, output, tlsparse)

Documentation:
- README.md + docs/ (architecture, development, 5 services, shared libs, DB schema & migrations)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-07 16:42:59 +02:00
commit d469e39da7
278 changed files with 1621301 additions and 0 deletions

View File

@ -0,0 +1,397 @@
// Package capture provides network packet capture functionality for ja4sentinel
package capture
import (
"fmt"
"log"
"net"
"regexp"
"strings"
"sync"
"sync/atomic"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"github.com/antitbone/ja4/sentinel/api"
)
// Capture configuration constants
const (
// DefaultSnapLen is the default snapshot length for packet capture
// Increased from 1600 to 65535 to capture full packets including large TLS handshakes
DefaultSnapLen = 65535
// DefaultPromiscuous is the default promiscuous mode setting
DefaultPromiscuous = false
// MaxBPFFilterLength is the maximum allowed length for BPF filters
MaxBPFFilterLength = 1024
)
// validBPFPattern checks if a BPF filter contains only valid characters
// This is a basic validation to prevent injection attacks
var validBPFPattern = regexp.MustCompile(`^[a-zA-Z0-9\s\(\)\-\_\.\*\+\?\:\=\!\&\|\<\>\[\]\/\@,]+$`)
// CaptureImpl implements the capture.Capture interface for packet capture
type CaptureImpl struct {
handle *pcap.Handle
mu sync.Mutex
snapLen int
promisc bool
isClosed bool
localIPs []string // Local IPs to filter (dst host)
linkType int // Link type from pcap handle
interfaceName string // Interface name (for diagnostics)
bpfFilter string // Applied BPF filter (for diagnostics)
// Metrics counters (atomic)
packetsReceived uint64 // Total packets received from interface
packetsSent uint64 // Total packets sent to channel
packetsDropped uint64 // Total packets dropped (channel full)
}
// New creates a new capture instance
func New() *CaptureImpl {
return &CaptureImpl{
snapLen: DefaultSnapLen,
promisc: DefaultPromiscuous,
}
}
// NewWithSnapLen creates a new capture instance with custom snapshot length
func NewWithSnapLen(snapLen int) *CaptureImpl {
if snapLen <= 0 || snapLen > 65535 {
snapLen = DefaultSnapLen
}
return &CaptureImpl{
snapLen: snapLen,
promisc: DefaultPromiscuous,
}
}
// Run starts network packet capture according to the configuration
func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error {
// Validate interface name (basic check)
if cfg.Interface == "" {
return fmt.Errorf("interface cannot be empty")
}
// Find available interfaces to validate the interface exists
ifaces, err := pcap.FindAllDevs()
if err != nil {
return fmt.Errorf("failed to list network interfaces: %w", err)
}
// Special handling for "any" interface
interfaceFound := cfg.Interface == "any"
if !interfaceFound {
for _, iface := range ifaces {
if iface.Name == cfg.Interface {
interfaceFound = true
break
}
}
}
if !interfaceFound {
return fmt.Errorf("interface %s not found (available: %v)", cfg.Interface, getInterfaceNames(ifaces))
}
handle, err := pcap.OpenLive(cfg.Interface, int32(c.snapLen), c.promisc, pcap.BlockForever)
if err != nil {
return fmt.Errorf("failed to open interface %s: %w", cfg.Interface, err)
}
c.mu.Lock()
c.handle = handle
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.handle != nil && !c.isClosed {
c.handle.Close()
c.handle = nil
}
c.mu.Unlock()
}()
// Store interface name for diagnostics
c.interfaceName = cfg.Interface
// Resolve local IPs for filtering (if not manually specified)
localIPs := cfg.LocalIPs
if len(localIPs) == 0 {
localIPs, err = c.detectLocalIPs(cfg.Interface)
if err != nil {
return fmt.Errorf("failed to detect local IPs: %w", err)
}
if len(localIPs) == 0 {
// NAT/VIP: destination IP may not be assigned to this interface.
// Fall back to port-only BPF filter instead of aborting.
log.Printf("WARN capture: no local IPs found on interface %s; using port-only BPF filter (NAT/VIP mode)", cfg.Interface)
}
}
c.localIPs = localIPs
// Build and apply BPF filter
bpfFilter := cfg.BPFFilter
if bpfFilter == "" {
bpfFilter = c.buildBPFFilter(cfg.ListenPorts, localIPs)
}
c.bpfFilter = bpfFilter
// Validate BPF filter before applying
if err := validateBPFFilter(bpfFilter); err != nil {
return fmt.Errorf("invalid BPF filter: %w", err)
}
err = handle.SetBPFFilter(bpfFilter)
if err != nil {
return fmt.Errorf("failed to set BPF filter '%s': %w", bpfFilter, err)
}
// Store link type once, after the handle is fully configured (BPF filter applied).
// A single write avoids the race where packetToRawPacket reads a stale value
// that existed before the BPF filter was set.
c.mu.Lock()
c.linkType = int(handle.LinkType())
c.mu.Unlock()
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Convert packet to RawPacket
rawPkt := c.packetToRawPacket(packet)
if rawPkt != nil {
atomic.AddUint64(&c.packetsReceived, 1)
select {
case out <- *rawPkt:
// Packet sent successfully
atomic.AddUint64(&c.packetsSent, 1)
default:
// Channel full, drop packet
atomic.AddUint64(&c.packetsDropped, 1)
}
}
}
return nil
}
// validateBPFFilter performs basic validation of BPF filter strings
func validateBPFFilter(filter string) error {
if filter == "" {
return nil
}
if len(filter) > MaxBPFFilterLength {
return fmt.Errorf("BPF filter too long (max %d characters)", MaxBPFFilterLength)
}
// Check for potentially dangerous patterns
if !validBPFPattern.MatchString(filter) {
return fmt.Errorf("BPF filter contains invalid characters")
}
// Check for unbalanced parentheses
openParens := 0
for _, ch := range filter {
if ch == '(' {
openParens++
} else if ch == ')' {
openParens--
if openParens < 0 {
return fmt.Errorf("BPF filter has unbalanced parentheses")
}
}
}
if openParens != 0 {
return fmt.Errorf("BPF filter has unbalanced parentheses")
}
return nil
}
// getInterfaceNames extracts interface names from a list of devices
func getInterfaceNames(ifaces []pcap.Interface) []string {
names := make([]string, len(ifaces))
for i, iface := range ifaces {
names[i] = iface.Name
}
return names
}
// detectLocalIPs detects local IP addresses on the specified interface
// Excludes loopback addresses (127.0.0.0/8, ::1) and IPv6 link-local (fe80::)
func (c *CaptureImpl) detectLocalIPs(interfaceName string) ([]string, error) {
var localIPs []string
// Special case: "any" interface - get all non-loopback IPs
if interfaceName == "any" {
ifaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("failed to list interfaces: %w", err)
}
for _, iface := range ifaces {
// Skip loopback interfaces
if iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue // Skip this interface, try others
}
for _, addr := range addrs {
ip := extractIP(addr)
if ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
localIPs = append(localIPs, ip.String())
}
}
}
return localIPs, nil
}
// Specific interface - get IPs from that interface only
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return nil, fmt.Errorf("failed to get interface %s: %w", interfaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("failed to get addresses for %s: %w", interfaceName, err)
}
for _, addr := range addrs {
ip := extractIP(addr)
if ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
localIPs = append(localIPs, ip.String())
}
}
return localIPs, nil
}
// extractIP extracts the IP address from a net.Addr
func extractIP(addr net.Addr) net.IP {
switch v := addr.(type) {
case *net.IPNet:
ip := v.IP
// Return IPv4 as 4-byte, IPv6 as 16-byte
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
case *net.IPAddr:
ip := v.IP
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
return nil
}
// buildBPFFilter builds a BPF filter for the specified ports and local IPs
// Filter: (tcp dst port 443 or tcp dst port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5)
// Uses "tcp dst port" to only capture client→server traffic (not server→client responses)
func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string {
if len(ports) == 0 {
return "tcp"
}
// Build port filter (dst port only to avoid capturing server responses)
portParts := make([]string, len(ports))
for i, port := range ports {
portParts[i] = fmt.Sprintf("tcp dst port %d", port)
}
portFilter := "(" + strings.Join(portParts, ") or (") + ")"
// Build destination host filter
if len(localIPs) == 0 {
return portFilter
}
hostParts := make([]string, len(localIPs))
for i, ip := range localIPs {
// Handle IPv6 addresses
if strings.Contains(ip, ":") {
hostParts[i] = fmt.Sprintf("dst host %s", ip)
} else {
hostParts[i] = fmt.Sprintf("dst host %s", ip)
}
}
hostFilter := "(" + strings.Join(hostParts, ") or (") + ")"
// Combine port and host filters
return portFilter + " and " + hostFilter
}
// joinString joins strings with a separator (kept for backward compatibility)
func joinString(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, part := range parts[1:] {
result += sep + part
}
return result
}
// packetToRawPacket converts a gopacket packet to RawPacket
// Uses the raw packet bytes from the link layer
func (c *CaptureImpl) packetToRawPacket(packet gopacket.Packet) *api.RawPacket {
// Try to get link layer contents + payload for full packet
var data []byte
linkLayer := packet.LinkLayer()
if linkLayer != nil {
// Combine link layer contents with payload to get full packet
data = append(data, linkLayer.LayerContents()...)
data = append(data, linkLayer.LayerPayload()...)
} else {
// Fallback to packet.Data()
data = packet.Data()
}
if len(data) == 0 {
return nil
}
return &api.RawPacket{
Data: data,
Timestamp: packet.Metadata().Timestamp.UnixNano(),
LinkType: c.linkType,
}
}
// Close properly closes the capture handle
func (c *CaptureImpl) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.handle != nil && !c.isClosed {
c.handle.Close()
c.handle = nil
c.isClosed = true
return nil
}
c.isClosed = true
return nil
}
// GetStats returns capture statistics (for monitoring/debugging)
func (c *CaptureImpl) GetStats() (received, sent, dropped uint64) {
return atomic.LoadUint64(&c.packetsReceived),
atomic.LoadUint64(&c.packetsSent),
atomic.LoadUint64(&c.packetsDropped)
}
// GetDiagnostics returns capture diagnostics information (for debugging)
func (c *CaptureImpl) GetDiagnostics() (interfaceName string, localIPs []string, bpfFilter string, linkType int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.interfaceName, c.localIPs, c.bpfFilter, c.linkType
}

View File

@ -0,0 +1,661 @@
package capture
import (
"net"
"strings"
"testing"
"time"
"github.com/antitbone/ja4/sentinel/api"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
func TestCaptureImpl_Run_EmptyInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "",
ListenPorts: []uint16{443},
}
out := make(chan api.RawPacket, 10)
err := c.Run(cfg, out)
if err == nil {
t.Error("Run() with empty interface should return error")
}
if err.Error() != "interface cannot be empty" {
t.Errorf("Run() error = %v, want 'interface cannot be empty'", err)
}
}
func TestCaptureImpl_Run_NonExistentInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "nonexistent_interface_xyz123",
ListenPorts: []uint16{443},
}
out := make(chan api.RawPacket, 10)
err := c.Run(cfg, out)
if err == nil {
t.Error("Run() with non-existent interface should return error")
}
}
func TestCaptureImpl_Run_InvalidBPFFilter(t *testing.T) {
// Get a real interface name
ifaces, err := pcap.FindAllDevs()
if err != nil || len(ifaces) == 0 {
t.Skip("No network interfaces available for testing")
}
c := New()
cfg := api.Config{
Interface: ifaces[0].Name,
ListenPorts: []uint16{443},
BPFFilter: "invalid; rm -rf /", // Invalid characters
}
out := make(chan api.RawPacket, 10)
err = c.Run(cfg, out)
if err == nil {
t.Error("Run() with invalid BPF filter should return error")
}
}
func TestCaptureImpl_Run_ChannelFull_DropsPackets(t *testing.T) {
// This test verifies that when the output channel is full,
// packets are dropped gracefully (non-blocking write)
// We can't easily test the full Run() loop without real interfaces,
// but we can verify the channel behavior with a small buffer
out := make(chan api.RawPacket, 1)
// Fill the channel
out <- api.RawPacket{Data: []byte{1, 2, 3}, Timestamp: time.Now().UnixNano()}
// Channel should be full now, select default should trigger
done := make(chan bool)
go func() {
select {
case out <- api.RawPacket{Data: []byte{4, 5, 6}, Timestamp: time.Now().UnixNano()}:
done <- false // Would block
default:
done <- true // Dropped as expected
}
}()
dropped := <-done
if !dropped {
t.Error("Expected packet to be dropped when channel is full")
}
}
func TestPacketToRawPacket(t *testing.T) {
t.Run("valid_packet", func(t *testing.T) {
// Create a simple TCP packet
eth := layers.Ethernet{
SrcMAC: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55},
DstMAC: []byte{0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB},
EthernetType: layers.EthernetTypeIPv4,
}
ip := layers.IPv4{
Version: 4,
TTL: 64,
Protocol: layers.IPProtocolTCP,
SrcIP: []byte{192, 168, 1, 1},
DstIP: []byte{10, 0, 0, 1},
}
tcp := layers.TCP{
SrcPort: 12345,
DstPort: 443,
}
tcp.SetNetworkLayerForChecksum(&ip)
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{}
gopacket.SerializeLayers(buf, opts, &eth, &ip, &tcp)
packet := gopacket.NewPacket(buf.Bytes(), layers.LinkTypeEthernet, gopacket.Default)
// Create capture instance for method call
c := New()
rawPkt := c.packetToRawPacket(packet)
if rawPkt == nil {
t.Fatal("packetToRawPacket() returned nil for valid packet")
}
if len(rawPkt.Data) == 0 {
t.Error("packetToRawPacket() returned empty data")
}
if rawPkt.Timestamp == 0 {
t.Error("packetToRawPacket() returned zero timestamp")
}
})
t.Run("empty_packet", func(t *testing.T) {
// Create packet with no data
packet := gopacket.NewPacket([]byte{}, layers.LinkTypeEthernet, gopacket.Default)
c := New()
rawPkt := c.packetToRawPacket(packet)
if rawPkt != nil {
t.Error("packetToRawPacket() should return nil for empty packet")
}
})
t.Run("nil_packet", func(t *testing.T) {
// packetToRawPacket will panic with nil packet due to Metadata() call
// This is expected behavior - the function is not designed to handle nil
defer func() {
if r := recover(); r == nil {
t.Error("packetToRawPacket() with nil packet should panic")
}
}()
c := New()
var packet gopacket.Packet
_ = c.packetToRawPacket(packet)
})
}
func TestGetInterfaceNames(t *testing.T) {
t.Run("empty_list", func(t *testing.T) {
names := getInterfaceNames([]pcap.Interface{})
if len(names) != 0 {
t.Errorf("getInterfaceNames() with empty list = %v, want []", names)
}
})
t.Run("single_interface", func(t *testing.T) {
ifaces := []pcap.Interface{
{Name: "eth0"},
}
names := getInterfaceNames(ifaces)
if len(names) != 1 || names[0] != "eth0" {
t.Errorf("getInterfaceNames() = %v, want [eth0]", names)
}
})
t.Run("multiple_interfaces", func(t *testing.T) {
ifaces := []pcap.Interface{
{Name: "eth0"},
{Name: "lo"},
{Name: "docker0"},
}
names := getInterfaceNames(ifaces)
if len(names) != 3 {
t.Errorf("getInterfaceNames() returned %d names, want 3", len(names))
}
expected := []string{"eth0", "lo", "docker0"}
for i, name := range names {
if name != expected[i] {
t.Errorf("getInterfaceNames()[%d] = %s, want %s", i, name, expected[i])
}
}
})
}
func TestValidateBPFFilter(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{
name: "empty filter",
filter: "",
wantErr: false,
},
{
name: "valid simple filter",
filter: "tcp port 443",
wantErr: false,
},
{
name: "valid complex filter",
filter: "(tcp port 443) or (tcp port 8443)",
wantErr: false,
},
{
name: "filter with special chars",
filter: "tcp port 443 and host 192.168.1.1",
wantErr: false,
},
{
name: "too long filter",
filter: string(make([]byte, MaxBPFFilterLength+1)),
wantErr: true,
},
{
name: "unbalanced parentheses - extra open",
filter: "(tcp port 443",
wantErr: true,
},
{
name: "unbalanced parentheses - extra close",
filter: "tcp port 443)",
wantErr: true,
},
{
name: "invalid characters - semicolon",
filter: "tcp port 443; rm -rf /",
wantErr: true,
},
{
name: "invalid characters - backtick",
filter: "tcp port `whoami`",
wantErr: true,
},
{
name: "invalid characters - dollar",
filter: "tcp port $HOME",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBPFFilter(tt.filter)
if (err != nil) != tt.wantErr {
t.Errorf("validateBPFFilter() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestJoinString(t *testing.T) {
tests := []struct {
name string
parts []string
sep string
want string
}{
{
name: "empty slice",
parts: []string{},
sep: ") or (",
want: "",
},
{
name: "single element",
parts: []string{"tcp port 443"},
sep: ") or (",
want: "tcp port 443",
},
{
name: "multiple elements",
parts: []string{"tcp port 443", "tcp port 8443"},
sep: ") or (",
want: "tcp port 443) or (tcp port 8443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := joinString(tt.parts, tt.sep)
if got != tt.want {
t.Errorf("joinString() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewCapture(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
if c.snapLen != DefaultSnapLen {
t.Errorf("snapLen = %d, want %d", c.snapLen, DefaultSnapLen)
}
if c.promisc != DefaultPromiscuous {
t.Errorf("promisc = %v, want %v", c.promisc, DefaultPromiscuous)
}
}
func TestNewWithSnapLen(t *testing.T) {
tests := []struct {
name string
snapLen int
wantSnapLen int
}{
{
name: "valid snapLen",
snapLen: 2048,
wantSnapLen: 2048,
},
{
name: "zero snapLen uses default",
snapLen: 0,
wantSnapLen: DefaultSnapLen,
},
{
name: "negative snapLen uses default",
snapLen: -100,
wantSnapLen: DefaultSnapLen,
},
{
name: "too large snapLen uses default",
snapLen: 100000,
wantSnapLen: DefaultSnapLen,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewWithSnapLen(tt.snapLen)
if c == nil {
t.Fatal("NewWithSnapLen() returned nil")
}
if c.snapLen != tt.wantSnapLen {
t.Errorf("snapLen = %d, want %d", c.snapLen, tt.wantSnapLen)
}
})
}
}
func TestCaptureImpl_Close(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
// Close should not panic on fresh instance
if err := c.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
// Multiple closes should be safe
if err := c.Close(); err != nil {
t.Errorf("Close() second call error = %v", err)
}
}
func TestValidateBPFFilter_BalancedParentheses(t *testing.T) {
// Test various balanced parentheses scenarios
validFilters := []string{
"(tcp port 443)",
"((tcp port 443))",
"(tcp port 443) or (tcp port 8443)",
"((tcp port 443) or (tcp port 8443))",
"(tcp port 443 and host 1.2.3.4) or (tcp port 8443)",
}
for _, filter := range validFilters {
t.Run(filter, func(t *testing.T) {
if err := validateBPFFilter(filter); err != nil {
t.Errorf("validateBPFFilter(%q) unexpected error = %v", filter, err)
}
})
}
}
func TestCaptureImpl_detectLocalIPs(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
t.Run("any_interface", func(t *testing.T) {
ips, err := c.detectLocalIPs("any")
if err != nil {
t.Errorf("detectLocalIPs(any) error = %v", err)
}
// Should return at least one non-loopback IP or empty if none available
for _, ip := range ips {
if ip == "127.0.0.1" || ip == "::1" {
t.Errorf("detectLocalIPs(any) should exclude loopback, got %s", ip)
}
}
})
t.Run("loopback_excluded", func(t *testing.T) {
ips, err := c.detectLocalIPs("any")
if err != nil {
t.Skipf("Skipping loopback test: %v", err)
}
// Verify no loopback addresses are included
for _, ip := range ips {
if ip == "127.0.0.1" {
t.Error("detectLocalIPs should exclude 127.0.0.1")
}
}
})
}
func TestCaptureImpl_detectLocalIPs_SpecificInterface(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
// Test with a non-existent interface
_, err := c.detectLocalIPs("nonexistent_interface_xyz")
if err == nil {
t.Error("detectLocalIPs with non-existent interface should return error")
}
}
func TestCaptureImpl_extractIP(t *testing.T) {
tests := []struct {
name string
addr net.Addr
wantIPv4 bool
wantIPv6 bool
}{
{
name: "IPv4",
addr: &net.IPNet{
IP: net.ParseIP("192.168.1.10"),
Mask: net.CIDRMask(24, 32),
},
wantIPv4: true,
},
{
name: "IPv6",
addr: &net.IPNet{
IP: net.ParseIP("2001:db8::1"),
Mask: net.CIDRMask(64, 128),
},
wantIPv6: true,
},
{
name: "nil",
addr: nil,
wantIPv4: false,
wantIPv6: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractIP(tt.addr)
if tt.wantIPv4 {
if got == nil || got.To4() == nil {
t.Error("extractIP() should return IPv4 address")
}
}
if tt.wantIPv6 {
if got == nil || got.To4() != nil {
t.Error("extractIP() should return IPv6 address")
}
}
if !tt.wantIPv4 && !tt.wantIPv6 {
if got != nil {
t.Error("extractIP() should return nil for nil address")
}
}
})
}
}
func TestCaptureImpl_buildBPFFilter(t *testing.T) {
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
tests := []struct {
name string
ports []uint16
localIPs []string
wantParts []string // Parts that should be in the filter
}{
{
name: "no ports",
ports: []uint16{},
localIPs: []string{},
wantParts: []string{"tcp"},
},
{
name: "single port no IPs",
ports: []uint16{443},
localIPs: []string{},
wantParts: []string{"tcp dst port 443"},
},
{
name: "single port with single IP",
ports: []uint16{443},
localIPs: []string{"192.168.1.10"},
wantParts: []string{"tcp dst port 443", "dst host 192.168.1.10"},
},
{
name: "multiple ports with multiple IPs",
ports: []uint16{443, 8443},
localIPs: []string{"192.168.1.10", "10.0.0.5"},
wantParts: []string{"tcp dst port 443", "tcp dst port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"},
},
{
name: "IPv6 address",
ports: []uint16{443},
localIPs: []string{"2001:db8::1"},
wantParts: []string{"tcp dst port 443", "dst host 2001:db8::1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.buildBPFFilter(tt.ports, tt.localIPs)
for _, part := range tt.wantParts {
if !strings.Contains(got, part) {
t.Errorf("buildBPFFilter() = %q, should contain %q", got, part)
}
}
})
}
}
func TestCaptureImpl_Run_AnyInterface(t *testing.T) {
t.Skip("integration: pcap on 'any' interface blocks until close; run with -run=Integration in a real network env")
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "any",
ListenPorts: []uint16{443},
LocalIPs: []string{"192.168.1.10"},
}
out := make(chan api.RawPacket, 10)
errCh := make(chan error, 1)
go func() { errCh <- c.Run(cfg, out) }()
// Allow up to 300ms for the handle to open (or fail immediately)
select {
case err := <-errCh:
// Immediate error: permission or "not found"
if err != nil && strings.Contains(err.Error(), "not found") {
t.Errorf("Run() with 'any' interface should be valid, got: %v", err)
}
case <-time.After(300 * time.Millisecond):
// Run() started successfully (blocking on packets) — close to stop it
c.Close()
}
}
func TestCaptureImpl_Run_WithManualLocalIPs(t *testing.T) {
t.Skip("integration: pcap on 'any' interface blocks until close; run with -run=Integration in a real network env")
c := New()
if c == nil {
t.Fatal("New() returned nil")
}
cfg := api.Config{
Interface: "any",
ListenPorts: []uint16{443},
LocalIPs: []string{"192.168.1.10", "10.0.0.5"},
}
out := make(chan api.RawPacket, 10)
errCh := make(chan error, 1)
go func() { errCh <- c.Run(cfg, out) }()
select {
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "not found") {
t.Errorf("Run() with manual LocalIPs should be valid, got: %v", err)
}
case <-time.After(300 * time.Millisecond):
c.Close()
}
}
// TestCaptureImpl_LinkTypeInitializedOnce verifies that linkType is set exactly once,
// after the BPF filter is applied (Bug 2 fix: removed the redundant early assignment).
func TestCaptureImpl_LinkTypeInitializedOnce(t *testing.T) {
c := New()
// Fresh instance: linkType must be zero before Run() is called.
if c.linkType != 0 {
t.Errorf("new CaptureImpl should have linkType=0, got %d", c.linkType)
}
// GetDiagnostics reflects linkType correctly.
_, _, _, lt := c.GetDiagnostics()
if lt != 0 {
t.Errorf("GetDiagnostics() linkType before Run() should be 0, got %d", lt)
}
// Simulate what Run() does: set linkType once under the mutex.
c.mu.Lock()
c.linkType = 1 // 1 = Ethernet
c.mu.Unlock()
_, _, _, lt = c.GetDiagnostics()
if lt != 1 {
t.Errorf("GetDiagnostics() linkType after set = %d, want 1", lt)
}
}
// TestBuildBPFFilter_NoLocalIPs verifies Bug 3 fix: when no local IPs are
// available (NAT/VIP), buildBPFFilter returns a port-only filter.
func TestBuildBPFFilter_NoLocalIPs(t *testing.T) {
c := New()
filter := c.buildBPFFilter([]uint16{443}, nil)
if strings.Contains(filter, "dst host") {
t.Errorf("port-only filter expected when localIPs nil, got: %s", filter)
}
if !strings.Contains(filter, "tcp dst port 443") {
t.Errorf("expected tcp dst port 443, got: %s", filter)
}
}
func TestBuildBPFFilter_EmptyLocalIPs(t *testing.T) {
c := New()
filter := c.buildBPFFilter([]uint16{443, 8443}, []string{})
if strings.Contains(filter, "dst host") {
t.Errorf("port-only filter expected when localIPs empty, got: %s", filter)
}
if !strings.Contains(filter, "tcp dst port 443") || !strings.Contains(filter, "tcp dst port 8443") {
t.Errorf("expected both ports in filter, got: %s", filter)
}
}