wip: register resolver scheme for apps

This commit is contained in:
Adrian Dewhurst 2026-04-22 16:48:01 -04:00
parent a3744bf1cf
commit 934907eba9
9 changed files with 143 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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