Files
tailscale/ipn/ipnlocal/dnsconfig_test.go
T
Michael Ben-Amiandmzbenami b062abb1ea appc,ipn/ipnlocal: install conn25 DNS routes when using exit node
We were early-returning when the node was using an exit node, before
Connectors 2025 split DNS routes were calculated and installed.

Now we assemble the routes first, then install them in both exit node
and non-exit-node contexts. The returned resolvers set UseWithExitNode
to true even though as of today, we believe they should be installed in
all cases without regard to that boolean value. With the boolean, we
preserve the flexibility to toggle behavior without touching ipnlocal.

We also add a TODO to turn the extra split DNS route gathering into a
feature hook (tailscale/corp#37125).

This does not affect appc connectors, which receive split DNS routes,
and the UseWithExitNode value directly from control.

Updates #16384

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-24 09:39:40 -04:00

561 lines
15 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",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
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"),
},
},
},
{
name: "subdomain_resolve_capability",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "myname.net.",
Addresses: ipps("100.101.101.101"),
}).View(),
AllCaps: set.SetOf([]tailcfg.NodeCapability{tailcfg.NodeAttrDNSSubdomainResolve}),
},
peers: nodeViews([]*tailcfg.Node{
{
ID: 1,
Name: "peer-with-cap.net.",
Addresses: ipps("100.102.0.1"),
CapMap: tailcfg.NodeCapMap{tailcfg.NodeAttrDNSSubdomainResolve: nil},
},
{
ID: 2,
Name: "peer-without-cap.net.",
Addresses: ipps("100.102.0.2"),
},
}),
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"),
"peer-with-cap.net.": ips("100.102.0.1"),
"peer-without-cap.net.": ips("100.102.0.2"),
},
},
},
{
// 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",
os: "windows", // the full Hosts map is built only for the Windows hosts-file path
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{
"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{
AcceptDNS: true,
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{
AcceptDNS: true,
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{
AcceptDNS: true,
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{
AcceptDNS: true,
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{
AcceptDNS: true,
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
dnsname.FQDN("example.com."): {
{Addr: "tailscale-app:app1", UseWithExitNode: true},
},
},
MagicDNSHostsUnrouted: true,
},
},
{
name: "conn25-split-dns-with-exit-node",
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,
StableID: "exit",
Name: "p1",
Cap: 26, // can proxy DNS over DoH
Addresses: ipps("100.102.0.1"),
Tags: []string{"tag:woo"},
Hostinfo: (&tailcfg.Hostinfo{
Services: []tailcfg.Service{
{
Proto: tailcfg.PeerAPI4,
Port: 1234,
},
},
}).View(),
},
}),
prefs: &ipn.Prefs{
CorpDNS: true,
ExitNodeID: "exit",
},
want: &dns.Config{
AcceptDNS: true,
Hosts: map[dnsname.FQDN][]netip.Addr{},
Routes: map[dnsname.FQDN][]*dnstype.Resolver{
dnsname.FQDN("example.com."): {
{Addr: "tailscale-app:app1", UseWithExitNode: true},
},
},
DefaultResolvers: []*dnstype.Resolver{
{Addr: "http://100.102.0.1:1234/dns-query"},
},
MagicDNSHostsUnrouted: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
goos := cmp.Or(tt.os, "linux")
var log tstest.MemLogger
got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, goos)
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)
}
}
}