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