From b8e018caaf2046c2dc8e3f5596f98989b2cc92ba Mon Sep 17 00:00:00 2001 From: Johann Wagner Date: Fri, 5 Jul 2024 08:24:00 +0200 Subject: [PATCH] Introduced NAT64 prefix rewriting --- main.go | 1 + pkg/apis/externaldns/types.go | 3 + source/nat64source.go | 112 ++++++++++++++++++++++++++++++++++ source/nat64source_test.go | 90 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 source/nat64source.go create mode 100644 source/nat64source_test.go diff --git a/main.go b/main.go index f05c46906..4bfbe49c4 100644 --- a/main.go +++ b/main.go @@ -179,6 +179,7 @@ func main() { // Combine multiple sources into a single, deduplicated source. endpointsSource := source.NewDedupSource(source.NewMultiSource(sources, sourceCfg.DefaultTargets)) + endpointsSource = source.NewNAT64Source(endpointsSource, cfg.NAT64Networks) endpointsSource = source.NewTargetFilterSource(endpointsSource, targetFilter) // RegexDomainFilter overrides DomainFilter diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 822993d09..ddb99c573 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -213,6 +213,7 @@ type Config struct { WebhookServer bool TraefikDisableLegacy bool TraefikDisableNew bool + NAT64Networks []string } var defaultConfig = &Config{ @@ -363,6 +364,7 @@ var defaultConfig = &Config{ WebhookServer: false, TraefikDisableLegacy: false, TraefikDisableNew: false, + NAT64Networks: []string{}, } // NewConfig returns new Config object @@ -452,6 +454,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("exclude-target-net", "Exclude target nets (optional)").StringsVar(&cfg.ExcludeTargetNets) app.Flag("traefik-disable-legacy", "Disable listeners on Resources under the traefik.containo.us API Group").Default(strconv.FormatBool(defaultConfig.TraefikDisableLegacy)).BoolVar(&cfg.TraefikDisableLegacy) app.Flag("traefik-disable-new", "Disable listeners on Resources under the traefik.io API Group").Default(strconv.FormatBool(defaultConfig.TraefikDisableNew)).BoolVar(&cfg.TraefikDisableNew) + app.Flag("nat64-networks", "Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional)").StringsVar(&cfg.NAT64Networks) // Flags related to providers providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr", "webhook"} diff --git a/source/nat64source.go b/source/nat64source.go new file mode 100644 index 000000000..dec879bd2 --- /dev/null +++ b/source/nat64source.go @@ -0,0 +1,112 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "fmt" + "net/netip" + + "sigs.k8s.io/external-dns/endpoint" +) + +// nat64Source is a Source that adds A endpoints for AAAA records including an NAT64 address. +type nat64Source struct { + source Source + nat64Prefixes []string +} + +// NewNAT64Source creates a new nat64Source wrapping the provided Source. +func NewNAT64Source(source Source, nat64Prefixes []string) Source { + return &nat64Source{source: source, nat64Prefixes: nat64Prefixes} +} + +// Endpoints collects endpoints from its wrapped source and returns them without duplicates. +func (s *nat64Source) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + parsedNAT64Prefixes := make([]netip.Prefix, 0) + for _, prefix := range s.nat64Prefixes { + pPrefix, err := netip.ParsePrefix(prefix) + if err != nil { + return nil, err + } + + if pPrefix.Bits() != 96 { + return nil, fmt.Errorf("NAT64 prefixes need to be /96 prefixes.") + } + parsedNAT64Prefixes = append(parsedNAT64Prefixes, pPrefix) + } + + additionalEndpoints := []*endpoint.Endpoint{} + + endpoints, err := s.source.Endpoints(ctx) + if err != nil { + return nil, err + } + + for _, ep := range endpoints { + if ep.RecordType != endpoint.RecordTypeAAAA { + continue + } + + v4Targets := make([]string, 0) + + for _, target := range ep.Targets { + ip, err := netip.ParseAddr(target) + if err != nil { + return nil, err + } + + var sPrefix *netip.Prefix + + for _, cPrefix := range parsedNAT64Prefixes { + if cPrefix.Contains(ip) { + sPrefix = &cPrefix + } + } + + // If we do not have a NAT64 prefix, we skip this record. + if sPrefix == nil { + continue + } + + ipBytes := ip.As16() + v4AddrBytes := ipBytes[12:16] + + v4Addr, isOk := netip.AddrFromSlice(v4AddrBytes) + if !isOk { + return nil, fmt.Errorf("Could not parse %v to IPv4 address", v4AddrBytes) + } + + v4Targets = append(v4Targets, v4Addr.String()) + } + + if len(v4Targets) == 0 { + continue + } + + v4EP := ep.DeepCopy() + v4EP.Targets = v4Targets + v4EP.RecordType = endpoint.RecordTypeA + + additionalEndpoints = append(additionalEndpoints, v4EP) + } + return append(endpoints, additionalEndpoints...), nil +} + +func (s *nat64Source) AddEventHandler(ctx context.Context, handler func()) { + s.source.AddEventHandler(ctx, handler) +} diff --git a/source/nat64source_test.go b/source/nat64source_test.go new file mode 100644 index 000000000..d7a6a0a33 --- /dev/null +++ b/source/nat64source_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "testing" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" +) + +// Validates that dedupSource is a Source +var _ Source = &nat64Source{} + +func TestNAT64Source(t *testing.T) { + t.Run("Endpoints", testNat64Source) +} + +// testDedupEndpoints tests that duplicates from the wrapped source are removed. +func testNat64Source(t *testing.T) { + for _, tc := range []struct { + title string + endpoints []*endpoint.Endpoint + expected []*endpoint.Endpoint + }{ + { + "single non-nat64 ipv6 endpoint returns one ipv6 endpoint", + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8:1::1"}}, + }, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8:1::1"}}, + }, + }, + { + "single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint", + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::192.0.2.42"}}, + }, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::192.0.2.42"}}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.0.2.42"}}, + }, + }, + { + "single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint", + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::c000:22a"}}, + }, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::c000:22a"}}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.0.2.42"}}, + }, + }, + } { + t.Run(tc.title, func(t *testing.T) { + mockSource := new(testutils.MockSource) + mockSource.On("Endpoints").Return(tc.endpoints, nil) + + // Create our object under test and get the endpoints. + source := NewNAT64Source(mockSource, []string{"2001:DB8::/96"}) + + endpoints, err := source.Endpoints(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Validate returned endpoints against desired endpoints. + validateEndpoints(t, endpoints, tc.expected) + + // Validate that the mock source was called. + mockSource.AssertExpectations(t) + }) + } +}