From b8e018caaf2046c2dc8e3f5596f98989b2cc92ba Mon Sep 17 00:00:00 2001 From: Johann Wagner Date: Fri, 5 Jul 2024 08:24:00 +0200 Subject: [PATCH 1/2] 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) + }) + } +} From 1b4843aa0256c99ebb01dbb22d02deb37ce2e70f Mon Sep 17 00:00:00 2001 From: Johann Wagner Date: Mon, 12 Aug 2024 18:10:39 +0200 Subject: [PATCH 2/2] Added documentation for NAT64 prefix rewriting --- docs/nat64.md | 21 +++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 22 insertions(+) create mode 100644 docs/nat64.md diff --git a/docs/nat64.md b/docs/nat64.md new file mode 100644 index 000000000..9edd4afbb --- /dev/null +++ b/docs/nat64.md @@ -0,0 +1,21 @@ +Configure NAT64 DNS Records +======================================= + +Some NAT64 configurations are entirely handled outside the Kubernetes cluster, therefore Kubernetes does not know anything about the associated IPv4 addresses. ExternalDNS should also be able to create A records for those cases. +Therefore, we can configure `nat64-networks`, which **must** be a /96 network. You can also specify multiple `nat64-networks` for more complex setups. +This creates an additional A record with a NAT64-translated IPv4 address for each AAAA record pointing to an IPv6 address within the given `nat64-networks`. + +This can be configured with the following flag passed to the operator binary. You can also pass multiple `nat64-networks` by using a comma as seperator. +```sh +--nat64-networks="2001:db8:96::/96" +``` + + +## Setup Example + +We use an external NAT64 resolver and SIIT (Stateless IP/ICMP Translation). Therefore, our nodes only have IPv6 IP adresses but can reach IPv4 addresses *and* can be reached via IPv4. +Outgoing connections are a classic NAT64 setup, where all IPv6 addresses gets translated to a small pool of IPv4 addresses. +Incoming connnections are mapped on a different IPv4 pool, e.g. `198.51.100.0/24`, which can get translated one-to-one to IPv6 addresses. We dedicate a `/96` network for this, for example `2001:db8:96::/96`, so `198.51.100.0/24` can translated to `2001:db8:96::c633:6400/120`. Note: `/120` IPv6 network has exactly as many IP addresses as `/24` IPv4 network. + +Therefore, the `/96` network can be configured as `nat64-networks`. This means, that `2001:0DB8:96::198.51.100.10` or `2001:db8:96::c633:640a` can be translated to `198.51.100.10`. +Any source can point a record to an IPv6 address within the given `nat64-networks`, for example `2001:db8:96::c633:640a`. This creates by default an AAAA record and - if `nat64-networks` is configured - also an A record with `198.51.100.10` as target. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 82d55858c..8a022c8d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Advanced Topics: - Initial Design: docs/initial-design.md - TTL: docs/ttl.md + - NAT64: docs/nat64.md - MultiTarget: docs/proposal/multi-target.md - Contributing: - Kubernetes Contributions: CONTRIBUTING.md