Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion controller/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -426,7 +427,11 @@ func selectRegistry(cfg *externaldns.Config, p provider.Provider) (registry.Regi
case "noop":
r, err = registry.NewNoopRegistry(p)
case "txt":
r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld)
overrides, err := parseTXTPrefixOverrides(cfg.TXTPrefixOverrides)
if err != nil {
return nil, err
}
r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld, overrides)
case "aws-sd":
r, err = registry.NewAWSSDRegistry(p, cfg.TXTOwnerID)
default:
Expand Down Expand Up @@ -463,6 +468,38 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e
return wrappers.WrapSources(sources, opts)
}

// parseTXTPrefixOverrides parses TXT prefix overrides from CLI flags
// Expects format "domain=prefix" and converts to map[string]string
// Returns error for invalid format entries
func parseTXTPrefixOverrides(overrides []string) (map[string]string, error) {
result := make(map[string]string, len(overrides))
var errors []string

for _, override := range overrides {
parts := strings.SplitN(override, "=", 2)
if len(parts) != 2 {
errors = append(errors, fmt.Sprintf("invalid format '%s': expected 'domain=prefix'", override))
continue
}

domain := strings.TrimSpace(parts[0])
prefix := strings.TrimSpace(parts[1])

if domain == "" {
errors = append(errors, fmt.Sprintf("invalid format '%s': domain cannot be empty", override))
continue
}

result[domain] = prefix
}

if len(errors) > 0 {
return nil, fmt.Errorf("invalid txt-prefix-override entries: %s", strings.Join(errors, ", "))
}

return result, nil
}

// RegexDomainFilter overrides DomainFilter
func createDomainFilter(cfg *externaldns.Config) *endpoint.DomainFilter {
if cfg.RegexDomainFilter != nil && cfg.RegexDomainFilter.String() != "" {
Expand Down
89 changes: 89 additions & 0 deletions controller/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,3 +679,92 @@ func TestControllerRunCancelContextStopsLoop(t *testing.T) {
t.Fatal("controller did not stop after context cancellation")
}
}

func TestParseTXTPrefixOverrides(t *testing.T) {
tests := []struct {
name string
input []string
expected map[string]string
expectError bool
}{
{
name: "empty input should return empty map",
input: []string{},
expected: map[string]string{},
},
{
name: "single override should parse correctly",
input: []string{"example.com=custom-prefix"},
expected: map[string]string{"example.com": "custom-prefix"},
},
{
name: "multiple overrides should parse correctly",
input: []string{"example.com=custom-prefix", "test.com=another-prefix"},
expected: map[string]string{"example.com": "custom-prefix", "test.com": "another-prefix"},
},
{
name: "override with template should parse correctly",
input: []string{"api.example.com=%{record_type}-api"},
expected: map[string]string{"api.example.com": "%{record_type}-api"},
},
{
name: "multiple domains with different prefixes should parse correctly",
input: []string{"prod.example.com=prod", "staging.example.com=stg", "dev.example.com=dev"},
expected: map[string]string{"prod.example.com": "prod", "staging.example.com": "stg", "dev.example.com": "dev"},
},
{
name: "domain with trailing dot should be handled correctly",
input: []string{"example.com.=prefix-with-dot"},
expected: map[string]string{"example.com.": "prefix-with-dot"},
},
{
name: "prefix with special characters should be handled correctly",
input: []string{"api.example.com=api-%{record_type}-v2"},
expected: map[string]string{"api.example.com": "api-%{record_type}-v2"},
},
{
name: "invalid format without equals should return error",
input: []string{"example.com"},
expectError: true,
},
{
name: "empty domain should return error",
input: []string{"=prefix"},
expectError: true,
},
{
name: "empty prefix should be accepted",
input: []string{"example.com="},
expected: map[string]string{"example.com": ""},
},
{
name: "mixed valid and invalid entries should return error",
input: []string{"example.com=valid", "invalid-entry", "test.com=another-valid"},
expectError: true,
},
{
name: "whitespace should be trimmed correctly",
input: []string{" example.com = prefix-with-spaces "},
expected: map[string]string{"example.com": "prefix-with-spaces"},
},
{
name: "duplicate domains should use last value",
input: []string{"example.com=first", "example.com=second"},
expected: map[string]string{"example.com": "second"},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := parseTXTPrefixOverrides(test.input)

if test.expectError {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
}
1 change: 1 addition & 0 deletions docs/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
| `--txt-owner-id="default"` | When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default) |
| `--txt-prefix=""` | When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix! |
| `--txt-suffix=""` | When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix! |
| `--txt-prefix-override=TXT-PREFIX-OVERRIDE` | When using the TXT registry, specify domain-specific prefix overrides in the format 'domain=prefix' (optional). Useful for apex records that are difficult to create, e.g., 'example.com=%{record_type}.' for apex domains. Can be specified multiple times for different domains. |
| `--txt-wildcard-replacement=""` | When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional) |
| `--[no-]txt-encrypt-enabled` | When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled) |
| `--txt-encrypt-aes-key=""` | When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true) |
Expand Down
16 changes: 16 additions & 0 deletions internal/idna/idna.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ limitations under the License.
package idna

