mirror of https://github.com/tailscale/tailscale/
appc,ipn/ipnlocal,net/dns/resolver: add App Connector wiring when enabled in prefs
An EmbeddedAppConnector is added that when configured observes DNS responses from the PeerAPI. If a response is found matching a configured domain, routes are advertised when necessary. The wiring from a configuration in the netmap capmap is not yet done, so while the connector can be enabled, no domains can yet be added. Updates tailscale/corp#15437 Signed-off-by: James Tucker <james@tailscale.com>pull/10040/head
parent
e7482f0df0
commit
b48b7d82d0
@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package appc implements App Connectors. An AppConnector provides domain
|
||||||
|
// oriented routing of traffic.
|
||||||
|
package appc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
"tailscale.com/types/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO(raggi): the sniproxy servicing portions of this package will be moved
|
||||||
|
* into the sniproxy or deprecated at some point, when doing so is not
|
||||||
|
* disruptive. At that time EmbeddedAppConnector can be renamed to AppConnector.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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 a new route advertisement if the route is not already
|
||||||
|
// being advertised.
|
||||||
|
AdvertiseRoute(netip.Prefix) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddedAppConnector 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 EmbeddedAppConnector 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmbeddedAppConnector creates a new EmbeddedAppConnector.
|
||||||
|
func NewEmbeddedAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *EmbeddedAppConnector {
|
||||||
|
return &EmbeddedAppConnector{
|
||||||
|
logf: logger.WithPrefix(logf, "appc: "),
|
||||||
|
routeAdvertiser: routeAdvertiser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDomains 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.
|
||||||
|
func (e *EmbeddedAppConnector) UpdateDomains(domains []string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
var old map[string][]netip.Addr
|
||||||
|
old, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
d = strings.ToLower(d)
|
||||||
|
e.domains[d] = old[d]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 *EmbeddedAppConnector) ObserveDNSResponse(res []byte) {
|
||||||
|
var p dnsmessage.Parser
|
||||||
|
if _, err := p.Start(res); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.SkipAllQuestions(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if domain[len(domain)-1] == '.' {
|
||||||
|
domain = domain[:len(domain)-1]
|
||||||
|
}
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
e.logf("[v2] observed DNS response for %s", domain)
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
addrs, ok := e.domains[domain]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// TODO(raggi): check for existing prefixes
|
||||||
|
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
|
||||||
|
e.logf("failed to advertise route for %v: %v", 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package appc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
xmaps "golang.org/x/exp/maps"
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
"tailscale.com/util/must"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateDomains(t *testing.T) {
|
||||||
|
a := NewEmbeddedAppConnector(t.Logf, nil)
|
||||||
|
a.UpdateDomains([]string{"example.com"})
|
||||||
|
if got, want := xmaps.Keys(a.domains), []string{"example.com"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr("192.0.0.8")
|
||||||
|
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||||
|
a.UpdateDomains([]string{"example.com"})
|
||||||
|
|
||||||
|
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// domains are explicitly downcased on set.
|
||||||
|
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||||
|
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveDNSResponse(t *testing.T) {
|
||||||
|
rc := &routeCollector{}
|
||||||
|
a := NewEmbeddedAppConnector(t.Logf, rc)
|
||||||
|
|
||||||
|
// a has no domains configured, so it should not advertise any routes
|
||||||
|
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||||
|
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||||
|
|
||||||
|
a.UpdateDomains([]string{"example.com"})
|
||||||
|
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||||
|
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||||
|
|
||||||
|
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||||
|
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("got %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't re-advertise routes that have already been advertised
|
||||||
|
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||||
|
if !slices.Equal(rc.routes, wantRoutes) {
|
||||||
|
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeCollector is a test helper that collects the list of routes advertised
|
||||||
|
type routeCollector struct {
|
||||||
|
routes []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeCollector implements RouteAdvertiser
|
||||||
|
var _ RouteAdvertiser = (*routeCollector)(nil)
|
||||||
|
|
||||||
|
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
|
||||||
|
rc.routes = append(rc.routes, pfx)
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in New Issue