tailscale/appc/appconnector.go

305 lines
8.1 KiB
Go
Raw Normal View History

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appc implements App Connectors.
// An AppConnector provides DNS domain oriented routing of traffic. An App
// Connector becomes a DNS server for a peer, authoritative for the set of
// configured domains. DNS resolution of the target domain triggers dynamic
// publication of routes to ensure that traffic to the domain is routed through
// the App Connector.
package appc
import (
"context"
"net/netip"
"slices"
"strings"
"sync"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/execqueue"
)
// RouteAdvertiser is an interface that allows the AppConnector to advertise
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds one or more route advertisements skipping any that
// are already advertised.
AdvertiseRoute(...netip.Prefix) error
// UnadvertiseRoute removes any matching route advertisements.
UnadvertiseRoute(...netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
// its function as a subsystem inside of a tailscale node. At the control plane
// side App Connector routing is configured in terms of domains rather than IP
// addresses.
// The AppConnectors responsibility inside tailscaled is to apply the routing
// and domain configuration as supplied in the map response.
// DNS requests for configured domains are observed. If the domains resolve to
// routes not yet served by the AppConnector the local node configuration is
// updated to advertise the new route.
type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// mu guards the fields that follow
mu sync.Mutex
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
// controlRoutes is the list of routes that were last supplied by control.
controlRoutes []netip.Prefix
// wildcards is the list of domain strings that match subdomains.
wildcards []string
// queue provides ordering for update operations
queue execqueue.ExecQueue
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
}
}
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
// given the new domains and routes.
func (e *AppConnector) UpdateDomainsAndRoutes(domains []string, routes []netip.Prefix) {
e.queue.Add(func() {
// Add the new routes first.
e.updateRoutes(routes)
e.updateDomains(domains)
})
}
// UpdateDomains asynchronously replaces the current set of configured domains
// with the supplied set of domains. Domains must not contain a trailing dot,
// and should be lower case. If the domain contains a leading '*' label it
// matches all subdomains of a domain.
func (e *AppConnector) UpdateDomains(domains []string) {
e.queue.Add(func() {
e.updateDomains(domains)
})
}
// Wait waits for the currently scheduled asynchronous configuration changes to
// complete.
func (e *AppConnector) Wait(ctx context.Context) {
e.queue.Wait(ctx)
}
func (e *AppConnector) updateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
var oldDomains map[string][]netip.Addr
oldDomains, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
e.wildcards = e.wildcards[:0]
for _, d := range domains {
d = strings.ToLower(d)
if len(d) == 0 {
continue
}
if strings.HasPrefix(d, "*.") {
e.wildcards = append(e.wildcards, d[2:])
continue
}
e.domains[d] = oldDomains[d]
delete(oldDomains, d)
}
// Ensure that still-live wildcards addresses are preserved as well.
for d, addrs := range oldDomains {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(d, wc) {
e.domains[d] = addrs
break
}
}
}
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
// by control for UpdateRoutes are supplemental to the routes discovered by DNS resolution, but are
// also more often whole ranges. UpdateRoutes will remove any single address routes that are now
// covered by new ranges.
func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
e.mu.Lock()
defer e.mu.Unlock()
// If there was no change since the last update, no work to do.
if slices.Equal(e.controlRoutes, routes) {
return
}
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
return
}
var toRemove []netip.Prefix
nextRoute:
for _, r := range routes {
for _, addr := range e.domains {
for _, a := range addr {
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
pfx := netip.PrefixFrom(a, a.BitLen())
toRemove = append(toRemove, pfx)
continue nextRoute
}
}
}
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
e.controlRoutes = routes
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
defer e.mu.Unlock()
return views.SliceOf(xmaps.Keys(e.domains))
}
// DomainRoutes returns a map of domains to resolved IP
// addresses.
func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
e.mu.Lock()
defer e.mu.Unlock()
drCopy := make(map[string][]netip.Addr)
for k, v := range e.domains {
drCopy[k] = append(drCopy[k], v...)
}
return drCopy
}
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return
}
if err := p.SkipAllQuestions(); err != nil {
return
}
nextAnswer:
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := h.Name.String()
if len(domain) == 0 {
return
}
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
e.mu.Lock()
addrs, ok := e.domains[domain]
// match wildcard domains
if !ok {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
ok = true
break
}
}
}
e.mu.Unlock()
if !ok {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if slices.Contains(addrs, addr) {
continue
}
for _, route := range e.controlRoutes {
if route.Contains(addr) {
// record the new address associated with the domain for faster matching in subsequent
// requests and for diagnostic records.
e.mu.Lock()
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
continue nextAnswer
}
}
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %s: %v: %v", domain, addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
e.mu.Lock()
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
}