appc: add flag shouldStoreRoutes and controlknob for it

When an app connector is reconfigured and domains to route are removed,
we would like to no longer advertise routes that were discovered for
those domains. In order to do this we plan to store which routes were
discovered for which domains.

Add a controlknob so that we can enable/disable the new behavior.

Updates #11008
Signed-off-by: Fran Bull <fran@tailscale.com>
pull/11918/head
Fran Bull 7 months ago committed by franbull
parent 79836e7bfd
commit 1bd1b387b2

@ -62,6 +62,9 @@ type AppConnector struct {
logf logger.Logf logf logger.Logf
routeAdvertiser RouteAdvertiser routeAdvertiser RouteAdvertiser
// storeRoutesFunc will be called to persist routes if it is not nil.
storeRoutesFunc func(*RouteInfo) error
// mu guards the fields that follow // mu guards the fields that follow
mu sync.Mutex mu sync.Mutex
@ -80,11 +83,24 @@ type AppConnector struct {
} }
// NewAppConnector creates a new AppConnector. // NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector { func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
return &AppConnector{ ac := &AppConnector{
logf: logger.WithPrefix(logf, "appc: "), logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser, routeAdvertiser: routeAdvertiser,
storeRoutesFunc: storeRoutesFunc,
}
if routeInfo != nil {
ac.domains = routeInfo.Domains
ac.wildcards = routeInfo.Wildcards
ac.controlRoutes = routeInfo.Control
} }
return ac
}
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
// and is storing its discovered routes persistently.
func (e *AppConnector) ShouldStoreRoutes() bool {
return e.storeRoutesFunc != nil
} }
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration // UpdateDomainsAndRoutes starts an asynchronous update of the configuration

