tailscale/ipn/store/stores.go
Andrew Lytvynov 76b9afb54d
ipn/store: make StateStore.All optional (#16409)
This method is only needed to migrate between store.FileStore and
tpm.tpmStore. We can make a runtime type assertion instead of
implementing an unused method for every platform.

Updates #15830

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-06-27 15:14:18 -07:00

344 lines
9.9 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package store provides various implementation of ipn.StateStore.
package store
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"tailscale.com/atomicfile"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/paths"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/testenv"
)
// Provider returns a StateStore for the provided path.
// The arg is of the form "prefix:rest", where prefix was previously registered with Register.
type Provider func(logf logger.Logf, arg string) (ipn.StateStore, error)
func init() {
Register("mem:", mem.New)
}
var knownStores map[string]Provider
// TPMPrefix is the path prefix used for TPM-encrypted StateStore.
const TPMPrefix = "tpmseal:"
// New returns a StateStore based on the provided arg
// and registered stores.
// The arg is of the form "prefix:rest", where prefix was previously
// registered with Register.
//
// By default the following stores are registered:
//
// - if the string begins with "mem:", the suffix
// is ignored and an in-memory store is used.
// - (Linux-only) if the string begins with "arn:",
// the suffix an AWS ARN for an SSM.
// - (Linux-only) if the string begins with "kube:",
// the suffix is a Kubernetes secret name
// - (Linux or Windows) if the string begins with "tpmseal:", the suffix is
// filepath that is sealed with the local TPM device.
// - In all other cases, the path is treated as a filepath.
func New(logf logger.Logf, path string) (ipn.StateStore, error) {
for prefix, sf := range knownStores {
if strings.HasPrefix(path, prefix) {
// We can't strip the prefix here as some NewStoreFunc (like arn:)
// expect the prefix.
if prefix == TPMPrefix {
if runtime.GOOS == "windows" {
path = TPMPrefix + TryWindowsAppDataMigration(logf, strings.TrimPrefix(path, TPMPrefix))
}
if err := maybeMigrateLocalStateFile(logf, path); err != nil {
return nil, fmt.Errorf("failed to migrate existing state file to TPM-sealed format: %w", err)
}
}
return sf(logf, path)
}
}
if runtime.GOOS == "windows" {
path = TryWindowsAppDataMigration(logf, path)
}
if err := maybeMigrateLocalStateFile(logf, path); err != nil {
return nil, fmt.Errorf("failed to migrate existing TPM-sealed state file to plaintext format: %w", err)
}
return NewFileStore(logf, path)
}
// Register registers a prefix to be used for
// NewStore. It panics if the prefix is empty, or if the
// prefix is already registered.
// The provided fn is called with the path passed to NewStore;
// the prefix is not stripped.
func Register(prefix string, fn Provider) {
if len(prefix) == 0 {
panic("prefix is empty")
}
if _, ok := knownStores[prefix]; ok {
panic(fmt.Sprintf("%q already registered", prefix))
}
mak.Set(&knownStores, prefix, fn)
}
// RegisterForTest registers a prefix to be used for NewStore in tests. An
// existing registered prefix will be replaced.
func RegisterForTest(t testenv.TB, prefix string, fn Provider) {
if len(prefix) == 0 {
panic("prefix is empty")
}
old := maps.Clone(knownStores)
t.Cleanup(func() { knownStores = old })
mak.Set(&knownStores, prefix, fn)
}
// HasKnownProviderPrefix reports whether path uses one of the registered
// Provider prefixes.
func HasKnownProviderPrefix(path string) bool {
for prefix := range knownStores {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// TryWindowsAppDataMigration attempts to copy the Windows state file
// from its old location to the new location. (Issue 2856)
//
// Tailscale 1.14 and before stored state under %LocalAppData%
// (usually "C:\WINDOWS\system32\config\systemprofile\AppData\Local"
// when tailscaled.exe is running as a non-user system service).
// However it is frequently cleared for almost any reason: Windows
// updates, System Restore, even various System Cleaner utilities.
//
// Returns a string of the path to use for the state file.
// This will be a fallback %LocalAppData% path if migration fails,
// a %ProgramData% path otherwise.
func TryWindowsAppDataMigration(logf logger.Logf, path string) string {
if path != paths.DefaultTailscaledStateFile() {
// If they're specifying a non-default path, just trust that they know
// what they are doing.
return path
}
oldFile := paths.LegacyStateFilePath()
return paths.TryConfigFileMigration(logf, oldFile, path)
}
// FileStore is a StateStore that uses a JSON file for persistence.
type FileStore struct {
path string
mu sync.RWMutex
cache map[ipn.StateKey][]byte
}
// Path returns the path that NewFileStore was called with.
func (s *FileStore) Path() string { return s.path }
func (s *FileStore) String() string { return fmt.Sprintf("FileStore(%q)", s.path) }
// NewFileStore returns a new file store that persists to path.
func NewFileStore(logf logger.Logf, path string) (ipn.StateStore, error) {
// We unconditionally call this to ensure that our perms are correct
if err := paths.MkStateDir(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("creating state directory: %w", err)
}
bs, err := os.ReadFile(path)
// Treat an empty file as a missing file.
// (https://github.com/tailscale/tailscale/issues/895#issuecomment-723255589)
if err == nil && len(bs) == 0 {
logf("store.NewFileStore(%q): file empty; treating it like a missing file [warning]", path)
err = os.ErrNotExist
}
if err != nil {
if os.IsNotExist(err) {
// Write out an initial file, to verify that we can write
// to the path.
if err = atomicfile.WriteFile(path, []byte("{}"), 0600); err != nil {
return nil, err
}
return &FileStore{
path: path,
cache: map[ipn.StateKey][]byte{},
}, nil
}
return nil, err
}
ret := &FileStore{
path: path,
cache: map[ipn.StateKey][]byte{},
}
if err := json.Unmarshal(bs, &ret.cache); err != nil {
return nil, err
}
return ret, nil
}
// ReadState implements the StateStore interface.
func (s *FileStore) ReadState(id ipn.StateKey) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
bs, ok := s.cache[id]
if !ok {
return nil, ipn.ErrStateNotExist
}
return bs, nil
}
// WriteState implements the StateStore interface.
func (s *FileStore) WriteState(id ipn.StateKey, bs []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if bytes.Equal(s.cache[id], bs) {
return nil
}
s.cache[id] = bytes.Clone(bs)
bs, err := json.MarshalIndent(s.cache, "", " ")
if err != nil {
return err
}
return atomicfile.WriteFile(s.path, bs, 0600)
}
func (s *FileStore) All() iter.Seq2[ipn.StateKey, []byte] {
return func(yield func(ipn.StateKey, []byte) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.cache {
if !yield(k, v) {
break
}
}
}
}
// Ensure FileStore implements ExportableStore for migration to/from
// tpm.tpmStore.
var _ ExportableStore = (*FileStore)(nil)
// ExportableStore is an ipn.StateStore that can export all of its contents.
// This interface is optional to implement, and used for migrating the state
// between different store implementations.
type ExportableStore interface {
ipn.StateStore
// All returns an iterator over all store keys. Using ReadState or
// WriteState is not safe while iterating and can lead to a deadlock. The
// order of keys in the iterator is not specified and may change between
// runs.
All() iter.Seq2[ipn.StateKey, []byte]
}
func maybeMigrateLocalStateFile(logf logger.Logf, path string) error {
path, toTPM := strings.CutPrefix(path, TPMPrefix)
// Extract JSON keys from the file on disk and guess what kind it is.
bs, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var content map[string]any
if err := json.Unmarshal(bs, &content); err != nil {
return fmt.Errorf("failed to unmarshal %q: %w", path, err)
}
keys := slices.Sorted(maps.Keys(content))
tpmKeys := []string{"key", "nonce", "data"}
slices.Sort(tpmKeys)
// TPM-sealed files will have exactly these keys.
existingFileSealed := slices.Equal(keys, tpmKeys)
// Plaintext files for nodes that registered at least once will have this
// key, plus other dynamic ones.
_, existingFilePlaintext := content["_machinekey"]
isTPM := existingFileSealed && !existingFilePlaintext
if isTPM == toTPM {
// No migration needed.
return nil
}
newTPMStore, ok := knownStores[TPMPrefix]
if !ok {
return errors.New("this build does not support TPM integration")
}
// Open from (old format) and to (new format) stores for migration. The
// "to" store will be at tmpPath.
var from, to ipn.StateStore
tmpPath := path + ".tmp"
if toTPM {
// Migrate plaintext file to be TPM-sealed.
from, err = NewFileStore(logf, path)
if err != nil {
return fmt.Errorf("NewFileStore(%q): %w", path, err)
}
to, err = newTPMStore(logf, TPMPrefix+tmpPath)
if err != nil {
return fmt.Errorf("newTPMStore(%q): %w", tmpPath, err)
}
} else {
// Migrate TPM-selaed file to plaintext.
from, err = newTPMStore(logf, TPMPrefix+path)
if err != nil {
return fmt.Errorf("newTPMStore(%q): %w", path, err)
}
to, err = NewFileStore(logf, tmpPath)
if err != nil {
return fmt.Errorf("NewFileStore(%q): %w", tmpPath, err)
}
}
defer os.Remove(tmpPath)
fromExp, ok := from.(ExportableStore)
if !ok {
return fmt.Errorf("%T does not implement the exportableStore interface", from)
}
// Copy all the items. This is pretty inefficient, because both stores
// write the file to disk for each WriteState, but that's ok for a one-time
// migration.
for k, v := range fromExp.All() {
if err := to.WriteState(k, v); err != nil {
return err
}
}
// Finally, overwrite the state file with the new one we created at
// tmpPath.
if err := atomicfile.Rename(tmpPath, path); err != nil {
return err
}
if toTPM {
logf("migrated %q from plaintext to TPM-sealed format", path)
} else {
logf("migrated %q from TPM-sealed to plaintext format", path)
}
return nil
}