ipn/ipnlocal, feature/appconnectors: move app connector code out of LocalBacked

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>
This commit is contained in:
Brad Fitzpatrick 2026-02-11 04:45:45 +00:00
parent 6cbfc2f3ba
commit 8527cb1ffd
22 changed files with 1107 additions and 1037 deletions

View File

@ -780,7 +780,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
sigs.k8s.io/structured-merge-diff/v6/value from k8s.io/apimachinery/pkg/runtime+
sigs.k8s.io/yaml from k8s.io/apimachinery/pkg/runtime/serializer/json+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/tailscale+
tailscale.com/client/tailscale from tailscale.com/cmd/k8s-operator+
@ -907,14 +906,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/appctype from tailscale.com/client/local
tailscale.com/types/bools from tailscale.com/tsnet+
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logger from tailscale.com/client/web+
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
@ -928,7 +927,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/client/local+
tailscale.com/types/views from tailscale.com/appc+
tailscale.com/types/views from tailscale.com/client/web+
tailscale.com/util/backoff from tailscale.com/cmd/k8s-operator+
tailscale.com/util/checkchange from tailscale.com/ipn/ipnlocal+
tailscale.com/util/cibuild from tailscale.com/health+
@ -939,15 +938,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/dnsname from tailscale.com/cmd/k8s-operator+
tailscale.com/util/eventbus from tailscale.com/tsd+
tailscale.com/util/execqueue from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/client/web+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineiter from tailscale.com/hostinfo+
tailscale.com/util/mak from tailscale.com/appc+
tailscale.com/util/mak from tailscale.com/cmd/k8s-operator+
tailscale.com/util/must from tailscale.com/logpolicy+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi
@ -959,7 +958,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/ringlog from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/cmd/k8s-operator+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/slicesx from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
@ -1015,7 +1014,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+
golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
golang.org/x/net/dns/dnsmessage from tailscale.com/ipn/ipnlocal+
golang.org/x/net/http/httpguts from golang.org/x/net/http2+
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+

View File

@ -40,7 +40,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 go4.org/mem from tailscale.com/control/controlbase+
go4.org/netipx from tailscale.com/ipn/ipnlocal+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnauth+
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled
@ -127,14 +126,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/dnstype from tailscale.com/client/tailscale/apitype+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
tailscale.com/types/ipproto from tailscale.com/ipn+
tailscale.com/types/key from tailscale.com/control/controlbase+
tailscale.com/types/lazy from tailscale.com/hostinfo+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logger from tailscale.com/cmd/tailscaled+
tailscale.com/types/logid from tailscale.com/cmd/tailscaled+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
@ -147,17 +145,17 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/control/controlclient+
tailscale.com/types/views from tailscale.com/appc+
tailscale.com/types/views from tailscale.com/control/controlclient+
tailscale.com/util/backoff from tailscale.com/control/controlclient+
tailscale.com/util/checkchange from tailscale.com/ipn/ipnlocal+
tailscale.com/util/cibuild from tailscale.com/health+
tailscale.com/util/clientmetric from tailscale.com/appc+
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/eventbus from tailscale.com/control/controlclient+
tailscale.com/util/execqueue from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
tailscale.com/util/httpm from tailscale.com/ipn/ipnlocal+
@ -174,7 +172,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/ringlog from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/control/controlclient+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/slicesx from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/pkey from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/policyclient from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/ptype from tailscale.com/ipn/ipnlocal+

View File

@ -45,7 +45,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 go4.org/mem from tailscale.com/control/controlbase+
go4.org/netipx from tailscale.com/ipn/ipnlocal+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/tailscale+
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
@ -145,14 +144,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/appctype from tailscale.com/client/local+
tailscale.com/types/dnstype from tailscale.com/client/tailscale/apitype+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
tailscale.com/types/ipproto from tailscale.com/ipn+
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/hostinfo+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logger from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/logid from tailscale.com/cmd/tailscaled+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
@ -165,17 +164,17 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/control/controlclient+
tailscale.com/types/views from tailscale.com/appc+
tailscale.com/types/views from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/backoff from tailscale.com/control/controlclient+
tailscale.com/util/checkchange from tailscale.com/ipn/ipnlocal+
tailscale.com/util/cibuild from tailscale.com/health+
tailscale.com/util/clientmetric from tailscale.com/appc+
tailscale.com/util/clientmetric from tailscale.com/client/local+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/eventbus from tailscale.com/client/local+
tailscale.com/util/execqueue from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
tailscale.com/util/httpm from tailscale.com/ipn/ipnlocal+
@ -194,7 +193,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/ringlog from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/control/controlclient+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/slicesx from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/syspolicy/pkey from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/policyclient from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/ptype from tailscale.com/ipn/ipnlocal+