@ -17,194 +17,238 @@ import (
"tailscale.com/util/must" "tailscale.com/util/must"
) )
func fakeStoreRoutes(*RouteInfo) error { return nil }
func TestUpdateDomains(t *testing.T) { func TestUpdateDomains(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
a := NewAppConnector(t.Logf, nil) ctx := context.Background()
a.UpdateDomains([]string{"example.com"}) var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, nil, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, nil, nil, nil)
}
a.UpdateDomains([]string{"example.com"})
a.Wait(ctx) a.Wait(ctx)
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) { if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want) t.Errorf("got %v; want %v", got, want)
} }
addr := netip.MustParseAddr("192.0.0.8") addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr) a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"}) a.UpdateDomains([]string{"example.com"})
a.Wait(ctx) a.Wait(ctx)
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) { if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want) t.Errorf("got %v; want %v", got, want)
} }
// domains are explicitly downcased on set. // domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"}) a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
a.Wait(ctx) a.Wait(ctx)
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) { if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want) t.Errorf("got %v; want %v", got, want)
}
} }
} }
func TestUpdateRoutes(t *testing.T) { func TestUpdateRoutes(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{} ctx := context.Background()
a := NewAppConnector(t.Logf, rc) rc := &appctest.RouteCollector{}
a.updateDomains([]string{"*.example.com"}) var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"*.example.com"})
// This route should be collapsed into the range // This route should be collapsed into the range
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")) a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
a.Wait(ctx) a.Wait(ctx)
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) { if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
} }
// This route should not be collapsed or removed // This route should not be collapsed or removed
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")) a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
a.Wait(ctx) a.Wait(ctx)
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")} routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
a.updateRoutes(routes) a.updateRoutes(routes)
slices.SortFunc(rc.Routes(), prefixCompare) slices.SortFunc(rc.Routes(), prefixCompare)
rc.SetRoutes(slices.Compact(rc.Routes())) rc.SetRoutes(slices.Compact(rc.Routes()))
slices.SortFunc(routes, prefixCompare) slices.SortFunc(routes, prefixCompare)
// Ensure that the non-matching /32 is preserved, even though it's in the domains table. // Ensure that the non-matching /32 is preserved, even though it's in the domains table.
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) { if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes) t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
} }
// Ensure that the contained /32 is removed, replaced by the /24. // Ensure that the contained /32 is removed, replaced by the /24.
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")} wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) { if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes()) t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
}
} }
} }
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) { func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
rc := &appctest.RouteCollector{} for _, shouldStore := range []bool{false, true} {
a := NewAppConnector(t.Logf, rc) rc := &appctest.RouteCollector{}
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")}) var a *AppConnector
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) if shouldStore {
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")} a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
a.updateRoutes(routes) } else {
a = NewAppConnector(t.Logf, rc, nil, nil)
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) { }
t.Fatalf("got %v, want %v", rc.Routes(), routes) mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
rc.SetRoutes([]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) { func TestDomainRoutes(t *testing.T) {
rc := &appctest.RouteCollector{} for _, shouldStore := range []bool{false, true} {
a := NewAppConnector(t.Logf, rc) rc := &appctest.RouteCollector{}
a.updateDomains([]string{"example.com"}) var a *AppConnector
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) if shouldStore {
a.Wait(context.Background()) a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
want := map[string][]netip.Addr{ a = NewAppConnector(t.Logf, rc, nil, nil)
"example.com": {netip.MustParseAddr("192.0.0.8")}, }
} a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(context.Background())
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) { if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want) t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
} }
} }
func TestObserveDNSResponse(t *testing.T) { func TestObserveDNSResponse(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{} ctx := context.Background()
a := NewAppConnector(t.Logf, rc) rc := &appctest.RouteCollector{}
var a *AppConnector
// a has no domains configured, so it should not advertise any routes if shouldStore {
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) { } else {
t.Errorf("got %v; want %v", got, want) a = NewAppConnector(t.Logf, rc, nil, nil)
} }
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} // 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)
}
a.updateDomains([]string{"example.com"}) wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// a CNAME record chain should result in a route being added if the chain a.updateDomains([]string{"example.com"})
// matches a routed domain. a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.updateDomains([]string{"www.example.com", "example.com"}) a.Wait(ctx)
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")) if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
a.Wait(ctx) t.Errorf("got %v; want %v", got, want)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32")) }
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// a CNAME record chain should result in a route being added if the chain // a CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain // matches a routed domain.
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")) a.updateDomains([]string{"www.example.com", "example.com"})
a.Wait(ctx) a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32")) a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) { wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
t.Errorf("got %v; want %v", got, want) 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 CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")) wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.Wait(ctx)
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"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")) a.Wait(ctx)
a.Wait(ctx) if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
if !slices.Equal(rc.Routes(), wantRoutes) { t.Errorf("got %v; want %v", got, want)
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes) }
}
// don't advertise addresses that are already in a control provided route // don't re-advertise routes that have already been advertised
pfx := netip.MustParsePrefix("192.0.2.0/24") a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
a.updateRoutes([]netip.Prefix{pfx}) a.Wait(ctx)
wantRoutes = append(wantRoutes, pfx) if !slices.Equal(rc.Routes(), wantRoutes) {
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")) t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
a.Wait(ctx) }
if !slices.Equal(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")
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) { a.updateRoutes([]netip.Prefix{pfx})
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"]) wantRoutes = append(wantRoutes, pfx)
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
a.Wait(ctx)
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"])
}
} }
} }
func TestWildcardDomains(t *testing.T) { func TestWildcardDomains(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{} ctx := context.Background()
a := NewAppConnector(t.Logf, rc) rc := &appctest.RouteCollector{}
var a *AppConnector
a.updateDomains([]string{"*.example.com"}) if shouldStore {
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")) a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
a.Wait(ctx) } else {
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) { a = NewAppConnector(t.Logf, rc, nil, nil)
t.Errorf("routes: got %v; want %v", got, want) }
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.updateDomains([]string{"*.example.com", "example.com"}) a.updateDomains([]string{"*.example.com"})
if _, ok := a.domains["foo.example.com"]; !ok { a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard") a.Wait(ctx)
} if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) { t.Errorf("routes: got %v; want %v", got, want)
t.Errorf("wildcards: got %v; want %v", got, want) }
} if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.updateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that. // There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.updateDomains([]string{"*.example.com", "example.com"}) a.updateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 { if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards) t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
} }
} }

