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>
309 lines
8.9 KiB
Go
309 lines
8.9 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package appc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"tailscale.com/ipn/ipnext"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/appctype"
|
|
"tailscale.com/types/dnstype"
|
|
"tailscale.com/types/opt"
|
|
)
|
|
|
|
func TestAppDNSRoutes(t *testing.T) {
|
|
getBytesForAttr := func(name string, domains []string, tags []string) []byte {
|
|
attr := appctype.AppConnectorAttr{
|
|
Name: name,
|
|
Domains: domains,
|
|
Connectors: tags,
|
|
}
|
|
bs, err := json.Marshal(attr)
|
|
if err != nil {
|
|
t.Fatalf("test setup: %v", err)
|
|
}
|
|
return bs
|
|
}
|
|
appOneBytes := getBytesForAttr("app1", []string{"example.com"}, []string{"tag:one"})
|
|
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
|
|
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
|
|
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
|
|
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
|
|
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
|
|
|
|
resolver := func(appName string) []*dnstype.Resolver {
|
|
return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
hasCap bool
|
|
config []tailcfg.RawMessage
|
|
want map[string][]*dnstype.Resolver
|
|
}{
|
|
{
|
|
name: "no-capability", // hasCap false should return nil regardless of config.
|
|
hasCap: false,
|
|
},
|
|
{
|
|
name: "no-apps", // hasCap true but no configured apps returns an empty map.
|
|
hasCap: true,
|
|
want: map[string][]*dnstype.Resolver{},
|
|
},
|
|
{
|
|
name: "bad-config", // bad config should return nil rather than error.
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
|
|
},
|
|
{
|
|
name: "single-app",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
|
want: map[string][]*dnstype.Resolver{
|
|
"example.com": resolver("app1"),
|
|
},
|
|
},
|
|
{
|
|
name: "single-app-multi-domain",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appThreeBytes)},
|
|
want: map[string][]*dnstype.Resolver{
|
|
"woo.b.example.com": resolver("app3"),
|
|
"hoo.b.example.com": resolver("app3"),
|
|
},
|
|
},
|
|
{
|
|
name: "multi-app-no-overlap",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
},
|
|
want: map[string][]*dnstype.Resolver{
|
|
"example.com": resolver("app1"),
|
|
"a.example.com": resolver("app2"),
|
|
},
|
|
},
|
|
{
|
|
name: "domain-collision-last-write-wins",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appThreeBytes), // app3: woo.b.example.com, hoo.b.example.com
|
|
tailcfg.RawMessage(appFourBytes), // app4: woo.b.example.com, c.example.com
|
|
},
|
|
want: map[string][]*dnstype.Resolver{
|
|
// app4 overwrites app3 for the shared domain
|
|
"woo.b.example.com": resolver("app4"),
|
|
"hoo.b.example.com": resolver("app3"),
|
|
"c.example.com": resolver("app4"),
|
|
},
|
|
},
|
|
{
|
|
name: "wildcards-are-stripped-and-deduped",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appFiveBytes)},
|
|
want: map[string][]*dnstype.Resolver{
|
|
// *.example.com and example.com should both normalize to example.com.
|
|
"example.com": resolver("app5"),
|
|
},
|
|
},
|
|
{
|
|
name: "domains-are-normalized-and-deduped",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appSixBytes)},
|
|
want: map[string][]*dnstype.Resolver{
|
|
// *.Example.com, EXAMPLE.com, EXAMPLE.COM should all normalize to example.com.
|
|
"example.com": resolver("app6"),
|
|
},
|
|
},
|
|
{
|
|
name: "sub-domains-and-top-domains-do-not-collide",
|
|
hasCap: true,
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appFiveBytes),
|
|
},
|
|
want: map[string][]*dnstype.Resolver{
|
|
// *.example.com normalizes to example.com; a.example.com remains distinct.
|
|
"a.example.com": resolver("app2"),
|
|
"example.com": resolver("app5"),
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
selfNode := &tailcfg.Node{}
|
|
if tt.config != nil {
|
|
selfNode.CapMap = tailcfg.NodeCapMap{
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
|
}
|
|
}
|
|
selfView := selfNode.View()
|
|
got := AppDNSRoutes(func(_ tailcfg.NodeCapability) bool {
|
|
return tt.hasCap
|
|
}, selfView)
|
|
if diff := cmp.Diff(tt.want, got); diff != "" {
|
|
t.Fatalf("AppDNSRoutes (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type testNodeBackend struct {
|
|
ipnext.NodeBackend
|
|
peers []tailcfg.NodeView
|
|
}
|
|
|
|
func (nb *testNodeBackend) AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView {
|
|
for _, p := range nb.peers {
|
|
if pred(p) {
|
|
base = append(base, p)
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
func (nb *testNodeBackend) PeerHasPeerAPI(p tailcfg.NodeView) bool {
|
|
return true
|
|
}
|
|
|
|
func TestPickConnector(t *testing.T) {
|
|
exampleApp := appctype.Conn25Attr{
|
|
Name: "example",
|
|
Connectors: []string{"tag:example"},
|
|
Domains: []string{"example.com"},
|
|
}
|
|
|
|
nvWithConnectorSet := func(id tailcfg.NodeID, isConnector bool, tags ...string) tailcfg.NodeView {
|
|
return (&tailcfg.Node{
|
|
ID: id,
|
|
Tags: tags,
|
|
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(isConnector)}).View(),
|
|
}).View()
|
|
}
|
|
|
|
nv := func(id tailcfg.NodeID, tags ...string) tailcfg.NodeView {
|
|
return nvWithConnectorSet(id, true, tags...)
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
candidates []tailcfg.NodeView
|
|
app appctype.Conn25Attr
|
|
want []tailcfg.NodeView
|
|
}{
|
|
{
|
|
name: "empty-everything",
|
|
candidates: []tailcfg.NodeView{},
|
|
app: appctype.Conn25Attr{},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "empty-candidates",
|
|
candidates: []tailcfg.NodeView{},
|
|
app: exampleApp,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "empty-app",
|
|
candidates: []tailcfg.NodeView{nv(1, "tag:example")},
|
|
app: appctype.Conn25Attr{},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "one-matches",
|
|
candidates: []tailcfg.NodeView{nv(1, "tag:example")},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{nv(1, "tag:example")},
|
|
},
|
|
{
|
|
name: "invalid-candidate",
|
|
candidates: []tailcfg.NodeView{
|
|
{},
|
|
nv(1, "tag:example"),
|
|
},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{
|
|
nv(1, "tag:example"),
|
|
},
|
|
},
|
|
{
|
|
name: "no-host-info",
|
|
candidates: []tailcfg.NodeView{
|
|
(&tailcfg.Node{
|
|
ID: 1,
|
|
Tags: []string{"tag:example"},
|
|
}).View(),
|
|
nv(2, "tag:example"),
|
|
},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{nv(2, "tag:example")},
|
|
},
|
|
{
|
|
name: "not-a-connector",
|
|
candidates: []tailcfg.NodeView{nvWithConnectorSet(1, false, "tag:example.com"), nv(2, "tag:example")},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{nv(2, "tag:example")},
|
|
},
|
|
{
|
|
name: "without-matches",
|
|
candidates: []tailcfg.NodeView{nv(1, "tag:woo"), nv(2, "tag:example")},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{nv(2, "tag:example")},
|
|
},
|
|
{
|
|
name: "multi-tags",
|
|
candidates: []tailcfg.NodeView{nv(1, "tag:woo", "tag:hoo"), nv(2, "tag:woo", "tag:example")},
|
|
app: exampleApp,
|
|
want: []tailcfg.NodeView{nv(2, "tag:woo", "tag:example")},
|
|
},
|
|
{
|
|
name: "multi-matches",
|
|
candidates: []tailcfg.NodeView{nv(1, "tag:woo", "tag:hoo"), nv(2, "tag:woo", "tag:example"), nv(3, "tag:example1", "tag:example")},
|
|
app: appctype.Conn25Attr{
|
|
Name: "example2",
|
|
Connectors: []string{"tag:example1", "tag:example"},
|
|
Domains: []string{"example.com"},
|
|
},
|
|
want: []tailcfg.NodeView{nv(2, "tag:woo", "tag:example"), nv(3, "tag:example1", "tag:example")},
|
|
},
|
|
{
|
|
name: "bit-of-everything",
|
|
candidates: []tailcfg.NodeView{
|
|
nv(3, "tag:woo", "tag:hoo"),
|
|
{},
|
|
nv(2, "tag:woo", "tag:example"),
|
|
nvWithConnectorSet(4, false, "tag:example"),
|
|
nv(1, "tag:example1", "tag:example"),
|
|
nv(7, "tag:example1", "tag:example"),
|
|
nvWithConnectorSet(5, false),
|
|
nv(6),
|
|
nvWithConnectorSet(8, false, "tag:example"),
|
|
nvWithConnectorSet(9, false),
|
|
nvWithConnectorSet(10, false),
|
|
},
|
|
app: appctype.Conn25Attr{
|
|
Name: "example2",
|
|
Connectors: []string{"tag:example1", "tag:example", "tag:example2"},
|
|
Domains: []string{"example.com"},
|
|
},
|
|
want: []tailcfg.NodeView{
|
|
nv(1, "tag:example1", "tag:example"),
|
|
nv(2, "tag:woo", "tag:example"),
|
|
nv(7, "tag:example1", "tag:example"),
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := PickConnector(&testNodeBackend{peers: tt.candidates}, tt.app)
|
|
if diff := cmp.Diff(tt.want, got); diff != "" {
|
|
t.Fatalf("PickConnectors (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|