mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
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:
committed by
Cuong Manh Le
parent
3afdaef6e6
commit
adc0e1a51e
118
internal/rulematcher/engine.go
Normal file
118
internal/rulematcher/engine.go
Normal 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
|
||||
}
|
||||
220
internal/rulematcher/engine_test.go
Normal file
220
internal/rulematcher/engine_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user