Files
tailscale/ipn/ipnlocal/dnsconfig_test.go
Fran Bull 9d13a6df9c appc,ipn/ipnlocal: Add split DNS entries for conn25 peers
If conn25 config is sent in the netmap: add split DNS entries to use
appropriately tagged peers' PeerAPI to resolve DNS requests for those
domains.

This will enable future work where we use the peers as connectors for
the configured domains.

Updates tailscale/corp#34252

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-01-26 08:10:38 -08:00

516 lines
14 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"cmp"
"encoding/json"
"net/netip"
"reflect"
"testing"
"tailscale.com/appc"
"tailscale.com/ipn"
"tailscale.com/net/dns"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
func ipps(ippStrs ...string) (ipps []netip.Prefix) {
for _, s := range ippStrs {
if ip, err := netip.ParseAddr(s); err == nil {
ipps = append(ipps, netip.PrefixFrom(ip, ip.BitLen()))
continue
}
ipps = append(ipps, netip.MustParsePrefix(s))
}
return
}
func ips(ss ...string) (ips []netip.Addr) {
for _, s := range ss {
ips = append(ips, netip.MustParseAddr(s))
}
return
}
func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView {
nv := make([]tailcfg.NodeView, len(v))
for i, n := range v {
nv[i] = n.View()
}
return nv
}
func TestDNSConfigForNetmap(t *testing.T) {
tests := []struct {
name string
nm *netmap.NetworkMap
expired bool
peers []tailcfg.NodeView
os string // version.OS value; empty means linux
cloud cloudenv.Cloud
prefs *ipn.Prefs
want *dns.Config
wantLog string
}{
{
name: "empty",
nm: &netmap.NetworkMap{},
prefs: &ipn.Prefs{},
want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
},
},
{
name: "self_name_and_peers",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
Addresses: ipps("100.101.101.101"),
}).View(),
},
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"),
},
{
ID: 2,
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
ID: 3,
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
prefs: &ipn.Prefs{},
want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{
"b.net.": ips("100.102.0.1", "100.102.0.2"),
"myname.net.": ips("100.101.101.101"),
"peera.net.": ips("100.102.0.1", "100.102.0.2"),
"v6-only.net.": ips("fe75::3"),
},
},
},
{
// An ephemeral node with only an IPv6 address
// should get IPv6 records for all its peers,
// even if they have IPv4.
name: "v6_only_self",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
Addresses: ipps("fe75::1"),
}).View(),
},
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peera.net.",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001"),
},
{
ID: 2,
Name: "b.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"),
},
{
ID: 3,
Name: "v6-only.net",
Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6
},
}),
prefs: &ipn.Prefs{},
want: &dns.Config{
OnlyIPv6: true,
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{
"b.net.": ips("fe75::2"),
"myname.net.": ips("fe75::1"),
"peera.net.": ips("fe75::1001"),
"v6-only.net.": ips("fe75::3"),
},
},
},
{
name: "extra_records",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
Addresses: ipps("100.101.101.101"),
}).View(),
DNS: tailcfg.DNSConfig{
ExtraRecords: []tailcfg.DNSRecord{
{Name: "foo.com", Value: "1.2.3.4"},
{Name: "bar.com", Value: "1::6"},
{Name: "sdlfkjsdklfj", Type: "IGNORE"},
},
},
},
prefs: &ipn.Prefs{},
want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{
"myname.net.": ips("100.101.101.101"),
"foo.com.": ips("1.2.3.4"),
"bar.com.": ips("1::6"),
},
},
},
{
name: "corp_dns_misc",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "host.some.domain.net.",
}).View(),
DNS: tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"foo.com", "bar.com"},
},
},
prefs: &ipn.Prefs{
CorpDNS: true,
},
want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa.": nil,
"100.100.in-addr.arpa.": nil,
"101.100.in-addr.arpa.": nil,
"102.100.in-addr.arpa.": nil,
"103.100.in-addr.arpa.": nil,
"104.100.in-addr.arpa.": nil,
"105.100.in-addr.arpa.": nil,
"106.100.in-addr.arpa.": nil,
"107.100.in-addr.arpa.": nil,
"108.100.in-addr.arpa.": nil,
"109.100.in-addr.arpa.": nil,
"110.100.in-addr.arpa.": nil,
"111.100.in-addr.arpa.": nil,
"112.100.in-addr.arpa.": nil,
"113.100.in-addr.arpa.": nil,
"114.100.in-addr.arpa.": nil,
"115.100.in-addr.arpa.": nil,
"116.100.in-addr.arpa.": nil,
"117.100.in-addr.arpa.": nil,
"118.100.in-addr.arpa.": nil,
"119.100.in-addr.arpa.": nil,
"120.100.in-addr.arpa.": nil,
"121.100.in-addr.arpa.": nil,
"122.100.in-addr.arpa.": nil,
"123.100.in-addr.arpa.": nil,
"124.100.in-addr.arpa.": nil,
"125.100.in-addr.arpa.": nil,
"126.100.in-addr.arpa.": nil,
"127.100.in-addr.arpa.": nil,
"64.100.in-addr.arpa.": nil,
"65.100.in-addr.arpa.": nil,
"66.100.in-addr.arpa.": nil,
"67.100.in-addr.arpa.": nil,
"68.100.in-addr.arpa.": nil,
"69.100.in-addr.arpa.": nil,
"70.100.in-addr.arpa.": nil,
"71.100.in-addr.arpa.": nil,
"72.100.in-addr.arpa.": nil,
"73.100.in-addr.arpa.": nil,
"74.100.in-addr.arpa.": nil,
"75.100.in-addr.arpa.": nil,
"76.100.in-addr.arpa.": nil,
"77.100.in-addr.arpa.": nil,
"78.100.in-addr.arpa.": nil,
"79.100.in-addr.arpa.": nil,
"80.100.in-addr.arpa.": nil,
"81.100.in-addr.arpa.": nil,
"82.100.in-addr.arpa.": nil,
"83.100.in-addr.arpa.": nil,
"84.100.in-addr.arpa.": nil,
"85.100.in-addr.arpa.": nil,
"86.100.in-addr.arpa.": nil,
"87.100.in-addr.arpa.": nil,
"88.100.in-addr.arpa.": nil,
"89.100.in-addr.arpa.": nil,
"90.100.in-addr.arpa.": nil,
"91.100.in-addr.arpa.": nil,
"92.100.in-addr.arpa.": nil,
"93.100.in-addr.arpa.": nil,
"94.100.in-addr.arpa.": nil,
"95.100.in-addr.arpa.": nil,
"96.100.in-addr.arpa.": nil,
"97.100.in-addr.arpa.": nil,
"98.100.in-addr.arpa.": nil,
"99.100.in-addr.arpa.": nil,
"some.domain.net.": nil,
},
SearchDomains: []dnsname.FQDN{
"foo.com.",
"bar.com.",
},
},
},
{
// Prior to fixing https://github.com/tailscale/tailscale/issues/2116,
// Android had cases where it needed FallbackResolvers. This was the
// negative test for the case where Override-local-DNS was set, so the
// fallback resolvers did not need to be used. This test is still valid
// so we keep it, but the fallback test has been removed.
name: "android_does_NOT_need_fallbacks",
os: "android",
nm: &netmap.NetworkMap{
DNS: tailcfg.DNSConfig{
Resolvers: []*dnstype.Resolver{
{Addr: "8.8.8.8"},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "8.8.4.4"},
},
Routes: map[string][]*dnstype.Resolver{
"foo.com.": {{Addr: "1.2.3.4"}},
},
},
},
prefs: &ipn.Prefs{
CorpDNS: true,
},
want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{},
DefaultResolvers: []*dnstype.Resolver{
{Addr: "8.8.8.8"},
},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"foo.com.": {{Addr: "1.2.3.4"}},
},
},
},
{
name: "exit_nodes_need_fallbacks",
nm: &netmap.NetworkMap{
DNS: tailcfg.DNSConfig{
FallbackResolvers: []*dnstype.Resolver{
{Addr: "8.8.4.4"},
},
},
},
prefs: &ipn.Prefs{
CorpDNS: true,
ExitNodeID: "some-id",
},
want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
DefaultResolvers: []*dnstype.Resolver{
{Addr: "8.8.4.4"},
},
},
},
{
name: "not_exit_node_NOT_need_fallbacks",
nm: &netmap.NetworkMap{
DNS: tailcfg.DNSConfig{
FallbackResolvers: []*dnstype.Resolver{
{Addr: "8.8.4.4"},
},
},
},
prefs: &ipn.Prefs{
CorpDNS: true,
},
want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
},
},
{
name: "self_expired",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
Addresses: ipps("100.101.101.101"),
}).View(),
},
expired: true,
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peera.net",
Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"),
},
}),
prefs: &ipn.Prefs{},
want: &dns.Config{},
},
{
name: "conn25-split-dns",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "a",
Addresses: ipps("100.101.101.101"),
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{
tailcfg.RawMessage(`{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`),
},
},
}).View(),
AllCaps: set.Of(tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName)),
},
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "p1",
Addresses: ipps("100.102.0.1"),
Tags: []string{"tag:woo"},
Hostinfo: (&tailcfg.Hostinfo{
Services: []tailcfg.Service{
{
Proto: tailcfg.PeerAPI4,
Port: 1234,
},
},
AppConnector: opt.NewBool(true),
}).View(),
},
}),
prefs: &ipn.Prefs{
CorpDNS: true,
},
want: &dns.Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"a.": ips("100.101.101.101"),
"p1.": ips("100.102.0.1"),
},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
dnsname.FQDN("example.com."): {
{Addr: "http://100.102.0.1:1234/dns-query"},
},
},
},
},
{
name: "conn25-split-dns-no-matching-peers",
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "a",
Addresses: ipps("100.101.101.101"),
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{
tailcfg.RawMessage(`{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`),
},
},
}).View(),
AllCaps: set.Of(tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName)),
},
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "p1",
Addresses: ipps("100.102.0.1"),
Tags: []string{"tag:nomatch"},
Hostinfo: (&tailcfg.Hostinfo{
Services: []tailcfg.Service{
{
Proto: tailcfg.PeerAPI4,
Port: 1234,
},
},
AppConnector: opt.NewBool(true),
}).View(),
},
}),
prefs: &ipn.Prefs{
CorpDNS: true,
},
want: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{
"a.": ips("100.101.101.101"),
"p1.": ips("100.102.0.1"),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verOS := cmp.Or(tt.os, "linux")
var log tstest.MemLogger
got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS)
if !reflect.DeepEqual(got, tt.want) {
gotj, _ := json.MarshalIndent(got, "", "\t")
wantj, _ := json.MarshalIndent(tt.want, "", "\t")
t.Errorf("wrong\n got: %s\n\nwant: %s\n", gotj, wantj)
}
if got := log.String(); got != tt.wantLog {
t.Errorf("log output wrong\n got: %q\nwant: %q\n", got, tt.wantLog)
}
})
}
}
func peersMap(s []tailcfg.NodeView) map[tailcfg.NodeID]tailcfg.NodeView {
m := make(map[tailcfg.NodeID]tailcfg.NodeView)
for _, n := range s {
if n.ID() == 0 {
panic("zero Node.ID")
}
m[n.ID()] = n
}
return m
}
func TestAllowExitNodeDNSProxyToServeName(t *testing.T) {
b := newTestLocalBackend(t)
if b.allowExitNodeDNSProxyToServeName("google.com") {
t.Fatal("unexpected true on backend with nil NetMap")
}
b.currentNode().SetNetMap(&netmap.NetworkMap{
DNS: tailcfg.DNSConfig{
ExitNodeFilteredSet: []string{
".ts.net",
"some.exact.bad",
},
},
})
tests := []struct {
name string
want bool
}{
// Allow by default:
{"google.com", true},
{"GOOGLE.com", true},
// Rejected by suffix:
{"foo.TS.NET", false},
{"foo.ts.net", false},
// Suffix doesn't match
{"ts.net", true},
// Rejected by exact match:
{"some.exact.bad", false},
{"SOME.EXACT.BAD", false},
// But a prefix is okay.
{"prefix-okay.some.exact.bad", true},
}
for _, tt := range tests {
got := b.allowExitNodeDNSProxyToServeName(tt.name)
if got != tt.want {
t.Errorf("for %q = %v; want %v", tt.name, got, tt.want)
}
}
}