// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package appc import ( "context" "net/netip" "reflect" "slices" "testing" "time" xmaps "golang.org/x/exp/maps" "golang.org/x/net/dns/dnsmessage" "tailscale.com/appc/appctest" "tailscale.com/tstest" "tailscale.com/util/mak" "tailscale.com/util/must" ) func fakeStoreRoutes(*RouteInfo) error { return nil } func TestUpdateDomains(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil) } a.UpdateDomains([]string{"example.com"}) a.Wait(ctx) if got, want := a.Domains().AsSlice(), []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"}) a.Wait(ctx) 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"}) a.Wait(ctx) if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) { t.Errorf("got %v; want %v", got, want) } } } func TestUpdateRoutes(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} 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 a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")) a.Wait(ctx) 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")}) } // This route should not be collapsed or removed a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")) a.Wait(ctx) routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")} a.updateRoutes(routes) slices.SortFunc(rc.Routes(), prefixCompare) rc.SetRoutes(slices.Compact(rc.Routes())) slices.SortFunc(routes, prefixCompare) // Ensure that the non-matching /32 is preserved, even though it's in the domains table. if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) { t.Errorf("added routes: got %v, want %v", rc.Routes(), routes) } // Ensure that the contained /32 is removed, replaced by the /24. wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")} if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) { t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes()) } } } func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) { for _, shouldStore := range []bool{false, true} { rc := &appctest.RouteCollector{} var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, rc, nil, nil) } 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) { for _, shouldStore := range []bool{false, true} { rc := &appctest.RouteCollector{} 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"}) 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) { t.Fatalf("DomainRoutes: got %v, want %v", got, want) } } } func TestObserveDNSResponse(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, rc, nil, nil) } // 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")) 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 // matches a routed domain. a.updateDomains([]string{"www.example.com", "example.com"}) a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")) a.Wait(ctx) 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 // 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) } wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128")) a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")) 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.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") a.updateRoutes([]netip.Prefix{pfx}) 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) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} 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"}) a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")) a.Wait(ctx) if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) { 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"}) 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. a.updateDomains([]string{"*.example.com", "example.com"}) if len(a.wildcards) != 1 { t.Errorf("expected only one wildcard domain, got %v", a.wildcards) } } } // 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()) } func dnsCNAMEResponse(address string, domains ...string) []byte { addr := netip.MustParseAddr(address) b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) b.EnableCompression() b.StartAnswers() if len(domains) >= 2 { for i, domain := range domains[:len(domains)-1] { b.CNAMEResource( dnsmessage.ResourceHeader{ Name: dnsmessage.MustNewName(domain), Type: dnsmessage.TypeCNAME, Class: dnsmessage.ClassINET, TTL: 0, }, dnsmessage.CNAMEResource{ CNAME: dnsmessage.MustNewName(domains[i+1]), }, ) } } domain := domains[len(domains)-1] 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()) } func prefixEqual(a, b netip.Prefix) bool { return a == b } func prefixCompare(a, b netip.Prefix) int { if a.Addr().Compare(b.Addr()) == 0 { return a.Bits() - b.Bits() } return a.Addr().Compare(b.Addr()) } func prefixes(in ...string) []netip.Prefix { toRet := make([]netip.Prefix, len(in)) for i, s := range in { toRet[i] = netip.MustParsePrefix(s) } return toRet } func TestUpdateRouteRouteRemoval(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { if !slices.Equal(routes, rc.Routes()) { t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) } if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) } } var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, rc, nil, nil) } // nothing has yet been advertised assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32")) a.Wait(ctx) // the routes passed to UpdateDomainsAndRoutes have been advertised assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{}) // one route the same, one different a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32")) a.Wait(ctx) // old behavior: routes are not removed, resulting routes are both old and new // (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication // the real one does) wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32") wantRemovedRoutes := []netip.Prefix{} if shouldStore { // new behavior: routes are removed, resulting routes are new only wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32") wantRemovedRoutes = prefixes("1.2.3.2/32") } assertRoutes("removal", wantRoutes, wantRemovedRoutes) } } func TestUpdateDomainRouteRemoval(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { if !slices.Equal(routes, rc.Routes()) { t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) } if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) } } var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, rc, nil, nil) } assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{}) a.Wait(ctx) // adding domains doesn't immediately cause any routes to be advertised assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{}) a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1")) a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2")) a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3")) a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4")) a.Wait(ctx) // observing dns responses causes routes to be advertised assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{}) a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{}) a.Wait(ctx) // old behavior, routes are not removed wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32") wantRemovedRoutes := []netip.Prefix{} if shouldStore { // new behavior, routes are removed for b.example.com wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32") wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32") } assertRoutes("removal", wantRoutes, wantRemovedRoutes) } } func TestUpdateWildcardRouteRemoval(t *testing.T) { for _, shouldStore := range []bool{false, true} { ctx := context.Background() rc := &appctest.RouteCollector{} assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { if !slices.Equal(routes, rc.Routes()) { t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) } if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) } } var a *AppConnector if shouldStore { a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) } else { a = NewAppConnector(t.Logf, rc, nil, nil) } assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{}) a.Wait(ctx) // adding domains doesn't immediately cause any routes to be advertised assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{}) a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1")) a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2")) a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3")) a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4")) a.Wait(ctx) // observing dns responses causes routes to be advertised assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{}) a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{}) a.Wait(ctx) // old behavior, routes are not removed wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32") wantRemovedRoutes := []netip.Prefix{} if shouldStore { // new behavior, routes are removed for *.b.example.com wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32") wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32") } assertRoutes("removal", wantRoutes, wantRemovedRoutes) } } func TestRoutesWithout(t *testing.T) { assert := func(msg string, got, want []netip.Prefix) { if !slices.Equal(want, got) { t.Errorf("%s: want %v, got %v", msg, want, got) } } assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{}) assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{}) assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32")) assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32")) assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{}) assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32")) } func TestRateLogger(t *testing.T) { clock := tstest.Clock{} wasCalled := false rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time) { if count != 3 { t.Fatalf("count for prev period: got %d, want 3", count) } wasCalled = true }) for i := 0; i < 3; i++ { clock.Advance(1 * time.Millisecond) rl.update() if wasCalled { t.Fatalf("wasCalled: got true, want false") } } clock.Advance(1 * time.Second) rl.update() if !wasCalled { t.Fatalf("wasCalled: got false, want true") } wasCalled = false rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time) { if count != 3 { t.Fatalf("count for prev period: got %d, want 3", count) } wasCalled = true }) for i := 0; i < 3; i++ { clock.Advance(1 * time.Minute) rl.update() if wasCalled { t.Fatalf("wasCalled: got true, want false") } } clock.Advance(1 * time.Hour) rl.update() if !wasCalled { t.Fatalf("wasCalled: got false, want true") } }