feature: add source IP exclusion with CIDR support

Features:
- Add exclude_source_ips configuration option
- Support single IPs (192.168.1.1) and CIDR ranges (10.0.0.0/8)
- Filter packets in parser before TLS processing
- Log exclusion configuration at startup
- New ipfilter package with IP/CIDR matching
- Unit tests for ipfilter package

Configuration example:
  exclude_source_ips:
    - "10.0.0.0/8"       # Exclude private network
    - "192.168.1.1"      # Exclude specific IP
    - "172.16.0.0/12"    # Exclude another range
    - "2001:db8::/32"    # IPv6 support

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
toto
2026-03-04 11:57:48 +01:00
parent bf93ee6c4a
commit 432509f8f4
6 changed files with 291 additions and 2 deletions

View File

@ -22,6 +22,7 @@ type Config struct {
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)
ExcludeSourceIPs []string `json:"exclude_source_ips,omitempty"` // Source IPs or CIDR ranges to exclude (e.g., ["10.0.0.0/8", "192.168.1.1"])
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)

View File

@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
@ -113,8 +114,19 @@ func main() {
// Create pipeline components
captureEngine := capture.New()
parser := tlsparse.NewParserWithTimeout(time.Duration(appConfig.Core.FlowTimeoutSec) * time.Second)
parser := tlsparse.NewParserWithTimeoutAndFilter(
time.Duration(appConfig.Core.FlowTimeoutSec)*time.Second,
appConfig.Core.ExcludeSourceIPs,
)
fingerprintEngine := fingerprint.NewEngine()
// Log exclusion configuration
if len(appConfig.Core.ExcludeSourceIPs) > 0 {
appLogger.Info("main", "Source IP exclusion enabled", map[string]string{
"exclude_count": fmt.Sprintf("%d", len(appConfig.Core.ExcludeSourceIPs)),
"exclude_ips": strings.Join(appConfig.Core.ExcludeSourceIPs, ", "),
})
}
// Create output builder with error callback for socket connection errors
outputBuilder := output.NewBuilder().WithErrorCallback(func(socketPath string, err error, attempt int) {

View File

@ -20,6 +20,11 @@ core:
# Or specify manually: ["192.168.1.10", "10.0.0.5", "2001:db8::1"]
local_ips: []
# Source IP addresses or CIDR ranges to exclude from capture
# Useful for filtering out internal traffic, health checks, or monitoring systems
# Examples: ["10.0.0.0/8", "192.168.1.1", "172.16.0.0/12"]
exclude_source_ips: []
# Timeout in seconds for TLS handshake extraction (default: 30)
flow_timeout_sec: 30

View File

@ -0,0 +1,84 @@
// Package ipfilter provides IP address and CIDR range matching for filtering
package ipfilter
import (
"fmt"
"net"
"sync"
)
// Filter checks if an IP address should be excluded based on a list of IPs or CIDR ranges
type Filter struct {
mu sync.RWMutex
networks []*net.IPNet
ips []net.IP
}
// New creates a new IP filter from a list of IP addresses or CIDR ranges
// Accepts formats like: "192.168.1.1", "10.0.0.0/8", "2001:db8::/32"
func New(excludeList []string) (*Filter, error) {
f := &Filter{
networks: make([]*net.IPNet, 0),
ips: make([]net.IP, 0),
}
for _, entry := range excludeList {
if entry == "" {
continue
}
// Try parsing as CIDR first
if _, ipNet, err := net.ParseCIDR(entry); err == nil {
f.networks = append(f.networks, ipNet)
continue
}
// Try parsing as single IP
if ip := net.ParseIP(entry); ip != nil {
f.ips = append(f.ips, ip)
continue
}
return nil, fmt.Errorf("invalid IP or CIDR: %s", entry)
}
return f, nil
}
// ShouldExclude checks if an IP address should be excluded
func (f *Filter) ShouldExclude(ipStr string) bool {
if f == nil {
return false
}
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
f.mu.RLock()
defer f.mu.RUnlock()
// Check against single IPs
for _, filterIP := range f.ips {
if ip.Equal(filterIP) {
return true
}
}
// Check against CIDR ranges
for _, network := range f.networks {
if network.Contains(ip) {
return true
}
}
return false
}
// Count returns the number of loaded filter entries
func (f *Filter) Count() (ips int, networks int) {
f.mu.RLock()
defer f.mu.RUnlock()
return len(f.ips), len(f.networks)
}

View File

@ -0,0 +1,160 @@
package ipfilter
import (
"testing"
)
func TestFilter_New(t *testing.T) {
tests := []struct {
name string
list []string
wantErr bool
}{
{
name: "empty list",
list: []string{},
wantErr: false,
},
{
name: "single IP",
list: []string{"192.168.1.1"},
wantErr: false,
},
{
name: "single CIDR",
list: []string{"10.0.0.0/8"},
wantErr: false,
},
{
name: "mixed IPs and CIDRs",
list: []string{"192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"},
wantErr: false,
},
{
name: "invalid IP",
list: []string{"999.999.999.999"},
wantErr: true,
},
{
name: "invalid CIDR",
list: []string{"10.0.0.0/33"},
wantErr: true,
},
{
name: "IPv6 address",
list: []string{"2001:db8::1"},
wantErr: false,
},
{
name: "IPv6 CIDR",
list: []string{"2001:db8::/32"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := New(tt.list)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && f == nil {
t.Error("New() should return non-nil filter on success")
}
})
}
}
func TestFilter_ShouldExclude(t *testing.T) {
f, err := New([]string{
"192.168.1.1",
"10.0.0.0/8",
"172.16.0.0/12",
"2001:db8::1",
"fc00::/7",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
tests := []struct {
name string
ip string
want bool
}{
// Exact IP matches
{"exact match", "192.168.1.1", true},
{"exact IPv6 match", "2001:db8::1", true},
// CIDR matches
{"CIDR match 10.0.0.1", "10.0.0.1", true},
{"CIDR match 10.255.255.255", "10.255.255.255", true},
{"CIDR match 172.16.0.1", "172.16.0.1", true},
{"CIDR match 172.31.255.255", "172.31.255.255", true},
{"CIDR IPv6 match", "fc00::1", true},
// No matches
{"no match 192.168.2.1", "192.168.2.1", false},
{"no match 11.0.0.1", "11.0.0.1", false},
{"no match 172.32.0.1", "172.32.0.1", false},
{"no match 8.8.8.8", "8.8.8.8", false},
// Invalid IP
{"invalid IP", "invalid", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := f.ShouldExclude(tt.ip); got != tt.want {
t.Errorf("ShouldExclude(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}
func TestFilter_ShouldExclude_NilFilter(t *testing.T) {
var f *Filter
if f.ShouldExclude("192.168.1.1") {
t.Error("ShouldExclude on nil filter should return false")
}
}
func TestFilter_Count(t *testing.T) {
f, err := New([]string{
"192.168.1.1",
"10.0.0.1",
"10.0.0.0/8",
"172.16.0.0/12",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ips, networks := f.Count()
if ips != 2 {
t.Errorf("Count() ips = %d, want 2", ips)
}
if networks != 2 {
t.Errorf("Count() networks = %d, want 2", networks)
}
}
func TestFilter_EmptyEntries(t *testing.T) {
f, err := New([]string{"", "192.168.1.1", ""})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ips, _ := f.Count()
if ips != 1 {
t.Errorf("Count() ips = %d, want 1 (empty entries should be skipped)", ips)
}
if !f.ShouldExclude("192.168.1.1") {
t.Error("Should exclude 192.168.1.1")
}
if f.ShouldExclude("192.168.1.2") {
t.Error("Should not exclude 192.168.1.2")
}
}

View File

@ -9,6 +9,7 @@ import (
"time"
"ja4sentinel/api"
"ja4sentinel/internal/ipfilter"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
@ -63,15 +64,35 @@ type ParserImpl struct {
closeOnce sync.Once
maxTrackedFlows int
maxHelloBufferBytes int
sourceIPFilter *ipfilter.Filter
}
// NewParser creates a new TLS parser with connection state tracking
func NewParser() *ParserImpl {
return NewParserWithTimeout(30 * time.Second)
return NewParserWithTimeoutAndFilter(30*time.Second, nil)
}
// NewParserWithTimeout creates a new TLS parser with a custom flow timeout
func NewParserWithTimeout(timeout time.Duration) *ParserImpl {
return NewParserWithTimeoutAndFilter(timeout, nil)
}
// NewParserWithTimeoutAndFilter creates a new TLS parser with timeout and source IP filter
func NewParserWithTimeoutAndFilter(timeout time.Duration, excludeSourceIPs []string) *ParserImpl {
var filter *ipfilter.Filter
if len(excludeSourceIPs) > 0 {
f, err := ipfilter.New(excludeSourceIPs)
if err != nil {
// Log error but continue without filter
filter = nil
} else {
filter = f
ips, networks := filter.Count()
_ = ips
_ = networks
}
}
p := &ParserImpl{
flows: make(map[string]*ConnectionFlow),
flowTimeout: timeout,
@ -79,6 +100,7 @@ func NewParserWithTimeout(timeout time.Duration) *ParserImpl {
cleanupClose: make(chan struct{}),
maxTrackedFlows: DefaultMaxTrackedFlows,
maxHelloBufferBytes: DefaultMaxHelloBufferBytes,
sourceIPFilter: filter,
}
go p.cleanupLoop()
return p
@ -219,6 +241,11 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
srcPort = uint16(tcp.SrcPort)
dstPort = uint16(tcp.DstPort)
// Check if source IP should be excluded
if p.sourceIPFilter != nil && p.sourceIPFilter.ShouldExclude(srcIP) {
return nil, nil // Source IP is excluded
}
// Get TCP payload (TLS data)
payload := tcp.Payload
if len(payload) == 0 {