Conn25 hands out dummy IP addresses for use in the connector flow from limited address pools. When the addresses are no longer in use we expire the corresponding entry from our table of address mappings and return the addresses to their pools for reuse. We currently expire addresses after the DNS TTL for the DNS response that caused the mappings to be created. Stop expiring mappings when there are active packet flows for the addresses in the mappings. Fixes tailscale/corp#43180 Co-authored-by: Fran Bull <fran@tailscale.com> Co-authored-by: Michael Ben-Ami <mzb@tailscale.com> Signed-off-by: Fran Bull <fran@tailscale.com> Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2808 lines
86 KiB
Go
2808 lines
86 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package conn25
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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"
|
|
"tailscale.com/ipn/ipnext"
|
|
"tailscale.com/net/dns"
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/net/packet"
|
|
"tailscale.com/net/tsdial"
|
|
"tailscale.com/net/tstun"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tsd"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/types/appctype"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/util/dnsname"
|
|
"tailscale.com/util/eventbus/eventbustest"
|
|
"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.reconfig(&config{
|
|
isConfigured: true,
|
|
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)
|
|
const appName = "a"
|
|
domainStr := "example.com."
|
|
cfg := &config{
|
|
isConfigured: true,
|
|
appsByName: map[string]appctype.Conn25Attr{appName: {}},
|
|
ipSets: ipSets{
|
|
v4Magic: mustIPSetFromPrefix("100.64.0.0/24"),
|
|
v6Magic: mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"),
|
|
v4Transit: mustIPSetFromPrefix("169.254.0.0/24"),
|
|
v6Transit: mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"),
|
|
},
|
|
}
|
|
c.reconfig(cfg)
|
|
domain := must.Get(dnsname.ToFQDN(domainStr))
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
dst netip.Addr
|
|
wantMagic netip.Addr
|
|
wantTransit netip.Addr
|
|
}{
|
|
{
|
|
name: "v4",
|
|
dst: netip.MustParseAddr("0.0.0.1"),
|
|
wantMagic: netip.MustParseAddr("100.64.0.0"), // first from magic pool
|
|
wantTransit: netip.MustParseAddr("169.254.0.0"), // first from transit pool
|
|
},
|
|
{
|
|
name: "v6",
|
|
dst: netip.MustParseAddr("::1"),
|
|
wantMagic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:100::"), // first from magic pool
|
|
wantTransit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:200::"), // first from transit pool
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
addrs, err := c.client.reserveAddresses(appName, domain, tt.dst, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tt.dst != addrs.dst {
|
|
t.Errorf("want %v, got %v", tt.dst, addrs.dst)
|
|
}
|
|
if tt.wantMagic != addrs.magic {
|
|
t.Errorf("want %v, got %v", tt.wantMagic, addrs.magic)
|
|
}
|
|
if tt.wantTransit != addrs.transit {
|
|
t.Errorf("want %v, got %v", tt.wantTransit, addrs.transit)
|
|
}
|
|
if appName != addrs.app {
|
|
t.Errorf("want %s, got %s", appName, addrs.app)
|
|
}
|
|
if domain != addrs.domain {
|
|
t.Errorf("want %s, got %s", domain, 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),
|
|
},
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): []tailcfg.RawMessage{
|
|
tailcfg.RawMessage("{}"),
|
|
},
|
|
}
|
|
|
|
c := newConn25(logger.Discard)
|
|
if c.isConfigured() {
|
|
t.Fatal("expected Conn25 isConfigured() to report unconfigured before reconfig")
|
|
}
|
|
|
|
sn := (&tailcfg.Node{
|
|
CapMap: capMap,
|
|
}).View()
|
|
cfg := mustConfig(t, sn)
|
|
c.reconfig(cfg)
|
|
|
|
if !c.isConfigured() {
|
|
t.Fatal("expected Conn25 isConfigured() to report configured after reconfig")
|
|
}
|
|
|
|
cfg, ok := c.getConfig()
|
|
if !ok {
|
|
t.Fatal("expected Conn25 getConfig() to report configured after reconfig")
|
|
}
|
|
|
|
if len(cfg.apps) != 1 || cfg.apps[0].Name != "app1" {
|
|
t.Fatalf("want apps to have one entry 'app1', got %v", cfg.apps)
|
|
}
|
|
}
|
|
|
|
func getRawMessages[T any](t *testing.T, attrs []T) []tailcfg.RawMessage {
|
|
t.Helper()
|
|
toRet := make([]tailcfg.RawMessage, 0, len(attrs))
|
|
for _, attr := range attrs {
|
|
bs, err := json.Marshal(attr)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error in test setup: %v", err)
|
|
}
|
|
toRet = append(toRet, tailcfg.RawMessage(bs))
|
|
}
|
|
return toRet
|
|
}
|
|
|
|
func TestConfigFromNodeView(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
rawAppCfg string
|
|
appCfg []appctype.Conn25Attr
|
|
rawPoolsCfg string
|
|
poolsCfg []appctype.Conn25PoolsAttr
|
|
tags []string
|
|
wantErr bool
|
|
wantAppsByDomain map[dnsname.FQDN][]string
|
|
wantAppsByWCDomain map[dnsname.FQDN][]string
|
|
wantSelfAppNames set.Set[string]
|
|
}{
|
|
{
|
|
name: "bad-app-config",
|
|
rawAppCfg: `bad`,
|
|
rawPoolsCfg: "{}",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "bad-pool-config",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
|
|
},
|
|
rawPoolsCfg: "bad",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "simple",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
|
|
{Name: "two", Domains: []string{"b.example.com"}, Connectors: []string{"tag:two"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
tags: []string{"tag:one"},
|
|
wantAppsByDomain: map[dnsname.FQDN][]string{
|
|
"a.example.com.": {"one"},
|
|
"b.example.com.": {"two"},
|
|
},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
|
|
wantSelfAppNames: set.SetOf([]string{"one"}),
|
|
},
|
|
{
|
|
name: "more-complex-with-connector-self-domains",
|
|
appCfg: []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"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
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"},
|
|
},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
|
|
wantSelfAppNames: set.SetOf([]string{"one", "four"}),
|
|
},
|
|
{
|
|
name: "eligible-connector-no-matching-tag-no-self-domains",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
|
|
{Name: "two", Domains: []string{"b.example.com"}, Connectors: []string{"tag:two"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
tags: []string{"tag:unrelated"},
|
|
wantAppsByDomain: map[dnsname.FQDN][]string{
|
|
"a.example.com.": {"one"},
|
|
"b.example.com.": {"two"},
|
|
},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{}},
|
|
{
|
|
name: "wildcard-collapse-and-deduplication",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"*.example.com", "example.com"}, Connectors: []string{"tag:one"}},
|
|
{Name: "two", Domains: []string{"example.com", "sub.example.com"}, Connectors: []string{"tag:two"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
tags: []string{"tag:one", "tag:two"},
|
|
wantAppsByDomain: map[dnsname.FQDN][]string{
|
|
"example.com.": {"one", "two"},
|
|
"sub.example.com.": {"two"},
|
|
},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{
|
|
"example.com.": {"one"},
|
|
},
|
|
wantSelfAppNames: set.SetOf([]string{"one", "two"}),
|
|
},
|
|
{
|
|
// Domain names that differ only in case must be treated as the same
|
|
// domain and the app name must appear exactly once in appNamesByDomain,
|
|
// not once per case variant.
|
|
name: "case-variant-exact-domains-deduplicated-within-app",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"EXAMPLE.com", "example.COM", "Example.COM"}, Connectors: []string{"tag:one"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
tags: []string{"tag:one"},
|
|
wantAppsByDomain: map[dnsname.FQDN][]string{
|
|
"example.com.": {"one"},
|
|
},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
|
|
wantSelfAppNames: set.SetOf([]string{"one"}),
|
|
},
|
|
{
|
|
// Same as above but for wildcard domains: *.EXAMPLE.com and *.example.COM
|
|
// must collapse to a single entry in appNamesByWCDomain.
|
|
name: "case-variant-wildcard-domains-deduplicated-within-app",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"*.EXAMPLE.com", "*.example.COM"}, Connectors: []string{"tag:one"}},
|
|
},
|
|
rawPoolsCfg: "{}",
|
|
tags: []string{"tag:one"},
|
|
wantAppsByDomain: map[dnsname.FQDN][]string{},
|
|
wantAppsByWCDomain: map[dnsname.FQDN][]string{"example.com.": {"one"}},
|
|
wantSelfAppNames: set.SetOf([]string{"one"}),
|
|
},
|
|
{
|
|
name: "too-many-pools",
|
|
appCfg: []appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
|
|
},
|
|
poolsCfg: []appctype.Conn25PoolsAttr{
|
|
{},
|
|
{},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
appCfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawAppCfg)}
|
|
if tt.appCfg != nil {
|
|
appCfg = getRawMessages(t, tt.appCfg)
|
|
}
|
|
poolsCfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawPoolsCfg)}
|
|
if tt.poolsCfg != nil {
|
|
poolsCfg = getRawMessages(t, tt.poolsCfg)
|
|
}
|
|
capMap := tailcfg.NodeCapMap{
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): appCfg,
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): poolsCfg,
|
|
}
|
|
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.wantAppsByWCDomain, c.appNamesByWCDomain); diff != "" {
|
|
t.Errorf("appsByWCDomain diff (-want, +got):\n%s", diff)
|
|
}
|
|
if diff := cmp.Diff(tt.wantSelfAppNames, c.selfAppNames); diff != "" {
|
|
t.Errorf("selfAppNames diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAppsForDomainName(t *testing.T) {
|
|
defaultSN := makeSelfNode(
|
|
t,
|
|
[]appctype.Conn25Attr{
|
|
{Name: "one", Domains: []string{"*.example.com", "example.com"}, Connectors: []string{"tag:one"}},
|
|
{Name: "two", Domains: []string{"sub.example.com", "example.com"}, Connectors: []string{"tag:two"}},
|
|
{Name: "three", Domains: []string{"*.sub.example.com"}, Connectors: []string{"tag:three"}},
|
|
{Name: "four", Domains: []string{"a.sub.example.com"}, Connectors: []string{"tag:four"}},
|
|
{Name: "self-routed", Domains: []string{"*.wildcard.com", "exact-match.com"}, Connectors: []string{"tag:self-routed"}},
|
|
},
|
|
appctype.Conn25PoolsAttr{},
|
|
[]string{"tag:self-routed"},
|
|
)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
isConnector bool
|
|
domain dnsname.FQDN
|
|
wantApps []string
|
|
}{
|
|
{
|
|
name: "no-match",
|
|
domain: "nomatch.com.",
|
|
wantApps: nil,
|
|
},
|
|
{
|
|
name: "exact-match",
|
|
domain: "example.com.",
|
|
wantApps: []string{"one", "two"},
|
|
},
|
|
{
|
|
name: "wildcard-subdomain-match",
|
|
domain: "a.example.com.",
|
|
wantApps: []string{"one"},
|
|
},
|
|
{
|
|
name: "exact-subdomain-match",
|
|
domain: "sub.example.com.",
|
|
wantApps: []string{"two"},
|
|
},
|
|
{
|
|
name: "wildcard-sub-of-subdomain-match",
|
|
domain: "b.sub.example.com.",
|
|
wantApps: []string{"three"},
|
|
},
|
|
{
|
|
name: "exact-sub-of-subdomain-match",
|
|
domain: "a.sub.example.com.",
|
|
wantApps: []string{"four"},
|
|
},
|
|
{
|
|
name: "exact-domain-matches-wildcard",
|
|
domain: "wildcard.com.",
|
|
wantApps: []string{"self-routed"},
|
|
},
|
|
{
|
|
name: "self-routed-exact-domain-suppressed",
|
|
isConnector: true,
|
|
domain: "exact-match.com.",
|
|
wantApps: nil,
|
|
},
|
|
{
|
|
// Self node is an eligible connector for "wildcard-self-app" via
|
|
// *.wildcard.com, so the wildcard match must also be suppressed.
|
|
name: "self-routed-wildcard-domain-suppressed",
|
|
isConnector: true,
|
|
domain: "sub.wildcard.com.",
|
|
wantApps: nil,
|
|
},
|
|
{
|
|
// "other-app" is not on a self-connector tag, so it must not be suppressed.
|
|
name: "non-self-routed-domain-not-suppressed",
|
|
isConnector: true,
|
|
domain: "example.com.",
|
|
wantApps: []string{"one", "two"},
|
|
},
|
|
{
|
|
// Even though the app's connector tag matches the self node's tags,
|
|
// if the node is not an eligible connector (Advertise=false) then
|
|
// isSelfRoutedApp returns false and the domain is forwarded normally.
|
|
name: "not-eligible-connector-not-suppressed",
|
|
domain: "exact-match.com.",
|
|
wantApps: []string{"self-routed"},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newConn25(logger.Discard)
|
|
if tt.isConnector {
|
|
c.prefsAdvertiseConnector.Store(true)
|
|
}
|
|
cfg := mustConfig(t, defaultSN)
|
|
c.reconfig(cfg)
|
|
cfg, ok := c.getConfig()
|
|
if !ok {
|
|
t.Fatal("could not get config")
|
|
}
|
|
gotApps := cfg.getAppsForConnectorDomain(tt.domain, tt.isConnector)
|
|
if diff := cmp.Diff(tt.wantApps, gotApps); diff != "" {
|
|
t.Errorf("unexpected appNames result: diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, pools appctype.Conn25PoolsAttr, 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))
|
|
}
|
|
poolsBytes, err := json.Marshal(pools)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error marshaling pools in test setup: %v", err)
|
|
}
|
|
capMap := tailcfg.NodeCapMap{
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
|
|
tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): {tailcfg.RawMessage(poolsBytes)},
|
|
}
|
|
|
|
return (&tailcfg.Node{
|
|
CapMap: capMap,
|
|
Tags: tags,
|
|
}).View()
|
|
}
|
|
|
|
var (
|
|
testPrefsNotConnector = (&ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: false}}).View()
|
|
)
|
|
|
|
func mustConfig(t *testing.T, selfNode tailcfg.NodeView) *config {
|
|
t.Helper()
|
|
cfg, err := configFromNodeView(selfNode)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func v4RangeFrom(from, to string) netipx.IPRange {
|
|
return netipx.IPRangeFrom(
|
|
netip.MustParseAddr("100.64.0."+from),
|
|
netip.MustParseAddr("100.64.0."+to),
|
|
)
|
|
}
|
|
|
|
func v6RangeFrom(from, to string) netipx.IPRange {
|
|
return netipx.IPRangeFrom(
|
|
netip.MustParseAddr("fd7a:115c:a1e0:a99c:"+from+"::"),
|
|
netip.MustParseAddr("fd7a:115c:a1e0:a99c:"+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)
|
|
case dnsmessage.TypeCNAME:
|
|
body, ok := (ans.Body).(*dnsmessage.CNAMEResource)
|
|
if !ok {
|
|
t.Fatalf("unexpected answer type, update test")
|
|
}
|
|
b.CNAMEResource(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
|
|
appDomains []string
|
|
domain string
|
|
v4Addrs []*dnsmessage.AResource
|
|
v6Addrs []*dnsmessage.AAAAResource
|
|
selfTags []string
|
|
isEligibleConnector bool
|
|
wantByMagicIP map[netip.Addr]*addrs
|
|
}{
|
|
{
|
|
name: "one-ip-matches",
|
|
appDomains: []string{"example.com"},
|
|
domain: "example.com.",
|
|
v4Addrs: []*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: "v6-ip-matches",
|
|
appDomains: []string{"example.com"},
|
|
domain: "example.com.",
|
|
v6Addrs: []*dnsmessage.AAAAResource{
|
|
{AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
|
|
{AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
|
|
},
|
|
wantByMagicIP: map[netip.Addr]*addrs{
|
|
netip.MustParseAddr("fd7a:115c:a1e0:a99c::"): {
|
|
domain: "example.com.",
|
|
dst: netip.MustParseAddr("::1"),
|
|
magic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::"),
|
|
transit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:40::"),
|
|
app: "app1",
|
|
},
|
|
netip.MustParseAddr("fd7a:115c:a1e0:a99c::1"): {
|
|
domain: "example.com.",
|
|
dst: netip.MustParseAddr("::2"),
|
|
magic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::1"),
|
|
transit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:40::1"),
|
|
app: "app1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple-ip-matches",
|
|
appDomains: []string{"example.com"},
|
|
domain: "example.com.",
|
|
v4Addrs: []*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",
|
|
appDomains: []string{"foo.example.com"},
|
|
domain: "bad.example.com.",
|
|
v4Addrs: []*dnsmessage.AResource{
|
|
{A: [4]byte{1, 0, 0, 0}},
|
|
{A: [4]byte{2, 0, 0, 0}},
|
|
},
|
|
},
|
|
{
|
|
name: "no-rewrite-self-routed-domain",
|
|
appDomains: []string{"example.com"},
|
|
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",
|
|
appDomains: []string{"example.com"},
|
|
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",
|
|
appDomains: []string{"example.com"},
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "subdomain-matches-wildcard",
|
|
appDomains: []string{"*.example.com"},
|
|
domain: "sub.example.com.",
|
|
v4Addrs: []*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: "sub.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: "exact-subdomain-matches",
|
|
appDomains: []string{"example.com", "sub.example.com"},
|
|
domain: "sub.example.com.",
|
|
v4Addrs: []*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: "sub.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: "wildcard-subdomain-matches-subdomain",
|
|
appDomains: []string{"example.com", "*.sub.example.com"},
|
|
domain: "a.sub.example.com.",
|
|
v4Addrs: []*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: "a.sub.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
|
|
if len(tt.v4Addrs) > 0 {
|
|
dnsResp = makeDNSResponse(t, tt.domain, tt.v4Addrs)
|
|
} else {
|
|
dnsResp = makeV6DNSResponse(t, tt.domain, tt.v6Addrs)
|
|
}
|
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
|
Name: "app1",
|
|
Connectors: []string{"tag:woo"},
|
|
Domains: tt.appDomains,
|
|
}}, appctype.Conn25PoolsAttr{
|
|
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10"), v4RangeFrom("20", "30")},
|
|
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")},
|
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
|
V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")},
|
|
}, tt.selfTags)
|
|
|
|
c := newConn25(logger.Discard)
|
|
cfg := mustConfig(t, sn)
|
|
c.reconfig(cfg)
|
|
c.prefsAdvertiseConnector.Store(tt.isEligibleConnector)
|
|
|
|
c.mapDNSResponse(dnsResp)
|
|
if diff := cmp.Diff(
|
|
tt.wantByMagicIP,
|
|
c.client.assignments.byMagicIP,
|
|
cmp.AllowUnexported(addrs{}),
|
|
cmpopts.IgnoreFields(addrs{}, "expiresAt"),
|
|
cmpopts.EquateComparable(netip.Addr{}),
|
|
); diff != "" {
|
|
t.Errorf("byMagicIP diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMapDNSResponseSetsExpiryBasedOnTTL(t *testing.T) {
|
|
configuredDomain := "example.com"
|
|
domainName := configuredDomain + "."
|
|
dnsMessageName := dnsmessage.MustNewName(domainName)
|
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
|
Name: "app1",
|
|
Connectors: []string{"tag:woo"},
|
|
Domains: []string{configuredDomain},
|
|
}}, appctype.Conn25PoolsAttr{
|
|
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")},
|
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
|
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))},
|
|
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
|
|
}, nil)
|
|
|
|
c := newConn25(logger.Discard)
|
|
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
|
|
c.client.assignments.clock = clock
|
|
cfg := mustConfig(t, sn)
|
|
c.reconfig(cfg)
|
|
|
|
ipOne := netip.MustParseAddr("1.0.0.1")
|
|
ipTwo := netip.MustParseAddr("1.0.0.2")
|
|
ipTooBig := netip.MustParseAddr("1.0.0.3")
|
|
ipTooSmall := netip.MustParseAddr("1.0.0.4")
|
|
dnsResp := makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 300},
|
|
Body: &dnsmessage.AResource{A: ipOne.As4()},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60},
|
|
Body: &dnsmessage.AResource{A: ipTwo.As4()},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 1000000},
|
|
Body: &dnsmessage.AResource{A: ipTooBig.As4()},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 1},
|
|
Body: &dnsmessage.AResource{A: ipTooSmall.As4()},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
c.mapDNSResponse(dnsResp)
|
|
|
|
assertExpiresAt := func(addr netip.Addr, want time.Time) {
|
|
t.Helper()
|
|
as, ok := c.client.assignments.lookupByDomainDst("example.com.", addr)
|
|
if !ok {
|
|
t.Fatal("setup wrong expected to find addrs")
|
|
}
|
|
got := as.expiresAt
|
|
if !got.Equal(want) {
|
|
t.Fatalf("want %v; got %v", want, got)
|
|
}
|
|
}
|
|
|
|
assertExpiresAt(ipOne, clock.Now().Add(300*time.Second))
|
|
assertExpiresAt(ipTwo, clock.Now().Add(60*time.Second))
|
|
assertExpiresAt(ipTooBig, clock.Now().Add(72*time.Hour)) // max we will do is 72 hours
|
|
assertExpiresAt(ipTooSmall, clock.Now().Add(60*time.Second)) // min we will do is 1 min
|
|
|
|
ipThree := netip.MustParseAddr("::1")
|
|
ipFour := netip.MustParseAddr("::2")
|
|
dnsRespV6 := makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 301},
|
|
Body: &dnsmessage.AAAAResource{AAAA: ipThree.As16()},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 61},
|
|
Body: &dnsmessage.AAAAResource{AAAA: ipFour.As16()},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
c.mapDNSResponse(dnsRespV6)
|
|
|
|
assertExpiresAt(ipThree, clock.Now().Add(301*time.Second))
|
|
assertExpiresAt(ipFour, clock.Now().Add(61*time.Second))
|
|
|
|
elapsed := time.Second * 30
|
|
clock.Advance(elapsed)
|
|
assertExpiresAt(ipThree, clock.Now().Add(301*time.Second).Add(-1*elapsed))
|
|
assertExpiresAt(ipFour, clock.Now().Add(61*time.Second).Add(-1*elapsed))
|
|
c.mapDNSResponse(dnsRespV6)
|
|
// after seeing the addresses again, the expiry time is pushed out.
|
|
assertExpiresAt(ipThree, clock.Now().Add(301*time.Second))
|
|
assertExpiresAt(ipFour, clock.Now().Add(61*time.Second))
|
|
|
|
}
|
|
|
|
func TestNormalizedDNSNames(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
domain string
|
|
want dnsname.FQDN
|
|
}{
|
|
{name: "no-change", domain: "example.com.", want: "example.com."},
|
|
{name: "mixed-case", domain: "eXAmPle.COM", want: "example.com."},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := normalizeDNSName(tt.domain)
|
|
if err != nil {
|
|
t.Errorf("unexpected error %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("Unexpected result, want %q, got %q", tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReserveAddressesDeduplicated(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
dst netip.Addr
|
|
}{
|
|
{
|
|
name: "v4",
|
|
dst: netip.MustParseAddr("0.0.0.1"),
|
|
},
|
|
{
|
|
name: "v6",
|
|
dst: netip.MustParseAddr("::1"),
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
const appName = "a"
|
|
conn25 := newConn25(t.Logf)
|
|
c := conn25.client
|
|
c.v4MagicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24"))
|
|
c.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"))
|
|
c.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
|
|
c.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
|
|
|
|
first, err := c.reserveAddresses(appName, "example.com.", tt.dst, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second, err := c.reserveAddresses(appName, "example.com.", tt.dst, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if first != second {
|
|
// reserveAddresses should return the existing entry when called for a domain that already has assigned addrs
|
|
t.Fatalf("want first==second, got first: %v, second: %v", first, second)
|
|
}
|
|
|
|
if got := len(c.assignments.byMagicIP); got != 1 {
|
|
t.Errorf("want 1 entry in byMagicIP, got %d", got)
|
|
}
|
|
if got := len(c.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 testProfileServices struct {
|
|
ipnext.ProfileServices
|
|
prefs ipn.PrefsView
|
|
}
|
|
|
|
func (p *testProfileServices) CurrentPrefs() ipn.PrefsView { return p.prefs }
|
|
func (p *testProfileServices) CurrentProfileState() (ipn.LoginProfileView, ipn.PrefsView) {
|
|
return ipn.LoginProfileView{}, 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) Profiles() ipnext.ProfileServices { return &testProfileServices{prefs: h.prefs} }
|
|
func (h *testHost) AuthReconfigAsync() { h.authReconfigAsync() }
|
|
|
|
type testSafeBackend struct {
|
|
ipnext.SafeBackend
|
|
sys *tsd.System
|
|
}
|
|
|
|
func newTestSafeBackend(t *testing.T) *testSafeBackend {
|
|
sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
|
|
dialer := tsdial.NewDialer(netmon.NewStatic())
|
|
sys.Dialer.Set(dialer)
|
|
ht := sys.HealthTracker.Get()
|
|
sys.DNSManager.Set(dns.NewManager(logger.Discard, nil, ht, dialer, nil, nil, "", sys.Bus.Get()))
|
|
sys.Tun.Set(&tstun.Wrapper{})
|
|
return &testSafeBackend{sys: sys}
|
|
}
|
|
|
|
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(t),
|
|
}
|
|
authReconfigAsyncCalled := make(chan struct{}, 1)
|
|
if err := ext.Init(&testHost{
|
|
nb: &testNodeBackend{
|
|
peers: []tailcfg.NodeView{connectorPeer},
|
|
peerAPIURL: peersAPI.URL,
|
|
},
|
|
prefs: testPrefsNotConnector,
|
|
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"},
|
|
}}, appctype.Conn25PoolsAttr{}, []string{})
|
|
|
|
cfg := mustConfig(t, sn)
|
|
ext.conn25.reconfig(cfg)
|
|
|
|
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 compareToRecords(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)
|
|
}
|
|
}
|
|
|
|
func assertParsesToAnswers(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)
|
|
}
|
|
}
|
|
|
|
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},
|
|
}}, appctype.Conn25PoolsAttr{
|
|
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")},
|
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
|
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))},
|
|
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
|
|
}, []string{})
|
|
|
|
cfg := mustConfig(t, sn)
|
|
|
|
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-multiple",
|
|
toMap: makeV6DNSResponse(t, domainName, []*dnsmessage.AAAAResource{
|
|
{AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()},
|
|
{AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()},
|
|
}),
|
|
assertFx: assertParsesToAnswers(
|
|
[]netip.Addr{
|
|
netip.MustParseAddr("2606:4700::6812:100"),
|
|
netip.MustParseAddr("2606:4700::6812:101"),
|
|
},
|
|
),
|
|
},
|
|
{
|
|
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-want-v4",
|
|
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")}),
|
|
},
|
|
{
|
|
name: "answer-type-mismatch-want-v6",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{
|
|
{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeAAAA,
|
|
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("2606:4700::6812:100")}),
|
|
},
|
|
{
|
|
name: "cname-resolves-to-magic-ip",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("a.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("a.example.com."),
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("b.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("b.example.com."),
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("c.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("c.example.com."),
|
|
Type: dnsmessage.TypeA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
|
|
},
|
|
},
|
|
nil,
|
|
),
|
|
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}),
|
|
},
|
|
{
|
|
name: "cname-aaaa-resolves-to-magic-ip",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{
|
|
{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeAAAA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("cdn.example.net.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("cdn.example.net."),
|
|
Type: dnsmessage.TypeAAAA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.AAAAResource{AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()},
|
|
},
|
|
},
|
|
nil,
|
|
),
|
|
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("2606:4700::6812:100")}),
|
|
},
|
|
{
|
|
name: "cname-broken-chain-skips-answer",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("cdn.example.net.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("unrelated.com."),
|
|
Type: dnsmessage.TypeA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
|
|
},
|
|
},
|
|
nil,
|
|
),
|
|
assertFx: assertParsesToAnswers(nil),
|
|
},
|
|
{
|
|
name: "cname-multi-source-same-target",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("z.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("a.example.com."),
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("z.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("z.example.com."),
|
|
Type: dnsmessage.TypeA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
|
|
},
|
|
},
|
|
nil,
|
|
),
|
|
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}),
|
|
},
|
|
{
|
|
name: "cname-has-loop",
|
|
toMap: makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsMessageName,
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("a.example.com.")},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("a.example.com."),
|
|
Type: dnsmessage.TypeCNAME,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.CNAMEResource{CNAME: dnsMessageName},
|
|
},
|
|
{
|
|
Header: dnsmessage.ResourceHeader{
|
|
Name: dnsmessage.MustNewName("z.example.com."),
|
|
Type: dnsmessage.TypeA,
|
|
Class: dnsmessage.ClassINET,
|
|
},
|
|
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
|
|
},
|
|
},
|
|
nil,
|
|
),
|
|
assertFx: assertParsesToAnswers(nil),
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newConn25(logger.Discard)
|
|
c.reconfig(cfg)
|
|
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(t),
|
|
}
|
|
authReconfigAsyncCalled := make(chan struct{}, 1)
|
|
if err := ext.Init(&testHost{
|
|
nb: &testNodeBackend{
|
|
peers: connectorPeers,
|
|
peerAPIURL: peersAPI.URL,
|
|
},
|
|
prefs: testPrefsNotConnector,
|
|
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"},
|
|
},
|
|
}, appctype.Conn25PoolsAttr{}, []string{})
|
|
|
|
cfg := mustConfig(t, sn)
|
|
ext.conn25.reconfig(cfg)
|
|
|
|
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.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)
|
|
}
|
|
// And doing it again shouldn't fail (this is done when resending mappings
|
|
// to a restarted connector)
|
|
if err := conn25.client.addTransitIPForConnector(as.transit, connectorPeers[1]); err != nil {
|
|
t.Errorf("error adding duplicate transitIP for a connector: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientTransitIPForMagicIP(t *testing.T) {
|
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{Name: "app1"}}, appctype.Conn25PoolsAttr{
|
|
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)
|
|
|
|
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")
|
|
dst := netip.MustParseAddr("0.0.0.1")
|
|
|
|
v6MappedMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::")
|
|
v6MappedTip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:100::")
|
|
v6UnmappedMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:1::")
|
|
v6NonMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:11::")
|
|
v6Dst := netip.MustParseAddr("::1")
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "v6-not-magic",
|
|
mip: v6NonMip,
|
|
wantTip: netip.Addr{},
|
|
wantErr: nil,
|
|
},
|
|
{
|
|
name: "v6-unmapped-magic-ip",
|
|
mip: v6UnmappedMip,
|
|
wantTip: netip.Addr{},
|
|
wantErr: ErrUnmappedMagicIP,
|
|
},
|
|
{
|
|
name: "v6-mapped-magic-ip",
|
|
mip: v6MappedMip,
|
|
wantTip: v6MappedTip,
|
|
wantErr: nil,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newConn25(t.Logf)
|
|
c.reconfig(cfg)
|
|
|
|
if err := c.client.assignments.insert(&addrs{
|
|
magic: mappedMip,
|
|
transit: mappedTip,
|
|
dst: dst,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.client.assignments.insert(&addrs{
|
|
magic: v6MappedMip,
|
|
transit: v6MappedTip,
|
|
dst: v6Dst,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tip, err := c.ClientTransitIPForMagicIP(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{{Name: "app1"}}, appctype.Conn25PoolsAttr{
|
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50
|
|
}, []string{})
|
|
cfg := mustConfig(t, sn)
|
|
|
|
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)
|
|
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}
|
|
mip, err := c.ConnectorRealIPForTransitIPConnection(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)
|
|
err := c.client.assignments.insert(&addrs{
|
|
transit: knownTip,
|
|
})
|
|
if err != nil {
|
|
t.Errorf("error inserting address assignment: %v", err)
|
|
return
|
|
}
|
|
|
|
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)
|
|
err := c.client.assignments.insert(&addrs{
|
|
transit: knownTip,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("error inserting address assignment: %v", err)
|
|
}
|
|
|
|
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) {
|
|
src := netip.MustParseAddr("100.64.0.1")
|
|
knownTip := netip.MustParseAddr("192.0.2.1")
|
|
unknownTip := netip.MustParseAddr("100.64.0.42")
|
|
|
|
v4TransitIPsBuilder := netipx.IPSetBuilder{}
|
|
v4TransitIPsBuilder.AddPrefix(netip.MustParsePrefix("192.0.2.0/24"))
|
|
v4TransitIPs := must.Get(v4TransitIPsBuilder.IPSet())
|
|
|
|
c := newConn25(t.Logf)
|
|
c.reconfig(&config{
|
|
isConfigured: true,
|
|
ipSets: ipSets{
|
|
v4Transit: v4TransitIPs,
|
|
},
|
|
})
|
|
|
|
if allow, _ := c.connector.packetFilterAllow(packet.Parsed{
|
|
Src: netip.AddrPortFrom(src, 1234),
|
|
Dst: netip.AddrPortFrom(knownTip, 1234),
|
|
}); !allow {
|
|
t.Fatal("knownTip: should have been allowed")
|
|
}
|
|
|
|
if allow, _ := c.connector.packetFilterAllow(packet.Parsed{
|
|
Src: netip.AddrPortFrom(src, 1234),
|
|
Dst: netip.AddrPortFrom(unknownTip, 1234),
|
|
}); allow {
|
|
t.Fatal("unknownTip: should not have been allowed")
|
|
}
|
|
}
|
|
|
|
func TestGetMagicRange(t *testing.T) {
|
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
|
Name: "app1",
|
|
Connectors: []string{"tag:woo"},
|
|
Domains: []string{"example.com"},
|
|
}}, appctype.Conn25PoolsAttr{
|
|
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)
|
|
c := newConn25(t.Logf)
|
|
c.reconfig(cfg)
|
|
ext := &extension{
|
|
conn25: c,
|
|
}
|
|
mRange := ext.getMagicRange()
|
|
somePrefixCovers := func(a netip.Addr) bool {
|
|
for _, r := range mRange.All() {
|
|
if r.Contains(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
ins := []string{
|
|
"0.0.0.1",
|
|
"0.0.0.2",
|
|
"0.0.0.3",
|
|
"::1",
|
|
"::2",
|
|
"::3",
|
|
}
|
|
outs := []string{
|
|
"0.0.0.0",
|
|
"0.0.0.4",
|
|
"::",
|
|
"::4",
|
|
}
|
|
for _, s := range ins {
|
|
if !somePrefixCovers(netip.MustParseAddr(s)) {
|
|
t.Fatalf("expected addr to be covered but was not: %s", s)
|
|
}
|
|
}
|
|
for _, s := range outs {
|
|
if somePrefixCovers(netip.MustParseAddr(s)) {
|
|
t.Fatalf("expected addr to NOT be covered but WAS: %s", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconfigDoesNotReissueInUseAddresses(t *testing.T) {
|
|
appName := "app1"
|
|
mustRange := func(from, to string) netipx.IPRange {
|
|
return netipx.IPRangeFrom(netip.MustParseAddr(from), netip.MustParseAddr(to))
|
|
}
|
|
beforeRangeV4 := mustRange("0.0.0.1", "0.0.0.3")
|
|
beforeRangeV6 := mustRange("::1", "::3")
|
|
afterRangeV4 := mustRange("0.0.0.4", "0.0.0.7")
|
|
afterRangeV6 := mustRange("::4", "::7")
|
|
makeNodeFromMagicRange := func(v4, v6 netipx.IPRange) tailcfg.NodeView {
|
|
return makeSelfNode(t, []appctype.Conn25Attr{{
|
|
Name: appName,
|
|
Connectors: []string{"tag:woo"},
|
|
Domains: []string{"example.com"},
|
|
}}, appctype.Conn25PoolsAttr{
|
|
V4MagicIPPool: []netipx.IPRange{v4},
|
|
V6MagicIPPool: []netipx.IPRange{v6},
|
|
V4TransitIPPool: []netipx.IPRange{mustRange("169.254.0.0", "169.254.0.10")},
|
|
V6TransitIPPool: []netipx.IPRange{mustRange("fd7a:115c:a1e0:a99c:0200::", "fd7a:115c:a1e0:a99c:0200::10")},
|
|
}, []string{})
|
|
}
|
|
domain := must.Get(dnsname.ToFQDN("example.com."))
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
dstOne netip.Addr
|
|
dstTwo netip.Addr
|
|
}{
|
|
{
|
|
name: "v4",
|
|
dstOne: netip.MustParseAddr("0.0.0.100"),
|
|
dstTwo: netip.MustParseAddr("0.0.0.101"),
|
|
},
|
|
{
|
|
name: "v6",
|
|
dstOne: netip.MustParseAddr("::100"),
|
|
dstTwo: netip.MustParseAddr("::101"),
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newConn25(t.Logf)
|
|
ext := &extension{
|
|
conn25: c,
|
|
}
|
|
|
|
_, err := c.client.reserveAddresses(appName, domain, tt.dstOne, 10)
|
|
if !errors.Is(err, errUninitializedIPPool) {
|
|
t.Fatalf("want %v, got %v", errUninitializedIPPool, err)
|
|
}
|
|
|
|
ext.onSelfChange(makeNodeFromMagicRange(beforeRangeV4, beforeRangeV6))
|
|
beforeAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstOne, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ext.onSelfChange(makeNodeFromMagicRange(afterRangeV4, afterRangeV6))
|
|
afterAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstTwo, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterAddrs.magic == beforeAddrs.magic {
|
|
t.Errorf("pool reissued magic: %v that was already assigned", beforeAddrs.magic)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAddressExpiryDependsOnActiveFlows creates a Conn25 and
|
|
//
|
|
// 1. runs a DNS response through it
|
|
// 2. uses the ClientFlowCreated/Removed API and advances the clock
|
|
// 3. runs a second DNS response through the Conn25
|
|
// 4. asserts things about the expected state of the clients assignments
|
|
// table based on 1-3
|
|
//
|
|
// to try and verify how the assignments table entries expiration is affected
|
|
// by the presence of active flows for the addresses in the entry
|
|
func TestAddressExpiryDependsOnActiveFlows(t *testing.T) {
|
|
configuredDomain := "example.com"
|
|
domainName := configuredDomain + "."
|
|
dnsMessageName := dnsmessage.MustNewName(domainName)
|
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
|
Name: "app1",
|
|
Connectors: []string{"tag:woo"},
|
|
Domains: []string{configuredDomain},
|
|
}}, appctype.Conn25PoolsAttr{
|
|
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")},
|
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
|
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))},
|
|
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
|
|
}, nil)
|
|
|
|
var ttlSecs uint32 = 300
|
|
ttlDur := time.Duration(ttlSecs) * time.Second
|
|
|
|
ipOne := netip.MustParseAddr("1.0.0.1")
|
|
dnsRespIPOne := makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: ttlSecs},
|
|
Body: &dnsmessage.AResource{A: ipOne.As4()},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
ipTwo := netip.MustParseAddr("1.0.0.2")
|
|
dnsRespIPTwo := makeDNSResponseForSections(t,
|
|
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
|
|
[]dnsmessage.Resource{
|
|
{
|
|
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: ttlSecs},
|
|
Body: &dnsmessage.AResource{A: ipTwo.As4()},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
flowsAndTimeFx func(*Conn25, *tstest.Clock, netip.Addr)
|
|
secondDNSResponse []byte
|
|
assertSecondDNSResponse func(*testing.T, []byte)
|
|
wantUnexpiredDstIPs set.Set[netip.Addr]
|
|
wantExpiredAtTime map[netip.Addr]time.Duration // since the startTime
|
|
}{
|
|
{
|
|
// The first dns response should create an assignments entry for ipOne
|
|
// (tested elsewhere).
|
|
// Then time advances past that entry's expiresAt.
|
|
// Then a second dns response creates an assignments entry for ipTwo.
|
|
// We clean up some expired assignments entries when we create a new one
|
|
// and so we expect the entry for ipOne to be removed, and the entry for
|
|
// ipTwo to be present.
|
|
name: "flows-zero",
|
|
flowsAndTimeFx: func(c *Conn25, clock *tstest.Clock, transit netip.Addr) {
|
|
clock.Advance(30 * time.Hour)
|
|
},
|
|
wantUnexpiredDstIPs: set.SetOf([]netip.Addr{ipTwo}),
|
|
wantExpiredAtTime: map[netip.Addr]time.Duration{
|
|
ipTwo: (30 * time.Hour) + ttlDur,
|
|
},
|
|
},
|
|
{
|
|
// Same as flows-zero except this time the datapath has let us know that
|
|
// there is a flow for the transit address that was assigned to the entry for
|
|
// ipOne.
|
|
// And so that entry does not get expired.
|
|
name: "flows-not-zero",
|
|
flowsAndTimeFx: func(c *Conn25, clock *tstest.Clock, transit netip.Addr) {
|
|
c.ClientFlowCreated(transit)
|
|
clock.Advance(30 * time.Hour)
|
|
},
|
|
wantUnexpiredDstIPs: set.SetOf([]netip.Addr{ipOne, ipTwo}),
|
|
wantExpiredAtTime: map[netip.Addr]time.Duration{
|
|
ipOne: (30 * time.Hour) + extendForActiveFlowDuration,
|
|
ipTwo: (30 * time.Hour) + ttlDur,
|
|
},
|
|
},
|
|
{
|
|
// Like flows-not-zero except that this time the datapath removed the
|
|
// client flow after creating it.
|
|
// So the expired entry is removed.
|
|
name: "last-flow-removed-a-while-ago",
|
|
flowsAndTimeFx: func(c *Conn25, clock *tstest.Clock, transit netip.Addr) {
|
|
c.ClientFlowCreated(transit)
|
|
clock.Advance(30 * time.Hour)
|
|
c.ClientFlowRemoved(transit)
|
|
clock.Advance(3 * time.Minute)
|
|
},
|
|
wantUnexpiredDstIPs: set.SetOf([]netip.Addr{ipTwo}),
|
|
wantExpiredAtTime: map[netip.Addr]time.Duration{
|
|
ipTwo: (30 * time.Hour) + (3 * time.Minute) + ttlDur,
|
|
},
|
|
},
|
|
{
|
|
// Like last-flow-removed-a-while-ago except the flow was removed recently,
|
|
// within the cooldown period.
|
|
// And so the expired entry is not removed.
|
|
name: "last-flow-recently-removed",
|
|
flowsAndTimeFx: func(c *Conn25, clock *tstest.Clock, transit netip.Addr) {
|
|
c.ClientFlowCreated(transit)
|
|
clock.Advance(30 * time.Hour)
|
|
c.ClientFlowRemoved(transit)
|
|
clock.Advance(1 * time.Second)
|
|
},
|
|
wantUnexpiredDstIPs: set.SetOf([]netip.Addr{ipOne, ipTwo}),
|
|
wantExpiredAtTime: map[netip.Addr]time.Duration{
|
|
ipOne: (30 * time.Hour) + extendForActiveFlowDuration + (1 * time.Second),
|
|
ipTwo: (30 * time.Hour) + (1 * time.Second) + ttlDur,
|
|
},
|
|
},
|
|
{
|
|
// Like flows-not-zero except that the second dns response is for the same address as the first.
|
|
// So the entry is not removed.
|
|
name: "repeated-response-with-expired-and-active-flow",
|
|
secondDNSResponse: dnsRespIPOne,
|
|
flowsAndTimeFx: func(c *Conn25, clock *tstest.Clock, transit netip.Addr) {
|
|
c.ClientFlowCreated(transit)
|
|
clock.Advance(30 * time.Hour)
|
|
},
|
|
wantUnexpiredDstIPs: set.SetOf([]netip.Addr{ipOne}),
|
|
wantExpiredAtTime: map[netip.Addr]time.Duration{
|
|
ipOne: (30 * time.Hour) + ttlDur,
|
|
},
|
|
assertSecondDNSResponse: assertParsesToAnswers(
|
|
[]netip.Addr{
|
|
netip.MustParseAddr("100.64.0.0"),
|
|
},
|
|
),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newConn25(logger.Discard)
|
|
startTime := time.Now()
|
|
clock := tstest.NewClock(tstest.ClockOpts{Start: startTime})
|
|
c.client.assignments.clock = clock
|
|
cfg := mustConfig(t, sn)
|
|
c.reconfig(cfg)
|
|
|
|
// we get a dns response for ipone
|
|
bs1 := c.mapDNSResponse(dnsRespIPOne)
|
|
assertParsesToAnswers(
|
|
[]netip.Addr{
|
|
netip.MustParseAddr("100.64.0.0"),
|
|
},
|
|
)(t, bs1)
|
|
|
|
ipOneDD := domainDst{
|
|
domain: dnsname.FQDN(domainName),
|
|
dst: ipOne,
|
|
}
|
|
|
|
// there are client flows and time passes
|
|
tt.flowsAndTimeFx(c, clock, c.client.assignments.byDomainDst[ipOneDD].transit)
|
|
|
|
// then a second dns response
|
|
dnsR2 := tt.secondDNSResponse
|
|
assertSecondResponseFx := tt.assertSecondDNSResponse
|
|
if dnsR2 == nil {
|
|
dnsR2 = dnsRespIPTwo
|
|
assertSecondResponseFx = assertParsesToAnswers(
|
|
[]netip.Addr{
|
|
netip.MustParseAddr("100.64.0.1"),
|
|
},
|
|
)
|
|
}
|
|
bs2 := c.mapDNSResponse(dnsR2)
|
|
assertSecondResponseFx(t, bs2)
|
|
|
|
// assert which addresses have expired / remain unexpired
|
|
assignmentsDsts := set.Set[netip.Addr]{}
|
|
for _, a := range c.client.assignments.byMagicIP {
|
|
assignmentsDsts.Add(a.dst)
|
|
}
|
|
if !assignmentsDsts.Equal(tt.wantUnexpiredDstIPs) {
|
|
t.Fatalf("unexpired dst IPs: want: %v, got %v", tt.wantUnexpiredDstIPs, assignmentsDsts)
|
|
}
|
|
|
|
for a, dur := range tt.wantExpiredAtTime {
|
|
dd := domainDst{
|
|
domain: dnsname.FQDN(domainName),
|
|
dst: a,
|
|
}
|
|
as := c.client.assignments.byDomainDst[dd]
|
|
expected := startTime.Add(dur)
|
|
if !as.expiresAt.Equal(expected) {
|
|
t.Fatalf("a: %v, as.ExpiredAt: %v, expected: %v, dur: %v", a, as.expiresAt, expected, dur)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|