@ -72,6 +72,10 @@ type Knobs struct {
// ProbeUDPLifetime is whether the node should probe UDP path lifetime on // ProbeUDPLifetime is whether the node should probe UDP path lifetime on
// the tail end of an active direct connection in magicsock. // the tail end of an active direct connection in magicsock.
ProbeUDPLifetime atomic.Bool ProbeUDPLifetime atomic.Bool
// AppCStoreRoutes is whether the node should store RouteInfo to StateStore
// if it's an app connector.
AppCStoreRoutes atomic.Bool
} }
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@ -96,6 +100,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables) forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables)
seamlessKeyRenewal = has(tailcfg.NodeAttrSeamlessKeyRenewal) seamlessKeyRenewal = has(tailcfg.NodeAttrSeamlessKeyRenewal)
probeUDPLifetime = has(tailcfg.NodeAttrProbeUDPLifetime) probeUDPLifetime = has(tailcfg.NodeAttrProbeUDPLifetime)
appCStoreRoutes = has(tailcfg.NodeAttrStoreAppCRoutes)
) )
if has(tailcfg.NodeAttrOneCGNATEnable) { if has(tailcfg.NodeAttrOneCGNATEnable) {
@ -118,6 +123,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
k.LinuxForceNfTables.Store(forceNfTables) k.LinuxForceNfTables.Store(forceNfTables)
k.SeamlessKeyRenewal.Store(seamlessKeyRenewal) k.SeamlessKeyRenewal.Store(seamlessKeyRenewal)
k.ProbeUDPLifetime.Store(probeUDPLifetime) k.ProbeUDPLifetime.Store(probeUDPLifetime)
k.AppCStoreRoutes.Store(appCStoreRoutes)
} }
// AsDebugJSON returns k as something that can be marshalled with json.Marshal // AsDebugJSON returns k as something that can be marshalled with json.Marshal
@ -141,5 +147,6 @@ func (k *Knobs) AsDebugJSON() map[string]any {
"LinuxForceNfTables": k.LinuxForceNfTables.Load(), "LinuxForceNfTables": k.LinuxForceNfTables.Load(),
"SeamlessKeyRenewal": k.SeamlessKeyRenewal.Load(), "SeamlessKeyRenewal": k.SeamlessKeyRenewal.Load(),
"ProbeUDPLifetime": k.ProbeUDPLifetime.Load(), "ProbeUDPLifetime": k.ProbeUDPLifetime.Load(),
"AppCStoreRoutes": k.AppCStoreRoutes.Load(),
} }
} }

@ -3516,8 +3516,22 @@ func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs i
return return
} }
if b.appConnector == nil { shouldAppCStoreRoutes := b.ControlKnobs().AppCStoreRoutes.Load()
b.appConnector = appc.NewAppConnector(b.logf, b) if b.appConnector == nil || b.appConnector.ShouldStoreRoutes() != shouldAppCStoreRoutes {
var ri *appc.RouteInfo
var storeFunc func(*appc.RouteInfo) error
if shouldAppCStoreRoutes {
var err error
ri, err = b.readRouteInfoLocked()
if err != nil {
ri = &appc.RouteInfo{}
if err != ipn.ErrStateNotExist {
b.logf("Unsuccessful Read RouteInfo: ", err)
}
}
storeFunc = b.storeRouteInfo
}
b.appConnector = appc.NewAppConnector(b.logf, b, ri, storeFunc)
} }
if nm == nil { if nm == nil {
return return

@ -55,6 +55,8 @@ import (
"tailscale.com/wgengine/wgcfg" "tailscale.com/wgengine/wgcfg"
) )
func fakeStoreRoutes(*appc.RouteInfo) error { return nil }
func inRemove(ip netip.Addr) bool { func inRemove(ip netip.Addr) bool {
for _, pfx := range removeFromDefaultRoute { for _, pfx := range removeFromDefaultRoute {
if pfx.Contains(ip) { if pfx.Contains(ip) {
@ -1291,13 +1293,19 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
} }
func TestOfferingAppConnector(t *testing.T) { func TestOfferingAppConnector(t *testing.T) {
b := newTestBackend(t) for _, shouldStore := range []bool{false, true} {
if b.OfferingAppConnector() { b := newTestBackend(t)
t.Fatal("unexpected offering app connector") if b.OfferingAppConnector() {
} t.Fatal("unexpected offering app connector")
b.appConnector = appc.NewAppConnector(t.Logf, nil) }
if !b.OfferingAppConnector() { if shouldStore {
t.Fatal("unexpected not offering app connector") b.appConnector = appc.NewAppConnector(t.Logf, nil, &appc.RouteInfo{}, fakeStoreRoutes)
} else {
b.appConnector = appc.NewAppConnector(t.Logf, nil, nil, nil)
}
if !b.OfferingAppConnector() {
t.Fatal("unexpected not offering app connector")
}
} }
} }
@ -1342,21 +1350,27 @@ func TestRouterAdvertiserIgnoresContainedRoutes(t *testing.T) {
} }
func TestObserveDNSResponse(t *testing.T) { func TestObserveDNSResponse(t *testing.T) {
b := newTestBackend(t) for _, shouldStore := range []bool{false, true} {
b := newTestBackend(t)
// ensure no error when no app connector is configured
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
rc := &appctest.RouteCollector{} // ensure no error when no app connector is configured
b.appConnector = appc.NewAppConnector(t.Logf, rc) b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
b.appConnector.UpdateDomains([]string{"example.com"})
b.appConnector.Wait(context.Background())
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) rc := &appctest.RouteCollector{}
b.appConnector.Wait(context.Background()) if shouldStore {
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} b.appConnector = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes)
if !slices.Equal(rc.Routes(), wantRoutes) { } else {
t.Fatalf("got routes %v, want %v", rc.Routes(), wantRoutes) b.appConnector = appc.NewAppConnector(t.Logf, rc, nil, nil)
}
b.appConnector.UpdateDomains([]string{"example.com"})
b.appConnector.Wait(context.Background())
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
b.appConnector.Wait(context.Background())
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Fatalf("got routes %v, want %v", rc.Routes(), wantRoutes)
}
} }
} }