View File

@ -249,7 +249,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
tailscale.com/appc from tailscale.com/feature/appconnectors+
💣 tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/local from tailscale.com/client/web+
@ -400,7 +400,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/appctype from tailscale.com/appc+
tailscale.com/types/bools from tailscale.com/wgengine/netlog
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+

View File

@ -199,7 +199,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/web+
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
@ -309,14 +308,14 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/appctype from tailscale.com/client/local
tailscale.com/types/bools from tailscale.com/tsnet+
tailscale.com/types/dnstype from tailscale.com/client/local+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/ipn+
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/cmd/tsidp+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logger from tailscale.com/client/web+
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
@ -330,26 +329,26 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/client/local+
tailscale.com/types/views from tailscale.com/appc+
tailscale.com/types/views from tailscale.com/client/web+
tailscale.com/util/backoff from tailscale.com/control/controlclient+
tailscale.com/util/checkchange from tailscale.com/ipn/ipnlocal+
tailscale.com/util/cibuild from tailscale.com/health+
tailscale.com/util/clientmetric from tailscale.com/appc+
tailscale.com/util/clientmetric from tailscale.com/client/local+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/eventbus from tailscale.com/client/local+
tailscale.com/util/execqueue from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/client/web+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/web+
tailscale.com/util/lineiter from tailscale.com/hostinfo+
tailscale.com/util/mak from tailscale.com/appc+
tailscale.com/util/mak from tailscale.com/cmd/tsidp+
tailscale.com/util/must from tailscale.com/cmd/tsidp+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi
@ -361,7 +360,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/util/ringlog from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/control/controlclient+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/slicesx from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy+
@ -418,7 +417,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
golang.org/x/net/dns/dnsmessage from tailscale.com/ipn/ipnlocal+
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+

View File

@ -25,14 +25,24 @@ func handleC2NAppConnectorDomainRoutesGet(b *ipnlocal.LocalBackend, w http.Respo
logf("c2n: GET /appconnector/routes received")
var res tailcfg.C2NAppConnectorDomainRoutesResponse
appConnector := b.AppConnector()
if appConnector == nil {
ext, ok := ipnlocal.GetExt[*extension](b)
if !ok {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
return
}
res.Domains = appConnector.DomainRoutes()
ext.mu.Lock()
ac := ext.appConnector
ext.mu.Unlock()
if ac == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
return
}
res.Domains = ac.DomainRoutes()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)

View File

