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