// 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 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)

	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-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)
	}
	// 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{{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)
	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")
	}
}

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)
			}
		})
	}
}