@ -687,185 +687,209 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
} }
func TestPeerAPIPrettyReplyCNAME(t *testing.T) { func TestPeerAPIPrettyReplyCNAME(t *testing.T) {
var h peerAPIHandler for _, shouldStore := range []bool{false, true} {
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") var h peerAPIHandler
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
h.ps = &peerAPIServer{ pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
b: &LocalBackend{ var a *appc.AppConnector
e: eng, if shouldStore {
pm: pm, a = appc.NewAppConnector(t.Logf, &appctest.RouteCollector{}, &appc.RouteInfo{}, fakeStoreRoutes)
store: pm.Store(), } else {
// configure as an app connector just to enable the API. a = appc.NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
appConnector: appc.NewAppConnector(t.Logf, &appctest.RouteCollector{}), }
}, h.ps = &peerAPIServer{
} b: &LocalBackend{
e: eng,
h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { pm: pm,
b.CNAMEResource( store: pm.Store(),
dnsmessage.ResourceHeader{ // configure as an app connector just to enable the API.
Name: dnsmessage.MustNewName("www.example.com."), appConnector: a,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName("example.com."),
},
)
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
}, },
) }
}}
f := filter.NewAllowAllForTest(logger.Discard)
h.ps.b.setFilter(f)
if !h.replyToDNSQueries() { h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
t.Errorf("unexpectedly deny; wanted to be a DNS server") b.CNAMEResource(
} dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("www.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName("example.com."),
},
)
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
},
)
}}
f := filter.NewAllowAllForTest(logger.Discard)
h.ps.b.setFilter(f)
w := httptest.NewRecorder() if !h.replyToDNSQueries() {
h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil)) t.Errorf("unexpectedly deny; wanted to be a DNS server")
if w.Code != http.StatusOK { }
t.Errorf("unexpected status code: %v", w.Code)
} w := httptest.NewRecorder()
var addrs []string h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
json.NewDecoder(w.Body).Decode(&addrs) if w.Code != http.StatusOK {
if len(addrs) == 0 { t.Errorf("unexpected status code: %v", w.Code)
t.Fatalf("no addresses returned") }
} var addrs []string
for _, addr := range addrs { json.NewDecoder(w.Body).Decode(&addrs)
netip.MustParseAddr(addr) if len(addrs) == 0 {
t.Fatalf("no addresses returned")
}
for _, addr := range addrs {
netip.MustParseAddr(addr)
}
} }
} }
func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) { func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
var h peerAPIHandler ctx := context.Background()
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") var h peerAPIHandler
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
rc := &appctest.RouteCollector{}
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) rc := &appctest.RouteCollector{}
pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
h.ps = &peerAPIServer{ pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
b: &LocalBackend{ var a *appc.AppConnector
e: eng, if shouldStore {
pm: pm, a = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes)
store: pm.Store(), } else {
appConnector: appc.NewAppConnector(t.Logf, rc), a = appc.NewAppConnector(t.Logf, rc, nil, nil)
}, }
} h.ps = &peerAPIServer{
h.ps.b.appConnector.UpdateDomains([]string{"example.com"}) b: &LocalBackend{
h.ps.b.appConnector.Wait(ctx) e: eng,
pm: pm,
h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { store: pm.Store(),
b.AResource( appConnector: a,
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
}, },
) }
}} h.ps.b.appConnector.UpdateDomains([]string{"example.com"})
f := filter.NewAllowAllForTest(logger.Discard) h.ps.b.appConnector.Wait(ctx)
h.ps.b.setFilter(f)
h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
},
)
}}
f := filter.NewAllowAllForTest(logger.Discard)
h.ps.b.setFilter(f)
if !h.ps.b.OfferingAppConnector() { if !h.ps.b.OfferingAppConnector() {
t.Fatal("expecting to be offering app connector") t.Fatal("expecting to be offering app connector")
} }
if !h.replyToDNSQueries() { if !h.replyToDNSQueries() {
t.Errorf("unexpectedly deny; wanted to be a DNS server") t.Errorf("unexpectedly deny; wanted to be a DNS server")
} }
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=example.com.", nil)) h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=example.com.", nil))
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("unexpected status code: %v", w.Code) t.Errorf("unexpected status code: %v", w.Code)
} }
h.ps.b.appConnector.Wait(ctx) h.ps.b.appConnector.Wait(ctx)
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.Routes(), wantRoutes) { if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("got %v; want %v", rc.Routes(), wantRoutes) t.Errorf("got %v; want %v", rc.Routes(), wantRoutes)
}
} }
} }
func TestPeerAPIReplyToDNSQueriesAreObservedWithCNAMEFlattening(t *testing.T) { func TestPeerAPIReplyToDNSQueriesAreObservedWithCNAMEFlattening(t *testing.T) {
ctx := context.Background() for _, shouldStore := range []bool{false, true} {
var h peerAPIHandler ctx := context.Background()
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") var h peerAPIHandler
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
rc := &appctest.RouteCollector{}
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) rc := &appctest.RouteCollector{}
pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
h.ps = &peerAPIServer{ pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
b: &LocalBackend{ var a *appc.AppConnector
e: eng, if shouldStore {
pm: pm, a = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes)
store: pm.Store(), } else {
appConnector: appc.NewAppConnector(t.Logf, rc), a = appc.NewAppConnector(t.Logf, rc, nil, nil)
}, }
} h.ps = &peerAPIServer{
h.ps.b.appConnector.UpdateDomains([]string{"www.example.com"}) b: &LocalBackend{
h.ps.b.appConnector.Wait(ctx) e: eng,
pm: pm,
h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { store: pm.Store(),
b.CNAMEResource( appConnector: a,
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("www.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName("example.com."),
},
)
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
}, },
) }
}} h.ps.b.appConnector.UpdateDomains([]string{"www.example.com"})
f := filter.NewAllowAllForTest(logger.Discard) h.ps.b.appConnector.Wait(ctx)
h.ps.b.setFilter(f)
h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) {
b.CNAMEResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("www.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName("example.com."),
},
)
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
},
)
}}
f := filter.NewAllowAllForTest(logger.Discard)
h.ps.b.setFilter(f)
if !h.ps.b.OfferingAppConnector() { if !h.ps.b.OfferingAppConnector() {
t.Fatal("expecting to be offering app connector") t.Fatal("expecting to be offering app connector")
} }
if !h.replyToDNSQueries() { if !h.replyToDNSQueries() {
t.Errorf("unexpectedly deny; wanted to be a DNS server") t.Errorf("unexpectedly deny; wanted to be a DNS server")
} }
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil)) h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil))
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("unexpected status code: %v", w.Code) t.Errorf("unexpected status code: %v", w.Code)
} }
h.ps.b.appConnector.Wait(ctx) h.ps.b.appConnector.Wait(ctx)
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.Routes(), wantRoutes) { if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("got %v; want %v", rc.Routes(), wantRoutes) t.Errorf("got %v; want %v", rc.Routes(), wantRoutes)
}
} }
} }

@ -2253,6 +2253,9 @@ const (
// NodeAttrAutoExitNode permits the automatic exit nodes feature. // NodeAttrAutoExitNode permits the automatic exit nodes feature.
NodeAttrAutoExitNode NodeCapability = "auto-exit-node" NodeAttrAutoExitNode NodeCapability = "auto-exit-node"
// NodeAttrStoreAppCRoutes configures the node to store app connector routes persistently.
NodeAttrStoreAppCRoutes NodeCapability = "store-appc-routes"
) )
// SetDNSRequest is a request to add a DNS record. // SetDNSRequest is a request to add a DNS record.

Loading…
Cancel
Save