From 934907eba9a44c1fac951207d8033b38bca0df6a Mon Sep 17 00:00:00 2001 From: Adrian Dewhurst Date: Wed, 22 Apr 2026 16:48:01 -0400 Subject: [PATCH] wip: register resolver scheme for apps --- appc/conn25.go | 19 ++++++- feature/conn25/conn25.go | 15 ++++-- ipn/ipnlocal/dnsconfig_test.go | 2 +- ipn/ipnlocal/node_backend.go | 13 ++--- net/dns/resolver/forwarder.go | 98 +++++++++++++++++++++++++++++----- net/dns/resolver/tsdns.go | 18 +++++++ types/dnstype/dnstype.go | 2 - types/dnstype/dnstype_clone.go | 7 ++- types/dnstype/dnstype_view.go | 12 ++--- 9 files changed, 143 insertions(+), 43 deletions(-) diff --git a/appc/conn25.go b/appc/conn25.go index 20796612e..870e0f161 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -5,11 +5,13 @@ package appc import ( "cmp" + "fmt" "slices" "tailscale.com/ipn/ipnext" "tailscale.com/tailcfg" "tailscale.com/types/appctype" + "tailscale.com/types/dnstype" "tailscale.com/util/set" ) @@ -52,7 +54,12 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod return matches } -func AppNameByDomain(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView) map[string]string { +// DNSAddrScheme is the custom URI scheme used for conn25-managed split DNS +// entries to determine the destination at query time rather than configuration +// time. +const DNSAddrScheme = "tailscale-app" + +func AppDNSRoutes(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView) map[string][]*dnstype.Resolver { if !hasCap(AppConnectorsExperimentalAttrName) { return nil } @@ -68,5 +75,13 @@ func AppNameByDomain(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.No appNamesByDomain[domain] = app.Name } } - return appNamesByDomain + + // TODO: filter out apps that have no peers? There is not enough information + // available here. + + m := make(map[string][]*dnstype.Resolver, len(appNamesByDomain)) + for domain, appName := range appNamesByDomain { + m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName)}} + } + return m } diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index d2eb29fdf..943522c44 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -27,7 +27,6 @@ import ( "tailscale.com/feature" "tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnlocal" - "tailscale.com/net/dns/resolver" "tailscale.com/net/packet" "tailscale.com/net/tsaddr" "tailscale.com/net/tstun" @@ -138,6 +137,7 @@ func (e *extension) installHooks(dph *datapathHandler) error { if !ok { return errors.New("could not access system dns manager") } + tun, ok := e.backend.Sys().Tun.GetOK() if !ok { return errors.New("could not access system tun") @@ -152,8 +152,13 @@ func (e *extension) installHooks(dph *datapathHandler) error { return e.conn25.mapDNSResponse(bs) }) - if !resolver.FranNewDynamicResolverThing.IsSet() { - resolver.FranNewDynamicResolverThing.Set(func(appName string) (string, error) { + if resolver := dnsManager.Resolver(); resolver != nil { + if err := resolver.RegisterCustomScheme(appc.DNSAddrScheme, func(addr string) (newAddr string, err error) { + scheme, appName, ok := strings.Cut(addr, ":") + if !ok || scheme != appc.DNSAddrScheme { + return "", fmt.Errorf("unexpected conn25 scheme %q", scheme) + } + if !e.conn25.isConfigured() { return "", errors.New("conn25 not configured") } @@ -167,7 +172,9 @@ func (e *extension) installHooks(dph *datapathHandler) error { return "", errors.New("no peer found for app") } return urlBase + "/dns-query", nil - }) + }); err != nil { + return fmt.Errorf("could not register DNS resolver scheme: %w", err) + } } // Intercept packets from the tun device and from WireGuard diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go index 9d30029ff..ffd90bcb7 100644 --- a/ipn/ipnlocal/dnsconfig_test.go +++ b/ipn/ipnlocal/dnsconfig_test.go @@ -431,7 +431,7 @@ func TestDNSConfigForNetmap(t *testing.T) { }, Routes: map[dnsname.FQDN][]*dnstype.Resolver{ dnsname.FQDN("example.com."): { - {Addr: "http://100.102.0.1:1234/dns-query"}, + {Addr: "tailscale-app:app1"}, }, }, }, diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 408ca2435..952372e10 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -862,15 +862,10 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. // Add split DNS routes, with no regard to exit node configuration. addSplitDNSRoutes(nm.DNS.Routes) - // Add split DNS routes for conn25 - appMap := appc.AppNameByDomain(nm.HasCap, nm.SelfNode) - if appMap != nil { - var m map[string][]*dnstype.Resolver - for domain, appName := range appMap { - mak.Set(&m, domain, []*dnstype.Resolver{{Addr: appName, IsFranNewDynamicResolverThing: true}}) - } - if m != nil { - addSplitDNSRoutes(m) + if buildfeatures.HasConn25 { + // Add split DNS routes for conn25 + if appRoutes := appc.AppDNSRoutes(nm.HasCap, nm.SelfNode); appRoutes != nil { + addSplitDNSRoutes(appRoutes) } } diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index 992539b1b..7955350d0 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "maps" "net" "net/http" "net/netip" @@ -41,15 +42,14 @@ import ( "tailscale.com/types/dnstype" "tailscale.com/types/logger" "tailscale.com/types/nettype" + "tailscale.com/types/views" "tailscale.com/util/cloudenv" "tailscale.com/util/dnsname" + "tailscale.com/util/mak" "tailscale.com/util/race" "tailscale.com/version" ) -// TODO comment, name -var FranNewDynamicResolverThing feature.Hook[func(string) (string, error)] - // headerBytes is the number of bytes in a DNS message header. const headerBytes = 12 @@ -326,6 +326,18 @@ type forwarder struct { // /etc/resolv.conf is missing/corrupt, and the peerapi ExitDNS stub // resolver lookup. cloudHostFallback []resolverAndDelay + // schemes are the collection of registered URI scheme names that + // dynamically decide which resolver to use at the time of each query. The + // key is the scheme (the portion before the first `:`) and the value is a + // handler that determines where the current query should be sent. + // Use schemeCacheLocked() to get the current contents that can continue to + // be accessed once mu is released. This allows the (much more common) + // resolver code path to avoid repeated locking and unlocking. + // When modified, call invalidateSchemeCacheLocked() before unlocking mu. + schemes map[string]CustomSchemeHandler + // schemeCache is an immutable copy of schemes. Do not read directly, + // use schemeCacheLocked() which will regenerate its contents as needed. + schemeCache views.Map[string, CustomSchemeHandler] // acceptDNS tracks the CorpDNS pref (--accept-dns) // This lets us skip health warnings if the forwarder receives inbound @@ -1004,23 +1016,35 @@ func (f *forwarder) resolvers(domain dnsname.FQDN) []resolverAndDelay { f.mu.Lock() routes := f.routes cloudHostFallback := f.cloudHostFallback + schemes := f.schemeCacheLocked() f.mu.Unlock() for _, route := range routes { if route.Suffix == "." || route.Suffix.Contains(domain) { triedToResolveResolver := false - resolvers := []resolverAndDelay{} + resolvers := make([]resolverAndDelay, 0, len(route.Resolvers)) for _, r := range route.Resolvers { - if r.name.IsFranNewDynamicResolverThing { - triedToResolveResolver = true - fx := FranNewDynamicResolverThing.Get() - if fx != nil { - url, err := fx(r.name.Addr) - if err != nil { - continue - } - r.name.Addr = url - } + scheme, _, ok := strings.Cut(r.name.Addr, ":") + if !ok { + resolvers = append(resolvers, r) + continue } + h := schemes.Get(scheme) + if h == nil { + resolvers = append(resolvers, r) + continue + } + + triedToResolveResolver = true + newAddr, err := h(r.name.Addr) + if err != nil { + continue + } + + r.name = &dnstype.Resolver{ + Addr: newAddr, + UseWithExitNode: r.name.UseWithExitNode, + } + resolvers = append(resolvers, r) } // if there turned out to actually be no resolvers for this route Suffix then @@ -1047,6 +1071,52 @@ func (f *forwarder) GetUpstreamResolvers(name dnsname.FQDN) []*dnstype.Resolver return upstreamResolvers } +// RegisterCustomScheme adds a [CustomSchemaHandler] that is called to provide +// an updated address when a [dnstype.Resolver.Addr] uses that scheme. +func (f *forwarder) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.schemes[scheme]; ok { + return fmt.Errorf("scheme %q already registered", scheme) + } + f.invalidateSchemeCacheLocked() + mak.Set(&f.schemes, scheme, h) + return nil +} + +// UnregisterCustomScheme removes a scheme previously registered via +// RegisterCustomScheme. +func (f *forwarder) UnregisterCustomScheme(scheme string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.schemes[scheme]; !ok { + return fmt.Errorf("scheme %q not registered", scheme) + } + f.invalidateSchemeCacheLocked() + delete(f.schemes, scheme) + return nil +} + +// invalidateSchemeCacheLocked clears f.schemeCache so that it will be rebuilt +// on the next call to f.schemeCacheLocked(). +func (f *forwarder) invalidateSchemeCacheLocked() { + f.schemeCache = views.Map[string, CustomSchemeHandler]{} +} + +// schemeCacheLocked returns an immutable copy of f.schemes that can be used +// after mu is unlocked. +func (f *forwarder) schemeCacheLocked() views.Map[string, CustomSchemeHandler] { + if !f.schemeCache.IsNil() { + return f.schemeCache + } + if f.schemes == nil { + return f.schemeCache // returns a nil view + } + // Regenerate the cache + f.schemeCache = views.MapOf(maps.Clone(f.schemes)) + return f.schemeCache +} + // forwardQuery is information and state about a forwarded DNS query that's // being sent to 1 or more upstreams. // diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 01f0c8a63..083de132d 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -293,6 +293,24 @@ func (r *Resolver) SetConfig(cfg Config) error { return nil } +// CustomSchemeHandler takes a URI (retrieved from [dnstype.Resolver.Addr]) and +// returns an updated URI to use for the current query. The result is only valid +// for right now and may change over time. +type CustomSchemeHandler func(addr string) (newAddr string, err error) + +// RegisterCustomScheme adds a [CustomSchemaHandler] that is called to provide +// an updated address to the forwarder when a [dnstype.Resolver.Addr] uses that +// scheme. +func (r *Resolver) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error { + return r.forwarder.RegisterCustomScheme(scheme, h) +} + +// UnregisterCustomScheme unregisters a scheme previously registered with +// RegisterCustomScheme. +func (r *Resolver) UnregisterCustomScheme(scheme string) error { + return r.forwarder.UnregisterCustomScheme(scheme) +} + // Close shuts down the resolver and ensures poll goroutines have exited. // The Resolver cannot be used again after Close is called. func (r *Resolver) Close() { diff --git a/types/dnstype/dnstype.go b/types/dnstype/dnstype.go index 149b159fd..1cd38d383 100644 --- a/types/dnstype/dnstype.go +++ b/types/dnstype/dnstype.go @@ -41,8 +41,6 @@ type Resolver struct { // there are situations where it is preferable to still use a Split DNS server and/or // global DNS server instead of the exit node. UseWithExitNode bool `json:",omitempty"` - - IsFranNewDynamicResolverThing bool `json:",omitempty"` } // IPPort returns r.Addr as an IP address and port if either diff --git a/types/dnstype/dnstype_clone.go b/types/dnstype/dnstype_clone.go index d95cd9479..e690ebaec 100644 --- a/types/dnstype/dnstype_clone.go +++ b/types/dnstype/dnstype_clone.go @@ -23,10 +23,9 @@ func (src *Resolver) Clone() *Resolver { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ResolverCloneNeedsRegeneration = Resolver(struct { - Addr string - BootstrapResolution []netip.Addr - UseWithExitNode bool - IsFranNewDynamicResolverThing bool + Addr string + BootstrapResolution []netip.Addr + UseWithExitNode bool }{}) // Clone duplicates src into dst and reports whether it succeeded. diff --git a/types/dnstype/dnstype_view.go b/types/dnstype/dnstype_view.go index a9618fa53..c91feb6b8 100644 --- a/types/dnstype/dnstype_view.go +++ b/types/dnstype/dnstype_view.go @@ -113,14 +113,12 @@ func (v ResolverView) BootstrapResolution() views.Slice[netip.Addr] { // exit node is in use. Normally, DNS resolution is delegated to the exit node but // there are situations where it is preferable to still use a Split DNS server and/or // global DNS server instead of the exit node. -func (v ResolverView) UseWithExitNode() bool { return v.ж.UseWithExitNode } -func (v ResolverView) IsFranNewDynamicResolverThing() bool { return v.ж.IsFranNewDynamicResolverThing } -func (v ResolverView) Equal(v2 ResolverView) bool { return v.ж.Equal(v2.ж) } +func (v ResolverView) UseWithExitNode() bool { return v.ж.UseWithExitNode } +func (v ResolverView) Equal(v2 ResolverView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ResolverViewNeedsRegeneration = Resolver(struct { - Addr string - BootstrapResolution []netip.Addr - UseWithExitNode bool - IsFranNewDynamicResolverThing bool + Addr string + BootstrapResolution []netip.Addr + UseWithExitNode bool }{})