|
|
@@ -0,0 +1,351 @@
|
|
|
+// Copyright (c) Tailscale Inc & contributors
|
|
|
+// SPDX-License-Identifier: BSD-3-Clause
|
|
|
+
|
|
|
+// Package netmapcache implements a persistent cache for [netmap.NetworkMap]
|
|
|
+// values, allowing a client to start up using stale but previously-valid state
|
|
|
+// even if a connection to the control plane is not immediately available.
|
|
|
+package netmapcache
|
|
|
+
|
|
|
+import (
|
|
|
+ "cmp"
|
|
|
+ "context"
|
|
|
+ "crypto/sha256"
|
|
|
+ "encoding/hex"
|
|
|
+ jsonv1 "encoding/json"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io/fs"
|
|
|
+ "iter"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "slices"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "tailscale.com/feature/buildfeatures"
|
|
|
+ "tailscale.com/tailcfg"
|
|
|
+ "tailscale.com/types/netmap"
|
|
|
+ "tailscale.com/util/mak"
|
|
|
+ "tailscale.com/util/set"
|
|
|
+)
|
|
|
+
|
|
|
+var (
|
|
|
+ // ErrKeyNotFound is a sentinel error reported by implementations of the [Store]
|
|
|
+ // interface when loading a key that is not found in the store.
|
|
|
+ ErrKeyNotFound = errors.New("storage key not found")
|
|
|
+
|
|
|
+ // ErrCacheNotAvailable is a sentinel error reported by cache methods when
|
|
|
+ // the netmap caching feature is not enabled in the build.
|
|
|
+ ErrCacheNotAvailable = errors.New("netmap cache is not available")
|
|
|
+)
|
|
|
+
|
|
|
+// A Cache manages a columnar cache of a [netmap.NetworkMap]. Each Cache holds
|
|
|
+// a single netmap value; use [Cache.Store] to update or replace the cached
|
|
|
+// value and [Cache.Load] to read the cached value.
|
|
|
+type Cache struct {
|
|
|
+ store Store
|
|
|
+
|
|
|
+ // wantKeys records the storage keys from the last write or load of a cached
|
|
|
+ // netmap. This is used to prune keys that are no longer referenced after an
|
|
|
+ // update.
|
|
|
+ wantKeys set.Set[string]
|
|
|
+
|
|
|
+ // lastWrote records the last values written to each stored key.
|
|
|
+ //
|
|
|
+ // TODO(creachadair): This is meant to avoid disk writes, but I'm not
|
|
|
+ // convinced we need it. Or maybe just track hashes of the content rather
|
|
|
+ // than caching a complete copy.
|
|
|
+ lastWrote map[string]lastWrote
|
|
|
+}
|
|
|
+
|
|
|
+// NewCache constructs a new empty [Cache] from the given [Store].
|
|
|
+// It will panic if s == nil.
|
|
|
+func NewCache(s Store) *Cache {
|
|
|
+ if s == nil {
|
|
|
+ panic("a non-nil Store is required")
|
|
|
+ }
|
|
|
+ return &Cache{
|
|
|
+ store: s,
|
|
|
+ wantKeys: make(set.Set[string]),
|
|
|
+ lastWrote: make(map[string]lastWrote),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type lastWrote struct {
|
|
|
+ digest string
|
|
|
+ at time.Time
|
|
|
+}
|
|
|
+
|
|
|
+func (c *Cache) writeJSON(ctx context.Context, key string, v any) error {
|
|
|
+ j, err := jsonv1.Marshal(v)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("JSON marshalling %q: %w", key, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // TODO(creachadair): Maybe use a hash instead of the contents? Do we need
|
|
|
+ // this at all?
|
|
|
+ last, ok := c.lastWrote[key]
|
|
|
+ if ok && cacheDigest(j) == last.digest {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := c.store.Store(ctx, key, j); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ // Track the storage keys the current map is using, for storage GC.
|
|
|
+ c.wantKeys.Add(key)
|
|
|
+ c.lastWrote[key] = lastWrote{
|
|
|
+ digest: cacheDigest(j),
|
|
|
+ at: time.Now(),
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (c *Cache) removeUnwantedKeys(ctx context.Context) error {
|
|
|
+ var errs []error
|
|
|
+ for key, err := range c.store.List(ctx, "") {
|
|
|
+ if err != nil {
|
|
|
+ errs = append(errs, err)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ if !c.wantKeys.Contains(key) {
|
|
|
+ if err := c.store.Remove(ctx, key); err != nil {
|
|
|
+ errs = append(errs, fmt.Errorf("remove key %q: %w", key, err))
|
|
|
+ }
|
|
|
+ delete(c.lastWrote, key) // even if removal failed, we don't want it
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return errors.Join(errs...)
|
|
|
+}
|
|
|
+
|
|
|
+// FileStore implements the [Store] interface using a directory of files, in
|
|
|
+// which each key is encoded as a filename in the directory.
|
|
|
+// The caller is responsible to ensure the directory path exists before
|
|
|
+// using the store methods.
|
|
|
+type FileStore string
|
|
|
+
|
|
|
+// List implements part of the [Store] interface.
|
|
|
+func (s FileStore) List(ctx context.Context, prefix string) iter.Seq2[string, error] {
|
|
|
+ return func(yield func(string, error) bool) {
|
|
|
+ des, err := os.ReadDir(string(s))
|
|
|
+ if os.IsNotExist(err) {
|
|
|
+ return // nothing to read
|
|
|
+ } else if err != nil {
|
|
|
+ yield("", err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // os.ReadDir reports entries already sorted, and the encoding preserves that.
|
|
|
+ for _, de := range des {
|
|
|
+ key, err := hex.DecodeString(de.Name())
|
|
|
+ if err != nil {
|
|
|
+ yield("", err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ name := string(key)
|
|
|
+ if !strings.HasPrefix(name, prefix) {
|
|
|
+ continue
|
|
|
+ } else if !yield(name, nil) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Load implements part of the [Store] interface.
|
|
|
+func (s FileStore) Load(ctx context.Context, key string) ([]byte, error) {
|
|
|
+ return os.ReadFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
|
|
|
+}
|
|
|
+
|
|
|
+// Store implements part of the [Store] interface.
|
|
|
+func (s FileStore) Store(ctx context.Context, key string, value []byte) error {
|
|
|
+ return os.WriteFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))), value, 0600)
|
|
|
+}
|
|
|
+
|
|
|
+// Remove implements part of the [Store] interface.
|
|
|
+func (s FileStore) Remove(ctx context.Context, key string) error {
|
|
|
+ err := os.Remove(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
|
|
|
+ if errors.Is(err, fs.ErrNotExist) {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return err
|
|
|
+}
|
|
|
+
|
|
|
+// Store records nm in the cache, replacing any previously-cached values.
|
|
|
+func (c *Cache) Store(ctx context.Context, nm *netmap.NetworkMap) error {
|
|
|
+ if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if selfID := nm.User(); selfID == 0 {
|
|
|
+ return errors.New("no user in netmap")
|
|
|
+ }
|
|
|
+
|
|
|
+ clear(c.wantKeys)
|
|
|
+ if err := c.writeJSON(ctx, "misc", netmapMisc{
|
|
|
+ MachineKey: &nm.MachineKey,
|
|
|
+ CollectServices: &nm.CollectServices,
|
|
|
+ DisplayMessages: &nm.DisplayMessages,
|
|
|
+ TKAEnabled: &nm.TKAEnabled,
|
|
|
+ TKAHead: &nm.TKAHead,
|
|
|
+ Domain: &nm.Domain,
|
|
|
+ DomainAuditLogID: &nm.DomainAuditLogID,
|
|
|
+ }); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := c.writeJSON(ctx, "dns", netmapDNS{DNS: &nm.DNS}); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := c.writeJSON(ctx, "derpmap", netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := c.writeJSON(ctx, "self", netmapNode{Node: &nm.SelfNode}); err != nil {
|
|
|
+ return err
|
|
|
+
|
|
|
+ // N.B. The NodeKey and AllCaps fields can be recovered from SelfNode on
|
|
|
+ // load, and do not need to be stored separately.
|
|
|
+ }
|
|
|
+ for _, p := range nm.Peers {
|
|
|
+ key := fmt.Sprintf("peer-%s", p.StableID())
|
|
|
+ if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for uid, u := range nm.UserProfiles {
|
|
|
+ key := fmt.Sprintf("user-%d", uid)
|
|
|
+ if err := c.writeJSON(ctx, key, netmapUserProfile{UserProfile: &u}); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if buildfeatures.HasSSH && nm.SSHPolicy != nil {
|
|
|
+ if err := c.writeJSON(ctx, "ssh", netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return c.removeUnwantedKeys(ctx)
|
|
|
+}
|
|
|
+
|
|
|
+// Load loads the cached [netmap.NetworkMap] value stored in c, if one is available.
|
|
|
+// It reports [ErrCacheNotAvailable] if no cached data are available.
|
|
|
+// On success, the Cached field of the returned network map is true.
|
|
|
+func (c *Cache) Load(ctx context.Context) (*netmap.NetworkMap, error) {
|
|
|
+ if !buildfeatures.HasCacheNetMap {
|
|
|
+ return nil, ErrCacheNotAvailable
|
|
|
+ }
|
|
|
+
|
|
|
+ nm := netmap.NetworkMap{Cached: true}
|
|
|
+
|
|
|
+ // At minimum, we require that the cache contain a "self" node, or the data
|
|
|
+ // are not usable.
|
|
|
+ if self, err := c.store.Load(ctx, "self"); errors.Is(err, ErrKeyNotFound) {
|
|
|
+ return nil, ErrCacheNotAvailable
|
|
|
+ } else if err := jsonv1.Unmarshal(self, &netmapNode{Node: &nm.SelfNode}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ c.wantKeys.Add("self")
|
|
|
+
|
|
|
+ // If we successfully recovered a SelfNode, pull out its related fields.
|
|
|
+ if s := nm.SelfNode; s.Valid() {
|
|
|
+ nm.NodeKey = s.Key()
|
|
|
+ nm.AllCaps = make(set.Set[tailcfg.NodeCapability])
|
|
|
+ for _, c := range s.Capabilities().All() {
|
|
|
+ nm.AllCaps.Add(c)
|
|
|
+ }
|
|
|
+ for c := range s.CapMap().All() {
|
|
|
+ nm.AllCaps.Add(c)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Unmarshal the contents of each specified cache entry directly into the
|
|
|
+ // fields of the output. See the comment in types.go for more detail.
|
|
|
+
|
|
|
+ if err := c.readJSON(ctx, "misc", &netmapMisc{
|
|
|
+ MachineKey: &nm.MachineKey,
|
|
|
+ CollectServices: &nm.CollectServices,
|
|
|
+ DisplayMessages: &nm.DisplayMessages,
|
|
|
+ TKAEnabled: &nm.TKAEnabled,
|
|
|
+ TKAHead: &nm.TKAHead,
|
|
|
+ Domain: &nm.Domain,
|
|
|
+ DomainAuditLogID: &nm.DomainAuditLogID,
|
|
|
+ }); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := c.readJSON(ctx, "dns", &netmapDNS{DNS: &nm.DNS}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if err := c.readJSON(ctx, "derpmap", &netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ for key, err := range c.store.List(ctx, "peer-") {
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ var peer tailcfg.NodeView
|
|
|
+ if err := c.readJSON(ctx, key, &netmapNode{Node: &peer}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ nm.Peers = append(nm.Peers, peer)
|
|
|
+ }
|
|
|
+ slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int { return cmp.Compare(a.ID(), b.ID()) })
|
|
|
+ for key, err := range c.store.List(ctx, "user-") {
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ var up tailcfg.UserProfileView
|
|
|
+ if err := c.readJSON(ctx, key, &netmapUserProfile{UserProfile: &up}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ mak.Set(&nm.UserProfiles, up.ID(), up)
|
|
|
+ }
|
|
|
+ if err := c.readJSON(ctx, "ssh", &netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ return &nm, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (c *Cache) readJSON(ctx context.Context, key string, value any) error {
|
|
|
+ data, err := c.store.Load(ctx, key)
|
|
|
+ if errors.Is(err, ErrKeyNotFound) {
|
|
|
+ return nil
|
|
|
+ } else if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := jsonv1.Unmarshal(data, value); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ c.wantKeys.Add(key)
|
|
|
+ c.lastWrote[key] = lastWrote{digest: cacheDigest(data), at: time.Now()}
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// Store is the interface to persistent key-value storage used by a [Cache].
|
|
|
+type Store interface {
|
|
|
+ // List lists all the stored keys having the specified prefixes, in
|
|
|
+ // lexicographic order.
|
|
|
+ //
|
|
|
+ // Each pair yielded by the iterator is either a valid storage key and a nil
|
|
|
+ // error, or an empty key and a non-nil error. After reporting an error, the
|
|
|
+ // iterator must immediately return.
|
|
|
+ List(ctx context.Context, prefix string) iter.Seq2[string, error]
|
|
|
+
|
|
|
+ // Load fetches the contents of the specified key.
|
|
|
+ // If the key is not found in the store, Load must report [ErrKeyNotFound].
|
|
|
+ Load(ctx context.Context, key string) ([]byte, error)
|
|
|
+
|
|
|
+ // Store marshals and stores the contents of the specified value under key.
|
|
|
+ // If the key already exists, its contents are replaced.
|
|
|
+ Store(ctx context.Context, key string, value []byte) error
|
|
|
+
|
|
|
+ // Remove removes the specified key from the store. If the key does not exist,
|
|
|
+ // Remove reports success (nil).
|
|
|
+ Remove(ctx context.Context, key string) error
|
|
|
+}
|
|
|
+
|
|
|
+// cacheDigest computes a string digest of the specified data, for use in
|
|
|
+// detecting cache hits.
|
|
|
+func cacheDigest(data []byte) string { h := sha256.Sum256(data); return string(h[:]) }
|