appc,feature/conn25: prevent clients from forwarding DNS requests and

modifying DNS responses for domains they are also connectors for

For Connectors 2025, determine if a client is configured as a
connector and what domains it is a connector for. When acting as a
client, don't install Split DNS routes to other connectors for those
domains, and don't alter DNS responses for those domains. The responses
are forwarded back to the original client, which in turn does the alteration,
swapping the real IP for a Magic IP.

A client is also a connector for a domain if it has tags that overlap
with tags in the configured policy, and --advertise-connector=true
in the prefs (not in the self-node Hostinfo from the netmap). We use the prefs
as the source of truth because control only gets a copy from the prefs, and
may drift. And the AppConnector field is currently zeroed out in the
self-node Hostinfo from control.

The extension adds a ProfileStateChange hook to process prefs changes,
and the config type is split into prefs and nodeview sub-configs.

Fixes tailscale/corp#39317

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
This commit is contained in:
Michael Ben-Ami
2026-04-07 15:54:52 -04:00
committed by mzbenami
parent 4f47c3c93d
commit 1dc08f4d41
5 changed files with 437 additions and 121 deletions
+205 -51
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"slices"
"testing"
"time"
@@ -17,6 +18,7 @@ import (
"go4.org/mem"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/net/dns"
"tailscale.com/net/packet"
@@ -339,7 +341,9 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
// Use the same Conn25 for each request in the test and seed it with a test app name.
c := newConn25(logger.Discard)
c.connector.config = config{
appsByName: map[string]appctype.Conn25Attr{appName: {}},
nv: nodeViewConfig{
appsByName: map[string]appctype.Conn25Attr{appName: {}},
},
}
for i, peer := range tt.ctipReqPeers {
@@ -398,7 +402,7 @@ func TestReserveIPs(t *testing.T) {
domainStr := "example.com."
mbd := map[dnsname.FQDN][]string{}
mbd["example.com."] = []string{app}
c.client.config.appNamesByDomain = mbd
c.client.config.nv.appNamesByDomain = mbd
domain := must.Get(dnsname.ToFQDN(domainStr))
for _, tt := range []struct {
@@ -453,29 +457,87 @@ func TestReconfig(t *testing.T) {
}
c := newConn25(logger.Discard)
if c.isConfigured() {
t.Fatal("expected Conn25 to report unconfigured before reconfig")
}
sn := (&tailcfg.Node{
CapMap: capMap,
}).View()
cfg := mustConfig(t, sn, testPrefsIsConnector)
c.reconfig(cfg)
err := c.reconfig(sn)
if err != nil {
t.Fatal(err)
if !c.isConfigured() {
t.Fatal("expected Conn25 to report configured after reconfig")
}
if len(c.client.config.apps) != 1 || c.client.config.apps[0].Name != "app1" {
t.Fatalf("want apps to have one entry 'app1', got %v", c.client.config.apps)
if !reflect.DeepEqual(c.client.config, c.connector.config) {
t.Fatal("client and connector have different configs")
}
if len(c.client.config.nv.apps) != 1 || c.client.config.nv.apps[0].Name != "app1" {
t.Fatalf("want apps to have one entry 'app1', got %v", c.client.config.nv.apps)
}
if c.client.config.prefs.isEligibleConnector != true {
t.Fatal("want prefs config to have isEligibleConnector=true")
}
c.reconfig(config{
prefs: prefsConfig{isConfigured: true},
})
if c.isConfigured() {
t.Fatal("expected Conn25 to report unconfigured with only prefs config")
}
c.reconfig(config{
nv: nodeViewConfig{isConfigured: true},
})
if c.isConfigured() {
t.Fatal("expected Conn25 to report unconfigured with only nodeview config")
}
}
func TestConfigReconfig(t *testing.T) {
func TestConfigFromPrefs(t *testing.T) {
for _, tt := range []struct {
name string
rawCfg string
cfg []appctype.Conn25Attr
tags []string
wantErr bool
wantAppsByDomain map[dnsname.FQDN][]string
wantSelfRoutedDomains set.Set[dnsname.FQDN]
name string
prefs ipn.PrefsView
expected prefsConfig
}{
{
name: "is-eligible-connector",
prefs: testPrefsIsConnector,
expected: prefsConfig{
isConfigured: true,
isEligibleConnector: true,
},
},
{
name: "not-eligible-connector",
prefs: testPrefsNotConnector,
expected: prefsConfig{
isConfigured: true,
isEligibleConnector: false,
},
},
} {
t.Run(tt.name, func(t *testing.T) {
if diff := cmp.Diff(tt.expected, configFromPrefs(tt.prefs), cmp.AllowUnexported(prefsConfig{})); diff != "" {
t.Errorf("unexpected prefsConfig (-want,+got): %s", diff)
}
})
}
}
func TestConfigFromNodeView(t *testing.T) {
for _, tt := range []struct {
name string
rawCfg string
cfg []appctype.Conn25Attr
tags []string
wantErr bool
wantAppsByDomain map[dnsname.FQDN][]string
wantSelfDomains set.Set[dnsname.FQDN]
}{
{
name: "bad-config",
@@ -493,10 +555,10 @@ func TestConfigReconfig(t *testing.T) {
"a.example.com.": {"one"},
"b.example.com.": {"two"},
},
wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
wantSelfDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
},
{
name: "more-complex",
name: "more-complex-with-connector-self-domains",
cfg: []appctype.Conn25Attr{
{Name: "one", Domains: []string{"1.a.example.com", "1.b.example.com"}, Connectors: []string{"tag:one", "tag:onea"}},
{Name: "two", Domains: []string{"2.b.example.com", "2.c.example.com"}, Connectors: []string{"tag:two", "tag:twoa"}},
@@ -513,7 +575,19 @@ func TestConfigReconfig(t *testing.T) {
"4.b.example.com.": {"four"},
"4.d.example.com.": {"four"},
},
wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}),
wantSelfDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}),
},
{
name: "eligible-connector-no-matching-tag-no-self-domains",
cfg: []appctype.Conn25Attr{
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
{Name: "two", Domains: []string{"b.example.com"}, Connectors: []string{"tag:two"}},
},
tags: []string{"tag:unrelated"},
wantAppsByDomain: map[dnsname.FQDN][]string{
"a.example.com.": {"one"},
"b.example.com.": {"two"},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
@@ -535,6 +609,7 @@ func TestConfigReconfig(t *testing.T) {
CapMap: capMap,
Tags: tt.tags,
}).View()
c, err := configFromNodeView(sn)
if (err != nil) != tt.wantErr {
t.Fatalf("wantErr: %t, err: %v", tt.wantErr, err)
@@ -542,8 +617,8 @@ func TestConfigReconfig(t *testing.T) {
if diff := cmp.Diff(tt.wantAppsByDomain, c.appNamesByDomain); diff != "" {
t.Errorf("appsByDomain diff (-want, +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantSelfRoutedDomains, c.selfRoutedDomains); diff != "" {
t.Errorf("selfRoutedDomains diff (-want, +got):\n%s", diff)
if diff := cmp.Diff(tt.wantSelfDomains, c.selfDomains); diff != "" {
t.Errorf("selfDomains diff (-want, +got):\n%s", diff)
}
})
}
@@ -562,12 +637,33 @@ func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tail
capMap := tailcfg.NodeCapMap{
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
}
return (&tailcfg.Node{
CapMap: capMap,
Tags: tags,
}).View()
}
var (
testPrefsIsConnector = (&ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: true}}).View()
testPrefsNotConnector = (&ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: false}}).View()
)
func mustConfig(t *testing.T, selfNode tailcfg.NodeView, prefs ipn.PrefsView) config {
t.Helper()
nvCfg, err := configFromNodeView(selfNode)
if err != nil {
t.Fatal(err)
}
prefsCfg := configFromPrefs(prefs)
return config{
nv: nvCfg,
prefs: prefsCfg,
}
}
func v4RangeFrom(from, to string) netipx.IPRange {
return netipx.IPRangeFrom(
netip.MustParseAddr("100.64.0."+from),
@@ -706,11 +802,13 @@ func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, a
func TestMapDNSResponseAssignsAddrs(t *testing.T) {
for _, tt := range []struct {
name string
domain string
v4Addrs []*dnsmessage.AResource
v6Addrs []*dnsmessage.AAAAResource
wantByMagicIP map[netip.Addr]addrs
name string
domain string
v4Addrs []*dnsmessage.AResource
v6Addrs []*dnsmessage.AAAAResource
selfTags []string
isEligibleConnector bool
wantByMagicIP map[netip.Addr]addrs
}{
{
name: "one-ip-matches",
@@ -783,6 +881,48 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
{A: [4]byte{2, 0, 0, 0}},
},
},
{
name: "no-rewrite-self-routed-domain",
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:woo"},
isEligibleConnector: true,
},
{
name: "rewrite-tagged-but-not-eligible-connector",
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:woo"},
// isEligibleConnector is false: tag matches but prefs not set,
// so DNS response should be rewritten normally.
wantByMagicIP: map[netip.Addr]addrs{
netip.MustParseAddr("100.64.0.0"): {
domain: "example.com.",
dst: netip.MustParseAddr("1.0.0.0"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("100.64.0.40"),
app: "app1",
},
},
},
{
name: "rewrite-eligible-connector-no-matching-tag",
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:unrelated"},
isEligibleConnector: true,
// isEligibleConnector is true but tag doesn't match the app,
// so DNS response should be rewritten normally.
wantByMagicIP: map[netip.Addr]addrs{
netip.MustParseAddr("100.64.0.0"): {
domain: "example.com.",
dst: netip.MustParseAddr("1.0.0.0"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("100.64.0.40"),
app: "app1",
},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
var dnsResp []byte
@@ -799,9 +939,15 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")},
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")},
}}, []string{})
}}, tt.selfTags)
prefs := testPrefsNotConnector
if tt.isEligibleConnector {
prefs = testPrefsIsConnector
}
c := newConn25(logger.Discard)
c.reconfig(sn)
cfg := mustConfig(t, sn, prefs)
c.reconfig(cfg)
c.mapDNSResponse(dnsResp)
if diff := cmp.Diff(tt.wantByMagicIP, c.client.assignments.byMagicIP, cmpopts.EquateComparable(addrs{}, netip.Addr{})); diff != "" {
@@ -831,7 +977,7 @@ func TestReserveAddressesDeduplicated(t *testing.T) {
c.client.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"))
c.client.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
c.client.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
c.client.config.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}}
c.client.config.nv.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}}
first, err := c.client.reserveAddresses("example.com.", tt.dst)
if err != nil {
@@ -880,16 +1026,25 @@ func (nb *testNodeBackend) PeerAPIBase(p tailcfg.NodeView) string {
return nb.peerAPIURL
}
type testProfileServices struct {
ipnext.ProfileServices
prefs ipn.PrefsView
}
func (p *testProfileServices) CurrentPrefs() ipn.PrefsView { return p.prefs }
type testHost struct {
ipnext.Host
nb ipnext.NodeBackend
hooks ipnext.Hooks
prefs ipn.PrefsView
authReconfigAsync func()
}
func (h *testHost) NodeBackend() ipnext.NodeBackend { return h.nb }
func (h *testHost) Hooks() *ipnext.Hooks { return &h.hooks }
func (h *testHost) AuthReconfigAsync() { h.authReconfigAsync() }
func (h *testHost) NodeBackend() ipnext.NodeBackend { return h.nb }
func (h *testHost) Hooks() *ipnext.Hooks { return &h.hooks }
func (h *testHost) Profiles() ipnext.ProfileServices { return &testProfileServices{prefs: h.prefs} }
func (h *testHost) AuthReconfigAsync() { h.authReconfigAsync() }
type testSafeBackend struct {
ipnext.SafeBackend
@@ -950,6 +1105,7 @@ func TestAddressAssignmentIsHandled(t *testing.T) {
peers: []tailcfg.NodeView{connectorPeer},
peerAPIURL: peersAPI.URL,
},
prefs: testPrefsNotConnector,
authReconfigAsync: func() {
authReconfigAsyncCalled <- struct{}{}
},
@@ -963,10 +1119,9 @@ func TestAddressAssignmentIsHandled(t *testing.T) {
Connectors: []string{"tag:woo"},
Domains: []string{"example.com"},
}}, []string{})
err := ext.conn25.reconfig(sn)
if err != nil {
t.Fatal(err)
}
cfg := mustConfig(t, sn, testPrefsNotConnector)
ext.conn25.reconfig(cfg)
as := addrs{
dst: netip.MustParseAddr("1.2.3.4"),
@@ -1046,6 +1201,8 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) {
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
}}, []string{})
cfg := mustConfig(t, sn, testPrefsNotConnector)
compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) {
t.Helper()
var got []netip.Addr
@@ -1323,9 +1480,7 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(logger.Discard)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
c.reconfig(cfg)
bs := c.mapDNSResponse(tt.toMap)
tt.assertFx(t, bs)
})
@@ -1376,6 +1531,7 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) {
peers: connectorPeers,
peerAPIURL: peersAPI.URL,
},
prefs: testPrefsNotConnector,
authReconfigAsync: func() {
authReconfigAsyncCalled <- struct{}{}
},
@@ -1396,10 +1552,9 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) {
Domains: []string{"hoo.example.com"},
},
}, []string{})
err := ext.conn25.reconfig(sn)
if err != nil {
t.Fatal(err)
}
cfg := mustConfig(t, sn, testPrefsNotConnector)
ext.conn25.reconfig(cfg)
type lookup struct {
connKey key.NodePublic
@@ -1576,6 +1731,7 @@ func TestClientTransitIPForMagicIP(t *testing.T) {
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10")},
}}, []string{})
cfg := mustConfig(t, sn, testPrefsNotConnector)
mappedMip := netip.MustParseAddr("100.64.0.0")
mappedTip := netip.MustParseAddr("169.0.0.0")
@@ -1634,9 +1790,8 @@ func TestClientTransitIPForMagicIP(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(t.Logf)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
c.reconfig(cfg)
if err := c.client.assignments.insert(addrs{
magic: mappedMip,
transit: mappedTip,
@@ -1666,6 +1821,8 @@ func TestConnectorRealIPForTransitIPConnection(t *testing.T) {
sn := makeSelfNode(t, []appctype.Conn25Attr{{
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50
}}, []string{})
cfg := mustConfig(t, sn, testPrefsIsConnector)
mappedSrc := netip.MustParseAddr("100.0.0.1")
unmappedSrc := netip.MustParseAddr("100.0.0.2")
mappedTip := netip.MustParseAddr("100.64.0.41")
@@ -1717,9 +1874,7 @@ func TestConnectorRealIPForTransitIPConnection(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(t.Logf)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
c.reconfig(cfg)
c.connector.transitIPs = map[netip.Addr]map[netip.Addr]appAddr{}
c.connector.transitIPs[mappedSrc] = map[netip.Addr]appAddr{}
c.connector.transitIPs[mappedSrc][mappedTip] = appAddr{addr: mappedMip}
@@ -1812,10 +1967,9 @@ func TestGetMagicRange(t *testing.T) {
V4MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.3"))},
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("::1"), netip.MustParseAddr("::3"))},
}}, []string{})
cfg := mustConfig(t, sn, testPrefsNotConnector)
c := newConn25(t.Logf)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
c.reconfig(cfg)
ext := &extension{
conn25: c,
}