diff options
| author | Brad Fitzpatrick <bradfitz@tailscale.com> | 2026-02-11 04:45:45 +0000 |
|---|---|---|
| committer | Brad Fitzpatrick <bradfitz@tailscale.com> | 2026-02-11 09:08:30 -0800 |
| commit | 8527cb1ffd79026a0db82cb04adc8290d5033344 (patch) | |
| tree | bedb90c2ec3852f3325c33f168785d13601447f1 /ipn | |
| parent | 6cbfc2f3babe5e6e55ddc589dee413801f663797 (diff) | |
| download | tailscale-bradfitz/feature_appconnectors.tar.xz tailscale-bradfitz/feature_appconnectors.zip | |
ipn/ipnlocal, feature/appconnectors: move app connector code out of LocalBackedbradfitz/feature_appconnectors
This is Claude Code's attempt at moving App Connector code out of
LocalBackend, with plenty of tips and guidance.
This is probably too big of a single commit (and untested, and not
sufficiently reviewed) but shared for discussion purposes, so we can
start thinking about what hooks we might actually want, and how we can
break something like this up into smaller chunks that are reviewable.
Updates #12614
Change-Id: I4c79abbef687bfb7bc81f94c393c08b7636fd3c6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Diffstat (limited to 'ipn')
| -rw-r--r-- | ipn/ipnext/ipnext.go | 49 | ||||
| -rw-r--r-- | ipn/ipnlocal/appconnector_test.go | 459 | ||||
| -rw-r--r-- | ipn/ipnlocal/dnsconfig_test.go | 94 | ||||
| -rw-r--r-- | ipn/ipnlocal/export_appconnector_test.go | 130 | ||||
| -rw-r--r-- | ipn/ipnlocal/extension_host.go | 28 | ||||
| -rw-r--r-- | ipn/ipnlocal/extension_host_test.go | 3 | ||||
| -rw-r--r-- | ipn/ipnlocal/local.go | 282 | ||||
| -rw-r--r-- | ipn/ipnlocal/local_test.go | 361 | ||||
| -rw-r--r-- | ipn/ipnlocal/node_backend.go | 18 | ||||
| -rw-r--r-- | ipn/ipnlocal/peerapi.go | 8 | ||||
| -rw-r--r-- | ipn/ipnlocal/peerapi_test.go | 251 | ||||
| -rw-r--r-- | ipn/localapi/localapi.go | 29 |
12 files changed, 725 insertions, 987 deletions
diff --git a/ipn/ipnext/ipnext.go b/ipn/ipnext/ipnext.go index 275e28c85..76d1d310d 100644 --- a/ipn/ipnext/ipnext.go +++ b/ipn/ipnext/ipnext.go @@ -201,6 +201,15 @@ type Host interface { // NodeBackend returns the [NodeBackend] for the currently active node // (which is approximately the same as the current profile). NodeBackend() NodeBackend + + // AdvertiseRoutesAsync enqueues adding the given route advertisements + // to the current node's prefs. Routes already present or disallowed are + // silently skipped. Errors are logged by the host. + AdvertiseRoutesAsync(routes []netip.Prefix) + + // UnadvertiseRoutesAsync enqueues removing the given route advertisements + // from the current node's prefs. Errors are logged by the host. + UnadvertiseRoutesAsync(routes []netip.Prefix) } // SafeBackend is a subset of the [ipnlocal.LocalBackend] type's methods that @@ -377,6 +386,46 @@ type Hooks struct { // ShouldUploadServices reports whether this node should include services // in Hostinfo from the portlist extension. ShouldUploadServices feature.Hook[func() bool] + + // OnAuthReconfig is called asynchronously after the backend reconfigures + // in response to a netmap or prefs change. The selfNode may be invalid if + // no netmap is available yet. It is currently used by the app connector + // extension to start, stop, or reconfigure its route discovery. + OnAuthReconfig feature.Hooks[func(selfNode tailcfg.NodeView, prefs ipn.PrefsView)] + + // OfferingAppConnector reports whether this node is currently offering + // app connector services. It is used by peerapi DNS handling, hostinfo + // updates, and filter configuration. Only one extension may set this. + OfferingAppConnector feature.Hook[func() bool] + + // ObserveDNSResponse passes a DNS response payload from the PeerAPI DNS + // server to registered observers. It is currently used by the app connector + // extension for route discovery, but multiple observers are supported. + ObserveDNSResponse feature.Hooks[func(dnsResponse []byte)] + + // ExtraLocalAddrs returns additional addresses to include in the packet + // filter's local network set. It is currently used by the app connector + // extension to add 0.0.0.0 and ::0 so that PeerAPI DNS access validation + // passes for app connector nodes. + ExtraLocalAddrs feature.Hooks[func() []netip.Addr] + + // ClearAutoRoutes is called when the user explicitly sets AdvertiseRoutes + // via the local API. The hook should clear any auto-discovered routes so + // that they do not conflict with the user's explicit configuration. It is + // currently used by the app connector extension. Only one extension may + // set this. + ClearAutoRoutes feature.Hook[func() error] + + // SplitDNSResolverPeers is called during DNS config generation to find + // peers that serve as split DNS resolvers for specific domains. The + // selfHasCap parameter reports whether the local node has a given + // capability, which callers use to gate experimental behavior. It is + // currently used by the conn25 extension. Only one extension may set this. + SplitDNSResolverPeers feature.Hook[func( + selfHasCap func(tailcfg.NodeCapability) bool, + self tailcfg.NodeView, + peers map[tailcfg.NodeID]tailcfg.NodeView, + ) map[string][]tailcfg.NodeView] } // NodeBackend is an interface to query the current node and its peers. 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() -} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index dc558b36e..afc112644 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -40,7 +40,6 @@ import ( "tailscale.com/net/netutil" "tailscale.com/tailcfg" "tailscale.com/tstime" - "tailscale.com/types/appctype" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" @@ -92,9 +91,6 @@ var handler = map[string]LocalAPIHandler{ } func init() { - if buildfeatures.HasAppConnectors { - Register("appc-route-info", (*Handler).serveGetAppcRouteInfo) - } if buildfeatures.HasAdvertiseRoutes { Register("check-ip-forwarding", (*Handler).serveCheckIPForwarding) Register("check-udp-gro-forwarding", (*Handler).serveCheckUDPGROForwarding) @@ -1003,8 +999,8 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if buildfeatures.HasAppConnectors { - if err := h.b.MaybeClearAppConnector(mp); err != nil { + if mp.AdvertiseRoutesSet { + if err := h.b.MaybeClearAutoRoutes(); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(resJSON{Error: err.Error()}) @@ -1738,24 +1734,3 @@ func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) { eventbus.Publish[Shutdown](ec).Publish(Shutdown{}) } -func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) { - if !buildfeatures.HasAppConnectors { - http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) - return - } - if r.Method != httpm.GET { - http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) - return - } - res, err := h.b.ReadRouteInfo() - if err != nil { - if errors.Is(err, ipn.ErrStateNotExist) { - res = &appctype.RouteInfo{} - } else { - WriteErrorJSON(w, err) - return - } - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(res) -} |