@ -0,0 +1,283 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package appconnectors
import (
"context"
"encoding/json"
"net/netip"
"slices"
"sync"
"tailscale.com/appc"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
"tailscale.com/util/execqueue"
)
// featureName is the name of the feature implemented by this package.
// It is also the [extension] name and the log prefix.
const featureName = "appconnectors"
func init() {
feature.Register(featureName)
ipnext.RegisterExtension(featureName, newExtension)
}
type extension struct {
logf logger.Logf
sb ipnext.SafeBackend
host ipnext.Host
mu sync.Mutex
appConnector *appc.AppConnector // or nil; guarded by mu
busClient *eventbus.Client
task execqueue.ExecQueue // serializes route update processing
}
func newExtension(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) {
return &extension{
logf: logger.WithPrefix(logf, "appconnectors: "),
sb: sb,
}, nil
}
func (e *extension) Name() string { return featureName }
func (e *extension) Init(h ipnext.Host) error {
e.host = h
h.Hooks().OnAuthReconfig.Add(e.onAuthReconfig)
h.Hooks().OfferingAppConnector.Set(e.offeringAppConnector)
h.Hooks().ObserveDNSResponse.Add(e.observeDNSResponse)
h.Hooks().ExtraLocalAddrs.Add(e.extraLocalAddrs)
h.Hooks().ClearAutoRoutes.Set(e.clearRoutes)
bus := e.sb.Sys().Bus.Get()
e.busClient = bus.Client("appconnectors")
eventbus.SubscribeFunc(e.busClient, e.onRouteUpdate)
eventbus.SubscribeFunc(e.busClient, e.onStoreRoutes)
return nil
}
// Wait waits for the app connector's internal queue to finish processing.
// It is used in tests to synchronize with asynchronous operations.
func (e *extension) Wait(ctx context.Context) {
e.mu.Lock()
ac := e.appConnector
e.mu.Unlock()
if ac != nil {
ac.Wait(ctx)
}
}
func (e *extension) Shutdown() error {
e.task.Shutdown()
if e.busClient != nil {
e.busClient.Close()
}
e.mu.Lock()
defer e.mu.Unlock()
e.appConnector.Close() // safe on nil
e.appConnector = nil
return nil
}
// onAuthReconfig is called asynchronously after the backend reconfigures
// in response to a netmap or prefs change. It manages the lifecycle of
// the AppConnector: creating, reconfiguring, or destroying it.
func (e *extension) onAuthReconfig(selfNode tailcfg.NodeView, prefs ipn.PrefsView) {
const appConnectorCapName = "tailscale.com/app-connectors"
e.mu.Lock()
defer e.mu.Unlock()
// App connectors have been disabled.
if !prefs.AppConnector().Advertise {
e.appConnector.Close() // clean up a previous connector (safe on nil)
e.appConnector = nil
return
}
// We don't (yet) have an app connector configured, or the configured
// connector has a different route persistence setting.
shouldStoreRoutes := e.sb.Sys().ControlKnobs().AppCStoreRoutes.Load()
if e.appConnector == nil || (shouldStoreRoutes != e.appConnector.ShouldStoreRoutes()) {
ri, err := e.readRouteInfo()
if err != nil && err != ipn.ErrStateNotExist {
e.logf("Unsuccessful Read RouteInfo: %v", err)
}
e.appConnector.Close() // clean up a previous connector (safe on nil)
e.appConnector = appc.NewAppConnector(appc.Config{
Logf: e.logf,
EventBus: e.sb.Sys().Bus.Get(),
RouteInfo: ri,
HasStoredRoutes: shouldStoreRoutes,
})
}
if !selfNode.Valid() {
return
}
attrs, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](selfNode.CapMap(), appConnectorCapName)
if err != nil {
e.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 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)
e.appConnector.UpdateDomainsAndRoutes(domains, routes)
// Re-advertise the stored routes, in case stored state got out of
// sync with previously advertised routes in prefs.
e.readvertiseRoutesLocked()
}
// readvertiseRoutesLocked re-advertises routes from the app connector's
// DomainRoutes. e.mu must be held.
func (e *extension) readvertiseRoutesLocked() {
if e.appConnector == nil {
return
}
domainRoutes := e.appConnector.DomainRoutes()
if domainRoutes == nil {
return
}
var prefixes []netip.Prefix
for _, ips := range domainRoutes {
for _, ip := range ips {
prefixes = append(prefixes, netip.PrefixFrom(ip, ip.BitLen()))
}
}
if len(prefixes) > 0 {
e.host.AdvertiseRoutesAsync(prefixes)
}
}
// onRouteUpdate handles route update events from the AppConnector.
func (e *extension) onRouteUpdate(ru appctype.RouteUpdate) {
e.task.Add(func() {
e.host.AdvertiseRoutesAsync(ru.Advertise)
e.host.UnadvertiseRoutesAsync(ru.Unadvertise)
})
}
// onStoreRoutes handles route store events from the AppConnector.
func (e *extension) onStoreRoutes(ri appctype.RouteInfo) {
shouldStoreRoutes := e.sb.Sys().ControlKnobs().AppCStoreRoutes.Load()
if shouldStoreRoutes {
if err := e.storeRouteInfo(ri); err != nil {
e.logf("failed to store route info: %v", err)
}
}
}
// observeDNSResponse passes a DNS response payload to the AppConnector.
func (e *extension) observeDNSResponse(res []byte) {
e.mu.Lock()
ac := e.appConnector
e.mu.Unlock()
if ac == nil {
return
}
if err := ac.ObserveDNSResponse(res); err != nil {
e.logf("ObserveDNSResponse error: %v", err)
}
}
// offeringAppConnector reports whether the AppConnector is active.
func (e *extension) offeringAppConnector() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.appConnector != nil
}
// extraLocalAddrs returns additional addresses for the packet filter's
// local network set when the app connector is active.
func (e *extension) extraLocalAddrs() []netip.Addr {
e.mu.Lock()
isActive := e.appConnector != nil
e.mu.Unlock()
if !isActive {
return nil
}
prefs := e.host.Profiles().CurrentPrefs()
if !prefs.AppConnector().Advertise {
return nil
}
return []netip.Addr{
netip.MustParseAddr("0.0.0.0"),
netip.MustParseAddr("::0"),
}
}
// clearRoutes clears auto-discovered routes from the AppConnector.
func (e *extension) clearRoutes() error {
e.mu.Lock()
ac := e.appConnector
e.mu.Unlock()
if ac != nil {
return ac.ClearRoutes()
}
return nil
}
const routeInfoStateStoreKey ipn.StateKey = "_routeInfo"
// readRouteInfo reads the stored route info from the state store.
func (e *extension) readRouteInfo() (*appctype.RouteInfo, error) {
profile, _ := e.host.Profiles().CurrentProfileState()
if profile.ID() == "" {
return &appctype.RouteInfo{}, nil
}
key := profile.Key() + "||" + routeInfoStateStoreKey
bs, err := e.sb.Sys().StateStore.Get().ReadState(key)
if err != nil {
return nil, err
}
ri := &appctype.RouteInfo{}
if err := json.Unmarshal(bs, ri); err != nil {
return nil, err
}
return ri, nil
}
// storeRouteInfo writes route info to the state store.
func (e *extension) storeRouteInfo(ri appctype.RouteInfo) error {
profile, _ := e.host.Profiles().CurrentProfileState()
if profile.ID() == "" {
return nil
}
key := profile.Key() + "||" + routeInfoStateStoreKey
bs, err := json.Marshal(ri)
if err != nil {
return err
}
return e.sb.Sys().StateStore.Get().WriteState(key, bs)
}

