feat: add configurable rule matching engine

Implement MatchingEngine in internal/rulematcher package to enable
configurable DNS policy rule evaluation order and behavior.

New components:
- MatchingConfig: Configuration for rule order and stop behavior
- MatchingEngine: Orchestrates rule matching with configurable order
- MatchingResult: Standardized result structure
- DefaultMatchingConfig(): Maintains backward compatibility

Key features:
- Configurable rule evaluation order (e.g., domain-first, MAC-first)
- StopOnFirstMatch configuration option
- Graceful handling of invalid rule types
- Comprehensive test coverage for all scenarios

The engine supports custom matching strategies while preserving
the default Networks → Macs → Domains order for backward compatibility.
This enables future configuration-driven rule matching without
breaking existing functionality.
This commit is contained in:
Cuong Manh Le
2025-09-16 18:37:56 +07:00
committed by Cuong Manh Le
parent 3afdaef6e6
commit adc0e1a51e
3 changed files with 353 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
package rulematcher
import (
"context"
)
// MatchingEngine orchestrates rule matching based on configurable order
type MatchingEngine struct {
config *MatchingConfig
matchers map[RuleType]RuleMatcher
}
// NewMatchingEngine creates a new matching engine with the given configuration
func NewMatchingEngine(config *MatchingConfig) *MatchingEngine {
if config == nil {
config = DefaultMatchingConfig()
}
engine := &MatchingEngine{
config: config,
matchers: map[RuleType]RuleMatcher{
RuleTypeNetwork: &NetworkRuleMatcher{},
RuleTypeMac: &MacRuleMatcher{},
RuleTypeDomain: &DomainRuleMatcher{},
},
}
return engine
}
// FindUpstreams determines which upstreams should handle a request based on policy rules
// It evaluates rules in the configured order and returns the first match (if StopOnFirstMatch is true)
// or all matches (if StopOnFirstMatch is false)
func (e *MatchingEngine) FindUpstreams(ctx context.Context, req *MatchRequest) *MatchingResult {
result := &MatchingResult{
Upstreams: []string{},
MatchedPolicy: "no policy",
MatchedNetwork: "no network",
MatchedRule: "no rule",
Matched: false,
SrcAddr: req.SourceIP.String(),
MatchedRuleType: "",
MatchingOrder: e.config.Order,
}
if req.Policy == nil {
return result
}
result.MatchedPolicy = req.Policy.Name
var allMatches []*MatchResult
// Evaluate rules in the configured order
for _, ruleType := range e.config.Order {
matcher, exists := e.matchers[ruleType]
if !exists {
continue
}
matchResult := matcher.Match(ctx, req)
if matchResult.Matched {
allMatches = append(allMatches, matchResult)
// If we should stop on first match, return immediately
if e.config.StopOnFirstMatch {
result.Upstreams = matchResult.Targets
result.Matched = true
result.MatchedRuleType = string(matchResult.RuleType)
// Set the appropriate matched field based on rule type
switch matchResult.RuleType {
case RuleTypeNetwork:
result.MatchedNetwork = matchResult.MatchedRule
case RuleTypeMac:
result.MatchedNetwork = matchResult.MatchedRule
case RuleTypeDomain:
result.MatchedRule = matchResult.MatchedRule
}
return result
}
}
}
// If we get here, either no matches were found or StopOnFirstMatch is false
if len(allMatches) > 0 {
// For now, we'll use the first match's targets
// In the future, we could implement more sophisticated target merging
result.Upstreams = allMatches[0].Targets
result.Matched = true
result.MatchedRuleType = string(allMatches[0].RuleType)
// Set the appropriate matched field based on rule type
switch allMatches[0].RuleType {
case RuleTypeNetwork:
result.MatchedNetwork = allMatches[0].MatchedRule
case RuleTypeMac:
result.MatchedNetwork = allMatches[0].MatchedRule
case RuleTypeDomain:
result.MatchedRule = allMatches[0].MatchedRule
}
}
return result
}
// MatchingResult represents the result of the matching engine
type MatchingResult struct {
Upstreams []string
MatchedPolicy string
MatchedNetwork string
MatchedRule string
Matched bool
SrcAddr string
MatchedRuleType string
MatchingOrder []RuleType
}

View File

