diff --git a/internal/rulematcher/engine.go b/internal/rulematcher/engine.go new file mode 100644 index 0000000..98887ea --- /dev/null +++ b/internal/rulematcher/engine.go @@ -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 +} diff --git a/internal/rulematcher/engine_test.go b/internal/rulematcher/engine_test.go new file mode 100644 index 0000000..30d677d --- /dev/null +++ b/internal/rulematcher/engine_test.go @@ -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) +} diff --git a/internal/rulematcher/types.go b/internal/rulematcher/types.go index c3499e4..ad43147 100644 --- a/internal/rulematcher/types.go +++ b/internal/rulematcher/types.go @@ -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, + } +}