appc,ipn/ipnlocal,types/appctype: implement control provided routes

Control can now send down a set of routes along with the domains, and
the routes will be advertised, with any newly overlapped routes being
removed to reduce the size of the routing table.

Fixes tailscale/corp#16833
Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker
2024-01-17 11:35:55 -08:00
committed by James Tucker
parent 543e7ed596
commit 24df1ef1ee
5 changed files with 159 additions and 2 deletions

View File

@@ -28,6 +28,9 @@ type RouteAdvertiser interface {
// AdvertiseRoute adds a new route advertisement if the route is not already
// being advertised.
AdvertiseRoute(netip.Prefix) error
// UnadvertiseRoute removes a route advertisement.
UnadvertiseRoute(netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
@@ -45,10 +48,14 @@ type AppConnector struct {
// 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
}
@@ -97,6 +104,42 @@ func (e *AppConnector) UpdateDomains(domains []string) {
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
}
nextRoute:
for _, r := range routes {
if err := e.routeAdvertiser.AdvertiseRoute(r); err != nil {
e.logf("failed to advertise route: %v: %v", r, err)
continue
}
for _, addr := range e.domains {
for _, a := range addr {
if r.Contains(a) {
pfx := netip.PrefixFrom(a, a.BitLen())
if err := e.routeAdvertiser.UnadvertiseRoute(pfx); err != nil {
e.logf("failed to unadvertise route: %v: %v", pfx, err)
}
continue nextRoute
}
}
}
}
e.controlRoutes = routes
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
@@ -132,6 +175,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
return
}
nextAnswer:
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
@@ -206,6 +250,16 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
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

View File

@@ -11,6 +11,7 @@ import (
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
@@ -36,6 +37,30 @@ func TestUpdateDomains(t *testing.T) {
}
}
func TestUpdateRoutes(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
a.UpdateRoutes(routes)
if !slices.EqualFunc(routes, rc.routes, prefixEqual) {
t.Fatalf("got %v, want %v", rc.routes, routes)
}
}
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
rc.routes = []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
a.UpdateRoutes(routes)
if !slices.EqualFunc(routes, rc.routes, prefixEqual) {
t.Fatalf("got %v, want %v", rc.routes, routes)
}
}
func TestDomainRoutes(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
@@ -79,7 +104,19 @@ func TestObserveDNSResponse(t *testing.T) {
// 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)
t.Errorf("rc.routes: got %v; want %v", rc.routes, wantRoutes)
}
// don't advertise addresses that are already in a control provided route
pfx := netip.MustParsePrefix("192.0.2.0/24")
a.UpdateRoutes([]netip.Prefix{pfx})
wantRoutes = append(wantRoutes, pfx)
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("rc.routes: got %v; want %v", rc.routes, wantRoutes)
}
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
}
}
@@ -160,3 +197,18 @@ func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}
func (rc *routeCollector) UnadvertiseRoute(pfx netip.Prefix) error {
routes := rc.routes
rc.routes = rc.routes[:0]
for _, r := range routes {
if r != pfx {
rc.routes = append(rc.routes, r)
}
}
return nil
}
func prefixEqual(a, b netip.Prefix) bool {
return a.Addr().Compare(b.Addr()) == 0 && a.Bits() == b.Bits()
}