tailscale/feature/conn25/conn25_test.go
Michael Ben-Ami 156e6ae5cd feature/conn25: install all the hooks
Install the previously uninstalled hooks for the filter and tstun
intercepts. Move the DNS manager hook installation into Init() with all
the others. Protect all implementations with a short-circuit if the node
is not configured to use Connectors 2025. The short-circuit pattern
replaces the previous pattern used in managing the DNS manager hook, of
setting it to nil in response to CapMap changes.

Fixes tailscale/corp#38716

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-03-27 11:52:34 -04:00

1653 lines
49 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"go4.org/mem"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/ipn/ipnext"
"tailscale.com/net/dns"
"tailscale.com/net/packet"
"tailscale.com/net/tsdial"
"tailscale.com/net/tstun"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/appctype"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/util/dnsname"
"tailscale.com/util/must"
"tailscale.com/util/set"
)
func mustIPSetFromPrefix(s string) *netipx.IPSet {
b := &netipx.IPSetBuilder{}
b.AddPrefix(netip.MustParsePrefix(s))
set, err := b.IPSet()
if err != nil {
panic(err)
}
return set
}
// TestHandleConnectorTransitIPRequest tests that if sent a
// request with a transit addr and a destination addr we store that mapping
// and can retrieve it.
func TestHandleConnectorTransitIPRequest(t *testing.T) {
const appName = "TestApp"
// Peer IPs
pipV4_1 := netip.MustParseAddr("100.101.101.101")
pipV4_2 := netip.MustParseAddr("100.101.101.102")
pipV6_1 := netip.MustParseAddr("fd7a:115c:a1e0::101")
pipV6_3 := netip.MustParseAddr("fd7a:115c:a1e0::103")
// Transit IPs
tipV4_1 := netip.MustParseAddr("0.0.0.1")
tipV4_2 := netip.MustParseAddr("0.0.0.2")
tipV6_1 := netip.MustParseAddr("FE80::1")
// Destination IPs
dipV4_1 := netip.MustParseAddr("10.0.0.1")
dipV4_2 := netip.MustParseAddr("10.0.0.2")
dipV4_3 := netip.MustParseAddr("10.0.0.3")
dipV6_1 := netip.MustParseAddr("fc00::1")
// Peer nodes
peerV4V6 := (&tailcfg.Node{
ID: tailcfg.NodeID(1),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV4_1, 32), netip.PrefixFrom(pipV6_1, 128)},
}).View()
peerV4Only := (&tailcfg.Node{
ID: tailcfg.NodeID(2),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV4_2, 32)},
}).View()
peerV6Only := (&tailcfg.Node{
ID: tailcfg.NodeID(3),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV6_3, 128)},
}).View()
tests := []struct {
name string
ctipReqPeers []tailcfg.NodeView // One entry per request and the other
ctipReqs []ConnectorTransitIPRequest // arrays in this struct must have the same
wants []ConnectorTransitIPResponse // cardinality
// For checking lookups:
// The outer array needs to correspond to the number of requests,
// can be nil if no lookups need to be done after the request is processed.
//
// The middle array is the set of lookups for the corresponding request.
//
// The inner array is a tuple of (PeerIP, TransitIP, ExpectedDestinationIP)
wantLookups [][][]netip.Addr
}{
// Single peer, single request with success ipV4
{
name: "one-peer-one-req-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
},
},
// Single peer, single request with success ipV6
{
name: "one-peer-one-req-ipv6",
ctipReqPeers: []tailcfg.NodeView{peerV6Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV6_3, tipV6_1, dipV6_1}},
},
},
// Single peer, multi request with success, ipV4
{
name: "one-peer-multi-req-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_2, DestinationIP: dipV4_2, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
{{pipV4_2, tipV4_2, dipV4_2}},
},
},
// Single peer, multi request remap tip, ipV4
{
name: "one-peer-remap-tip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
{{pipV4_2, tipV4_1, dipV4_2}},
},
},
// Single peer, multi request with success, ipV4 and ipV6
{
name: "one-peer-multi-req-ipv4-ipv6",
ctipReqPeers: []tailcfg.NodeView{peerV4V6, peerV4V6},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_1, tipV4_1, dipV4_1}},
{{pipV4_1, tipV4_1, dipV4_1}, {pipV6_1, tipV6_1, dipV6_1}, {pipV4_1, tipV6_1, netip.Addr{}}},
},
},
// Single peer, multi map with success, ipV4
{
name: "one-peer-multi-map-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_2, App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}, {Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_2}},
},
},
// Single peer, error reuse same tip in one request, ensure all non-dup requests are processed
{
name: "one-peer-multi-map-duplicate-tip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_3, App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{
{Code: OK, Message: ""},
{Code: DuplicateTransitIP, Message: dupeTransitIPMessage},
{Code: OK, Message: ""}},
},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_3}},
},
},
// Multi peer, success reuse same tip in one request
{
name: "multi-peer-duplicate-tip",
ctipReqPeers: []tailcfg.NodeView{peerV4V6, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_1, tipV4_1, dipV4_1}},
{{pipV4_1, tipV4_1, dipV4_1}, {pipV4_2, tipV4_1, dipV4_2}},
},
},
// Single peer, multi map, multiple tip to same dip
{
name: "one-peer-multi-map-multi-tip-to-dip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_1, App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}, {Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_1}},
},
},
// Single peer, ipv4 tip, no ipv4 pip, but ipv6 tip works
{
name: "one-peer-missing-ipv4-family",
ctipReqPeers: []tailcfg.NodeView{peerV6Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{
{Code: NoMatchingPeerIPFamily, Message: noMatchingPeerIPFamilyMessage},
{Code: OK, Message: ""},
}},
},
wantLookups: [][][]netip.Addr{
{{pipV6_3, tipV4_1, netip.Addr{}}, {pipV6_3, tipV6_1, dipV6_1}},
},
},
// Single peer, ipv6 tip, no ipv6 pip, but ipv4 tip works
{
name: "one-peer-missing-ipv6-family",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName},
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{
{Code: NoMatchingPeerIPFamily, Message: noMatchingPeerIPFamilyMessage},
{Code: OK, Message: ""},
}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV6_1, netip.Addr{}}, {pipV4_2, tipV4_1, dipV4_1}},
},
},
// Single peer, mismatched transit and destination ips
{
name: "one-peer-mismatched-tip-dip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV6_1, App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: AddrFamilyMismatch, Message: addrFamilyMismatchMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, netip.Addr{}}},
},
},
// Single peer, invalid app name
{
name: "one-peer-invalid-app",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: "Unknown App"}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: UnknownAppName, Message: unknownAppNameMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, netip.Addr{}}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch {
case len(tt.ctipReqPeers) != len(tt.ctipReqs):
t.Fatalf("error in test setup: ctipReqPeers has length %d does not match ctipReqs length %d",
len(tt.ctipReqPeers), len(tt.ctipReqs))
case len(tt.ctipReqPeers) != len(tt.wants):
t.Fatalf("error in test setup: ctipReqPeers has length %d does not match wants length %d",
len(tt.ctipReqPeers), len(tt.wants))
case len(tt.ctipReqPeers) != len(tt.wantLookups):
t.Fatalf("error in test setup: ctipReqPeers has length %d does not match wantLookups length %d",
len(tt.ctipReqPeers), len(tt.wantLookups))
}
// 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: {}},
}
for i, peer := range tt.ctipReqPeers {
req := tt.ctipReqs[i]
want := tt.wants[i]
resp := c.handleConnectorTransitIPRequest(peer, req)
// Ensure that we have the expected number of responses
if len(resp.TransitIPs) != len(want.TransitIPs) {
t.Fatalf("wrong number of TransitIPs in response %d: got %d, want %d",
i, len(resp.TransitIPs), len(want.TransitIPs))
}
// Validate the contents of each response
for j, tipResp := range resp.TransitIPs {
wantResp := want.TransitIPs[j]
if tipResp.Code != wantResp.Code {
t.Errorf("transitIP.Code mismatch in response %d, tipresp %d: got %d, want %d",
i, j, tipResp.Code, wantResp.Code)
}
if tipResp.Message != wantResp.Message {
t.Errorf("transitIP.Message mismatch in response %d, tipresp %d: got %q, want %q",
i, j, tipResp.Message, wantResp.Message)
}
}
// Validate the state of the transitIP map after each request
if tt.wantLookups[i] != nil {
for j, wantLookup := range tt.wantLookups[i] {
if len(wantLookup) != 3 {
t.Fatalf("test setup error: wantLookup for request %d lookup %d contains %d IPs, expected 3",
i, j, len(wantLookup))
}
pip, tip, wantDip := wantLookup[0], wantLookup[1], wantLookup[2]
aa, _ := c.connector.lookupBySrcIPAndTransitIP(pip, tip)
gotDip := aa.addr
if gotDip != wantDip {
t.Errorf("wrong result on lookup[%d][%d] ([%v], [%v]): got [%v] expected [%v]",
i, j, pip, tip, gotDip, wantDip)
}
}
}
}
})
}
}
func TestReserveIPs(t *testing.T) {
c := newConn25(logger.Discard)
c.client.magicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24"))
c.client.transitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
mbd := map[dnsname.FQDN][]string{}
mbd["example.com."] = []string{"a"}
c.client.config.appNamesByDomain = mbd
dst := netip.MustParseAddr("0.0.0.1")
addrs, err := c.client.reserveAddresses("example.com.", dst)
if err != nil {
t.Fatal(err)
}
wantDst := netip.MustParseAddr("0.0.0.1") // same as dst we pass in
wantMagic := netip.MustParseAddr("100.64.0.0") // first from magic pool
wantTransit := netip.MustParseAddr("169.254.0.0") // first from transit pool
wantApp := "a" // the app name related to example.com.
wantDomain := must.Get(dnsname.ToFQDN("example.com."))
if wantDst != addrs.dst {
t.Errorf("want %v, got %v", wantDst, addrs.dst)
}
if wantMagic != addrs.magic {
t.Errorf("want %v, got %v", wantMagic, addrs.magic)
}
if wantTransit != addrs.transit {
t.Errorf("want %v, got %v", wantTransit, addrs.transit)
}
if wantApp != addrs.app {
t.Errorf("want %s, got %s", wantApp, addrs.app)
}
if wantDomain != addrs.domain {
t.Errorf("want %s, got %s", wantDomain, addrs.domain)
}
}
func TestReconfig(t *testing.T) {
rawCfg := `{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`
capMap := tailcfg.NodeCapMap{
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{
tailcfg.RawMessage(rawCfg),
},
}
c := newConn25(logger.Discard)
sn := (&tailcfg.Node{
CapMap: capMap,
}).View()
err := c.reconfig(sn)
if err != nil {
t.Fatal(err)
}
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)
}
}
func TestConfigReconfig(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: "bad-config",
rawCfg: `bad`,
wantErr: true,
},
{
name: "simple",
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:one"},
wantAppsByDomain: map[dnsname.FQDN][]string{
"a.example.com.": {"one"},
"b.example.com.": {"two"},
},
wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
},
{
name: "more-complex",
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"}},
{Name: "three", Domains: []string{"1.b.example.com", "1.c.example.com"}, Connectors: []string{}},
{Name: "four", Domains: []string{"4.b.example.com", "4.d.example.com"}, Connectors: []string{"tag:four"}},
},
tags: []string{"tag:onea", "tag:four", "tag:unrelated"},
wantAppsByDomain: map[dnsname.FQDN][]string{
"1.a.example.com.": {"one"},
"1.b.example.com.": {"one", "three"},
"1.c.example.com.": {"three"},
"2.b.example.com.": {"two"},
"2.c.example.com.": {"two"},
"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."}),
},
} {
t.Run(tt.name, func(t *testing.T) {
cfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawCfg)}
if tt.cfg != nil {
cfg = []tailcfg.RawMessage{}
for _, attr := range tt.cfg {
bs, err := json.Marshal(attr)
if err != nil {
t.Fatalf("unexpected error in test setup: %v", err)
}
cfg = append(cfg, tailcfg.RawMessage(bs))
}
}
capMap := tailcfg.NodeCapMap{
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
}
sn := (&tailcfg.Node{
CapMap: capMap,
Tags: tt.tags,
}).View()
c, err := configFromNodeView(sn)
if (err != nil) != tt.wantErr {
t.Fatalf("wantErr: %t, err: %v", tt.wantErr, err)
}
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)
}
})
}
}
func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tailcfg.NodeView {
t.Helper()
cfg := make([]tailcfg.RawMessage, 0, len(attrs))
for i, attr := range attrs {
bs, err := json.Marshal(attr)
if err != nil {
t.Fatalf("unexpected error in test setup at index %d: %v", i, err)
}
cfg = append(cfg, tailcfg.RawMessage(bs))
}
capMap := tailcfg.NodeCapMap{
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
}
return (&tailcfg.Node{
CapMap: capMap,
Tags: tags,
}).View()
}
func rangeFrom(from, to string) netipx.IPRange {
return netipx.IPRangeFrom(
netip.MustParseAddr("100.64.0."+from),
netip.MustParseAddr("100.64.0."+to),
)
}
func makeDNSResponse(t *testing.T, domain string, addrs []*dnsmessage.AResource) []byte {
t.Helper()
name := dnsmessage.MustNewName(domain)
questions := []dnsmessage.Question{
{
Name: name,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
}
var answers []dnsmessage.Resource
for _, addr := range addrs {
ans := dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{
Name: name,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: addr,
}
answers = append(answers, ans)
}
additional := []dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: name,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: [4]byte{9, 9, 9, 9}},
},
}
return makeDNSResponseForSections(t, questions, answers, additional)
}
func makeV6DNSResponse(t *testing.T, domain string, addrs []*dnsmessage.AAAAResource) []byte {
t.Helper()
name := dnsmessage.MustNewName(domain)
questions := []dnsmessage.Question{
{
Name: name,
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
},
}
var answers []dnsmessage.Resource
for _, addr := range addrs {
ans := dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{
Name: name,
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
},
Body: addr,
}
answers = append(answers, ans)
}
return makeDNSResponseForSections(t, questions, answers, nil)
}
func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, answers []dnsmessage.Resource, additional []dnsmessage.Resource) []byte {
t.Helper()
b := dnsmessage.NewBuilder(nil,
dnsmessage.Header{
ID: 1,
Response: true,
Authoritative: true,
RCode: dnsmessage.RCodeSuccess,
})
b.EnableCompression()
if err := b.StartQuestions(); err != nil {
t.Fatal(err)
}
for _, q := range questions {
if err := b.Question(q); err != nil {
t.Fatal(err)
}
}
if err := b.StartAnswers(); err != nil {
t.Fatal(err)
}
for _, ans := range answers {
switch ans.Header.Type {
case dnsmessage.TypeA:
body, ok := (ans.Body).(*dnsmessage.AResource)
if !ok {
t.Fatalf("unexpected answer type, update test")
}
b.AResource(ans.Header, *body)
case dnsmessage.TypeAAAA:
body, ok := (ans.Body).(*dnsmessage.AAAAResource)
if !ok {
t.Fatalf("unexpected answer type, update test")
}
b.AAAAResource(ans.Header, *body)
default:
t.Fatalf("unhandled answer type, update test: %v", ans.Header.Type)
}
}
if err := b.StartAdditionals(); err != nil {
t.Fatal(err)
}
for _, add := range additional {
body, ok := (add.Body).(*dnsmessage.AResource)
if !ok {
t.Fatalf("unexpected additional type, update test")
}
b.AResource(add.Header, *body)
}
outbs, err := b.Finish()
if err != nil {
t.Fatal(err)
}
return outbs
}
func TestMapDNSResponseAssignsAddrs(t *testing.T) {
for _, tt := range []struct {
name string
domain string
addrs []*dnsmessage.AResource
wantByMagicIP map[netip.Addr]addrs
}{
{
name: "one-ip-matches",
domain: "example.com.",
addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
// these are 'expected' because they are the beginning of the provided pools
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: "multiple-ip-matches",
domain: "example.com.",
addrs: []*dnsmessage.AResource{
{A: [4]byte{1, 0, 0, 0}},
{A: [4]byte{2, 0, 0, 0}},
},
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",
},
netip.MustParseAddr("100.64.0.1"): {
domain: "example.com.",
dst: netip.MustParseAddr("2.0.0.0"),
magic: netip.MustParseAddr("100.64.0.1"),
transit: netip.MustParseAddr("100.64.0.41"),
app: "app1",
},
},
},
{
name: "no-domain-match",
domain: "x.example.com.",
addrs: []*dnsmessage.AResource{
{A: [4]byte{1, 0, 0, 0}},
{A: [4]byte{2, 0, 0, 0}},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
dnsResp := makeDNSResponse(t, tt.domain, tt.addrs)
sn := makeSelfNode(t, []appctype.Conn25Attr{{
Name: "app1",
Connectors: []string{"tag:woo"},
Domains: []string{"example.com"},
MagicIPPool: []netipx.IPRange{rangeFrom("0", "10"), rangeFrom("20", "30")},
TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")},
}}, []string{})
c := newConn25(logger.Discard)
c.reconfig(sn)
c.mapDNSResponse(dnsResp)
if diff := cmp.Diff(tt.wantByMagicIP, c.client.assignments.byMagicIP, cmpopts.EquateComparable(addrs{}, netip.Addr{})); diff != "" {
t.Errorf("byMagicIP diff (-want, +got):\n%s", diff)
}
})
}
}
func TestReserveAddressesDeduplicated(t *testing.T) {
c := newConn25(logger.Discard)
c.client.magicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24"))
c.client.transitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
c.client.config.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}}
dst := netip.MustParseAddr("0.0.0.1")
first, err := c.client.reserveAddresses("example.com.", dst)
if err != nil {
t.Fatal(err)
}
second, err := c.client.reserveAddresses("example.com.", dst)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Errorf("expected same addrs on repeated call, got first=%v second=%v", first, second)
}
if got := len(c.client.assignments.byMagicIP); got != 1 {
t.Errorf("want 1 entry in byMagicIP, got %d", got)
}
if got := len(c.client.assignments.byDomainDst); got != 1 {
t.Errorf("want 1 entry in byDomainDst, got %d", got)
}
}
type testNodeBackend struct {
ipnext.NodeBackend
peers []tailcfg.NodeView
peerAPIURL string // should be per peer but there's only one peer in our test so this is ok for now
}
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 (nb *testNodeBackend) PeerAPIBase(p tailcfg.NodeView) string {
return nb.peerAPIURL
}
type testHost struct {
ipnext.Host
nb ipnext.NodeBackend
hooks ipnext.Hooks
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() }
type testSafeBackend struct {
ipnext.SafeBackend
sys *tsd.System
}
func newTestSafeBackend() *testSafeBackend {
sb := &testSafeBackend{}
sys := &tsd.System{}
sys.Dialer.Set(&tsdial.Dialer{Logf: logger.Discard})
sys.DNSManager.Set(&dns.Manager{})
sys.Tun.Set(&tstun.Wrapper{})
sb.sys = sys
return sb
}
func (b *testSafeBackend) Sys() *tsd.System { return b.sys }
// TestAddressAssignmentIsHandled tests that after enqueueAddress has been called
// we handle the assignment asynchronously by:
// - making a peerapi request to a peer.
// - calling AuthReconfigAsync on the host.
func TestAddressAssignmentIsHandled(t *testing.T) {
// make a fake peer to test against
received := make(chan ConnectorTransitIPRequest, 1)
peersAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v0/connector/transit-ip" {
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
var req ConnectorTransitIPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
received <- req
resp := ConnectorTransitIPResponse{
TransitIPs: []TransitIPResponse{{Code: OK}},
}
json.NewEncoder(w).Encode(resp)
}))
defer peersAPI.Close()
connectorPeer := (&tailcfg.Node{
ID: tailcfg.NodeID(1),
Tags: []string{"tag:woo"},
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
Key: key.NodePublicFromRaw32(mem.B([]byte{0: 0xff, 1: 0xff, 31: 0x01})),
}).View()
ext := &extension{
conn25: newConn25(logger.Discard),
backend: newTestSafeBackend(),
}
authReconfigAsyncCalled := make(chan struct{}, 1)
if err := ext.Init(&testHost{
nb: &testNodeBackend{
peers: []tailcfg.NodeView{connectorPeer},
peerAPIURL: peersAPI.URL,
},
authReconfigAsync: func() {
authReconfigAsyncCalled <- struct{}{}
},
}); err != nil {
t.Fatal(err)
}
defer ext.Shutdown()
sn := makeSelfNode(t, []appctype.Conn25Attr{{
Name: "app1",
Connectors: []string{"tag:woo"},
Domains: []string{"example.com"},
}}, []string{})
err := ext.conn25.reconfig(sn)
if err != nil {
t.Fatal(err)
}
as := addrs{
dst: netip.MustParseAddr("1.2.3.4"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("169.254.0.1"),
domain: "example.com.",
app: "app1",
}
if err := ext.conn25.client.assignments.insert(as); err != nil {
t.Fatalf("error inserting address assignments: %v", err)
}
ext.conn25.client.enqueueAddressAssignment(as)
select {
case got := <-received:
if len(got.TransitIPs) != 1 {
t.Fatalf("want 1 TransitIP in request, got %d", len(got.TransitIPs))
}
tip := got.TransitIPs[0]
if tip.TransitIP != as.transit {
t.Errorf("TransitIP: got %v, want %v", tip.TransitIP, as.transit)
}
if tip.DestinationIP != as.dst {
t.Errorf("DestinationIP: got %v, want %v", tip.DestinationIP, as.dst)
}
if tip.App != as.app {
t.Errorf("App: got %q, want %q", tip.App, as.app)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for connector to receive request")
}
select {
case <-authReconfigAsyncCalled:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for AuthReconfigAsync to be called")
}
}
func parseResponse(t *testing.T, buf []byte) ([]dnsmessage.Resource, []dnsmessage.Resource) {
t.Helper()
var p dnsmessage.Parser
header, err := p.Start(buf)
if err != nil {
t.Fatalf("parsing DNS response: %v", err)
}
if header.RCode != dnsmessage.RCodeSuccess {
t.Fatalf("RCode want: %v, got: %v", dnsmessage.RCodeSuccess, header.RCode)
}
if err := p.SkipAllQuestions(); err != nil {
t.Fatalf("skipping questions: %v", err)
}
answers, err := p.AllAnswers()
if err != nil {
t.Fatalf("reading answers: %v", err)
}
if err := p.SkipAllAuthorities(); err != nil {
t.Fatalf("skipping questions: %v", err)
}
additionals, err := p.AllAdditionals()
if err != nil {
t.Fatalf("reading additionals: %v", err)
}
return answers, additionals
}
func TestMapDNSResponseRewritesResponses(t *testing.T) {
configuredDomain := "example.com"
domainName := configuredDomain + "."
dnsMessageName := dnsmessage.MustNewName(domainName)
sn := makeSelfNode(t, []appctype.Conn25Attr{{
Name: "app1",
Connectors: []string{"tag:connector"},
Domains: []string{configuredDomain},
MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")},
TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")},
}}, []string{})
compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) {
t.Helper()
var got []netip.Addr
for _, r := range resources {
if b, ok := r.Body.(*dnsmessage.AResource); ok {
got = append(got, netip.AddrFrom4(b.A))
} else if b, ok := r.Body.(*dnsmessage.AAAAResource); ok {
got = append(got, netip.AddrFrom16(b.AAAA))
}
}
if diff := cmp.Diff(want, got, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
t.Fatalf("A/AAAA records mismatch (-want +got):\n%s", diff)
}
}
assertParsesToAnswers := func(want []netip.Addr) func(t *testing.T, bs []byte) {
return func(t *testing.T, bs []byte) {
t.Helper()
answers, _ := parseResponse(t, bs)
compareToRecords(t, answers, want)
}
}
assertParsesToAdditionals := func(want []netip.Addr) func(t *testing.T, bs []byte) {
return func(t *testing.T, bs []byte) {
t.Helper()
_, additionals := parseResponse(t, bs)
compareToRecords(t, additionals, want)
}
}
assertBytes := func(want []byte) func(t *testing.T, bs []byte) {
return func(t *testing.T, bs []byte) {
t.Helper()
if diff := cmp.Diff(want, bs); diff != "" {
t.Fatalf("bytes mismatch (-want +got):\n%s", diff)
}
}
}
assertServFail := func(t *testing.T, bs []byte) {
var p dnsmessage.Parser
header, err := p.Start(bs)
if err != nil {
t.Fatalf("parsing DNS response: %v", err)
}
if header.RCode != dnsmessage.RCodeServerFailure {
t.Fatalf("RCode want: %v, got: %v", dnsmessage.RCodeServerFailure, header.RCode)
}
}
ipv6ResponseUnhandledDomain := makeV6DNSResponse(t, "tailscale.com.", []*dnsmessage.AAAAResource{
{AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()},
{AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()},
})
ipv4ResponseUnhandledDomain := makeDNSResponse(t, "tailscale.com.", []*dnsmessage.AResource{
{A: netip.MustParseAddr("1.2.3.4").As4()},
{A: netip.MustParseAddr("5.6.7.8").As4()},
})
nonINETQuestionResp := makeDNSResponseForSections(t, []dnsmessage.Question{
{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassCHAOS,
},
}, nil, nil)
for _, tt := range []struct {
name string
toMap []byte
assertFx func(*testing.T, []byte)
}{
{
name: "unparseable",
toMap: []byte{1, 2, 3, 4},
assertFx: assertBytes([]byte{1, 2, 3, 4}),
},
{
name: "maps-multi-typea-answers",
toMap: makeDNSResponse(t, domainName, []*dnsmessage.AResource{
{A: netip.MustParseAddr("1.2.3.4").As4()},
{A: netip.MustParseAddr("5.6.7.8").As4()},
}),
assertFx: assertParsesToAnswers(
[]netip.Addr{
netip.MustParseAddr("100.64.0.0"),
netip.MustParseAddr("100.64.0.1"),
},
),
},
{
name: "ipv6-no-answers",
toMap: makeV6DNSResponse(t, domainName, []*dnsmessage.AAAAResource{
{AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()},
{AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()},
}),
assertFx: assertParsesToAnswers(nil),
},
{
name: "not-our-domain",
toMap: ipv4ResponseUnhandledDomain,
assertFx: assertBytes(ipv4ResponseUnhandledDomain),
},
{
name: "ipv6-not-our-domain",
toMap: ipv6ResponseUnhandledDomain,
assertFx: assertBytes(ipv6ResponseUnhandledDomain),
},
{
name: "case-insensitive",
toMap: makeDNSResponse(t, "eXample.com.", []*dnsmessage.AResource{
{A: netip.MustParseAddr("1.2.3.4").As4()},
{A: netip.MustParseAddr("5.6.7.8").As4()},
}),
assertFx: assertParsesToAnswers(
[]netip.Addr{
netip.MustParseAddr("100.64.0.0"),
netip.MustParseAddr("100.64.0.1"),
},
),
},
{
name: "unhandled-keeps-additional-section",
toMap: makeDNSResponse(t, "tailscale.com.", []*dnsmessage.AResource{
{A: netip.MustParseAddr("1.2.3.4").As4()},
{A: netip.MustParseAddr("5.6.7.8").As4()},
}),
assertFx: assertParsesToAdditionals(
// additionals are added in makeDNSResponse
[]netip.Addr{
netip.MustParseAddr("9.9.9.9"),
},
),
},
{
name: "handled-strips-additional-section",
toMap: makeDNSResponse(t, domainName, []*dnsmessage.AResource{
{A: netip.MustParseAddr("1.2.3.4").As4()},
{A: netip.MustParseAddr("5.6.7.8").As4()},
}),
assertFx: assertParsesToAdditionals(nil),
},
{
name: "servfail-when-we-should-handle-but-cant",
// produced by
// makeDNSResponse(t, domainName, []*dnsmessage.AResource{{A: netip.MustParseAddr("1.2.3.4").As4()}})
// and then taking 17 bytes off the end. So that the parsing of it breaks after we have decided we should handle it.
// Frozen like this so that it doesn't depend on the implementation of dnsmessage.
toMap: []byte{0, 1, 132, 0, 0, 1, 0, 1, 0, 0, 0, 1, 7, 101, 120, 97, 109, 112, 108, 101, 3, 99, 111, 109, 0, 0, 1, 0, 1, 192, 12, 0, 1, 0, 1, 0, 0, 0, 0, 0, 4, 1, 2, 3},
assertFx: assertServFail,
},
{
name: "not-inet-question",
toMap: nonINETQuestionResp,
assertFx: assertBytes(nonINETQuestionResp),
},
{
name: "not-inet-answer",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{
{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassCHAOS,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers(nil),
},
{
name: "answer-domain-mismatch",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{
{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("tailscale.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers(nil),
},
{
name: "answer-type-mismatch",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{
{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AAAAResource{AAAA: netip.MustParseAddr("1.2.3.4").As16()},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("5.6.7.8").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}),
},
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(logger.Discard)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
bs := c.mapDNSResponse(tt.toMap)
tt.assertFx(t, bs)
})
}
}
func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) {
// make a fake peer API to test against, for all peers
peersAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v0/connector/transit-ip" {
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
var req ConnectorTransitIPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
resp := ConnectorTransitIPResponse{
TransitIPs: []TransitIPResponse{{Code: OK}},
}
json.NewEncoder(w).Encode(resp)
}))
defer peersAPI.Close()
connectorPeers := []tailcfg.NodeView{
(&tailcfg.Node{
ID: tailcfg.NodeID(1),
Tags: []string{"tag:woo"},
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
Key: key.NodePublicFromRaw32(mem.B([]byte{0: 0xff, 31: 0x01})),
}).View(),
(&tailcfg.Node{
ID: tailcfg.NodeID(2),
Tags: []string{"tag:hoo"},
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
Key: key.NodePublicFromRaw32(mem.B([]byte{0: 0xff, 31: 0x02})),
}).View(),
}
ext := &extension{
conn25: newConn25(logger.Discard),
backend: newTestSafeBackend(),
}
authReconfigAsyncCalled := make(chan struct{}, 1)
if err := ext.Init(&testHost{
nb: &testNodeBackend{
peers: connectorPeers,
peerAPIURL: peersAPI.URL,
},
authReconfigAsync: func() {
authReconfigAsyncCalled <- struct{}{}
},
}); err != nil {
t.Fatal(err)
}
defer ext.Shutdown()
sn := makeSelfNode(t, []appctype.Conn25Attr{
{
Name: "app1",
Connectors: []string{"tag:woo"},
Domains: []string{"woo.example.com"},
},
{
Name: "app2",
Connectors: []string{"tag:hoo"},
Domains: []string{"hoo.example.com"},
},
}, []string{})
err := ext.conn25.reconfig(sn)
if err != nil {
t.Fatal(err)
}
type lookup struct {
connKey key.NodePublic
expectedIPs []netip.Prefix
expectedOk bool
}
transitIPs := []netip.Prefix{
netip.MustParsePrefix("169.254.0.1/32"),
netip.MustParsePrefix("169.254.0.2/32"),
netip.MustParsePrefix("169.254.0.3/32"),
}
// Each step performs an insert on the provided addrs
// and then does the lookups.
steps := []struct {
name string
as addrs
lookups []lookup
}{
{
name: "step-1-conn1-tip1",
as: addrs{
dst: netip.MustParseAddr("1.2.3.1"),
magic: netip.MustParseAddr("100.64.0.1"),
transit: transitIPs[0].Addr(),
domain: "woo.example.com.",
app: "app1",
},
lookups: []lookup{
{
connKey: connectorPeers[0].Key(),
expectedIPs: []netip.Prefix{
transitIPs[0],
},
expectedOk: true,
},
{
connKey: connectorPeers[1].Key(),
expectedIPs: nil,
expectedOk: false,
},
},
},
{
name: "step-2-conn1-tip2",
as: addrs{
dst: netip.MustParseAddr("1.2.3.2"),
magic: netip.MustParseAddr("100.64.0.2"),
transit: transitIPs[1].Addr(),
domain: "woo.example.com.",
app: "app1",
},
lookups: []lookup{
{
connKey: connectorPeers[0].Key(),
expectedIPs: []netip.Prefix{
transitIPs[0],
transitIPs[1],
},
expectedOk: true,
},
},
},
{
name: "step-3-conn2-tip1",
as: addrs{
dst: netip.MustParseAddr("1.2.3.3"),
magic: netip.MustParseAddr("100.64.0.3"),
transit: transitIPs[2].Addr(),
domain: "hoo.example.com.",
app: "app2",
},
lookups: []lookup{
{
connKey: connectorPeers[0].Key(),
expectedIPs: []netip.Prefix{
transitIPs[0],
transitIPs[1],
},
expectedOk: true,
},
{
connKey: connectorPeers[1].Key(),
expectedIPs: []netip.Prefix{
transitIPs[2],
},
expectedOk: true,
},
},
},
}
for _, tt := range steps {
t.Run(tt.name, func(t *testing.T) {
// Add and enqueue the addrs, and then wait for the send to complete
// (as indicated by authReconfig being called).
if err := ext.conn25.client.assignments.insert(tt.as); err != nil {
t.Fatalf("error inserting address assignment: %v", err)
}
if err := ext.conn25.client.enqueueAddressAssignment(tt.as); err != nil {
t.Fatalf("error enqueuing address assignment: %v", err)
}
select {
case <-authReconfigAsyncCalled:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for AuthReconfigAsync to be called")
}
// Check that each of the lookups behaves as expected
for i, lu := range tt.lookups {
got, ok := ext.conn25.client.assignments.lookupTransitIPsByConnKey(lu.connKey)
if ok != lu.expectedOk {
t.Fatalf("unexpected ok result at index %d wanted %v, got %v", i, lu.expectedOk, ok)
}
slices.SortFunc(got, func(a, b netip.Prefix) int { return a.Compare(b) })
if diff := cmp.Diff(lu.expectedIPs, got, cmpopts.EquateComparable(netip.Prefix{})); diff != "" {
t.Fatalf("transit IPs mismatch at index %d, (-want +got):\n%s", i, diff)
}
}
})
}
}
func TestTransitIPConnMapping(t *testing.T) {
conn25 := newConn25(t.Logf)
as := addrs{
dst: netip.MustParseAddr("1.2.3.1"),
magic: netip.MustParseAddr("100.64.0.1"),
transit: netip.MustParseAddr("169.254.0.1"),
domain: "woo.example.com.",
app: "app1",
}
connectorPeers := []tailcfg.NodeView{
(&tailcfg.Node{
ID: tailcfg.NodeID(0),
Tags: []string{"tag:woo"},
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
Key: key.NodePublic{},
}).View(),
(&tailcfg.Node{
ID: tailcfg.NodeID(2),
Tags: []string{"tag:hoo"},
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
Key: key.NodePublicFromRaw32(mem.B([]byte{0: 0xff, 31: 0x02})),
}).View(),
}
// Adding a transit IP that isn't known should fail
if err := conn25.client.addTransitIPForConnector(as.transit, connectorPeers[1]); err == nil {
t.Error("adding an unknown transit IP should fail")
}
// Insert the address assignments
conn25.client.assignments.insert(as)
// Adding a transit IP for a node with an unset key should fail
if err := conn25.client.addTransitIPForConnector(as.transit, connectorPeers[0]); err == nil {
t.Error("adding an transit IP mapping for a connector with a zero key should fail")
}
// Adding a transit IP that is known should succeed
if err := conn25.client.addTransitIPForConnector(as.transit, connectorPeers[1]); err != nil {
t.Errorf("unexpected error for first time add: %v", err)
}
// But doing it again should fail
if err := conn25.client.addTransitIPForConnector(as.transit, connectorPeers[1]); err == nil {
t.Error("adding a duplicate transitIP for a connector should fail")
}
}
func TestClientTransitIPForMagicIP(t *testing.T) {
sn := makeSelfNode(t, []appctype.Conn25Attr{{
MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10
}}, []string{})
mappedMip := netip.MustParseAddr("100.64.0.0")
mappedTip := netip.MustParseAddr("169.0.0.0")
unmappedMip := netip.MustParseAddr("100.64.0.1")
nonMip := netip.MustParseAddr("100.64.0.11")
for _, tt := range []struct {
name string
mip netip.Addr
wantTip netip.Addr
wantErr error
}{
{
name: "not-a-magic-ip",
mip: nonMip,
wantTip: netip.Addr{},
wantErr: nil,
},
{
name: "unmapped-magic-ip",
mip: unmappedMip,
wantTip: netip.Addr{},
wantErr: ErrUnmappedMagicIP,
},
{
name: "mapped-magic-ip",
mip: mappedMip,
wantTip: mappedTip,
wantErr: nil,
},
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(t.Logf)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
c.client.assignments.insert(addrs{
magic: mappedMip,
transit: mappedTip,
})
tip, err := c.client.transitIPForMagicIP(tt.mip)
if tip != tt.wantTip {
t.Fatalf("checking transit ip: want %v, got %v", tt.wantTip, tip)
}
if err != tt.wantErr {
t.Fatalf("checking error: want %v, got %v", tt.wantErr, err)
}
})
}
}
func TestConnectorRealIPForTransitIPConnection(t *testing.T) {
sn := makeSelfNode(t, []appctype.Conn25Attr{{
TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50
}}, []string{})
mappedSrc := netip.MustParseAddr("100.0.0.1")
unmappedSrc := netip.MustParseAddr("100.0.0.2")
mappedTip := netip.MustParseAddr("100.64.0.41")
unmappedTip := netip.MustParseAddr("100.64.0.42")
nonTip := netip.MustParseAddr("100.0.0.3")
mappedMip := netip.MustParseAddr("100.64.0.1")
for _, tt := range []struct {
name string
src netip.Addr
tip netip.Addr
wantMip netip.Addr
wantErr error
}{
{
name: "not-a-transit-ip-unmapped-src",
src: unmappedSrc,
tip: nonTip,
wantMip: netip.Addr{},
wantErr: nil,
},
{
name: "not-a-transit-ip-mapped-src",
src: mappedSrc,
tip: nonTip,
wantMip: netip.Addr{},
wantErr: nil,
},
{
name: "unmapped-src-transit-ip",
src: unmappedSrc,
tip: unmappedTip,
wantMip: netip.Addr{},
wantErr: ErrUnmappedSrcAndTransitIP,
},
{
name: "unmapped-tip-transit-ip",
src: mappedSrc,
tip: unmappedTip,
wantMip: netip.Addr{},
wantErr: ErrUnmappedSrcAndTransitIP,
},
{
name: "mapped-src-and-transit-ip",
src: mappedSrc,
tip: mappedTip,
wantMip: mappedMip,
wantErr: nil,
},
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(t.Logf)
if err := c.reconfig(sn); err != nil {
t.Fatal(err)
}
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}
mip, err := c.connector.realIPForTransitIPConnection(tt.src, tt.tip)
if mip != tt.wantMip {
t.Fatalf("checking magic ip: want %v, got %v", tt.wantMip, mip)
}
if err != tt.wantErr {
t.Fatalf("checking error: want %v, got %v", tt.wantErr, err)
}
})
}
}
func TestIsKnownTransitIP(t *testing.T) {
knownTip := netip.MustParseAddr("100.64.0.41")
unknownTip := netip.MustParseAddr("100.64.0.42")
c := newConn25(t.Logf)
c.client.assignments.insert(addrs{
transit: knownTip,
})
if !c.client.isKnownTransitIP(knownTip) {
t.Fatal("knownTip: should have been known")
}
if c.client.isKnownTransitIP(unknownTip) {
t.Fatal("unknownTip: should not have been known")
}
}
func TestLinkLocalAllow(t *testing.T) {
knownTip := netip.MustParseAddr("100.64.0.41")
c := newConn25(t.Logf)
c.client.assignments.insert(addrs{
transit: knownTip,
})
if allow, _ := c.client.linkLocalAllow(packet.Parsed{
Dst: netip.AddrPortFrom(knownTip, 1234),
}); !allow {
t.Fatal("knownTip: should have been allowed")
}
if allow, _ := c.client.linkLocalAllow(packet.Parsed{
Dst: netip.AddrPort{},
}); allow {
t.Fatal("unknownTip: should not have been allowed")
}
}
func TestConnectorPacketFilterAllow(t *testing.T) {
knownTip := netip.MustParseAddr("100.64.0.41")
knownSrc := netip.MustParseAddr("100.64.0.1")
unknownTip := netip.MustParseAddr("100.64.0.42")
unknownSrc := netip.MustParseAddr("100.64.0.42")
c := newConn25(t.Logf)
c.connector.transitIPs = map[netip.Addr]map[netip.Addr]appAddr{}
c.connector.transitIPs[knownSrc] = map[netip.Addr]appAddr{}
c.connector.transitIPs[knownSrc][knownTip] = appAddr{}
if allow, _ := c.connector.packetFilterAllow(packet.Parsed{
Src: netip.AddrPortFrom(knownSrc, 1234),
Dst: netip.AddrPortFrom(knownTip, 1234),
}); !allow {
t.Fatal("knownTip: should have been allowed")
}
if allow, _ := c.connector.packetFilterAllow(packet.Parsed{
Src: netip.AddrPortFrom(unknownSrc, 1234),
Dst: netip.AddrPortFrom(knownTip, 1234),
}); allow {
t.Fatal("unknownSrc: should not have been allowed")
}
if allow, _ := c.connector.packetFilterAllow(packet.Parsed{
Src: netip.AddrPortFrom(knownSrc, 1234),
Dst: netip.AddrPortFrom(unknownTip, 1234),
}); allow {
t.Fatal("unknownTip: should not have been allowed")
}
}