Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
+452
@@ -0,0 +1,452 @@
|
||||
// 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/tailcfg/nodecap"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
|
||||
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 cache 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[cacheKey]
|
||||
|
||||
// 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[cacheKey]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[cacheKey]),
|
||||
lastWrote: make(map[cacheKey]lastWrote),
|
||||
}
|
||||
}
|
||||
|
||||
type lastWrote struct {
|
||||
digest string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *Cache) writeJSON(ctx context.Context, key cacheKey, v any) error {
|
||||
j, err := jsonv1.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON marshalling %q: %w", key, err)
|
||||
}
|
||||
|
||||
// If the digest of this value has not changed since it was last written,
|
||||
// record that the key is of interest, but do not actually perform a write.
|
||||
last, ok := c.lastWrote[key]
|
||||
if ok && cacheDigest(j) == last.digest {
|
||||
c.wantKeys.Add(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.store.Store(ctx, string(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
|
||||
}
|
||||
ckey := cacheKey(key)
|
||||
if !c.wantKeys.Contains(ckey) {
|
||||
if err := c.store.Remove(ctx, key); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove key %q: %w", key, err))
|
||||
}
|
||||
delete(c.lastWrote, ckey) // 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) {
|
||||
data, err := os.ReadFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("key %q not found: %w", key, ErrKeyNotFound)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// cacheKey is a type wrapper for strings used as cache keys.
|
||||
type cacheKey string
|
||||
|
||||
const (
|
||||
selfKey cacheKey = "self"
|
||||
miscKey cacheKey = "msic"
|
||||
dnsKey cacheKey = "dns"
|
||||
derpMapKey cacheKey = "derpmap"
|
||||
peerKeyPrefix cacheKey = "peer-" // + stable ID
|
||||
userKeyPrefix cacheKey = "user-" // + profile ID
|
||||
sshPolicyKey cacheKey = "ssh"
|
||||
packetFilterKey cacheKey = "filter"
|
||||
)
|
||||
|
||||
// UpdateSelfOnly updates nm in the cache, replacing any previously cached
|
||||
// values for nm.Self and other tailnet metadata for the current node, but
|
||||
// skipping the Peers and UserProfiles fields. Any existing peer and profile
|
||||
// data are left unmodified.
|
||||
func (c *Cache) UpdateSelfOnly(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")
|
||||
}
|
||||
// Since we are not modifying peers or users (and in particular, not
|
||||
// removing any), we do not need to do any garbage collection on the storage
|
||||
// keys.
|
||||
return c.updateSelfOnly(ctx, nm)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Because we may modify which peers and user profiles are valid, we need to
|
||||
// keep track of which storage keys we actually touch during the store.
|
||||
// This is used by c.removeUnwantedKeys to clean up keys that are no longer
|
||||
// referenced after peers and/or profiles are removed from nm.
|
||||
clear(c.wantKeys)
|
||||
if err := c.updateSelfOnly(ctx, nm); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range nm.Peers {
|
||||
key := peerKeyPrefix + cacheKey(p.StableID())
|
||||
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for uid, u := range nm.UserProfiles {
|
||||
key := fmt.Sprintf("%s%d", userKeyPrefix, uid)
|
||||
if err := c.writeJSON(ctx, cacheKey(key), netmapUserProfile{UserProfile: &u}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return c.removeUnwantedKeys(ctx)
|
||||
}
|
||||
|
||||
// UpdatePeers updates the cache to add or replace ("upsert") any peers
|
||||
// specified in update, and discard any peers specified in remove. This does
|
||||
// not affect any previous cached values for the self node or user profiles.
|
||||
//
|
||||
// It is expected that update and remove will be disjoint, but in the event
|
||||
// they are not, remove takes precedence. If the cache does not already contain
|
||||
// at least a self node, UpdatePeers reports an error without changing anything.
|
||||
func (c *Cache) UpdatePeers(ctx context.Context, update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
|
||||
if !buildfeatures.HasCacheNetMap {
|
||||
return nil
|
||||
}
|
||||
if !c.wantKeys.Contains(selfKey) {
|
||||
return errors.New("no netmap is cached to apply an update")
|
||||
}
|
||||
|
||||
// Attempt all updates and removals before reporting any errors.
|
||||
var errs []error
|
||||
for _, p := range update {
|
||||
// We may already have an entry for this peer, or it may be new, but
|
||||
// rather than do a lookup we can just replace the existing entry if it
|
||||
// exists.
|
||||
key := peerKeyPrefix + cacheKey(p.StableID())
|
||||
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
c.wantKeys.Add(key)
|
||||
}
|
||||
for _, q := range remove {
|
||||
key := peerKeyPrefix + cacheKey(q)
|
||||
if err := c.store.Remove(ctx, string(key)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
c.wantKeys.Delete(key)
|
||||
delete(c.lastWrote, key)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// updateSelfOnly updates the "static" parts of the netmap in the cache. It is
|
||||
// shared between [Cache.UpdateSelfOnly] and [Cache.Store].
|
||||
func (c *Cache) updateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
|
||||
if err := c.writeJSON(ctx, miscKey, 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, dnsKey, netmapDNS{DNS: &nm.DNS}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.writeJSON(ctx, derpMapKey, netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.writeJSON(ctx, selfKey, 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.
|
||||
}
|
||||
if err := c.writeJSON(ctx, packetFilterKey, netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
|
||||
return err
|
||||
}
|
||||
if buildfeatures.HasSSH && nm.SSHPolicy != nil {
|
||||
if err := c.writeJSON(ctx, sshPolicyKey, netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, string(selfKey)); 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(selfKey)
|
||||
|
||||
// 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[nodecap.Cap])
|
||||
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, miscKey, &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, dnsKey, &netmapDNS{DNS: &nm.DNS}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.readJSON(ctx, derpMapKey, &netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, err := range c.store.List(ctx, string(peerKeyPrefix)) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var peer tailcfg.NodeView
|
||||
if err := c.readJSON(ctx, cacheKey(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, string(userKeyPrefix)) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var up tailcfg.UserProfileView
|
||||
if err := c.readJSON(ctx, cacheKey(key), &netmapUserProfile{UserProfile: &up}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mak.Set(&nm.UserProfiles, up.ID(), up)
|
||||
}
|
||||
if err := c.readJSON(ctx, sshPolicyKey, &netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.readJSON(ctx, packetFilterKey, &netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
|
||||
return nil, err
|
||||
} else if r := nm.PacketFilterRules; r.Len() != 0 {
|
||||
// Reconstitute packet match expressions from the filter rules,
|
||||
nm.PacketFilter, err = filter.MatchesFromFilterRules(r.AsSlice())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &nm, nil
|
||||
}
|
||||
|
||||
func (c *Cache) readJSON(ctx context.Context, key cacheKey, value any) error {
|
||||
data, err := c.store.Load(ctx, string(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[:]) }
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netmapcache
|
||||
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// The fields in the following wrapper types are all pointers, even when their
|
||||
// target type is also a pointer, so that they can be used to unmarshal
|
||||
// directly into the fields of another value. These wrappers intentionally do
|
||||
// not omit zero or empty values, since we want the cache to reflect the value
|
||||
// the object had at the time it was written, even if the default changes
|
||||
// later.
|
||||
//
|
||||
// Moreover, these are all struct types so that each cached record will be a
|
||||
// JSON object even if the underlying value marshals to an array or primitive
|
||||
// type, and so that we have a seam if we want to replace or version the cached
|
||||
// representation separately from the default JSON layout.
|
||||
|
||||
type netmapMisc struct {
|
||||
MachineKey *key.MachinePublic
|
||||
CollectServices *bool
|
||||
DisplayMessages *map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
|
||||
TKAEnabled *bool
|
||||
TKAHead *tka.AUMHash
|
||||
Domain *string
|
||||
DomainAuditLogID *string
|
||||
}
|
||||
|
||||
type netmapSSH struct {
|
||||
SSHPolicy **tailcfg.SSHPolicy
|
||||
}
|
||||
|
||||
type netmapDNS struct {
|
||||
DNS *tailcfg.DNSConfig
|
||||
}
|
||||
|
||||
type netmapDERPMap struct {
|
||||
DERPMap **tailcfg.DERPMap
|
||||
}
|
||||
|
||||
type netmapNode struct {
|
||||
Node *tailcfg.NodeView
|
||||
}
|
||||
|
||||
type netmapUserProfile struct {
|
||||
UserProfile *tailcfg.UserProfileView
|
||||
}
|
||||
|
||||
type netmapPacketFilter struct {
|
||||
Rules *views.Slice[tailcfg.FilterRule]
|
||||
|
||||
// Match expressions are derived from the rules.
|
||||
}
|
||||
Reference in new issue
Block a user