@@ -0,0 +1,220 @@
package rulematcher
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/Control-D-Inc/ctrld/testhelper"
)
func TestMatchingEngine(t *testing.T) {
cfg := testhelper.SampleConfig(t)
// Convert Cidrs to IPNets like in the original test
for _, nc := range cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
tests := []struct {
name string
config *MatchingConfig
request *MatchRequest
expected *MatchingResult
}{
{
name: "Default config - network match first",
config: DefaultMatchingConfig(),
request: &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
SourceMac: "14:45:A0:67:83:0A",
Domain: "example.ru",
Policy: cfg.Listener["0"].Policy,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{"upstream.1", "upstream.0"},
MatchedPolicy: "My Policy",
MatchedNetwork: "network.0",
MatchedRule: "no rule",
Matched: true,
SrcAddr: "192.168.0.1",
MatchedRuleType: "network",
MatchingOrder: []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain},
},
},
{
name: "Custom order - domain first",
config: &MatchingConfig{
Order: []RuleType{RuleTypeDomain, RuleTypeNetwork, RuleTypeMac},
StopOnFirstMatch: true,
},
request: &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
SourceMac: "14:45:A0:67:83:0A",
Domain: "example.ru",
Policy: cfg.Listener["0"].Policy,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{"upstream.1"},
MatchedPolicy: "My Policy",
MatchedNetwork: "no network",
MatchedRule: "*.ru",
Matched: true,
SrcAddr: "192.168.0.1",
MatchedRuleType: "domain",
MatchingOrder: []RuleType{RuleTypeDomain, RuleTypeNetwork, RuleTypeMac},
},
},
{
name: "Custom order - MAC first",
config: &MatchingConfig{
Order: []RuleType{RuleTypeMac, RuleTypeNetwork, RuleTypeDomain},
StopOnFirstMatch: true,
},
request: &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
SourceMac: "14:45:A0:67:83:0A",
Domain: "example.ru",
Policy: cfg.Listener["0"].Policy,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{"upstream.2"},
MatchedPolicy: "My Policy",
MatchedNetwork: "14:45:a0:67:83:0a",
MatchedRule: "no rule",
Matched: true,
SrcAddr: "192.168.0.1",
MatchedRuleType: "mac",
MatchingOrder: []RuleType{RuleTypeMac, RuleTypeNetwork, RuleTypeDomain},
},
},
{
name: "No policy",
config: DefaultMatchingConfig(),
request: &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
SourceMac: "14:45:A0:67:83:0A",
Domain: "example.ru",
Policy: nil,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{},
MatchedPolicy: "no policy",
MatchedNetwork: "no network",
MatchedRule: "no rule",
Matched: false,
SrcAddr: "192.168.0.1",
MatchedRuleType: "",
MatchingOrder: []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain},
},
},
{
name: "No matches",
config: DefaultMatchingConfig(),
request: &MatchRequest{
SourceIP: net.ParseIP("10.0.0.1"),
SourceMac: "00:11:22:33:44:55",
Domain: "example.com",
Policy: cfg.Listener["0"].Policy,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{},
MatchedPolicy: "My Policy",
MatchedNetwork: "no network",
MatchedRule: "no rule",
Matched: false,
SrcAddr: "10.0.0.1",
MatchedRuleType: "",
MatchingOrder: []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain},
},
},
{
name: "Nil config uses default",
config: nil,
request: &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
SourceMac: "14:45:A0:67:83:0A",
Domain: "example.ru",
Policy: cfg.Listener["0"].Policy,
Config: cfg,
},
expected: &MatchingResult{
Upstreams: []string{"upstream.1", "upstream.0"},
MatchedPolicy: "My Policy",
MatchedNetwork: "network.0",
MatchedRule: "no rule",
Matched: true,
SrcAddr: "192.168.0.1",
MatchedRuleType: "network",
MatchingOrder: []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
engine := NewMatchingEngine(tc.config)
result := engine.FindUpstreams(context.Background(), tc.request)
assert.Equal(t, tc.expected.Upstreams, result.Upstreams)
assert.Equal(t, tc.expected.MatchedPolicy, result.MatchedPolicy)
assert.Equal(t, tc.expected.MatchedNetwork, result.MatchedNetwork)
assert.Equal(t, tc.expected.MatchedRule, result.MatchedRule)
assert.Equal(t, tc.expected.Matched, result.Matched)
assert.Equal(t, tc.expected.SrcAddr, result.SrcAddr)
assert.Equal(t, tc.expected.MatchedRuleType, result.MatchedRuleType)
assert.Equal(t, tc.expected.MatchingOrder, result.MatchingOrder)
})
}
}
func TestDefaultMatchingConfig(t *testing.T) {
config := DefaultMatchingConfig()
assert.Equal(t, []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain}, config.Order)
assert.True(t, config.StopOnFirstMatch)
}
func TestMatchingEngineWithInvalidRuleType(t *testing.T) {
cfg := testhelper.SampleConfig(t)
// Convert Cidrs to IPNets like in the original test
for _, nc := range cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
config := &MatchingConfig{
Order: []RuleType{RuleType("invalid"), RuleTypeNetwork},
StopOnFirstMatch: true,
}
engine := NewMatchingEngine(config)
request := &MatchRequest{
SourceIP: net.ParseIP("192.168.0.1"),
Policy: cfg.Listener["0"].Policy,
Config: cfg,
}
result := engine.FindUpstreams(context.Background(), request)
// Should still work, just skip the invalid rule type
assert.True(t, result.Matched)
assert.Equal(t, "network", result.MatchedRuleType)
}

View File

@@ -38,3 +38,18 @@ type MatchResult struct {
MatchedRule string
RuleType RuleType
}
// MatchingConfig defines the configuration for rule matching behavior
type MatchingConfig struct {
Order []RuleType `json:"order" yaml:"order"`
StopOnFirstMatch bool `json:"stop_on_first_match" yaml:"stop_on_first_match"`
}
// DefaultMatchingConfig returns the default matching configuration
// This maintains backward compatibility with the current behavior
func DefaultMatchingConfig() *MatchingConfig {
return &MatchingConfig{
Order: []RuleType{RuleTypeNetwork, RuleTypeMac, RuleTypeDomain},
StopOnFirstMatch: true,
}
}