View File

@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package appconnectors
import (
"encoding/json"
"errors"
"net/http"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/types/appctype"
"tailscale.com/util/httpm"
)
func init() {
localapi.Register("appc-route-info", serveGetAppcRouteInfo)
}
func serveGetAppcRouteInfo(h *localapi.Handler, w http.ResponseWriter, r *http.Request) {
ext, ok := ipnlocal.GetExt[*extension](h.LocalBackend())
if !ok {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
ri, err := ext.readRouteInfo()
if err != nil {
if errors.Is(err, ipn.ErrStateNotExist) {
ri = &appctype.RouteInfo{}
} else {
localapi.WriteErrorJSON(w, err)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ri)
}

View File

@ -56,6 +56,7 @@ func (e *extension) Name() string {
// Init implements [ipnext.Extension].
func (e *extension) Init(host ipnext.Host) error {
host.Hooks().SplitDNSResolverPeers.Set(appc.PickSplitDNSPeers)
return nil
}

View File

@ -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.

View File

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

View File

@ -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")

View File

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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

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

View File

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

View File

@ -195,7 +195,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/web+
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
@ -304,14 +303,14 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
LDW tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/appctype from tailscale.com/client/local
tailscale.com/types/bools from tailscale.com/tsnet+
tailscale.com/types/dnstype from tailscale.com/client/local+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/ipn+
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/hostinfo+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logger from tailscale.com/client/web+
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
@ -325,26 +324,26 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/client/local+
tailscale.com/types/views from tailscale.com/appc+
tailscale.com/types/views from tailscale.com/client/web+
tailscale.com/util/backoff from tailscale.com/control/controlclient+
tailscale.com/util/checkchange from tailscale.com/ipn/ipnlocal+
tailscale.com/util/cibuild from tailscale.com/health+
tailscale.com/util/clientmetric from tailscale.com/appc+
tailscale.com/util/clientmetric from tailscale.com/client/local+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
LA 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/eventbus from tailscale.com/client/local+
tailscale.com/util/execqueue from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/client/web+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/web+
tailscale.com/util/lineiter from tailscale.com/hostinfo+
tailscale.com/util/mak from tailscale.com/appc+
tailscale.com/util/mak from tailscale.com/control/controlclient+
tailscale.com/util/must from tailscale.com/logpolicy+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi
@ -356,7 +355,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/util/ringlog from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/control/controlclient+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/slicesx from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy+
@ -411,7 +410,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
golang.org/x/net/dns/dnsmessage from tailscale.com/ipn/ipnlocal+
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+