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:
@ -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)
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@ -113,9 +114,20 @@ 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) {
|
||||
appLogger.Error("output", "UNIX socket connection failed", map[string]string{
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
84
internal/ipfilter/ipfilter.go
Normal file
84
internal/ipfilter/ipfilter.go
Normal 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)
|
||||
}
|
||||
160
internal/ipfilter/ipfilter_test.go
Normal file
160
internal/ipfilter/ipfilter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user