From 8527cb1ffd79026a0db82cb04adc8290d5033344 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 11 Feb 2026 04:45:45 +0000 Subject: [PATCH] ipn/ipnlocal, feature/appconnectors: move app connector code out of LocalBacked This is Claude Code's attempt at moving App Connector code out of LocalBackend, with plenty of tips and guidance. This is probably too big of a single commit (and untested, and not sufficiently reviewed) but shared for discussion purposes, so we can start thinking about what hooks we might actually want, and how we can break something like this up into smaller chunks that are reviewable. Updates #12614 Change-Id: I4c79abbef687bfb7bc81f94c393c08b7636fd3c6 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Brad Fitzpatrick --- cmd/k8s-operator/depaware.txt | 17 +- cmd/tailscaled/depaware-min.txt | 14 +- cmd/tailscaled/depaware-minbox.txt | 15 +- cmd/tailscaled/depaware.txt | 4 +- cmd/tsidp/depaware.txt | 19 +- feature/appconnectors/appconnectors.go | 16 +- feature/appconnectors/ext.go | 283 ++++++++++++++ feature/appconnectors/ext_localapi.go | 44 +++ feature/conn25/conn25.go | 1 + ipn/ipnext/ipnext.go | 49 +++ ipn/ipnlocal/appconnector_test.go | 459 +++++++++++++++++++++++ ipn/ipnlocal/dnsconfig_test.go | 94 +---- ipn/ipnlocal/export_appconnector_test.go | 130 +++++++ ipn/ipnlocal/extension_host.go | 28 ++ ipn/ipnlocal/extension_host_test.go | 3 + ipn/ipnlocal/local.go | 282 ++------------ ipn/ipnlocal/local_test.go | 361 +----------------- ipn/ipnlocal/node_backend.go | 18 +- ipn/ipnlocal/peerapi.go | 8 +- ipn/ipnlocal/peerapi_test.go | 251 ------------- ipn/localapi/localapi.go | 29 +- tsnet/depaware.txt | 19 +- 22 files changed, 1107 insertions(+), 1037 deletions(-) create mode 100644 feature/appconnectors/ext.go create mode 100644 feature/appconnectors/ext_localapi.go create mode 100644 ipn/ipnlocal/appconnector_test.go create mode 100644 ipn/ipnlocal/export_appconnector_test.go 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+