From c06f6eb3f15112e967adeff991124dd6e1c4748a Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Mon, 22 Dec 2025 10:36:54 +0000 Subject: [PATCH 1/3] fix(domain-exclusion): domain exclusion filter fix Signed-off-by: ivan katliarchuk --- controller/execute.go | 8 ++-- controller/execute_test.go | 77 ++++++++++++++++++++++++++++++++++ docs/flags.md | 2 +- endpoint/domain_filter_test.go | 35 ++++++++++++++++ pkg/apis/externaldns/types.go | 2 +- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/controller/execute.go b/controller/execute.go index d6b465526d..fc0b78ce28 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -463,13 +463,15 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e return wrappers.WrapSources(sources, opts) } +// TODO: move to endpoint package +// TODO: unify and combine all filters not just regex or plain // RegexDomainFilter overrides DomainFilter func createDomainFilter(cfg *externaldns.Config) *endpoint.DomainFilter { - if cfg.RegexDomainFilter != nil && cfg.RegexDomainFilter.String() != "" { + if (cfg.RegexDomainFilter != nil && cfg.RegexDomainFilter.String() != "") || + (cfg.RegexDomainExclusion != nil && cfg.RegexDomainExclusion.String() != "") { return endpoint.NewRegexDomainFilter(cfg.RegexDomainFilter, cfg.RegexDomainExclusion) - } else { - return endpoint.NewDomainFilterWithExclusions(cfg.DomainFilter, cfg.ExcludeDomains) } + return endpoint.NewDomainFilterWithExclusions(cfg.DomainFilter, cfg.ExcludeDomains) } // handleSigterm listens for a SIGTERM signal and triggers the provided cancel function diff --git a/controller/execute_test.go b/controller/execute_test.go index a3e5b461b5..fe0a1204f3 100644 --- a/controller/execute_test.go +++ b/controller/execute_test.go @@ -331,12 +331,15 @@ func TestBuildProvider(t *testing.T) { } } +// TODO: this test should live in endpoint package func TestCreateDomainFilter(t *testing.T) { tests := []struct { name string cfg *externaldns.Config expectedDomainFilter *endpoint.DomainFilter isConfigured bool + matchDomain string + expectMatch bool }{ { name: "RegexDomainFilter", @@ -381,6 +384,77 @@ func TestCreateDomainFilter(t *testing.T) { expectedDomainFilter: endpoint.NewDomainFilterWithExclusions([]string{}, []string{}), isConfigured: false, }, + { + name: "RegexDomainExclusionWithoutRegexFilter", + cfg: &externaldns.Config{ + RegexDomainExclusion: regexp.MustCompile(`test-v1\.3\.example-test\.in`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(nil, regexp.MustCompile(`test-v1\.3\.example-test\.in`)), + isConfigured: true, + matchDomain: "test-v1.3.example-test.in", + expectMatch: false, + }, + { + name: "RegexDomainFilterWithMultipleDomains", + cfg: &externaldns.Config{ + RegexDomainFilter: regexp.MustCompile(`(example\.com|test\.org)`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(regexp.MustCompile(`(example\.com|test\.org)`), nil), + isConfigured: true, + matchDomain: "api.example.com", + expectMatch: true, + }, + { + name: "RegexDomainFilterWithWildcardPattern", + cfg: &externaldns.Config{ + RegexDomainFilter: regexp.MustCompile(`.*\.staging\..*`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(regexp.MustCompile(`.*\.staging\..*`), nil), + isConfigured: true, + matchDomain: "app.staging.example.com", + expectMatch: true, + }, + { + name: "RegexDomainExclusionWithComplexPattern", + cfg: &externaldns.Config{ + RegexDomainExclusion: regexp.MustCompile(`^(internal|private)-.*\.example\.com$`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(nil, regexp.MustCompile(`^(internal|private)-.*\.example\.com$`)), + isConfigured: true, + matchDomain: "internal-service.example.com", + expectMatch: false, + }, + { + name: "RegexFilterAndExclusionBothPresent", + cfg: &externaldns.Config{ + RegexDomainFilter: regexp.MustCompile(`.*\.prod\..*`), + RegexDomainExclusion: regexp.MustCompile(`temp-.*\.prod\..*`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(regexp.MustCompile(`.*\.prod\..*`), regexp.MustCompile(`temp-.*\.prod\..*`)), + isConfigured: true, + matchDomain: "temp-api.prod.example.com", + expectMatch: false, + }, + { + name: "RegexWithEscapedSpecialChars", + cfg: &externaldns.Config{ + RegexDomainFilter: regexp.MustCompile(`test\-api\.v\d+\.example\.com`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(regexp.MustCompile(`test\-api\.v\d+\.example\.com`), nil), + isConfigured: true, + matchDomain: "test-api.v2.example.com", + expectMatch: true, + }, + { + name: "RegexExclusionWithNumericPattern", + cfg: &externaldns.Config{ + RegexDomainExclusion: regexp.MustCompile(`\d{3,}-temp\..*`), + }, + expectedDomainFilter: endpoint.NewRegexDomainFilter(nil, regexp.MustCompile(`\d{3,}-temp\..*`)), + isConfigured: true, + matchDomain: "123-temp.example.com", + expectMatch: false, + }, } for _, tt := range tests { @@ -388,6 +462,9 @@ func TestCreateDomainFilter(t *testing.T) { filter := createDomainFilter(tt.cfg) assert.Equal(t, tt.isConfigured, filter.IsConfigured()) assert.Equal(t, tt.expectedDomainFilter, filter) + if tt.matchDomain != "" { + assert.Equal(t, tt.expectMatch, filter.Match(tt.matchDomain)) + } }) } } diff --git a/docs/flags.md b/docs/flags.md index 1b294dbea4..b592c23160 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -57,7 +57,7 @@ | `--domain-filter=` | Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional) | | `--exclude-domains=` | Exclude subdomains (optional) | | `--regex-domain-filter=` | Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional) | -| `--regex-domain-exclusion=` | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); Require 'regex-domain-filter' | +| `--regex-domain-exclusion=` | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); | | `--zone-name-filter=` | Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional) | | `--zone-id-filter=` | Filter target zones by hosted zone id; specify multiple times for multiple zones (optional) | | `--google-project=""` | When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP. | diff --git a/endpoint/domain_filter_test.go b/endpoint/domain_filter_test.go index 3287434642..1b78f84ba2 100644 --- a/endpoint/domain_filter_test.go +++ b/endpoint/domain_filter_test.go @@ -489,6 +489,41 @@ func TestDomainFilterMatchWithEmptyFilter(t *testing.T) { } } +func TestNewDomainFilterWithExclusionsHandlesEmptyInputs(t *testing.T) { + tests := []struct { + name string + filters []string + exclude []string + }{ + { + name: "NilSlices", + filters: nil, + exclude: nil, + }, + { + name: "EmptySlices", + filters: []string{}, + exclude: []string{}, + }, + { + name: "WhitespaceOnly", + filters: []string{" ", ""}, + exclude: []string{"", " "}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + domainFilter := NewDomainFilterWithExclusions(tt.filters, tt.exclude) + + assert.False(t, domainFilter.IsConfigured()) + assert.Empty(t, domainFilter.Filters) + assert.Empty(t, domainFilter.exclude) + assert.True(t, domainFilter.Match("example.com")) + }) + } +} + func TestRegexDomainFilter(t *testing.T) { for i, tt := range regexDomainFilterTests { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index fccedde6d7..2b900b0197 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -663,7 +663,7 @@ func bindFlags(b FlagBinder, cfg *Config) { b.StringsVar("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)", []string{""}, &cfg.DomainFilter) b.StringsVar("exclude-domains", "Exclude subdomains (optional)", []string{""}, &cfg.ExcludeDomains) b.RegexpVar("regex-domain-filter", "Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)", defaultConfig.RegexDomainFilter, &cfg.RegexDomainFilter) - b.RegexpVar("regex-domain-exclusion", "Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); Require 'regex-domain-filter' ", defaultConfig.RegexDomainExclusion, &cfg.RegexDomainExclusion) + b.RegexpVar("regex-domain-exclusion", "Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); ", defaultConfig.RegexDomainExclusion, &cfg.RegexDomainExclusion) b.StringsVar("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneNameFilter) b.StringsVar("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneIDFilter) b.StringVar("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.", defaultConfig.GoogleProject, &cfg.GoogleProject) From dfe2352a80e2cea22ff8af6e67e7562dfb3a4fbf Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Mon, 22 Dec 2025 10:51:09 +0000 Subject: [PATCH 2/3] fix(domain-exclusion): domain exclusion filter fix Signed-off-by: ivan katliarchuk --- endpoint/domain_filter.go | 23 ++++++++++++---- endpoint/domain_filter_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/endpoint/domain_filter.go b/endpoint/domain_filter.go index 502c639cc6..78d98c0179 100644 --- a/endpoint/domain_filter.go +++ b/endpoint/domain_filter.go @@ -135,16 +135,29 @@ func matchFilter(filters []string, domain string, emptyval bool) bool { } // matchRegex determines if a domain matches the configured regular expressions in DomainFilter. -// negativeRegex, if set, takes precedence over regex. Therefore, matchRegex returns true when -// only regex regular expression matches the domain -// Otherwise, if either negativeRegex matches or regex does not match the domain, it returns false +// The function checks exclusion first, then inclusion: +// 1. If negativeRegex is set and matches the domain, return false (excluded) +// 2. If regex is set and matches the domain, return true (included) +// 3. If regex is not set but negativeRegex is set, return true (not excluded, no inclusion filter) +// 4. If regex is set but doesn't match, return false (not included) func matchRegex(regex *regexp.Regexp, negativeRegex *regexp.Regexp, domain string) bool { strippedDomain := normalizeDomain(domain) + // First check exclusion - if domain matches exclusion, reject it if negativeRegex != nil && negativeRegex.String() != "" { - return !negativeRegex.MatchString(strippedDomain) + if negativeRegex.MatchString(strippedDomain) { + return false + } + } + + // Then check inclusion filter if set + if regex != nil && regex.String() != "" { + return regex.MatchString(strippedDomain) } - return regex.MatchString(strippedDomain) + + // If only exclusion is set (no inclusion filter), accept the domain + // since it didn't match the exclusion + return true } // IsConfigured returns true if any inclusion or exclusion rules have been specified. diff --git a/endpoint/domain_filter_test.go b/endpoint/domain_filter_test.go index 1b78f84ba2..05c41f461e 100644 --- a/endpoint/domain_filter_test.go +++ b/endpoint/domain_filter_test.go @@ -427,6 +427,54 @@ var regexDomainFilterTests = []regexDomainFilterTest{ "regexExclude": "^example\\.(?:foo|bar)\\.org$", }, }, + { + // Test case: domain doesn't match include filter, also doesn't match exclusion + // Should be REJECTED because it doesn't match the include filter + regexp.MustCompile(`foo\.org$`), + regexp.MustCompile(`^temp\.`), + []string{"bar.org", "example.com", "test.net"}, + false, + map[string]string{ + "regexInclude": `foo\.org$`, + "regexExclude": `^temp\.`, + }, + }, + { + // Test case: domain matches include filter, doesn't match exclusion + // Should be ACCEPTED + regexp.MustCompile(`\.prod\.example\.com$`), + regexp.MustCompile(`^temp-`), + []string{"api.prod.example.com", "web.prod.example.com"}, + true, + map[string]string{ + "regexInclude": `\.prod\.example\.com$`, + "regexExclude": `^temp-`, + }, + }, + { + // Test case: domain matches both include and exclusion + // Exclusion should take precedence - REJECTED + regexp.MustCompile(`\.prod\.example\.com$`), + regexp.MustCompile(`^temp-`), + []string{"temp-api.prod.example.com", "temp-web.prod.example.com"}, + false, + map[string]string{ + "regexInclude": `\.prod\.example\.com$`, + "regexExclude": `^temp-`, + }, + }, + { + // Test case: domain doesn't match include filter + // Should be REJECTED even if exclusion doesn't match + regexp.MustCompile(`\.staging\.example\.com$`), + regexp.MustCompile(`^internal-`), + []string{"api.prod.example.com", "web.dev.example.com", "service.test.org"}, + false, + map[string]string{ + "regexInclude": `\.staging\.example\.com$`, + "regexExclude": `^internal-`, + }, + }, } func TestDomainFilterMatch(t *testing.T) { From 6b8805fc7a08118a44312e742930fd346fb9f90c Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Mon, 22 Dec 2025 11:36:56 +0000 Subject: [PATCH 3/3] fix(domain-exclusion): domain exclusion filter fix Signed-off-by: ivan katliarchuk --- docs/flags.md | 2 +- pkg/apis/externaldns/types.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/flags.md b/docs/flags.md index b592c23160..400556f592 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -57,7 +57,7 @@ | `--domain-filter=` | Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional) | | `--exclude-domains=` | Exclude subdomains (optional) | | `--regex-domain-filter=` | Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional) | -| `--regex-domain-exclusion=` | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); | +| `--regex-domain-exclusion=` | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional) | | `--zone-name-filter=` | Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional) | | `--zone-id-filter=` | Filter target zones by hosted zone id; specify multiple times for multiple zones (optional) | | `--google-project=""` | When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP. | diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 2b900b0197..46feb70ae1 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -663,7 +663,7 @@ func bindFlags(b FlagBinder, cfg *Config) { b.StringsVar("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)", []string{""}, &cfg.DomainFilter) b.StringsVar("exclude-domains", "Exclude subdomains (optional)", []string{""}, &cfg.ExcludeDomains) b.RegexpVar("regex-domain-filter", "Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)", defaultConfig.RegexDomainFilter, &cfg.RegexDomainFilter) - b.RegexpVar("regex-domain-exclusion", "Regex filter that excludes domains and target zones matched by regex-domain-filter (optional); ", defaultConfig.RegexDomainExclusion, &cfg.RegexDomainExclusion) + b.RegexpVar("regex-domain-exclusion", "Regex filter that excludes domains and target zones matched by regex-domain-filter (optional)", defaultConfig.RegexDomainExclusion, &cfg.RegexDomainExclusion) b.StringsVar("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneNameFilter) b.StringsVar("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneIDFilter) b.StringVar("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.", defaultConfig.GoogleProject, &cfg.GoogleProject)