Skip to content

Commit

Permalink
Merge pull request #4593 from johannwagner/feature/nat64
Browse files Browse the repository at this point in the history
feat: NAT64 network source
  • Loading branch information
k8s-ci-robot authored Sep 5, 2024
2 parents bf70e3f + 1b4843a commit 848e309
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 0 deletions.
21 changes: 21 additions & 0 deletions docs/nat64.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Rate Limits: docs/rate-limits.md
- Contributing:
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ type Config struct {
WebhookServer bool
TraefikDisableLegacy bool
TraefikDisableNew bool
NAT64Networks []string
}

var defaultConfig = &Config{
Expand Down Expand Up @@ -349,6 +350,7 @@ var defaultConfig = &Config{
WebhookServer: false,
TraefikDisableLegacy: false,
TraefikDisableNew: false,
NAT64Networks: []string{},
}

// NewConfig returns new Config object
Expand Down Expand Up @@ -438,6 +440,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", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rdns", "rfc2136", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "webhook"}
Expand Down
112 changes: 112 additions & 0 deletions source/nat64source.go
Original file line number Diff line number Diff line change
@@ -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)
}
90 changes: 90 additions & 0 deletions source/nat64source_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}

0 comments on commit 848e309

Please sign in to comment.