import (
"strings"

log "github.com/sirupsen/logrus"
"golang.org/x/net/idna"
)

Expand All @@ -27,3 +30,16 @@ var (
idna.StrictDomainName(false),
)
)

// NormalizeDNSName converts a DNS name to a canonical form, so that we can use string equality
// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot
func NormalizeDNSName(dnsName string) string {
s, err := Profile.ToASCII(strings.TrimSpace(dnsName))
if err != nil {
log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err)
}
if !strings.HasSuffix(s, ".") {
s += "."
}
return s
}
98 changes: 98 additions & 0 deletions internal/idna/idna_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,101 @@ func TestProfileWithDefault(t *testing.T) {
})
}
}

func TestNormalizeDNSName(tt *testing.T) {
records := []struct {
dnsName string
expect string
}{
{
"3AAAA.FOO.BAR.COM ",
"3aaaa.foo.bar.com.",
},
{
" example.foo.com.",
"example.foo.com.",
},
{
"example123.foo.com ",
"example123.foo.com.",
},
{
"foo",
"foo.",
},
{
"123foo.bar",
"123foo.bar.",
},
{
"foo.com",
"foo.com.",
},
{
"foo.com.",
"foo.com.",
},
{
"_foo.com.",
"_foo.com.",
},
{
"\u005Ffoo.com.",
"_foo.com.",
},
{
".foo.com.",
".foo.com.",
},
{
"foo123.COM",
"foo123.com.",
},
{
"my-exaMple3.FOO.BAR.COM",
"my-example3.foo.bar.com.",
},
{
" my-example1214.FOO-1235.BAR-foo.COM ",
"my-example1214.foo-1235.bar-foo.com.",
},
{
"my-example-my-example-1214.FOO-1235.BAR-foo.COM",
"my-example-my-example-1214.foo-1235.bar-foo.com.",
},
{
"點看.org.",
"xn--c1yn36f.org.",
},
{
"nordic-ø.xn--kitty-點看pd34d.com",
"xn--nordic--w1a.xn--xn--kitty-pd34d-hn01b3542b.com.",
},
{
"nordic-ø.kitty😸.com.",
"xn--nordic--w1a.xn--kitty-pd34d.com.",
},
{
" nordic-ø.kitty😸.COM",
"xn--nordic--w1a.xn--kitty-pd34d.com.",
},
{
"xn--nordic--w1a.kitty😸.com.",
"xn--nordic--w1a.xn--kitty-pd34d.com.",
},
{
"*.example.com.",
"*.example.com.",
},
{
"*.example.com",
"*.example.com.",
},
}
for _, r := range records {
tt.Run(r.dnsName, func(t *testing.T) {
gotName := NormalizeDNSName(r.dnsName)
assert.Equal(t, r.expect, gotName)
})
}
}
3 changes: 3 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ type Config struct {
TXTOwnerOld string
TXTPrefix string
TXTSuffix string
TXTPrefixOverrides []string
TXTEncryptEnabled bool
TXTEncryptAESKey string `secure:"yes"`
Interval time.Duration
Expand Down Expand Up @@ -380,6 +381,7 @@ var defaultConfig = &Config{
TXTOwnerOld: "",
TXTPrefix: "",
TXTSuffix: "",
TXTPrefixOverrides: []string{},
TXTWildcardReplacement: "",
UpdateEvents: false,
WebhookProviderReadTimeout: 5 * time.Second,
Expand Down Expand Up @@ -788,6 +790,7 @@ func bindFlags(b FlagBinder, cfg *Config) {
b.StringVar("txt-owner-id", "When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)", defaultConfig.TXTOwnerID, &cfg.TXTOwnerID)
b.StringVar("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!", defaultConfig.TXTPrefix, &cfg.TXTPrefix)
b.StringVar("txt-suffix", "When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!", defaultConfig.TXTSuffix, &cfg.TXTSuffix)
b.StringsVar("txt-prefix-override", "When using the TXT registry, specify domain-specific prefix overrides in the format 'domain=prefix' (optional). Useful for apex records that are difficult to create, e.g., 'example.com=%{record_type}.' for apex domains. Can be specified multiple times for different domains.", defaultConfig.TXTPrefixOverrides, &cfg.TXTPrefixOverrides)
b.StringVar("txt-wildcard-replacement", "When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)", defaultConfig.TXTWildcardReplacement, &cfg.TXTWildcardReplacement)
b.BoolVar("txt-encrypt-enabled", "When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)", defaultConfig.TXTEncryptEnabled, &cfg.TXTEncryptEnabled)
b.StringVar("txt-encrypt-aes-key", "When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)", defaultConfig.TXTEncryptAESKey, &cfg.TXTEncryptAESKey)
Expand Down
38 changes: 38 additions & 0 deletions pkg/apis/externaldns/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1494,3 +1494,41 @@ func TestBinderEnumValidationDifference(t *testing.T) {
cfgC := runWithCobra(t, appArgs)
assert.Equal(t, "bogus", cfgC.GoogleZoneVisibility)
}

func TestTXTPrefixOverridesFlagParsing(t *testing.T) {
tests := []struct {
name string
args []string
expected []string
}{
{
name: "no overrides should be handled correctly",
args: []string{"--source", "fake", "--provider", "inmemory"},
expected: nil,
},
{
name: "single override flag should parse correctly",
args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "example.com=custom"},
expected: []string{"example.com=custom"},
},
{
name: "multiple override flags should parse correctly",
args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "example.com=custom", "--txt-prefix-override", "test.com=another"},
expected: []string{"example.com=custom", "test.com=another"},
},
{
name: "override with template should parse correctly",
args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "api.example.com=%{record_type}-api"},
expected: []string{"api.example.com=%{record_type}-api"},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := NewConfig()
err := cfg.ParseFlags(test.args)
require.NoError(t, err)
assert.Equal(t, test.expected, cfg.TXTPrefixOverrides)
})
}
}
16 changes: 1 addition & 15 deletions plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package plan
import (
"fmt"
"slices"
"strings"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
Expand Down Expand Up @@ -142,7 +141,7 @@ func (t *planTable) addCandidate(e *endpoint.Endpoint) {

func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey {
key := planKey{
dnsName: normalizeDNSName(e.DNSName),
dnsName: idna.NormalizeDNSName(e.DNSName),
setIdentifier: e.SetIdentifier,
}

Expand Down Expand Up @@ -373,19 +372,6 @@ func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.Ma
return filtered
}

// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality
// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot
func normalizeDNSName(dnsName string) string {
s, err := idna.Profile.ToASCII(strings.TrimSpace(dnsName))
if err != nil {
log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err)
}
if !strings.HasSuffix(s, ".") {
s += "."
}
return s
}

func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool {
if slices.Contains(excludeRecords, record) {
return false
Expand Down
Loading