290a6cc03c
Installed SplitDNS routes are always treated as wildcard domains, so the domains that we pass to the local resolver should be normalized and have any leading *. wildcard prefix removed. When looking at DNS responses to see if the domain matches, we need to consider both exact matches and wildcard matches. We now keep separate maps of exact-match domains and wildcard domains, and when we match we check to see if there's a match in the exact-match map, otherwise we check against the wild card match map until we find a match, removing a label after each check. Rather than looking for matching self-hosted domains (domains serviced by the connector being run on the self-node), the apps that are being serviced by the connector on the self-node are tracked instead. When checking to see if a DNS response should be rewritten, it is ignored if any of the matching apps for the domain are in the self-hosted apps set. Fixes tailscale/corp#39272 Signed-off-by: George Jones <george@tailscale.com>
418 lines
12 KiB
Go
418 lines
12 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package appc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"tailscale.com/ipn/ipnext"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/appctype"
|
|
"tailscale.com/types/opt"
|
|
)
|
|
|
|
func TestPickSplitDNSPeers(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"})
|
|
|
|
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
|
|
return (&tailcfg.Node{
|
|
ID: id,
|
|
Name: name,
|
|
Tags: tags,
|
|
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
|
|
}).View()
|
|
}
|
|
nvp1 := makeNodeView(1, "p1", []string{"tag:one"})
|
|
nvp2 := makeNodeView(2, "p2", []string{"tag:four1", "tag:four2"})
|
|
nvp3 := makeNodeView(3, "p3", []string{"tag:two", "tag:three1"})
|
|
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
peers []tailcfg.NodeView
|
|
config []tailcfg.RawMessage
|
|
isEligibleConnector bool
|
|
selfTags []string
|
|
want map[string][]tailcfg.NodeView
|
|
}{
|
|
{
|
|
name: "empty",
|
|
},
|
|
{
|
|
name: "bad-config", // bad config should return a nil map rather than error.
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
|
|
},
|
|
{
|
|
name: "no-peers",
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
|
},
|
|
{
|
|
name: "peers-that-are-not-connectors",
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
|
peers: []tailcfg.NodeView{
|
|
(&tailcfg.Node{
|
|
ID: 5,
|
|
Name: "p5",
|
|
Tags: []string{"tag:one"},
|
|
}).View(),
|
|
(&tailcfg.Node{
|
|
ID: 6,
|
|
Name: "p6",
|
|
Tags: []string{"tag:one"},
|
|
}).View(),
|
|
},
|
|
},
|
|
{
|
|
name: "peers-that-dont-match-tags",
|
|
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
|
peers: []tailcfg.NodeView{
|
|
makeNodeView(5, "p5", []string{"tag:seven"}),
|
|
makeNodeView(6, "p6", nil),
|
|
},
|
|
},
|
|
{
|
|
name: "matching-tagged-connector-peers",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appThreeBytes),
|
|
tailcfg.RawMessage(appFourBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
nvp2,
|
|
nvp3,
|
|
nvp4,
|
|
makeNodeView(5, "p5", nil),
|
|
},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// p5 has no matching tags and so doesn't appear
|
|
"example.com": {nvp1},
|
|
"a.example.com": {nvp3, nvp4},
|
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
|
"hoo.b.example.com": {nvp3, nvp4},
|
|
"c.example.com": {nvp2, nvp4},
|
|
},
|
|
},
|
|
{
|
|
name: "self-connector-exclude-self-domains",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appThreeBytes),
|
|
tailcfg.RawMessage(appFourBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
nvp2,
|
|
nvp3,
|
|
nvp4,
|
|
},
|
|
isEligibleConnector: true,
|
|
selfTags: []string{"tag:three1"},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// woo.b.example.com and hoo.b.example.com are covered
|
|
// by tag:three1, and so is this self-node.
|
|
// So those domains should not be routed to peers.
|
|
// woo.b.example.com is also covered by another tag,
|
|
// but still not included since this connector can route to it.
|
|
"example.com": {nvp1},
|
|
"a.example.com": {nvp3, nvp4},
|
|
"c.example.com": {nvp2, nvp4},
|
|
},
|
|
},
|
|
{
|
|
name: "self-eligible-connector-no-matching-tag-include-all-domains",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appThreeBytes),
|
|
tailcfg.RawMessage(appFourBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
nvp2,
|
|
nvp3,
|
|
nvp4,
|
|
},
|
|
isEligibleConnector: true,
|
|
selfTags: []string{"tag:unrelated"},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// Self has prefs set but no tags matching any app,
|
|
// so no domains are self-routed and all appear.
|
|
"example.com": {nvp1},
|
|
"a.example.com": {nvp3, nvp4},
|
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
|
"hoo.b.example.com": {nvp3, nvp4},
|
|
"c.example.com": {nvp2, nvp4},
|
|
},
|
|
},
|
|
{
|
|
name: "self-not-eligible-connector-but-tagged-include-all-domains",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appThreeBytes),
|
|
tailcfg.RawMessage(appFourBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
nvp2,
|
|
nvp3,
|
|
nvp4,
|
|
},
|
|
selfTags: []string{"tag:three1"},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// Even though this self node has a tag for an app
|
|
// the prefs don't advertise as connector, so
|
|
// should still route through other connectors.
|
|
"example.com": {nvp1},
|
|
"a.example.com": {nvp3, nvp4},
|
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
|
"hoo.b.example.com": {nvp3, nvp4},
|
|
"c.example.com": {nvp2, nvp4},
|
|
},
|
|
},
|
|
{
|
|
name: "wildcards-are-stripped-and-deduped",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appOneBytes),
|
|
tailcfg.RawMessage(appFiveBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// All the domains should be normalized to example.com
|
|
"example.com": {nvp1},
|
|
},
|
|
},
|
|
{
|
|
name: "domains-are-normalized-and-deduped",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appSixBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// All the domains should be normalized to example.com
|
|
"example.com": {nvp1},
|
|
},
|
|
},
|
|
{
|
|
name: "sub-domains-and-top-domains-do-not-collide",
|
|
config: []tailcfg.RawMessage{
|
|
tailcfg.RawMessage(appTwoBytes),
|
|
tailcfg.RawMessage(appFiveBytes),
|
|
},
|
|
peers: []tailcfg.NodeView{
|
|
nvp1,
|
|
nvp3,
|
|
},
|
|
want: map[string][]tailcfg.NodeView{
|
|
// The sub.example.com should remain distinct from example.com
|
|
"example.com": {nvp1},
|
|
"a.example.com": {nvp3},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
selfNode := &tailcfg.Node{}
|
|
if tt.config != nil {
|
|
selfNode.CapMap = tailcfg.NodeCapMap{
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
|
}
|
|
}
|
|
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
|
|
selfView := selfNode.View()
|
|
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
|
for _, p := range tt.peers {
|
|
peers[p.ID()] = p
|
|
}
|
|
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
|
return true
|
|
}, selfView, peers, tt.isEligibleConnector)
|
|
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|