diff --git a/appc/appconnector.go b/appc/appconnector.go index 8c3e2c160..4df696b0d 100644 --- a/appc/appconnector.go +++ b/appc/appconnector.go @@ -36,6 +36,19 @@ type RouteAdvertiser interface { UnadvertiseRoute(...netip.Prefix) error } +// RouteInfo is a data structure used to persist the in memory state of an AppConnector +// so that we can know, even after a restart, which routes came from ACLs and which were +// learned from domains. +type RouteInfo struct { + // Control is the routes from the 'routes' section of an app connector acl. + Control []netip.Prefix `json:",omitempty"` + // Domains are the routes discovered by observing DNS lookups for configured domains. + Domains map[string][]netip.Addr `json:",omitempty"` + // Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards, + // its result is added to Domains. + Wildcards []string `json:",omitempty"` +} + // AppConnector 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 diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index dea812ef9..ab6a4f816 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -6206,6 +6206,43 @@ func (b *LocalBackend) UnadvertiseRoute(toRemove ...netip.Prefix) error { return err } +// namespace a key with the profile manager's current profile key, if any +func namespaceKeyForCurrentProfile(pm *profileManager, key ipn.StateKey) ipn.StateKey { + return pm.CurrentProfile().Key + "||" + key +} + +const routeInfoStateStoreKey ipn.StateKey = "_routeInfo" + +func (b *LocalBackend) storeRouteInfo(ri *appc.RouteInfo) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.pm.CurrentProfile().ID == "" { + return nil + } + key := namespaceKeyForCurrentProfile(b.pm, routeInfoStateStoreKey) + bs, err := json.Marshal(ri) + if err != nil { + return err + } + return b.pm.WriteState(key, bs) +} + +func (b *LocalBackend) readRouteInfoLocked() (*appc.RouteInfo, error) { + if b.pm.CurrentProfile().ID == "" { + return &appc.RouteInfo{}, nil + } + key := namespaceKeyForCurrentProfile(b.pm, routeInfoStateStoreKey) + bs, err := b.pm.Store().ReadState(key) + ri := &appc.RouteInfo{} + if err != nil { + return nil, err + } + if err := json.Unmarshal(bs, ri); err != nil { + return nil, err + } + return ri, nil +} + // seamlessRenewalEnabled reports whether seamless key renewals are enabled // (i.e. we saw our self node with the SeamlessKeyRenewal attr in a netmap). // This enables beta functionality of renewing node keys without breaking diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index c34c8e67f..450599a8f 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -3451,3 +3451,66 @@ func TestEnableAutoUpdates(t *testing.T) { t.Fatalf("disabling auto-updates: got error: %v", err) } } + +func TestReadWriteRouteInfo(t *testing.T) { + // set up a backend with more than one profile + b := newTestBackend(t) + prof1 := ipn.LoginProfile{ID: "id1", Key: "key1"} + prof2 := ipn.LoginProfile{ID: "id2", Key: "key2"} + b.pm.knownProfiles["id1"] = &prof1 + b.pm.knownProfiles["id2"] = &prof2 + b.pm.currentProfile = &prof1 + + // set up routeInfo + ri1 := &appc.RouteInfo{} + ri1.Wildcards = []string{"1"} + + ri2 := &appc.RouteInfo{} + ri2.Wildcards = []string{"2"} + + // read before write + readRi, err := b.readRouteInfoLocked() + if readRi != nil { + t.Fatalf("read before writing: want nil, got %v", readRi) + } + if err != ipn.ErrStateNotExist { + t.Fatalf("read before writing: want %v, got %v", ipn.ErrStateNotExist, err) + } + + // write the first routeInfo + if err := b.storeRouteInfo(ri1); err != nil { + t.Fatal(err) + } + + // write the other routeInfo as the other profile + if err := b.pm.SwitchProfile("id2"); err != nil { + t.Fatal(err) + } + if err := b.storeRouteInfo(ri2); err != nil { + t.Fatal(err) + } + + // read the routeInfo of the first profile + if err := b.pm.SwitchProfile("id1"); err != nil { + t.Fatal(err) + } + readRi, err = b.readRouteInfoLocked() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(readRi.Wildcards, ri1.Wildcards) { + t.Fatalf("read prof1 routeInfo wildcards: want %v, got %v", ri1.Wildcards, readRi.Wildcards) + } + + // read the routeInfo of the second profile + if err := b.pm.SwitchProfile("id2"); err != nil { + t.Fatal(err) + } + readRi, err = b.readRouteInfoLocked() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(readRi.Wildcards, ri2.Wildcards) { + t.Fatalf("read prof2 routeInfo wildcards: want %v, got %v", ri2.Wildcards, readRi.Wildcards) + } +}