diff --git a/api/types.go b/api/types.go index 1f5c26e..385eaa4 100644 --- a/api/types.go +++ b/api/types.go @@ -21,6 +21,7 @@ type Config struct { Interface string `json:"interface"` ListenPorts []uint16 `json:"listen_ports"` BPFFilter string `json:"bpf_filter,omitempty"` + LocalIPs []string `json:"local_ips,omitempty"` // Local IPs to monitor (empty = auto-detect, excludes loopback) FlowTimeoutSec int `json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30) PacketBufferSize int `json:"packet_buffer_size,omitempty"` // Buffer size for packet channel (default: 1000) LogLevel string `json:"log_level,omitempty"` // Log level: debug, info, warn, error (default: info) @@ -44,8 +45,9 @@ type TCPMeta struct { // RawPacket represents a raw packet captured from the network type RawPacket struct { - Data []byte `json:"-"` // Not serialized + Data []byte `json:"-"` // Raw packet data including link-layer header Timestamp int64 `json:"timestamp"` // nanoseconds since epoch + LinkType int `json:"-"` // Link type (1=Ethernet, 101=Linux SLL, etc.) } // TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata diff --git a/cmd/ja4sentinel/main.go b/cmd/ja4sentinel/main.go index 894a416..87e4732 100644 --- a/cmd/ja4sentinel/main.go +++ b/cmd/ja4sentinel/main.go @@ -22,7 +22,7 @@ import ( var ( // Version information (set via ldflags) - Version = "1.0.9" + Version = "1.1.6" BuildTime = "unknown" GitCommit = "unknown" ) diff --git a/config.yml.example b/config.yml.example index 1ca645f..5563142 100644 --- a/config.yml.example +++ b/config.yml.example @@ -3,6 +3,8 @@ core: # Network interface to capture traffic from + # Use "any" to capture from all interfaces (recommended) + # Or specify a specific interface (e.g., eth0, ens192, etc.) interface: eth0 # TCP ports to monitor for TLS handshakes @@ -10,9 +12,14 @@ core: - 443 - 8443 - # Optional BPF filter (leave empty for auto-generated filter based on listen_ports) + # Optional BPF filter (leave empty for auto-generated filter based on listen_ports and local_ips) bpf_filter: "" + # Local IP addresses to monitor (traffic destined to these IPs will be captured) + # Leave empty for auto-detection (recommended) - excludes loopback addresses + # Or specify manually: ["192.168.1.10", "10.0.0.5", "2001:db8::1"] + local_ips: [] + # Timeout in seconds for TLS handshake extraction (default: 30) flow_timeout_sec: 30 diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 1ca03b3..9b57759 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -3,7 +3,9 @@ package capture import ( "fmt" + "net" "regexp" + "strings" "sync" "github.com/google/gopacket" @@ -29,11 +31,13 @@ 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 + 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 } // New creates a new capture instance @@ -68,11 +72,14 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { return fmt.Errorf("failed to list network interfaces: %w", err) } - interfaceFound := false - for _, iface := range ifaces { - if iface.Name == cfg.Interface { - interfaceFound = true - break + // 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 { @@ -86,6 +93,7 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { c.mu.Lock() c.handle = handle + c.linkType = int(handle.LinkType()) c.mu.Unlock() defer func() { @@ -97,10 +105,23 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { c.mu.Unlock() }() + // 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 { + return fmt.Errorf("no local IPs found on interface %s", cfg.Interface) + } + } + c.localIPs = localIPs + // Build and apply BPF filter bpfFilter := cfg.BPFFilter if bpfFilter == "" { - bpfFilter = buildBPFForPorts(cfg.ListenPorts) + bpfFilter = c.buildBPFFilter(cfg.ListenPorts, localIPs) } // Validate BPF filter before applying @@ -117,7 +138,7 @@ func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { for packet := range packetSource.Packets() { // Convert packet to RawPacket - rawPkt := packetToRawPacket(packet) + rawPkt := c.packetToRawPacket(packet) if rawPkt != nil { select { case out <- *rawPkt: @@ -174,20 +195,116 @@ func getInterfaceNames(ifaces []pcap.Interface) []string { return names } -// buildBPFForPorts builds a BPF filter for the specified TCP ports -func buildBPFForPorts(ports []uint16) string { +// detectLocalIPs detects local IP addresses on the specified interface +// Excludes loopback addresses (127.0.0.0/8, ::1) +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() { + 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() { + 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 port 443 or tcp port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5) +func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string { if len(ports) == 0 { return "tcp" } - filterParts := make([]string, len(ports)) + // Build port filter + portParts := make([]string, len(ports)) for i, port := range ports { - filterParts[i] = fmt.Sprintf("tcp port %d", port) + portParts[i] = fmt.Sprintf("tcp port %d", port) } - return "(" + joinString(filterParts, ") or (") + ")" + 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 +// joinString joins strings with a separator (kept for backward compatibility) func joinString(parts []string, sep string) string { if len(parts) == 0 { return "" @@ -200,8 +317,21 @@ func joinString(parts []string, sep string) string { } // packetToRawPacket converts a gopacket packet to RawPacket -func packetToRawPacket(packet gopacket.Packet) *api.RawPacket { - data := packet.Data() +// 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 } @@ -209,6 +339,7 @@ func packetToRawPacket(packet gopacket.Packet) *api.RawPacket { return &api.RawPacket{ Data: data, Timestamp: packet.Metadata().Timestamp.UnixNano(), + LinkType: c.linkType, } } diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index 20de684..5aaa1e9 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -1,6 +1,8 @@ package capture import ( + "net" + "strings" "testing" "time" @@ -128,7 +130,10 @@ func TestPacketToRawPacket(t *testing.T) { gopacket.SerializeLayers(buf, opts, ð, &ip, &tcp) packet := gopacket.NewPacket(buf.Bytes(), layers.LinkTypeEthernet, gopacket.Default) - rawPkt := packetToRawPacket(packet) + + // Create capture instance for method call + c := New() + rawPkt := c.packetToRawPacket(packet) if rawPkt == nil { t.Fatal("packetToRawPacket() returned nil for valid packet") @@ -144,7 +149,9 @@ func TestPacketToRawPacket(t *testing.T) { t.Run("empty_packet", func(t *testing.T) { // Create packet with no data packet := gopacket.NewPacket([]byte{}, layers.LinkTypeEthernet, gopacket.Default) - rawPkt := packetToRawPacket(packet) + + c := New() + rawPkt := c.packetToRawPacket(packet) if rawPkt != nil { t.Error("packetToRawPacket() should return nil for empty packet") @@ -159,8 +166,9 @@ func TestPacketToRawPacket(t *testing.T) { t.Error("packetToRawPacket() with nil packet should panic") } }() + c := New() var packet gopacket.Packet - _ = packetToRawPacket(packet) + _ = c.packetToRawPacket(packet) }) } @@ -269,39 +277,6 @@ func TestValidateBPFFilter(t *testing.T) { } } -func TestBuildBPFForPorts(t *testing.T) { - tests := []struct { - name string - ports []uint16 - want string - }{ - { - name: "no ports", - ports: []uint16{}, - want: "tcp", - }, - { - name: "single port", - ports: []uint16{443}, - want: "(tcp port 443)", - }, - { - name: "multiple ports", - ports: []uint16{443, 8443, 9443}, - want: "(tcp port 443) or (tcp port 8443) or (tcp port 9443)", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildBPFForPorts(tt.ports) - if got != tt.want { - t.Errorf("buildBPFForPorts() = %v, want %v", got, tt.want) - } - }) - } -} - func TestJoinString(t *testing.T) { tests := []struct { name string @@ -428,3 +403,209 @@ func TestValidateBPFFilter_BalancedParentheses(t *testing.T) { }) } } + +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 port 443"}, + }, + { + name: "single port with single IP", + ports: []uint16{443}, + localIPs: []string{"192.168.1.10"}, + wantParts: []string{"tcp 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 port 443", "tcp 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 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) { + c := New() + if c == nil { + t.Fatal("New() returned nil") + } + + // Test that "any" interface is accepted (validation only, won't actually run) + cfg := api.Config{ + Interface: "any", + ListenPorts: []uint16{443}, + LocalIPs: []string{"192.168.1.10"}, // Provide manual IPs to avoid detection + } + + // We can't actually run capture without root permissions, but we can test validation + // This test will fail at the pcap.OpenLive stage without root, which is expected + out := make(chan api.RawPacket, 10) + err := c.Run(cfg, out) + + // If we get "operation not permitted" or similar, that's expected without root + // If we get "interface not found", that's a bug + if err != nil { + if strings.Contains(err.Error(), "not found") { + t.Errorf("Run() with 'any' interface should be valid, got: %v", err) + } + // Permission errors are expected in non-root environments + t.Logf("Run() error (expected without root): %v", err) + } +} + +func TestCaptureImpl_Run_WithManualLocalIPs(t *testing.T) { + c := New() + if c == nil { + t.Fatal("New() returned nil") + } + + // Test with manually specified local IPs + cfg := api.Config{ + Interface: "any", + ListenPorts: []uint16{443}, + LocalIPs: []string{"192.168.1.10", "10.0.0.5"}, + } + + out := make(chan api.RawPacket, 10) + err := c.Run(cfg, out) + + // Same as above - permission errors are expected + if err != nil && strings.Contains(err.Error(), "not found") { + t.Errorf("Run() with manual LocalIPs should be valid, got: %v", err) + } +} diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go index 7a2313e..7a89640 100644 --- a/internal/tlsparse/parser.go +++ b/internal/tlsparse/parser.go @@ -128,20 +128,73 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { 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) + var ipLayer gopacket.Layer + var tcpLayer gopacket.Layer + var data []byte + + // Handle different link types + // LinkType 1 = Ethernet, LinkType 101 = Linux SLL (cooked capture) + const ( + LinkTypeEthernet = 1 + LinkTypeLinuxSLL = 101 + SLL_HEADER_LEN = 16 + ) + + // Check if this is a Linux SLL packet + if pkt.LinkType == LinkTypeLinuxSLL && len(pkt.Data) >= SLL_HEADER_LEN { + // Verify SLL protocol type (bytes 12-13, big-endian) + protoType := uint16(pkt.Data[12])<<8 | uint16(pkt.Data[13]) + if protoType == 0x0800 || protoType == 0x86DD { + // Strip SLL header and parse as raw IP + data = pkt.Data[SLL_HEADER_LEN:] + } else { + data = pkt.Data + } + } else { + // Ethernet or unknown - use data as-is + data = pkt.Data + } + + // Try parsing with Ethernet first (for physical interfaces) + packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default) + ipLayer = packet.Layer(layers.LayerTypeIPv4) if ipLayer == nil { ipLayer = packet.Layer(layers.LayerTypeIPv6) } + tcpLayer = packet.Layer(layers.LayerTypeTCP) + + // If no IP/TCP layer found with Ethernet, try direct IP decoding + // This handles raw IP data (e.g., after stripping SLL header) + if ipLayer == nil || tcpLayer == nil { + // Try IPv4 + ipv4 := &layers.IPv4{} + if err := ipv4.DecodeFromBytes(data, nil); err == nil { + ipLayer = ipv4 + // Try to decode TCP from IPv4 payload + tcp := &layers.TCP{} + if err := tcp.DecodeFromBytes(ipv4.Payload, nil); err == nil { + tcpLayer = tcp + } + } + } + + // Try IPv6 if IPv4 didn't work + if ipLayer == nil { + ipv6 := &layers.IPv6{} + if err := ipv6.DecodeFromBytes(data, nil); err == nil { + ipLayer = ipv6 + // Try to decode TCP from IPv6 payload + tcp := &layers.TCP{} + if err := tcp.DecodeFromBytes(ipv6.Payload, nil); err == nil { + tcpLayer = tcp + } + } + } + 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 } diff --git a/internal/tlsparse/parser_test.go b/internal/tlsparse/parser_test.go index 43fe785..c4c162c 100644 --- a/internal/tlsparse/parser_test.go +++ b/internal/tlsparse/parser_test.go @@ -443,6 +443,7 @@ func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, return api.RawPacket{ Data: buf.Bytes(), Timestamp: time.Now().UnixNano(), + LinkType: 1, // Ethernet } } @@ -1047,3 +1048,262 @@ func TestProcess_EmptyPacketData(t *testing.T) { t.Error("Process() with empty data should return error") } } + +// TestProcess_SLLPacket tests parsing of Linux SLL (cooked capture) packets +func TestProcess_SLLPacket(t *testing.T) { + p := NewParser() + if p == nil { + t.Fatal("NewParser() returned nil") + } + defer p.Close() + + srcIP := "192.168.1.100" + dstIP := "10.0.0.1" + srcPort := uint16(54321) + dstPort := uint16(443) + + // Create a valid ClientHello payload + clientHello := createTLSClientHello(0x0303) + + // Build SLL packet instead of Ethernet + pkt := buildSLLRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + // Debug: try to parse the packet manually + packet := gopacket.NewPacket(pkt.Data, layers.LinkTypeLinuxSLL, gopacket.Default) + ipLayer := packet.Layer(layers.LayerTypeIPv4) + if ipLayer == nil { + t.Logf("DEBUG: SLL packet - no IPv4 layer found") + t.Logf("DEBUG: Packet data (first 50 bytes): % x", pkt.Data[:min(50, len(pkt.Data))]) + t.Logf("DEBUG: Packet layers: %v", packet.Layers()) + } + + result, err := p.Process(pkt) + if err != nil { + t.Fatalf("Process() with SLL packet error = %v", err) + } + if result == nil { + t.Fatal("Process() with SLL packet should return TLSClientHello") + } + if result.SrcIP != srcIP { + t.Errorf("SrcIP = %v, want %v", result.SrcIP, srcIP) + } + if result.DstIP != dstIP { + t.Errorf("DstIP = %v, want %v", result.DstIP, dstIP) + } +} + +// TestProcess_SLLPacket_IPv6 tests parsing of Linux SLL IPv6 packets +func TestProcess_SLLPacket_IPv6(t *testing.T) { + p := NewParser() + if p == nil { + t.Fatal("NewParser() returned nil") + } + defer p.Close() + + srcIP := "2001:db8::1" + dstIP := "2001:db8::2" + srcPort := uint16(54321) + dstPort := uint16(443) + + // Create a valid ClientHello payload + clientHello := createTLSClientHello(0x0303) + + // Build SLL IPv6 packet + pkt := buildSLLRawPacketIPv6(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + result, err := p.Process(pkt) + if err != nil { + t.Fatalf("Process() with SLL IPv6 packet error = %v", err) + } + if result == nil { + t.Fatal("Process() with SLL IPv6 packet should return TLSClientHello") + } + if result.SrcIP != srcIP { + t.Errorf("SrcIP = %v, want %v", result.SrcIP, srcIP) + } + if result.DstIP != dstIP { + t.Errorf("DstIP = %v, want %v", result.DstIP, dstIP) + } +} + +// TestProcess_EthernetFallback tests that Ethernet parsing still works +func TestProcess_EthernetFallback(t *testing.T) { + p := NewParser() + if p == nil { + t.Fatal("NewParser() returned nil") + } + defer p.Close() + + srcIP := "192.168.1.100" + dstIP := "10.0.0.1" + srcPort := uint16(54321) + dstPort := uint16(443) + + clientHello := createTLSClientHello(0x0303) + + // Build standard Ethernet packet + pkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + result, err := p.Process(pkt) + if err != nil { + t.Fatalf("Process() with Ethernet packet error = %v", err) + } + if result == nil { + t.Fatal("Process() with Ethernet packet should return TLSClientHello") + } +} + +// buildSLLRawPacket builds a Linux SLL (cooked capture) packet +// Manually constructs SLL header since layers.LinuxSLL doesn't implement SerializableLayer +func buildSLLRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte) api.RawPacket { + t.Helper() + + // Linux SLL header (16 bytes) - manually constructed + // See: https://www.tcpdump.org/linktypes/LINKTYPE_LINUX_SLL.html + // Packet type (2 bytes): 0x0000 = PACKET_HOST + // Address length (2 bytes): 0x0006 = 6 bytes (MAC) + // Address (8 bytes): 00:11:22:33:44:55 + 2 padding bytes + // Protocol type (2 bytes): 0x0800 = IPv4 + sllHeader := make([]byte, 16) + sllHeader[0] = 0x00 // Packet type: PACKET_HOST (high byte) + sllHeader[1] = 0x00 // Packet type: PACKET_HOST (low byte) + sllHeader[2] = 0x00 // Address length (high byte) + sllHeader[3] = 0x06 // Address length (low byte) = 6 + // Address (8 bytes, only 6 used) + sllHeader[4] = 0x00 + sllHeader[5] = 0x11 + sllHeader[6] = 0x22 + sllHeader[7] = 0x33 + sllHeader[8] = 0x44 + sllHeader[9] = 0x55 + sllHeader[10] = 0x00 // Padding + sllHeader[11] = 0x00 // Padding + sllHeader[12] = 0x08 // Protocol type: IPv4 (high byte) + sllHeader[13] = 0x00 // Protocol type: IPv4 (low byte) + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + SrcIP: net.ParseIP(srcIP).To4(), + DstIP: net.ParseIP(dstIP).To4(), + Protocol: layers.IPProtocolTCP, + } + + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(srcPort), + DstPort: layers.TCPPort(dstPort), + Seq: 1, + ACK: true, + Window: 65535, + } + if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { + t.Fatalf("SetNetworkLayerForChecksum() error = %v", err) + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + } + + // Serialize IP + TCP + payload (SLL header is prepended manually) + if err := gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(payload)); err != nil { + t.Fatalf("SerializeLayers() error = %v", err) + } + + // Prepend SLL header + packetData := append(sllHeader, buf.Bytes()...) + + return api.RawPacket{ + Data: packetData, + Timestamp: time.Now().UnixNano(), + LinkType: 101, // Linux SLL + } +} + +// buildSLLRawPacketIPv6 builds a Linux SLL IPv6 packet +func buildSLLRawPacketIPv6(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte) api.RawPacket { + t.Helper() + + // Linux SLL header for IPv6 + // Protocol type: 0x86DD = IPv6 + sllHeader := make([]byte, 16) + sllHeader[0] = 0x00 // Packet type: PACKET_HOST (high byte) + sllHeader[1] = 0x00 // Packet type: PACKET_HOST (low byte) + sllHeader[2] = 0x00 // Address length (high byte) + sllHeader[3] = 0x06 // Address length (low byte) = 6 + // Address (8 bytes, only 6 used) + sllHeader[4] = 0x00 + sllHeader[5] = 0x11 + sllHeader[6] = 0x22 + sllHeader[7] = 0x33 + sllHeader[8] = 0x44 + sllHeader[9] = 0x55 + sllHeader[10] = 0x00 // Padding + sllHeader[11] = 0x00 // Padding + sllHeader[12] = 0x86 // Protocol type: IPv6 (high byte) + sllHeader[13] = 0xDD // Protocol type: IPv6 (low byte) + + ip := &layers.IPv6{ + Version: 6, + HopLimit: 64, + SrcIP: net.ParseIP(srcIP).To16(), + DstIP: net.ParseIP(dstIP).To16(), + NextHeader: layers.IPProtocolTCP, + } + + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(srcPort), + DstPort: layers.TCPPort(dstPort), + Seq: 1, + ACK: true, + Window: 65535, + } + if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { + t.Fatalf("SetNetworkLayerForChecksum() error = %v", err) + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + } + + if err := gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(payload)); err != nil { + t.Fatalf("SerializeLayers() error = %v", err) + } + + // Prepend SLL header + packetData := append(sllHeader, buf.Bytes()...) + + return api.RawPacket{ + Data: packetData, + Timestamp: time.Now().UnixNano(), + LinkType: 101, // Linux SLL + } +} + +// TestParser_SLLPacketType tests different SLL packet types +func TestParser_SLLPacketType(t *testing.T) { + // Test that the parser handles SLL packets with different packet types + p := NewParser() + defer p.Close() + + // PACKET_HOST (0) - packet destined for local host + srcIP := "192.168.1.100" + dstIP := "10.0.0.1" + srcPort := uint16(54321) + dstPort := uint16(443) + + clientHello := createTLSClientHello(0x0303) + + pkt := buildSLLRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + result, err := p.Process(pkt) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + if result == nil { + t.Fatal("Process() should return TLSClientHello for PACKET_HOST") + } +} diff --git a/packaging/rpm/ja4sentinel.spec b/packaging/rpm/ja4sentinel.spec index f7dc35b..fc1a0b5 100644 --- a/packaging/rpm/ja4sentinel.spec +++ b/packaging/rpm/ja4sentinel.spec @@ -3,7 +3,7 @@ %if %{defined build_version} %define spec_version %{build_version} %else -%define spec_version 1.1.5 +%define spec_version 1.1.6 %endif Name: ja4sentinel @@ -178,6 +178,19 @@ fi - Set TimeoutStopSec=2 for immediate service stop on restart/stop - Consolidate config files into single example (config.yml.example) +%changelog + +* Wed Mar 04 2026 Jacquin Antoine - 1.1.6-1 +- FEATURE: Add support for capturing traffic to local machine IPs only +- Add local_ips configuration option (auto-detect or manual list) +- Auto-detection excludes loopback addresses (127.x.x.x, ::1) +- Support interface "any" for capturing on all network interfaces +- Add Linux SLL (cooked capture) support for interface "any" +- Generate BPF filter with "dst host" for local IP filtering +- Add LinkType field to RawPacket for proper packet parsing +- Add unit tests for local IP detection and SLL packet parsing +- Update version to 1.1.6 + * Sat Feb 28 2026 Jacquin Antoine - 1.0.4-1 - Add systemd sdnotify support (READY, WATCHDOG, STOPPING signals) - Enable systemd watchdog with 30s timeout