Files
netbird/client/internal/dns_test.go
2026-08-21 16:05:51 +02:00

226 lines
7.5 KiB
Go

package internal
import (
"net/netip"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/dns"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCreatePTRRecord_IPv4(t *testing.T) {
record := nbdns.SimpleRecord{
Name: "peer1.netbird.cloud.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "100.64.0.5",
}
prefix := netip.MustParsePrefix("100.64.0.0/16")
ptr, ok := createPTRRecord(record, prefix)
require.True(t, ok)
assert.Equal(t, "5.0.64.100.in-addr.arpa.", ptr.Name)
assert.Equal(t, int(dns.TypePTR), ptr.Type)
assert.Equal(t, "peer1.netbird.cloud.", ptr.RData)
}
func TestCreatePTRRecord_IPv6(t *testing.T) {
record := nbdns.SimpleRecord{
Name: "peer1.netbird.cloud.",
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "fd00:1234:5678::1",
}
prefix := netip.MustParsePrefix("fd00:1234:5678::/48")
ptr, ok := createPTRRecord(record, prefix)
require.True(t, ok)
assert.Equal(t, "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", ptr.Name)
assert.Equal(t, int(dns.TypePTR), ptr.Type)
assert.Equal(t, "peer1.netbird.cloud.", ptr.RData)
}
func TestCreatePTRRecord_OutOfRange(t *testing.T) {
record := nbdns.SimpleRecord{
Name: "peer1.netbird.cloud.",
Type: int(dns.TypeA),
RData: "10.0.0.1",
}
prefix := netip.MustParsePrefix("100.64.0.0/16")
_, ok := createPTRRecord(record, prefix)
assert.False(t, ok)
}
func TestGenerateReverseZoneName_IPv4(t *testing.T) {
tests := []struct {
prefix string
expected string
}{
{"100.64.0.0/16", "64.100.in-addr.arpa."},
{"10.0.0.0/8", "10.in-addr.arpa."},
{"192.168.1.0/24", "1.168.192.in-addr.arpa."},
}
for _, tt := range tests {
t.Run(tt.prefix, func(t *testing.T) {
zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix))
require.NoError(t, err)
assert.Equal(t, tt.expected, zone)
})
}
}
func TestGenerateReverseZoneName_IPv6(t *testing.T) {
tests := []struct {
prefix string
expected string
}{
{"fd00:1234:5678::/48", "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa."},
{"fd00::/16", "0.0.d.f.ip6.arpa."},
{"fd12:3456:789a:bcde::/64", "e.d.c.b.a.9.8.7.6.5.4.3.2.1.d.f.ip6.arpa."},
}
for _, tt := range tests {
t.Run(tt.prefix, func(t *testing.T) {
zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix))
require.NoError(t, err)
assert.Equal(t, tt.expected, zone)
})
}
}
func TestCollectPTRRecords_BothFamilies(t *testing.T) {
config := &nbdns.Config{
CustomZones: []nbdns.CustomZone{
{
Domain: "netbird.cloud.",
Records: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.1"},
{Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00::1"},
{Name: "peer2.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.2"},
},
},
},
}
v4Records := collectPTRRecords(config, netip.MustParsePrefix("100.64.0.0/16"))
assert.Len(t, v4Records, 2, "should collect 2 A record PTRs for the v4 prefix")
v6Records := collectPTRRecords(config, netip.MustParsePrefix("fd00::/64"))
assert.Len(t, v6Records, 1, "should collect 1 AAAA record PTR for the v6 prefix")
}
func TestAddReverseZone_IPv6(t *testing.T) {
config := &nbdns.Config{
CustomZones: []nbdns.CustomZone{
{
Domain: "netbird.cloud.",
Records: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00:1234:5678::1"},
},
},
},
}
addReverseZone(config, netip.MustParsePrefix("fd00:1234:5678::/48"))
require.Len(t, config.CustomZones, 2)
reverseZone := config.CustomZones[1]
assert.Equal(t, "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", reverseZone.Domain)
assert.Len(t, reverseZone.Records, 1)
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
}
// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag
// through the legacy DNSConfig path. A non-authoritative zone is match-only:
// the local resolver falls through to the upstream for an in-zone name it does
// not define. The built-in peer zone is the authoritative one and must stay
// that way, so the flag has to travel per zone rather than be derived.
func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "netbird.cloud.",
Records: []*mgmProto.SimpleRecord{
{Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"},
},
},
{
Domain: "corp.internal.",
NonAuthoritative: true,
SearchDomainDisabled: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
zones := make(map[string]nbdns.CustomZone, len(config.CustomZones))
for _, zone := range config.CustomZones {
zones[zone.Domain] = zone
}
peerZone, ok := zones["netbird.cloud."]
require.True(t, ok, "peer zone must survive")
assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative")
accountZone, ok := zones["corp.internal."]
require.True(t, ok, "account zone must survive")
assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed")
assert.True(t, accountZone.SearchDomainDisabled)
}
// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause
// in toDNSConfig: a config carrying exactly one zone is treated as
// authoritative no matter what the server said, because servers that predate
// the NonAuthoritative field send only the peer FQDN zone.
//
// The clause can only ever downgrade an explicit true to false, so a server
// that legitimately sends a single non-authoritative zone — an account whose
// only zone is a custom one, with no peer records to build the built-in zone
// from — gets that zone's whole apex black-holed on the client. Real accounts
// always carry the peer zone alongside, which is why this is latent. Narrowing
// it needs a way to tell "unset" from "false" on the wire, or the account
// domain passed down here; until then this test states the contract so a
// change to it is deliberate.
func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "corp.internal.",
NonAuthoritative: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
require.NotEmpty(t, config.CustomZones)
assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain)
assert.False(t, config.CustomZones[0].NonAuthoritative,
"a lone zone is forced authoritative for pre-NonAuthoritative servers")
// The reverse zone the config gains afterwards must not feed back into the
// decision: the compat gate counts the zones the server sent.
require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix")
assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain)
}