summaryrefslogtreecommitdiffhomepage
path: root/ipn/ipnlocal
diff options
context:
space:
mode:
Diffstat (limited to 'ipn/ipnlocal')
-rw-r--r--ipn/ipnlocal/appconnector_test.go459
-rw-r--r--ipn/ipnlocal/dnsconfig_test.go94
-rw-r--r--ipn/ipnlocal/export_appconnector_test.go130
-rw-r--r--ipn/ipnlocal/extension_host.go28
-rw-r--r--ipn/ipnlocal/extension_host_test.go3
-rw-r--r--ipn/ipnlocal/local.go282
-rw-r--r--ipn/ipnlocal/local_test.go361
-rw-r--r--ipn/ipnlocal/node_backend.go18
-rw-r--r--ipn/ipnlocal/peerapi.go8
-rw-r--r--ipn/ipnlocal/peerapi_test.go251
10 files changed, 674 insertions, 960 deletions
diff --git a/ipn/ipnlocal/appconnector_test.go b/ipn/ipnlocal/appconnector_test.go
new file mode 100644
index 000000000..44d0f49ea
--- /dev/null
+++ b/ipn/ipnlocal/appconnector_test.go
@@ -0,0 +1,459 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_appconnectors
+
+package ipnlocal_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "golang.org/x/net/dns/dnsmessage"
+ _ "tailscale.com/feature/appconnectors"
+ "tailscale.com/health"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/ipnlocal"
+ "tailscale.com/ipn/store/mem"
+ "tailscale.com/tailcfg"
+ "tailscale.com/tsd"
+ "tailscale.com/types/appctype"
+ "tailscale.com/types/logger"
+ "tailscale.com/types/netmap"
+ "tailscale.com/util/eventbus/eventbustest"
+ "tailscale.com/util/must"
+ "tailscale.com/util/usermetric"
+ "tailscale.com/wgengine"
+ "tailscale.com/wgengine/filter"
+)
+
+// enableAppConnector configures the backend with app connector prefs and
+// a netmap that advertises the given domains via a wildcard connector.
+// It synchronously triggers OnAuthReconfig to activate the extension,
+// then waits for the app connector's async queue to drain.
+func enableAppConnector(t *testing.T, b *ipnlocal.LocalBackend, domains ...string) {
+ t.Helper()
+
+ // Ensure extensions are initialized (normally happens during Start()).
+ b.InitExtensionsForTest()
+
+ if len(domains) == 0 {
+ domains = []string{}
+ }
+ domainsJSON, _ := json.Marshal(domains)
+ appCfg := fmt.Sprintf(`{"name":"test","connectors":["*"],"domains":%s}`, domainsJSON)
+
+ b.SetNetMapForTest(&netmap.NetworkMap{
+ SelfNode: (&tailcfg.Node{
+ Name: "test.ts.net",
+ CapMap: tailcfg.NodeCapMap{
+ "tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)},
+ },
+ }).View(),
+ })
+
+ prefs := b.Prefs().AsStruct()
+ prefs.AppConnector = ipn.AppConnectorPrefs{Advertise: true}
+ b.EditPrefs(&ipn.MaskedPrefs{
+ Prefs: *prefs,
+ AppConnectorSet: true,
+ })
+
+ b.TriggerOnAuthReconfigForTest()
+
+ // Wait for the app connector's async domain/route processing to complete.
+ b.WaitAppConnectorForTest(t.Context())
+}
+
+func TestOfferingAppConnector(t *testing.T) {
+ b := ipnlocal.ExportNewTestBackend(t)
+ if b.OfferingAppConnector() {
+ t.Fatal("unexpected offering app connector")
+ }
+
+ enableAppConnector(t, b)
+
+ if !b.OfferingAppConnector() {
+ t.Fatal("expected offering app connector")
+ }
+
+ // Disable app connector.
+ b.EditPrefs(&ipn.MaskedPrefs{
+ Prefs: ipn.Prefs{
+ AppConnector: ipn.AppConnectorPrefs{Advertise: false},
+ },
+ AppConnectorSet: true,
+ })
+ b.TriggerOnAuthReconfigForTest()
+
+ if b.OfferingAppConnector() {
+ t.Fatal("unexpected offering app connector after disable")
+ }
+}
+
+func TestRouteAdvertiser(t *testing.T) {
+ b := ipnlocal.ExportNewTestBackend(t)
+ testPrefix := netip.MustParsePrefix("192.0.0.8/32")
+
+ if err := b.AdvertiseRoute(testPrefix); err != nil {
+ t.Fatal(err)
+ }
+
+ routes := b.Prefs().AdvertiseRoutes()
+ if routes.Len() != 1 || routes.At(0) != testPrefix {
+ t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
+ }
+
+ if err := b.UnadvertiseRoute(testPrefix); err != nil {
+ t.Fatal(err)
+ }
+
+ routes = b.Prefs().AdvertiseRoutes()
+ if routes.Len() != 0 {
+ t.Fatalf("got routes %v, want none", routes)
+ }
+}
+
+func TestRouterAdvertiserIgnoresContainedRoutes(t *testing.T) {
+ b := ipnlocal.ExportNewTestBackend(t)
+ testPrefix := netip.MustParsePrefix("192.0.0.0/24")
+
+ if err := b.AdvertiseRoute(testPrefix); err != nil {
+ t.Fatal(err)
+ }
+
+ routes := b.Prefs().AdvertiseRoutes()
+ if routes.Len() != 1 || routes.At(0) != testPrefix {
+ t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
+ }
+
+ if err := b.AdvertiseRoute(netip.MustParsePrefix("192.0.0.8/32")); err != nil {
+ t.Fatal(err)
+ }
+
+ // The /32 is not added because it is contained within the /24.
+ routes = b.Prefs().AdvertiseRoutes()
+ if routes.Len() != 1 || routes.At(0) != testPrefix {
+ t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
+ }
+}
+
+func TestObserveDNSResponse(t *testing.T) {
+ b := ipnlocal.ExportNewTestBackend(t)
+ bus := b.SysForTest().Bus.Get()
+ w := eventbustest.NewWatcher(t, bus)
+
+ // Ensure no panic when no app connector is configured.
+ b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
+
+ // Enable app connector with "example.com" domain.
+ enableAppConnector(t, b, "example.com")
+
+ b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
+ b.WaitAppConnectorForTest(t.Context())
+
+ if err := eventbustest.Expect(w,
+ eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
+ ); err != nil {
+ t.Error(err)
+ }
+}
+
+func TestReconfigureAppConnector(t *testing.T) {
+ b := ipnlocal.ExportNewTestBackend(t)
+
+ // Without advertise prefs, no app connector should be active.
+ b.TriggerOnAuthReconfigForTest()
+ if b.OfferingAppConnector() {
+ t.Fatal("unexpected app connector")
+ }
+
+ // Enable app connector with a domain.
+ enableAppConnector(t, b, "example.com")
+ if !b.OfferingAppConnector() {
+ t.Fatal("expected app connector")
+ }
+
+ // Disable the connector and verify it is removed.
+ b.EditPrefs(&ipn.MaskedPrefs{
+ Prefs: ipn.Prefs{
+ AppConnector: ipn.AppConnectorPrefs{Advertise: false},
+ },
+ AppConnectorSet: true,
+ })
+ b.TriggerOnAuthReconfigForTest()
+ if b.OfferingAppConnector() {
+ t.Fatal("expected no app connector")
+ }
+}
+
+func TestPeerAPIPrettyReplyCNAME(t *testing.T) {
+ sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
+
+ ht := health.NewTracker(sys.Bus.Get())
+ reg := new(usermetric.Registry)
+ eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
+ pm := must.Get(ipnlocal.ExportNewProfileManager(new(mem.Store), t.Logf, ht))
+ sys.Set(pm.Store())
+ sys.Set(eng)
+
+ b := ipnlocal.ExportNewTestLocalBackendWithSys(t, sys)
+ b.SetProfileManagerForTest(pm)
+
+ enableAppConnector(t, b)
+
+ ps := ipnlocal.NewPeerAPIServerForTest(b)
+ ps.SetResolver(&fakeResolver{build: func(b *dnsmessage.Builder) {
+ b.CNAMEResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName("www.example.com."),
+ Type: dnsmessage.TypeCNAME,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.CNAMEResource{
+ CNAME: dnsmessage.MustNewName("example.com."),
+ },
+ )
+ b.AResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName("example.com."),
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.AResource{
+ A: [4]byte{192, 0, 0, 8},
+ },
+ )
+ }})
+ b.SetFilterForTest(filter.NewAllowAllForTest(logger.Discard))
+
+ h := ipnlocal.NewPeerAPIHandlerForTest(ps, netip.MustParseAddrPort("100.150.151.152:12345"))
+ if !h.ReplyToDNSQueries() {
+ t.Errorf("unexpectedly deny; wanted to be a DNS server")
+ }
+
+ w := httptest.NewRecorder()
+ h.HandleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
+ if w.Code != http.StatusOK {
+ t.Errorf("unexpected status code: %v", w.Code)
+ }
+ var addrs []string
+ json.NewDecoder(w.Body).Decode(&addrs)
+ if len(addrs) == 0 {
+ t.Fatalf("no addresses returned")
+ }
+ for _, addr := range addrs {
+ netip.MustParseAddr(addr)
+ }
+}
+
+func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) {
+ sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
+ bw := eventbustest.NewWatcher(t, sys.Bus.Get())
+
+ ht := health.NewTracker(sys.Bus.Get())
+ pm := must.Get(ipnlocal.ExportNewProfileManager(new(mem.Store), t.Logf, ht))
+ reg := new(usermetric.Registry)
+ eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
+ sys.Set(pm.Store())
+ sys.Set(eng)
+
+ b := ipnlocal.ExportNewTestLocalBackendWithSys(t, sys)
+ b.SetProfileManagerForTest(pm)
+
+ enableAppConnector(t, b, "example.com")
+
+ if !b.OfferingAppConnector() {
+ t.Fatal("expecting to be offering app connector")
+ }
+
+ ps := ipnlocal.NewPeerAPIServerForTest(b)
+ ps.SetResolver(&fakeResolver{build: func(b *dnsmessage.Builder) {
+ b.AResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName("example.com."),
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.AResource{
+ A: [4]byte{192, 0, 0, 8},
+ },
+ )
+ }})
+ b.SetFilterForTest(filter.NewAllowAllForTest(logger.Discard))
+
+ h := ipnlocal.NewPeerAPIHandlerForTest(ps, netip.MustParseAddrPort("100.150.151.152:12345"))
+ if !h.ReplyToDNSQueries() {
+ t.Errorf("unexpectedly deny; wanted to be a DNS server")
+ }
+
+ w := httptest.NewRecorder()
+ h.HandleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=example.com.", nil))
+ if w.Code != http.StatusOK {
+ t.Errorf("unexpected status code: %v", w.Code)
+ }
+
+ if err := eventbustest.Expect(bw,
+ eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
+ ); err != nil {
+ t.Error(err)
+ }
+}
+
+func TestPeerAPIReplyToDNSQueriesAreObservedWithCNAMEFlattening(t *testing.T) {
+ sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
+ bw := eventbustest.NewWatcher(t, sys.Bus.Get())
+
+ ht := health.NewTracker(sys.Bus.Get())
+ reg := new(usermetric.Registry)
+ eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
+ pm := must.Get(ipnlocal.ExportNewProfileManager(new(mem.Store), t.Logf, ht))
+ sys.Set(pm.Store())
+ sys.Set(eng)
+
+ b := ipnlocal.ExportNewTestLocalBackendWithSys(t, sys)
+ b.SetProfileManagerForTest(pm)
+
+ enableAppConnector(t, b, "www.example.com")
+
+ if !b.OfferingAppConnector() {
+ t.Fatal("expecting to be offering app connector")
+ }
+
+ ps := ipnlocal.NewPeerAPIServerForTest(b)
+ ps.SetResolver(&fakeResolver{build: func(b *dnsmessage.Builder) {
+ b.CNAMEResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName("www.example.com."),
+ Type: dnsmessage.TypeCNAME,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.CNAMEResource{
+ CNAME: dnsmessage.MustNewName("example.com."),
+ },
+ )
+ b.AResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName("example.com."),
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.AResource{
+ A: [4]byte{192, 0, 0, 8},
+ },
+ )
+ }})
+ b.SetFilterForTest(filter.NewAllowAllForTest(logger.Discard))
+
+ h := ipnlocal.NewPeerAPIHandlerForTest(ps, netip.MustParseAddrPort("100.150.151.152:12345"))
+ if !h.ReplyToDNSQueries() {
+ t.Errorf("unexpectedly deny; wanted to be a DNS server")
+ }
+
+ w := httptest.NewRecorder()
+ h.HandleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
+ if w.Code != http.StatusOK {
+ t.Errorf("unexpected status code: %v", w.Code)
+ }
+
+ if err := eventbustest.Expect(bw,
+ eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
+ ); err != nil {
+ t.Error(err)
+ }
+}
+
+// fakeResolver implements peerDNSQueryHandler for testing.
+type fakeResolver struct {
+ build func(*dnsmessage.Builder)
+}
+
+func (f *fakeResolver) HandlePeerDNSQuery(_ context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
+ b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
+ b.EnableCompression()
+ b.StartAnswers()
+ f.build(&b)
+ return b.Finish()
+}
+
+// dnsResponse creates a DNS response buffer for the given domain and address.
+func dnsResponse(domain, address string) []byte {
+ addr := netip.MustParseAddr(address)
+ b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
+ b.EnableCompression()
+ b.StartAnswers()
+ switch addr.BitLen() {
+ case 32:
+ b.AResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName(domain),
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.AResource{
+ A: addr.As4(),
+ },
+ )
+ case 128:
+ b.AAAAResource(
+ dnsmessage.ResourceHeader{
+ Name: dnsmessage.MustNewName(domain),
+ Type: dnsmessage.TypeAAAA,
+ Class: dnsmessage.ClassINET,
+ TTL: 0,
+ },
+ dnsmessage.AAAAResource{
+ AAAA: addr.As16(),
+ },
+ )
+ default:
+ panic("invalid address length")
+ }
+ return must.Get(b.Finish())
+}
+
+type textUpdate struct {
+ Advertise []string
+ Unadvertise []string
+}
+
+func routeUpdateToText(u appctype.RouteUpdate) textUpdate {
+ var out textUpdate
+ for _, p := range u.Advertise {
+ out.Advertise = append(out.Advertise, p.String())
+ }
+ for _, p := range u.Unadvertise {
+ out.Unadvertise = append(out.Unadvertise, p.String())
+ }
+ return out
+}
+
+func mustPrefix(ss ...string) (out []netip.Prefix) {
+ for _, s := range ss {
+ out = append(out, netip.MustParsePrefix(s))
+ }
+ return
+}
+
+// eqUpdate generates an eventbus test filter that matches an appctype.RouteUpdate
+// message equal to want, or reports an error giving a human-readable diff.
+func eqUpdate(want appctype.RouteUpdate) func(appctype.RouteUpdate) error {
+ return func(got appctype.RouteUpdate) error {
+ if diff := cmp.Diff(routeUpdateToText(got), routeUpdateToText(want)); diff != "" {
+ return fmt.Errorf("wrong update (-got, +want):\n%s", diff)
+ }
+ return nil
+ }
+}
diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go
index 9d30029ff..1eacec354 100644
--- a/ipn/ipnlocal/dnsconfig_test.go
+++ b/ipn/ipnlocal/dnsconfig_test.go
@@ -10,14 +10,12 @@ import (
"reflect"
"testing"
- "tailscale.com/appc"
"tailscale.com/ipn"
"tailscale.com/net/dns"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
"tailscale.com/types/netmap"
- "tailscale.com/types/opt"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
@@ -389,102 +387,12 @@ func TestDNSConfigForNetmap(t *testing.T) {
prefs: &ipn.Prefs{},
want: &dns.Config{},
},
- {
- name: "conn25-split-dns",
- nm: &netmap.NetworkMap{
- SelfNode: (&tailcfg.Node{
- Name: "a",
- Addresses: ipps("100.101.101.101"),
- CapMap: tailcfg.NodeCapMap{
- tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{
- tailcfg.RawMessage(`{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`),
- },
- },
- }).View(),
- AllCaps: set.Of(tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName)),
- },
- peers: nodeViews([]*tailcfg.Node{
- {
- ID: 1,
- Name: "p1",
- Addresses: ipps("100.102.0.1"),
- Tags: []string{"tag:woo"},
- Hostinfo: (&tailcfg.Hostinfo{
- Services: []tailcfg.Service{
- {
- Proto: tailcfg.PeerAPI4,
- Port: 1234,
- },
- },
- AppConnector: opt.NewBool(true),
- }).View(),
- },
- }),
- prefs: &ipn.Prefs{
- CorpDNS: true,
- },
- want: &dns.Config{
- AcceptDNS: true,
- Hosts: map[dnsname.FQDN][]netip.Addr{
- "a.": ips("100.101.101.101"),
- "p1.": ips("100.102.0.1"),
- },
- Routes: map[dnsname.FQDN][]*dnstype.Resolver{
- dnsname.FQDN("example.com."): {
- {Addr: "http://100.102.0.1:1234/dns-query"},
- },
- },
- },
- },
- {
- name: "conn25-split-dns-no-matching-peers",
- nm: &netmap.NetworkMap{
- SelfNode: (&tailcfg.Node{
- Name: "a",
- Addresses: ipps("100.101.101.101"),
- CapMap: tailcfg.NodeCapMap{
- tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{
- tailcfg.RawMessage(`{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`),
- },
- },
- }).View(),
- AllCaps: set.Of(tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName)),
- },
- peers: nodeViews([]*tailcfg.Node{
- {
- ID: 1,
- Name: "p1",
- Addresses: ipps("100.102.0.1"),
- Tags: []string{"tag:nomatch"},
- Hostinfo: (&tailcfg.Hostinfo{
- Services: []tailcfg.Service{
- {
- Proto: tailcfg.PeerAPI4,
- Port: 1234,
- },
- },
- AppConnector: opt.NewBool(true),
- }).View(),
- },
- }),
- prefs: &ipn.Prefs{
- CorpDNS: true,
- },
- want: &dns.Config{
- AcceptDNS: true,
- Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
- Hosts: map[dnsname.FQDN][]netip.Addr{
- "a.": ips("100.101.101.101"),
- "p1.": ips("100.102.0.1"),
- },
- },
- },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verOS := cmp.Or(tt.os, "linux")
var log tstest.MemLogger
- got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS)
+ got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS, nil)
if !reflect.DeepEqual(got, tt.want) {
gotj, _ := json.MarshalIndent(got, "", "\t")
wantj, _ := json.MarshalIndent(tt.want, "", "\t")
diff --git a/ipn/ipnlocal/export_appconnector_test.go b/ipn/ipnlocal/export_appconnector_test.go
new file mode 100644
index 000000000..f8b8ec809
--- /dev/null
+++ b/ipn/ipnlocal/export_appconnector_test.go
@@ -0,0 +1,130 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_appconnectors
+
+package ipnlocal
+
+import (
+ "context"
+ "net/http"
+ "net/netip"
+
+ "tailscale.com/health"
+ "tailscale.com/ipn/store/mem"
+ "tailscale.com/tailcfg"
+ "tailscale.com/tsd"
+ "tailscale.com/types/logger"
+ "tailscale.com/types/netmap"
+ "tailscale.com/wgengine/filter"
+)
+
+// Exported wrappers for use by appconnector_test.go (package ipnlocal_test).
+
+var (
+ ExportNewTestBackend = newTestBackend
+ ExportNewTestLocalBackendWithSys = newTestLocalBackendWithSys
+)
+
+// ExportNewProfileManager wraps newProfileManager for testing.
+func ExportNewProfileManager(store *mem.Store, logf logger.Logf, ht *health.Tracker) (*profileManager, error) {
+ return newProfileManager(store, logf, ht)
+}
+
+// InitExtensionsForTest initializes all registered extensions on the backend.
+// In production, this happens during the first call to Start().
+func (b *LocalBackend) InitExtensionsForTest() {
+ b.extHost.Init()
+}
+
+// TriggerOnAuthReconfigForTest synchronously invokes the OnAuthReconfig hooks,
+// which in production are called asynchronously by authReconfigLocked.
+func (b *LocalBackend) TriggerOnAuthReconfigForTest() {
+ nm := b.NetMap()
+ var selfNode tailcfg.NodeView
+ if nm != nil {
+ selfNode = nm.SelfNodeOrZero()
+ }
+ prefs := b.Prefs()
+ for _, f := range b.extHost.Hooks().OnAuthReconfig {
+ f(selfNode, prefs)
+ }
+}
+
+// SetNetMapForTest sets the netmap on the backend's current node.
+func (b *LocalBackend) SetNetMapForTest(nm *netmap.NetworkMap) {
+ b.currentNode().SetNetMap(nm)
+}
+
+// SysForTest returns the backend's system dependencies for testing.
+func (b *LocalBackend) SysForTest() *tsd.System {
+ return b.sys
+}
+
+// SetFilterForTest sets the packet filter on the backend.
+func (b *LocalBackend) SetFilterForTest(f *filter.Filter) {
+ b.setFilter(f)
+}
+
+// SetProfileManagerForTest overrides the backend's profile manager.
+func (b *LocalBackend) SetProfileManagerForTest(pm *profileManager) {
+ b.pm = pm
+}
+
+// PeerAPIServerForTest wraps an unexported peerAPIServer for external test access.
+type PeerAPIServerForTest struct {
+ ps *peerAPIServer
+}
+
+// NewPeerAPIServerForTest creates a peerAPIServer for testing.
+func NewPeerAPIServerForTest(b *LocalBackend) *PeerAPIServerForTest {
+ return &PeerAPIServerForTest{ps: &peerAPIServer{b: b}}
+}
+
+// PeerDNSQueryHandlerForTest is an exported alias for the unexported
+// peerDNSQueryHandler interface, for use in external test packages.
+type PeerDNSQueryHandlerForTest = peerDNSQueryHandler
+
+// SetResolver sets the DNS resolver for the peerAPI server.
+func (s *PeerAPIServerForTest) SetResolver(r PeerDNSQueryHandlerForTest) {
+ s.ps.resolver = r
+}
+
+// PeerAPIHandlerForTest wraps an unexported peerAPIHandler for external test access.
+type PeerAPIHandlerForTest struct {
+ h peerAPIHandler
+}
+
+// NewPeerAPIHandlerForTest creates a peerAPIHandler for testing.
+func NewPeerAPIHandlerForTest(ps *PeerAPIServerForTest, remoteAddr netip.AddrPort) *PeerAPIHandlerForTest {
+ return &PeerAPIHandlerForTest{h: peerAPIHandler{
+ ps: ps.ps,
+ remoteAddr: remoteAddr,
+ }}
+}
+
+// ReplyToDNSQueries reports whether the handler will serve DNS queries.
+func (h *PeerAPIHandlerForTest) ReplyToDNSQueries() bool {
+ return h.h.replyToDNSQueries()
+}
+
+// HandleDNSQuery serves a DNS query.
+func (h *PeerAPIHandlerForTest) HandleDNSQuery(w http.ResponseWriter, r *http.Request) {
+ h.h.handleDNSQuery(w, r)
+}
+
+// WaitAppConnectorForTest waits for the app connector extension's internal
+// queue to finish processing. This is needed because domain and route updates
+// are processed asynchronously.
+func (b *LocalBackend) WaitAppConnectorForTest(ctx context.Context) {
+ ext := b.extHost.FindExtensionByName("appconnectors")
+ if ext == nil {
+ return
+ }
+ type waiter interface {
+ Wait(context.Context)
+ }
+ if w, ok := ext.(waiter); ok {
+ w.Wait(ctx)
+ }
+}
diff --git a/ipn/ipnlocal/extension_host.go b/ipn/ipnlocal/extension_host.go
index 125a23294..9498b78c2 100644
--- a/ipn/ipnlocal/extension_host.go
+++ b/ipn/ipnlocal/extension_host.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"maps"
+ "net/netip"
"reflect"
"slices"
"strings"
@@ -124,6 +125,9 @@ type Backend interface {
NodeBackend() ipnext.NodeBackend
+ AdvertiseRoute(routes ...netip.Prefix) error
+ UnadvertiseRoute(routes ...netip.Prefix) error
+
ipnext.SafeBackend
}
@@ -401,6 +405,30 @@ func (h *ExtensionHost) SendNotifyAsync(n ipn.Notify) {
})
}
+// AdvertiseRoutesAsync implements [ipnext.Host].
+func (h *ExtensionHost) AdvertiseRoutesAsync(routes []netip.Prefix) {
+ if h == nil || len(routes) == 0 {
+ return
+ }
+ h.enqueueBackendOperation(func(b Backend) {
+ if err := b.AdvertiseRoute(routes...); err != nil {
+ h.logf("failed to advertise routes: %v", err)
+ }
+ })
+}
+
+// UnadvertiseRoutesAsync implements [ipnext.Host].
+func (h *ExtensionHost) UnadvertiseRoutesAsync(routes []netip.Prefix) {
+ if h == nil || len(routes) == 0 {
+ return
+ }
+ h.enqueueBackendOperation(func(b Backend) {
+ if err := b.UnadvertiseRoute(routes...); err != nil {
+ h.logf("failed to unadvertise routes: %v", err)
+ }
+ })
+}
+
// NotifyProfileChange invokes registered profile state change callbacks
// and updates the current profile and prefs in the host.
// It strips private keys from the [ipn.Prefs] before preserving
diff --git a/ipn/ipnlocal/extension_host_test.go b/ipn/ipnlocal/extension_host_test.go
index 3bd302aea..1ce4d8a3c 100644
--- a/ipn/ipnlocal/extension_host_test.go
+++ b/ipn/ipnlocal/extension_host_test.go
@@ -1377,6 +1377,9 @@ func (b *testBackend) SendNotify(ipn.Notify) { panic("not implemented"
func (b *testBackend) NodeBackend() ipnext.NodeBackend { panic("not implemented") }
func (b *testBackend) TailscaleVarRoot() string { panic("not implemented") }
+func (b *testBackend) AdvertiseRoute(routes ...netip.Prefix) error { return nil }
+func (b *testBackend) UnadvertiseRoute(routes ...netip.Prefix) error { return nil }
+
func (b *testBackend) SwitchToBestProfile(reason string) {
b.mu.Lock()
defer b.mu.Unlock()
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index 981e2df73..0694314c6 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -10,7 +10,6 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
- "encoding/json"
"errors"
"fmt"
"io"
@@ -34,7 +33,6 @@ import (
"go4.org/mem"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
- "tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlclient"
"tailscale.com/control/controlknobs"
@@ -70,7 +68,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstime"
- "tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/empty"
"tailscale.com/types/key"
@@ -86,7 +83,6 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
- "tailscale.com/util/execqueue"
"tailscale.com/util/goroutines"
"tailscale.com/util/mak"
"tailscale.com/util/osuser"
@@ -191,7 +187,6 @@ type LocalBackend struct {
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
eventClient *eventbus.Client
- appcTask execqueue.ExecQueue // handles updates from appc
health *health.Tracker // always non-nil
polc policyclient.Client // always non-nil
@@ -275,7 +270,6 @@ type LocalBackend struct {
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
ccGen clientGen // function for producing controlclient; lazily populated
sshServer SSHServer // or nil, initialized lazily.
- appConnector *appc.AppConnector // or nil, initialized when configured.
// notifyCancel cancels notifications to the current SetNotifyCallback.
notifyCancel context.CancelFunc
cc controlclient.Client // TODO(nickkhyl): move to nodeBackend
@@ -541,6 +535,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
return nil, fmt.Errorf("failed to create extension host: %w", err)
}
b.pm.SetExtensionHost(b.extHost)
+ b.setNodeBackendHooks(nb)
if b.unregisterSysPolicyWatch, err = b.registerSysPolicyWatch(); err != nil {
return nil, err
@@ -616,36 +611,15 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
if buildfeatures.HasPortList {
eventbus.SubscribeFunc(ec, b.setPortlistServices)
}
- eventbus.SubscribeFunc(ec, b.onAppConnectorRouteUpdate)
- eventbus.SubscribeFunc(ec, b.onAppConnectorStoreRoutes)
mConn.SetNetInfoCallback(b.setNetInfo) // TODO(tailscale/tailscale#17887): move to eventbus
return b, nil
}
-func (b *LocalBackend) onAppConnectorRouteUpdate(ru appctype.RouteUpdate) {
- // TODO(creachadair, 2025-10-02): It is currently possible for updates produced under
- // one profile to arrive and be applied after a switch to another profile.
- // We need to find a way to ensure that changes to the backend state are applied
- // consistently in the presnce of profile changes, which currently may not happen in
- // a single atomic step. See: https://github.com/tailscale/tailscale/issues/17414
- b.appcTask.Add(func() {
- if err := b.AdvertiseRoute(ru.Advertise...); err != nil {
- b.logf("appc: failed to advertise routes: %v: %v", ru.Advertise, err)
- }
- if err := b.UnadvertiseRoute(ru.Unadvertise...); err != nil {
- b.logf("appc: failed to unadvertise routes: %v: %v", ru.Unadvertise, err)
- }
- })
-}
-
-func (b *LocalBackend) onAppConnectorStoreRoutes(ri appctype.RouteInfo) {
- // Whether or not routes should be stored can change over time.
- shouldStoreRoutes := b.ControlKnobs().AppCStoreRoutes.Load()
- if shouldStoreRoutes {
- if err := b.storeRouteInfo(ri); err != nil {
- b.logf("appc: failed to store route info: %v", err)
- }
+// setNodeBackendHooks wires extension hooks into the given nodeBackend.
+func (b *LocalBackend) setNodeBackendHooks(nb *nodeBackend) {
+ if f, ok := b.extHost.Hooks().SplitDNSResolverPeers.GetOk(); ok {
+ nb.pickSplitDNSPeers = f
}
}
@@ -662,6 +636,7 @@ func (b *LocalBackend) currentNode() *nodeBackend {
return v
}
v := newNodeBackend(cmp.Or(b.ctx, context.Background()), b.logf, b.sys.Bus.Get())
+ b.setNodeBackendHooks(v)
if b.currentNodeAtomic.CompareAndSwap(nil, v) {
v.ready()
}
@@ -1124,7 +1099,6 @@ func (b *LocalBackend) Shutdown() {
// 1. Event handlers also acquire b.mu, they can deadlock with c.Shutdown().
// 2. Event handlers may not guard against undesirable post/in-progress
// LocalBackend.Shutdown() behaviors.
- b.appcTask.Shutdown()
b.eventClient.Close()
b.em.close()
@@ -1171,7 +1145,6 @@ func (b *LocalBackend) Shutdown() {
if b.notifyCancel != nil {
b.notifyCancel()
}
- b.appConnector.Close()
b.mu.Unlock()
b.webClientShutdown()
@@ -2475,7 +2448,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
hostinfo.FrontendLogID = opts.FrontendLogID
hostinfo.Userspace.Set(b.sys.IsNetstack())
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
- hostinfo.AppConnector.Set(b.appConnector != nil)
+ hostinfo.AppConnector.Set(b.OfferingAppConnector())
hostinfo.StateEncrypted = b.stateEncrypted()
b.logf.JSON(1, "Hostinfo", hostinfo)
@@ -2825,9 +2798,10 @@ func (b *LocalBackend) updateFilterLocked(prefs ipn.PrefsView) {
// The correct filter rules are synthesized by the coordination server
// and sent down, but the address needs to be part of the 'local net' for the
// filter package to even bother checking the filter rules, so we set them here.
- if buildfeatures.HasAppConnectors && prefs.AppConnector().Advertise {
- localNetsB.Add(netip.MustParseAddr("0.0.0.0"))
- localNetsB.Add(netip.MustParseAddr("::0"))
+ for _, f := range b.extHost.Hooks().ExtraLocalAddrs {
+ for _, addr := range f() {
+ localNetsB.Add(addr)
+ }
}
}
localNets, _ := localNetsB.IPSet()
@@ -4310,20 +4284,14 @@ func (b *LocalBackend) SetUseExitNodeEnabled(actor ipnauth.Actor, v bool) (ipn.P
return b.editPrefsLocked(actor, mp)
}
-// MaybeClearAppConnector clears the routes from any AppConnector if
-// AdvertiseRoutes has been set in the MaskedPrefs.
-func (b *LocalBackend) MaybeClearAppConnector(mp *ipn.MaskedPrefs) error {
- if !buildfeatures.HasAppConnectors {
- return nil
+// MaybeClearAutoRoutes clears auto-discovered routes (e.g., from the app
+// connector extension) if any hook is registered. It is called when the user
+// explicitly sets AdvertiseRoutes via the local API.
+func (b *LocalBackend) MaybeClearAutoRoutes() error {
+ if f, ok := b.extHost.Hooks().ClearAutoRoutes.GetOk(); ok {
+ return f()
}
- var err error
- if ac := b.AppConnector(); ac != nil && mp.AdvertiseRoutesSet {
- err = ac.ClearRoutes()
- if err != nil {
- b.logf("appc: clear routes error: %v", err)
- }
- }
- return err
+ return nil
}
// EditPrefs applies the changes in mp to the current prefs,
@@ -4952,110 +4920,6 @@ func (b *LocalBackend) blockEngineUpdatesLocked(block bool) {
b.blocked = block
}
-// reconfigAppConnectorLocked updates the app connector state based on the
-// current network map and preferences.
-// b.mu must be held.
-func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
- if !buildfeatures.HasAppConnectors {
- return
- }
- const appConnectorCapName = "tailscale.com/app-connectors"
- defer func() {
- if b.hostinfo != nil {
- b.hostinfo.AppConnector.Set(b.appConnector != nil)
- }
- }()
-
- // App connectors have been disabled.
- if !prefs.AppConnector().Advertise {
- b.appConnector.Close() // clean up a previous connector (safe on nil)
- b.appConnector = nil
- return
- }
-
- // We don't (yet) have an app connector configured, or the configured
- // connector has a different route persistence setting.
- shouldStoreRoutes := b.ControlKnobs().AppCStoreRoutes.Load()
- if b.appConnector == nil || (shouldStoreRoutes != b.appConnector.ShouldStoreRoutes()) {
- ri, err := b.readRouteInfoLocked()
- if err != nil && err != ipn.ErrStateNotExist {
- b.logf("Unsuccessful Read RouteInfo: %v", err)
- }
- b.appConnector.Close() // clean up a previous connector (safe on nil)
- b.appConnector = appc.NewAppConnector(appc.Config{
- Logf: b.logf,
- EventBus: b.sys.Bus.Get(),
- RouteInfo: ri,
- HasStoredRoutes: shouldStoreRoutes,
- })
- }
- if nm == nil {
- return
- }
-
- attrs, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](nm.SelfNode.CapMap(), appConnectorCapName)
- if err != nil {
- b.logf("[unexpected] error parsing app connector mapcap: %v", err)
- return
- }
-
- // Geometric cost, assumes that the number of advertised tags is small
- selfHasTag := func(attrTags []string) bool {
- return nm.SelfNode.Tags().ContainsFunc(func(tag string) bool {
- return slices.Contains(attrTags, tag)
- })
- }
-
- var (
- domains []string
- routes []netip.Prefix
- )
- for _, attr := range attrs {
- if slices.Contains(attr.Connectors, "*") || selfHasTag(attr.Connectors) {
- domains = append(domains, attr.Domains...)
- routes = append(routes, attr.Routes...)
- }
- }
- slices.Sort(domains)
- slices.SortFunc(routes, func(i, j netip.Prefix) int { return i.Addr().Compare(j.Addr()) })
- domains = slices.Compact(domains)
- routes = slices.Compact(routes)
- b.appConnector.UpdateDomainsAndRoutes(domains, routes)
-}
-
-func (b *LocalBackend) readvertiseAppConnectorRoutes() {
- // Note: we should never call b.appConnector methods while holding b.mu.
- // This can lead to a deadlock, like
- // https://github.com/tailscale/corp/issues/25965.
- //
- // Grab a copy of the field, since b.mu only guards access to the
- // b.appConnector field itself.
- appConnector := b.AppConnector()
-
- if appConnector == nil {
- return
- }
- domainRoutes := appConnector.DomainRoutes()
- if domainRoutes == nil {
- return
- }
-
- // Re-advertise the stored routes, in case stored state got out of
- // sync with previously advertised routes in prefs.
- var prefixes []netip.Prefix
- for _, ips := range domainRoutes {
- for _, ip := range ips {
- prefixes = append(prefixes, netip.PrefixFrom(ip, ip.BitLen()))
- }
- }
- // Note: AdvertiseRoute will trim routes that are already
- // advertised, so if everything is already being advertised this is
- // a noop.
- if err := b.AdvertiseRoute(prefixes...); err != nil {
- b.logf("error advertising stored app connector routes: %v", err)
- }
-}
-
// authReconfig pushes a new configuration into wgengine, if engine
// updates are not currently blocked, based on the cached netmap and
// user prefs.
@@ -5092,8 +4956,14 @@ func (b *LocalBackend) authReconfigLocked() {
disableSubnetsIfPAC := cn.SelfHasCap(tailcfg.NodeAttrDisableSubnetsIfPAC)
dohURL, dohURLOK := cn.exitNodeCanProxyDNS(prefs.ExitNodeID())
dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, version.OS())
- // If the current node is an app connector, ensure the app connector machine is started
- b.reconfigAppConnectorLocked(nm, prefs)
+ // Notify extensions (e.g., app connector) about the reconfig asynchronously.
+ selfNode := nm.SelfNodeOrZero()
+ authReconfigPrefs := prefs
+ go func() {
+ for _, f := range b.extHost.Hooks().OnAuthReconfig {
+ f(selfNode, authReconfigPrefs)
+ }
+ }()
if !prefs.WantRunning() {
b.logf("[v1] authReconfig: skipping because !WantRunning.")
@@ -5143,9 +5013,6 @@ func (b *LocalBackend) authReconfigLocked() {
b.logf("[v1] authReconfig: ra=%v dns=%v 0x%02x: %v", prefs.RouteAll(), prefs.CorpDNS(), flags, err)
b.initPeerAPIListenerLocked()
- if buildfeatures.HasAppConnectors {
- go b.goTracker.Go(b.readvertiseAppConnectorRoutes)
- }
}
// shouldUseOneCGNATRoute reports whether we should prefer to make one big
@@ -5664,9 +5531,7 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
f(b, hi, prefs)
}
- if buildfeatures.HasAppConnectors {
- hi.AppConnector.Set(prefs.AppConnector().Advertise)
- }
+ hi.AppConnector.Set(prefs.AppConnector().Advertise)
// The [tailcfg.Hostinfo.ExitNodeID] field tells control which exit node
// was selected, if any.
@@ -6652,24 +6517,10 @@ func (b *LocalBackend) OfferingExitNode() bool {
// OfferingAppConnector reports whether b is currently offering app
// connector services.
func (b *LocalBackend) OfferingAppConnector() bool {
- if !buildfeatures.HasAppConnectors {
- return false
+ if f, ok := b.extHost.Hooks().OfferingAppConnector.GetOk(); ok {
+ return f()
}
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.appConnector != nil
-}
-
-// AppConnector returns the current AppConnector, or nil if not configured.
-//
-// TODO(nickkhyl): move app connectors to [nodeBackend], or perhaps a feature package?
-func (b *LocalBackend) AppConnector() *appc.AppConnector {
- if !buildfeatures.HasAppConnectors {
- return nil
- }
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.appConnector
+ return false
}
// allowExitNodeDNSProxyToServeName reports whether the Exit Node DNS
@@ -7062,6 +6913,7 @@ func (b *LocalBackend) resetForProfileChangeLocked() error {
return nil
}
newNode := newNodeBackend(b.ctx, b.logf, b.sys.Bus.Get())
+ b.setNodeBackendHooks(newNode)
if oldNode := b.currentNodeAtomic.Swap(newNode); oldNode != nil {
oldNode.shutdown(errNodeContextChanged)
}
@@ -7202,22 +7054,12 @@ func (b *LocalBackend) DebugBreakDERPConns() error {
return b.MagicConn().DebugBreakDERPConns()
}
-// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
-// App Connector to enable route discovery.
-func (b *LocalBackend) ObserveDNSResponse(res []byte) error {
- if !buildfeatures.HasAppConnectors {
- return nil
+// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to
+// registered observers (e.g., the app connector extension) for route discovery.
+func (b *LocalBackend) ObserveDNSResponse(res []byte) {
+ for _, f := range b.extHost.Hooks().ObserveDNSResponse {
+ f(res)
}
- var appConnector *appc.AppConnector
- b.mu.Lock()
- if b.appConnector == nil {
- b.mu.Unlock()
- return nil
- }
- appConnector = b.appConnector
- b.mu.Unlock()
-
- return appConnector.ObserveDNSResponse(res)
}
// ErrDisallowedAutoRoute is returned by AdvertiseRoute when a route that is not allowed is requested.
@@ -7303,58 +7145,6 @@ func (b *LocalBackend) UnadvertiseRoute(toRemove ...netip.Prefix) error {
return err
}
-// namespace a key with the profile manager's current profile key, if any
-func namespaceKeyForCurrentProfile(pm *profileManager, key ipn.StateKey) ipn.StateKey {
- return pm.CurrentProfile().Key() + "||" + key
-}
-
-const routeInfoStateStoreKey ipn.StateKey = "_routeInfo"
-
-func (b *LocalBackend) storeRouteInfo(ri appctype.RouteInfo) error {
- if !buildfeatures.HasAppConnectors {
- return feature.ErrUnavailable
- }
- b.mu.Lock()
- defer b.mu.Unlock()
- if b.pm.CurrentProfile().ID() == "" {
- return nil
- }
- key := namespaceKeyForCurrentProfile(b.pm, routeInfoStateStoreKey)
- bs, err := json.Marshal(ri)
- if err != nil {
- return err
- }
- return b.pm.WriteState(key, bs)
-}
-
-func (b *LocalBackend) readRouteInfoLocked() (*appctype.RouteInfo, error) {
- if !buildfeatures.HasAppConnectors {
- return nil, feature.ErrUnavailable
- }
- if b.pm.CurrentProfile().ID() == "" {
- return &appctype.RouteInfo{}, nil
- }
- key := namespaceKeyForCurrentProfile(b.pm, routeInfoStateStoreKey)
- bs, err := b.pm.Store().ReadState(key)
- ri := &appctype.RouteInfo{}
- if err != nil {
- return nil, err
- }
- if err := json.Unmarshal(bs, ri); err != nil {
- return nil, err
- }
- return ri, nil
-}
-
-// ReadRouteInfo returns the app connector route information that is
-// stored in prefs to be consistent across restarts. It should be up
-// to date with the RouteInfo in memory being used by appc.
-func (b *LocalBackend) ReadRouteInfo() (*appctype.RouteInfo, error) {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.readRouteInfoLocked()
-}
-
// seamlessRenewalEnabled reports whether seamless key renewals are enabled.
//
// As of 2025-09-11, this is the default behaviour unless nodes receive
diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go
index cd44acdd1..34e518142 100644
--- a/ipn/ipnlocal/local_test.go
+++ b/ipn/ipnlocal/local_test.go
@@ -28,9 +28,6 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
memro "go4.org/mem"
"go4.org/netipx"
- "golang.org/x/net/dns/dnsmessage"
- "tailscale.com/appc"
- "tailscale.com/appc/appctest"
"tailscale.com/control/controlclient"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
@@ -51,7 +48,6 @@ import (
"tailscale.com/tstest"
"tailscale.com/tstest/deptest"
"tailscale.com/tstest/typewalk"
- "tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/ipproto"
"tailscale.com/types/key"
@@ -2295,7 +2291,7 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
}
prefs := &ipn.Prefs{ExitNodeID: tc.exitNode, CorpDNS: true}
- got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), false, t.Logf, "")
+ got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), false, t.Logf, "", nil)
if !resolversEqual(t, got.DefaultResolvers, tc.wantDefaultResolvers) {
t.Errorf("DefaultResolvers: got %#v, want %#v", got.DefaultResolvers, tc.wantDefaultResolvers)
}
@@ -2356,101 +2352,6 @@ func TestProfileMkdirAll(t *testing.T) {
})
}
-func TestOfferingAppConnector(t *testing.T) {
- for _, shouldStore := range []bool{false, true} {
- b := newTestBackend(t)
- bus := b.sys.Bus.Get()
- if b.OfferingAppConnector() {
- t.Fatal("unexpected offering app connector")
- }
- b.appConnector = appc.NewAppConnector(appc.Config{
- Logf: t.Logf, EventBus: bus, HasStoredRoutes: shouldStore,
- })
- if !b.OfferingAppConnector() {
- t.Fatal("unexpected not offering app connector")
- }
- }
-}
-
-func TestRouteAdvertiser(t *testing.T) {
- b := newTestBackend(t)
- testPrefix := netip.MustParsePrefix("192.0.0.8/32")
-
- ra := appc.RouteAdvertiser(b)
- must.Do(ra.AdvertiseRoute(testPrefix))
-
- routes := b.Prefs().AdvertiseRoutes()
- if routes.Len() != 1 || routes.At(0) != testPrefix {
- t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
- }
-
- must.Do(ra.UnadvertiseRoute(testPrefix))
-
- routes = b.Prefs().AdvertiseRoutes()
- if routes.Len() != 0 {
- t.Fatalf("got routes %v, want none", routes)
- }
-}
-
-func TestRouterAdvertiserIgnoresContainedRoutes(t *testing.T) {
- b := newTestBackend(t)
- testPrefix := netip.MustParsePrefix("192.0.0.0/24")
- ra := appc.RouteAdvertiser(b)
- must.Do(ra.AdvertiseRoute(testPrefix))
-
- routes := b.Prefs().AdvertiseRoutes()
- if routes.Len() != 1 || routes.At(0) != testPrefix {
- t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
- }
-
- must.Do(ra.AdvertiseRoute(netip.MustParsePrefix("192.0.0.8/32")))
-
- // the above /32 is not added as it is contained within the /24
- routes = b.Prefs().AdvertiseRoutes()
- if routes.Len() != 1 || routes.At(0) != testPrefix {
- t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
- }
-}
-
-func TestObserveDNSResponse(t *testing.T) {
- for _, shouldStore := range []bool{false, true} {
- b := newTestBackend(t)
- bus := b.sys.Bus.Get()
- w := eventbustest.NewWatcher(t, bus)
-
- // ensure no error when no app connector is configured
- if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
- t.Errorf("ObserveDNSResponse: %v", err)
- }
-
- rc := &appctest.RouteCollector{}
- a := appc.NewAppConnector(appc.Config{
- Logf: t.Logf,
- EventBus: bus,
- RouteAdvertiser: rc,
- HasStoredRoutes: shouldStore,
- })
- a.UpdateDomains([]string{"example.com"})
- a.Wait(t.Context())
- b.appConnector = a
-
- if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
- t.Errorf("ObserveDNSResponse: %v", err)
- }
- a.Wait(t.Context())
- wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
- if !slices.Equal(rc.Routes(), wantRoutes) {
- t.Fatalf("got routes %v, want %v", rc.Routes(), wantRoutes)
- }
-
- if err := eventbustest.Expect(w,
- eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
- ); err != nil {
- t.Error(err)
- }
- }
-}
-
func TestCoveredRouteRangeNoDefault(t *testing.T) {
tests := []struct {
existingRoute netip.Prefix
@@ -2497,128 +2398,6 @@ func TestCoveredRouteRangeNoDefault(t *testing.T) {
}
}
-func TestReconfigureAppConnector(t *testing.T) {
- b := newTestBackend(t)
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
- if b.appConnector != nil {
- t.Fatal("unexpected app connector")
- }
-
- b.EditPrefs(&ipn.MaskedPrefs{
- Prefs: ipn.Prefs{
- AppConnector: ipn.AppConnectorPrefs{
- Advertise: true,
- },
- },
- AppConnectorSet: true,
- })
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
- if b.appConnector == nil {
- t.Fatal("expected app connector")
- }
-
- appCfg := `{
- "name": "example",
- "domains": ["example.com"],
- "connectors": ["tag:example"]
- }`
-
- nm := &netmap.NetworkMap{
- SelfNode: (&tailcfg.Node{
- Name: "example.ts.net",
- Tags: []string{"tag:example"},
- CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
- "tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)},
- }),
- }).View(),
- }
-
- b.currentNode().SetNetMap(nm)
-
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
- b.appConnector.Wait(context.Background())
-
- want := []string{"example.com"}
- if !slices.Equal(b.appConnector.Domains().AsSlice(), want) {
- t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want)
- }
- if v, _ := b.hostinfo.AppConnector.Get(); !v {
- t.Fatalf("expected app connector service")
- }
-
- // disable the connector in order to assert that the service is removed
- b.EditPrefs(&ipn.MaskedPrefs{
- Prefs: ipn.Prefs{
- AppConnector: ipn.AppConnectorPrefs{
- Advertise: false,
- },
- },
- AppConnectorSet: true,
- })
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
- if b.appConnector != nil {
- t.Fatal("expected no app connector")
- }
- if v, _ := b.hostinfo.AppConnector.Get(); v {
- t.Fatalf("expected no app connector service")
- }
-}
-
-func TestBackfillAppConnectorRoutes(t *testing.T) {
- // Create backend with an empty app connector.
- b := newTestBackend(t)
- // newTestBackend creates a backend with a non-nil netmap,
- // but this test requires a nil netmap.
- // Otherwise, instead of backfilling, [LocalBackend.reconfigAppConnectorLocked]
- // uses the domains and routes from netmap's [appctype.AppConnectorAttr].
- // Additionally, a non-nil netmap makes reconfigAppConnectorLocked
- // asynchronous, resulting in a flaky test.
- // Therefore, we set the netmap to nil to simulate a fresh backend start
- // or a profile switch where the netmap is not yet available.
- b.setNetMapLocked(nil)
- if err := b.Start(ipn.Options{}); err != nil {
- t.Fatal(err)
- }
- if _, err := b.EditPrefs(&ipn.MaskedPrefs{
- Prefs: ipn.Prefs{
- AppConnector: ipn.AppConnectorPrefs{Advertise: true},
- },
- AppConnectorSet: true,
- }); err != nil {
- t.Fatal(err)
- }
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
-
- // Smoke check that AdvertiseRoutes doesn't have the test IP.
- ip := netip.MustParseAddr("1.2.3.4")
- routes := b.Prefs().AdvertiseRoutes().AsSlice()
- if slices.Contains(routes, netip.PrefixFrom(ip, ip.BitLen())) {
- t.Fatalf("AdvertiseRoutes %v on a fresh backend already contains advertised route for %v", routes, ip)
- }
-
- // Store the test IP in profile data, but not in Prefs.AdvertiseRoutes.
- b.ControlKnobs().AppCStoreRoutes.Store(true)
- if err := b.storeRouteInfo(appctype.RouteInfo{
- Domains: map[string][]netip.Addr{
- "example.com": {ip},
- },
- }); err != nil {
- t.Fatal(err)
- }
-
- // Mimic b.authReconfigure for the app connector bits.
- b.mu.Lock()
- b.reconfigAppConnectorLocked(b.NetMap(), b.pm.prefs)
- b.mu.Unlock()
- b.readvertiseAppConnectorRoutes()
-
- // Check that Prefs.AdvertiseRoutes got backfilled with routes stored in
- // profile data.
- routes = b.Prefs().AdvertiseRoutes().AsSlice()
- if !slices.Contains(routes, netip.PrefixFrom(ip, ip.BitLen())) {
- t.Fatalf("AdvertiseRoutes %v was not backfilled from stored app connector routes with %v", routes, ip)
- }
-}
func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool {
if a == nil && b == nil {
@@ -2655,43 +2434,6 @@ func routesEqual(t *testing.T, a, b map[dnsname.FQDN][]*dnstype.Resolver) bool {
return true
}
-// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
-func dnsResponse(domain, address string) []byte {
- addr := netip.MustParseAddr(address)
- b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
- b.EnableCompression()
- b.StartAnswers()
- switch addr.BitLen() {
- case 32:
- b.AResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName(domain),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.AResource{
- A: addr.As4(),
- },
- )
- case 128:
- b.AAAAResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName(domain),
- Type: dnsmessage.TypeAAAA,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.AAAAResource{
- AAAA: addr.As16(),
- },
- )
- default:
- panic("invalid address length")
- }
- return must.Get(b.Finish())
-}
-
func TestSetExitNodeIDPolicy(t *testing.T) {
zeroValHostinfoView := new(tailcfg.Hostinfo).View()
pfx := netip.MustParsePrefix
@@ -5617,69 +5359,6 @@ func TestEnableAutoUpdates(t *testing.T) {
}
}
-func TestReadWriteRouteInfo(t *testing.T) {
- // set up a backend with more than one profile
- b := newTestBackend(t)
- prof1 := ipn.LoginProfile{ID: "id1", Key: "key1"}
- prof2 := ipn.LoginProfile{ID: "id2", Key: "key2"}
- b.pm.knownProfiles["id1"] = prof1.View()
- b.pm.knownProfiles["id2"] = prof2.View()
- b.pm.currentProfile = prof1.View()
-
- // set up routeInfo
- ri1 := appctype.RouteInfo{}
- ri1.Wildcards = []string{"1"}
-
- ri2 := appctype.RouteInfo{}
- ri2.Wildcards = []string{"2"}
-
- // read before write
- readRi, err := b.readRouteInfoLocked()
- if readRi != nil {
- t.Fatalf("read before writing: want nil, got %v", readRi)
- }
- if err != ipn.ErrStateNotExist {
- t.Fatalf("read before writing: want %v, got %v", ipn.ErrStateNotExist, err)
- }
-
- // write the first routeInfo
- if err := b.storeRouteInfo(ri1); err != nil {
- t.Fatal(err)
- }
-
- // write the other routeInfo as the other profile
- if _, _, err := b.pm.SwitchToProfileByID("id2"); err != nil {
- t.Fatal(err)
- }
- if err := b.storeRouteInfo(ri2); err != nil {
- t.Fatal(err)
- }
-
- // read the routeInfo of the first profile
- if _, _, err := b.pm.SwitchToProfileByID("id1"); err != nil {
- t.Fatal(err)
- }
- readRi, err = b.readRouteInfoLocked()
- if err != nil {
- t.Fatal(err)
- }
- if !slices.Equal(readRi.Wildcards, ri1.Wildcards) {
- t.Fatalf("read prof1 routeInfo wildcards: want %v, got %v", ri1.Wildcards, readRi.Wildcards)
- }
-
- // read the routeInfo of the second profile
- if _, _, err := b.pm.SwitchToProfileByID("id2"); err != nil {
- t.Fatal(err)
- }
- readRi, err = b.readRouteInfoLocked()
- if err != nil {
- t.Fatal(err)
- }
- if !slices.Equal(readRi.Wildcards, ri2.Wildcards) {
- t.Fatalf("read prof2 routeInfo wildcards: want %v, got %v", ri2.Wildcards, readRi.Wildcards)
- }
-}
-
func TestFillAllowedSuggestions(t *testing.T) {
tests := []struct {
name string
@@ -7207,44 +6886,6 @@ func toStrings[T ~string](in []T) []string {
return out
}
-type textUpdate struct {
- Advertise []string
- Unadvertise []string
-}
-
-func routeUpdateToText(u appctype.RouteUpdate) textUpdate {
- var out textUpdate
- for _, p := range u.Advertise {
- out.Advertise = append(out.Advertise, p.String())
- }
- for _, p := range u.Unadvertise {
- out.Unadvertise = append(out.Unadvertise, p.String())
- }
- return out
-}
-
-func mustPrefix(ss ...string) (out []netip.Prefix) {
- for _, s := range ss {
- out = append(out, netip.MustParsePrefix(s))
- }
- return
-}
-
-// eqUpdate generates an eventbus test filter that matches an appctype.RouteUpdate
-// message equal to want, or reports an error giving a human-readable diff.
-//
-// TODO(creachadair): This is copied from the appc test package, but we can't
-// put it into the appctest package because the appc tests depend on it and
-// that makes a cycle. Clean up those tests and put this somewhere common.
-func eqUpdate(want appctype.RouteUpdate) func(appctype.RouteUpdate) error {
- return func(got appctype.RouteUpdate) error {
- if diff := cmp.Diff(routeUpdateToText(got), routeUpdateToText(want)); diff != "" {
- return fmt.Errorf("wrong update (-got, +want):\n%s", diff)
- }
- return nil
- }
-}
-
type fakeAttestationKey struct{ key.HardwareAttestationKey }
func (f *fakeAttestationKey) Clone() key.HardwareAttestationKey {
diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go
index b70d71cb9..03230f4e8 100644
--- a/ipn/ipnlocal/node_backend.go
+++ b/ipn/ipnlocal/node_backend.go
@@ -13,7 +13,6 @@ import (
"sync/atomic"
"go4.org/netipx"
- "tailscale.com/appc"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/net/dns"
@@ -104,6 +103,14 @@ type nodeBackend struct {
// nodeByAddr maps nodes' own addresses (excluding subnet routes) to node IDs.
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
nodeByAddr map[netip.Addr]tailcfg.NodeID
+
+ // pickSplitDNSPeers, if set, returns split DNS resolver peers for
+ // specific domains. It is set by LocalBackend based on extension hooks.
+ pickSplitDNSPeers func(
+ selfHasCap func(tailcfg.NodeCapability) bool,
+ self tailcfg.NodeView,
+ peers map[tailcfg.NodeID]tailcfg.NodeView,
+ ) map[string][]tailcfg.NodeView
}
func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *nodeBackend {
@@ -550,7 +557,7 @@ func (nb *nodeBackend) setFilter(f *filter.Filter) {
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, versionOS string) *dns.Config {
nb.mu.Lock()
defer nb.mu.Unlock()
- return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, versionOS)
+ return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, versionOS, nb.pickSplitDNSPeers)
}
func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
@@ -657,7 +664,7 @@ func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]*
//
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS.
-func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config {
+func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string, pickSplitDNSPeers func(func(tailcfg.NodeCapability) bool, tailcfg.NodeView, map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView) *dns.Config {
if nm == nil {
return nil
}
@@ -840,7 +847,10 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
addSplitDNSRoutes(nm.DNS.Routes)
// Add split DNS routes for conn25
- conn25DNSTargets := appc.PickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers)
+ var conn25DNSTargets map[string][]tailcfg.NodeView
+ if pickSplitDNSPeers != nil {
+ conn25DNSTargets = pickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers)
+ }
if conn25DNSTargets != nil {
var m map[string][]*dnstype.Resolver
for domain, candidateSplitDNSPeers := range conn25DNSTargets {
diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go
index aa4c1ef52..0f361ef7d 100644
--- a/ipn/ipnlocal/peerapi.go
+++ b/ipn/ipnlocal/peerapi.go
@@ -770,12 +770,8 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
// TODO(raggi): consider pushing the integration down into the resolver
// instead to avoid re-parsing the DNS response for improved performance in
// the future.
- if buildfeatures.HasAppConnectors && h.ps.b.OfferingAppConnector() {
- if err := h.ps.b.ObserveDNSResponse(res); err != nil {
- h.logf("ObserveDNSResponse error: %v", err)
- // This is not fatal, we probably just failed to parse the upstream
- // response. Return it to the caller anyway.
- }
+ if h.ps.b.OfferingAppConnector() {
+ h.ps.b.ObserveDNSResponse(res)
}
if pretty {
diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go
index 63abf089c..5a4523216 100644
--- a/ipn/ipnlocal/peerapi_test.go
+++ b/ipn/ipnlocal/peerapi_test.go
@@ -4,26 +4,19 @@
package ipnlocal
import (
- "context"
- "encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
- "slices"
"strings"
"testing"
"go4.org/netipx"
- "golang.org/x/net/dns/dnsmessage"
- "tailscale.com/appc"
- "tailscale.com/appc/appctest"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
- "tailscale.com/types/appctype"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/eventbus/eventbustest"
@@ -245,247 +238,3 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
}
}
-func TestPeerAPIPrettyReplyCNAME(t *testing.T) {
- for _, shouldStore := range []bool{false, true} {
- var h peerAPIHandler
- h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
-
- sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
-
- ht := health.NewTracker(sys.Bus.Get())
- reg := new(usermetric.Registry)
- eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
- pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht))
- a := appc.NewAppConnector(appc.Config{
- Logf: t.Logf,
- EventBus: sys.Bus.Get(),
- HasStoredRoutes: shouldStore,
- })
- t.Cleanup(a.Close)
- sys.Set(pm.Store())
- sys.Set(eng)
-
- b := newTestLocalBackendWithSys(t, sys)
- b.pm = pm
- b.appConnector = a // configure as an app connector just to enable the API.
-
- h.ps = &peerAPIServer{b: b}
- h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
- b.CNAMEResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName("www.example.com."),
- Type: dnsmessage.TypeCNAME,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.CNAMEResource{
- CNAME: dnsmessage.MustNewName("example.com."),
- },
- )
- b.AResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName("example.com."),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.AResource{
- A: [4]byte{192, 0, 0, 8},
- },
- )
- }}
- f := filter.NewAllowAllForTest(logger.Discard)
- h.ps.b.setFilter(f)
-
- if !h.replyToDNSQueries() {
- t.Errorf("unexpectedly deny; wanted to be a DNS server")
- }
-
- w := httptest.NewRecorder()
- h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
- if w.Code != http.StatusOK {
- t.Errorf("unexpected status code: %v", w.Code)
- }
- var addrs []string
- json.NewDecoder(w.Body).Decode(&addrs)
- if len(addrs) == 0 {
- t.Fatalf("no addresses returned")
- }
- for _, addr := range addrs {
- netip.MustParseAddr(addr)
- }
- }
-}
-
-func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) {
- for _, shouldStore := range []bool{false, true} {
- var h peerAPIHandler
- h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
-
- sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
- bw := eventbustest.NewWatcher(t, sys.Bus.Get())
-
- rc := &appctest.RouteCollector{}
- ht := health.NewTracker(sys.Bus.Get())
- pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht))
-
- reg := new(usermetric.Registry)
- eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
- a := appc.NewAppConnector(appc.Config{
- Logf: t.Logf,
- EventBus: sys.Bus.Get(),
- RouteAdvertiser: rc,
- HasStoredRoutes: shouldStore,
- })
- t.Cleanup(a.Close)
- sys.Set(pm.Store())
- sys.Set(eng)
-
- b := newTestLocalBackendWithSys(t, sys)
- b.pm = pm
- b.appConnector = a
-
- h.ps = &peerAPIServer{b: b}
- h.ps.b.appConnector.UpdateDomains([]string{"example.com"})
- a.Wait(t.Context())
-
- h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
- b.AResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName("example.com."),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.AResource{
- A: [4]byte{192, 0, 0, 8},
- },
- )
- }}
- f := filter.NewAllowAllForTest(logger.Discard)
- h.ps.b.setFilter(f)
-
- if !h.ps.b.OfferingAppConnector() {
- t.Fatal("expecting to be offering app connector")
- }
- if !h.replyToDNSQueries() {
- t.Errorf("unexpectedly deny; wanted to be a DNS server")
- }
-
- w := httptest.NewRecorder()
- h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=example.com.", nil))
- if w.Code != http.StatusOK {
- t.Errorf("unexpected status code: %v", w.Code)
- }
- a.Wait(t.Context())
-
- wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
- if !slices.Equal(rc.Routes(), wantRoutes) {
- t.Errorf("got %v; want %v", rc.Routes(), wantRoutes)
- }
-
- if err := eventbustest.Expect(bw,
- eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
- ); err != nil {
- t.Error(err)
- }
- }
-}
-
-func TestPeerAPIReplyToDNSQueriesAreObservedWithCNAMEFlattening(t *testing.T) {
- for _, shouldStore := range []bool{false, true} {
- ctx := context.Background()
- var h peerAPIHandler
- h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
-
- sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
- bw := eventbustest.NewWatcher(t, sys.Bus.Get())
-
- ht := health.NewTracker(sys.Bus.Get())
- reg := new(usermetric.Registry)
- rc := &appctest.RouteCollector{}
- eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg, sys.Bus.Get(), sys.Set)
- pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht))
- a := appc.NewAppConnector(appc.Config{
- Logf: t.Logf,
- EventBus: sys.Bus.Get(),
- RouteAdvertiser: rc,
- HasStoredRoutes: shouldStore,
- })
- t.Cleanup(a.Close)
- sys.Set(pm.Store())
- sys.Set(eng)
-
- b := newTestLocalBackendWithSys(t, sys)
- b.pm = pm
- b.appConnector = a
-
- h.ps = &peerAPIServer{b: b}
- h.ps.b.appConnector.UpdateDomains([]string{"www.example.com"})
- a.Wait(ctx)
-
- h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
- b.CNAMEResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName("www.example.com."),
- Type: dnsmessage.TypeCNAME,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.CNAMEResource{
- CNAME: dnsmessage.MustNewName("example.com."),
- },
- )
- b.AResource(
- dnsmessage.ResourceHeader{
- Name: dnsmessage.MustNewName("example.com."),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- TTL: 0,
- },
- dnsmessage.AResource{
- A: [4]byte{192, 0, 0, 8},
- },
- )
- }}
- f := filter.NewAllowAllForTest(logger.Discard)
- h.ps.b.setFilter(f)
-
- if !h.ps.b.OfferingAppConnector() {
- t.Fatal("expecting to be offering app connector")
- }
- if !h.replyToDNSQueries() {
- t.Errorf("unexpectedly deny; wanted to be a DNS server")
- }
-
- w := httptest.NewRecorder()
- h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
- if w.Code != http.StatusOK {
- t.Errorf("unexpected status code: %v", w.Code)
- }
- a.Wait(ctx)
-
- wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
- if !slices.Equal(rc.Routes(), wantRoutes) {
- t.Errorf("got %v; want %v", rc.Routes(), wantRoutes)
- }
-
- if err := eventbustest.Expect(bw,
- eqUpdate(appctype.RouteUpdate{Advertise: mustPrefix("192.0.0.8/32")}),
- ); err != nil {
- t.Error(err)
- }
- }
-}
-
-type fakeResolver struct {
- build func(*dnsmessage.Builder)
-}
-
-func (f *fakeResolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
- b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
- b.EnableCompression()
- b.StartAnswers()
- f.build(&b)
- return b.Finish()
-}