mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 15:23:45 +00:00
ipn/store: automatically migrate between plaintext and encrypted state (#16318)
Add a new `--encrypt-state` flag to `cmd/tailscaled`. Based on that flag, migrate the existing state file to/from encrypted format if needed. Updates #15830 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
parent
d2c1ed22c3
commit
6feb3c35cb
@ -48,5 +48,9 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return rename(tmpName, filename)
|
||||
return Rename(tmpName, filename)
|
||||
}
|
||||
|
||||
// Rename srcFile to dstFile, similar to [os.Rename] but preserving file
|
||||
// attributes and ACLs on Windows.
|
||||
func Rename(srcFile, dstFile string) error { return rename(srcFile, dstFile) }
|
||||
|
@ -64,6 +64,7 @@ import (
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/osshare"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
"tailscale.com/wgengine"
|
||||
@ -126,6 +127,7 @@ var args struct {
|
||||
debug string
|
||||
port uint16
|
||||
statepath string
|
||||
encryptState bool
|
||||
statedir string
|
||||
socketpath string
|
||||
birdSocketPath string
|
||||
@ -193,6 +195,7 @@ func main() {
|
||||
flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`)
|
||||
flag.Var(flagtype.PortValue(&args.port, defaultPort()), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
|
||||
flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an ephemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile())
|
||||
flag.BoolVar(&args.encryptState, "encrypt-state", defaultEncryptState(), "encrypt the state file on disk; uses TPM on Linux and Windows, on all other platforms this flag is not supported")
|
||||
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
|
||||
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
|
||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
|
||||
@ -268,6 +271,28 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if args.encryptState {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
|
||||
log.SetFlags(0)
|
||||
log.Fatalf("--encrypt-state is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
// Check if we have TPM support in this build.
|
||||
if !store.HasKnownProviderPrefix(store.TPMPrefix + "/") {
|
||||
log.SetFlags(0)
|
||||
log.Fatal("--encrypt-state is not supported in this build of tailscaled")
|
||||
}
|
||||
// Check if we have TPM access.
|
||||
if !hostinfo.New().TPM.Present() {
|
||||
log.SetFlags(0)
|
||||
log.Fatal("--encrypt-state is not supported on this device or a TPM is not accessible")
|
||||
}
|
||||
// Check for conflicting prefix in --state, like arn: or kube:.
|
||||
if args.statepath != "" && store.HasKnownProviderPrefix(args.statepath) {
|
||||
log.SetFlags(0)
|
||||
log.Fatal("--encrypt-state can only be used with --state set to a local file path")
|
||||
}
|
||||
}
|
||||
|
||||
if args.disableLogs {
|
||||
envknob.SetNoLogsNoSupport()
|
||||
}
|
||||
@ -315,13 +340,17 @@ func trySynologyMigration(p string) error {
|
||||
}
|
||||
|
||||
func statePathOrDefault() string {
|
||||
var path string
|
||||
if args.statepath != "" {
|
||||
return args.statepath
|
||||
path = args.statepath
|
||||
}
|
||||
if args.statedir != "" {
|
||||
return filepath.Join(args.statedir, "tailscaled.state")
|
||||
if path == "" && args.statedir != "" {
|
||||
path = filepath.Join(args.statedir, "tailscaled.state")
|
||||
}
|
||||
return ""
|
||||
if path != "" && !store.HasKnownProviderPrefix(path) && args.encryptState {
|
||||
path = store.TPMPrefix + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// serverOptions is the configuration of the Tailscale node agent.
|
||||
@ -974,3 +1003,15 @@ func applyIntegrationTestEnvKnob() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultEncryptState() bool {
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
|
||||
// TPM encryption is only configurable on Windows and Linux. Other
|
||||
// platforms either use system APIs and are not configurable
|
||||
// (Android/Apple), or don't support any form of encryption yet
|
||||
// (plan9/FreeBSD/etc).
|
||||
return false
|
||||
}
|
||||
v, _ := syspolicy.GetBoolean(syspolicy.EncryptState, false)
|
||||
return v
|
||||
}
|
||||
|
@ -10,4 +10,7 @@ export const sessionStateStorage: IPNStateStorage = {
|
||||
getState(id) {
|
||||
return window.sessionStorage[`ipn-state-${id}`] || ""
|
||||
},
|
||||
all() {
|
||||
return JSON.stringify(window.sessionStorage)
|
||||
},
|
||||
}
|
||||
|
1
cmd/tsconnect/src/types/wasm_js.d.ts
vendored
1
cmd/tsconnect/src/types/wasm_js.d.ts
vendored
@ -44,6 +44,7 @@ declare global {
|
||||
interface IPNStateStorage {
|
||||
setState(id: string, value: string): void
|
||||
getState(id: string): string
|
||||
all(): string
|
||||
}
|
||||
|
||||
type IPNConfig = {
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
@ -579,6 +580,29 @@ func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *jsStateStore) All() iter.Seq2[ipn.StateKey, []byte] {
|
||||
return func(yield func(ipn.StateKey, []byte) bool) {
|
||||
jsValue := s.jsStateStorage.Call("all")
|
||||
if jsValue.String() == "" {
|
||||
return
|
||||
}
|
||||
buf, err := hex.DecodeString(jsValue.String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var state map[string][]byte
|
||||
if err := json.Unmarshal(buf, &state); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range state {
|
||||
if !yield(ipn.StateKey(k), v) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapSlice[T any, M any](a []T, f func(T) M) []M {
|
||||
n := make([]M, len(a))
|
||||
for i, e := range a {
|
||||
|
@ -19,6 +19,7 @@
|
||||
<string id="SINCE_V1_80">Tailscale version 1.80.0 and later</string>
|
||||
<string id="SINCE_V1_82">Tailscale version 1.82.0 and later</string>
|
||||
<string id="SINCE_V1_84">Tailscale version 1.84.0 and later</string>
|
||||
<string id="SINCE_V1_86">Tailscale version 1.86.0 and later</string>
|
||||
<string id="Tailscale_Category">Tailscale</string>
|
||||
<string id="UI_Category">UI customization</string>
|
||||
<string id="Settings_Category">Settings</string>
|
||||
@ -278,6 +279,14 @@ See https://tailscale.com/kb/1315/mdm-keys#set-your-organization-name for more d
|
||||
If you enable or don't configure this policy, the onboarding flow will be shown to new users who have not yet signed in to a Tailscale account.
|
||||
|
||||
If you disable this policy, the onboarding flow will never be shown.]]></string>
|
||||
<string id="EncryptState">Encrypt client state file stored on disk</string>
|
||||
<string id="EncryptState_Help"><![CDATA[This policy configures encryption of the Tailscale client state file on disk.
|
||||
|
||||
If you enable this policy, the state file will be encrypted using the local TPM device. If a local TPM device is not present or not accessible, Tailscale will fail to start.
|
||||
|
||||
If you disable this policy, the state file is stored in plaintext.
|
||||
|
||||
If the policy is unconfigured, state encryption will be enabled on newer client versions when the device has a properly-configured TPM.]]></string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="LoginURL">
|
||||
|
@ -66,6 +66,10 @@
|
||||
displayName="$(string.SINCE_V1_84)">
|
||||
<and><reference ref="TAILSCALE_PRODUCT"/></and>
|
||||
</definition>
|
||||
<definition name="SINCE_V1_86"
|
||||
displayName="$(string.SINCE_V1_86)">
|
||||
<and><reference ref="TAILSCALE_PRODUCT"/></and>
|
||||
</definition>
|
||||
</definitions>
|
||||
</supportedOn>
|
||||
<categories>
|
||||
@ -365,5 +369,15 @@
|
||||
<text id="KeyExpirationNoticePrompt" valueName="KeyExpirationNotice" required="true" />
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="EncryptState" class="Machine" displayName="$(string.EncryptState)" explainText="$(string.EncryptState_Help)" key="Software\Policies\Tailscale" valueName="EncryptState">
|
||||
<parentCategory ref="Top_Category" />
|
||||
<supportedOn ref="SINCE_V1_86" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
</policies>
|
||||
</policyDefinitions>
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -37,7 +38,7 @@ func init() {
|
||||
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
|
||||
hi.TPM = infoOnce()
|
||||
})
|
||||
store.Register(storePrefix, newStore)
|
||||
store.Register(store.TPMPrefix, newStore)
|
||||
}
|
||||
|
||||
func info() *tailcfg.TPMInfo {
|
||||
@ -103,10 +104,8 @@ func propToString(v uint32) string {
|
||||
return string(slices.DeleteFunc(chars, func(b byte) bool { return b < ' ' || b > '~' }))
|
||||
}
|
||||
|
||||
const storePrefix = "tpmseal:"
|
||||
|
||||
func newStore(logf logger.Logf, path string) (ipn.StateStore, error) {
|
||||
path = strings.TrimPrefix(path, storePrefix)
|
||||
path = strings.TrimPrefix(path, store.TPMPrefix)
|
||||
if err := paths.MkStateDir(filepath.Dir(path)); err != nil {
|
||||
return nil, fmt.Errorf("creating state directory: %w", err)
|
||||
}
|
||||
@ -205,6 +204,19 @@ func (s *tpmStore) writeSealed() error {
|
||||
return atomicfile.WriteFile(s.path, buf, 0600)
|
||||
}
|
||||
|
||||
func (s *tpmStore) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The nested levels of encoding and encryption are confusing, so here's what's
|
||||
// going on in plain English.
|
||||
//
|
||||
|
@ -6,13 +6,22 @@ package tpm
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func TestPropToString(t *testing.T) {
|
||||
@ -29,11 +38,9 @@ func TestPropToString(t *testing.T) {
|
||||
}
|
||||
|
||||
func skipWithoutTPM(t testing.TB) {
|
||||
tpm, err := open()
|
||||
if err != nil {
|
||||
if !tpmSupported() {
|
||||
t.Skip("TPM not available")
|
||||
}
|
||||
tpm.Close()
|
||||
}
|
||||
|
||||
func TestSealUnseal(t *testing.T) {
|
||||
@ -67,7 +74,7 @@ func TestSealUnseal(t *testing.T) {
|
||||
func TestStore(t *testing.T) {
|
||||
skipWithoutTPM(t)
|
||||
|
||||
path := storePrefix + filepath.Join(t.TempDir(), "state")
|
||||
path := store.TPMPrefix + filepath.Join(t.TempDir(), "state")
|
||||
store, err := newStore(t.Logf, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -180,3 +187,153 @@ func BenchmarkStore(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateStateToTPM(t *testing.T) {
|
||||
if !tpmSupported() {
|
||||
t.Logf("using mock tpmseal provider")
|
||||
store.RegisterForTest(t, store.TPMPrefix, newMockTPMSeal)
|
||||
}
|
||||
|
||||
storePath := filepath.Join(t.TempDir(), "store")
|
||||
// Make sure migration doesn't cause a failure when no state file exists.
|
||||
if _, err := store.New(t.Logf, store.TPMPrefix+storePath); err != nil {
|
||||
t.Fatalf("store.New failed for new tpmseal store: %v", err)
|
||||
}
|
||||
os.Remove(storePath)
|
||||
|
||||
initial, err := store.New(t.Logf, storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed for new file store: %v", err)
|
||||
}
|
||||
|
||||
// Populate initial state file.
|
||||
content := map[ipn.StateKey][]byte{
|
||||
"foo": []byte("bar"),
|
||||
"baz": []byte("qux"),
|
||||
}
|
||||
for k, v := range content {
|
||||
if err := initial.WriteState(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Expected file keys for plaintext and sealed versions of state.
|
||||
keysPlaintext := []string{"foo", "baz"}
|
||||
keysTPMSeal := []string{"key", "nonce", "data"}
|
||||
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
path string
|
||||
wantKeys []string
|
||||
}{
|
||||
{
|
||||
desc: "plaintext-to-plaintext",
|
||||
path: storePath,
|
||||
wantKeys: keysPlaintext,
|
||||
},
|
||||
{
|
||||
desc: "plaintext-to-tpmseal",
|
||||
path: store.TPMPrefix + storePath,
|
||||
wantKeys: keysTPMSeal,
|
||||
},
|
||||
{
|
||||
desc: "tpmseal-to-tpmseal",
|
||||
path: store.TPMPrefix + storePath,
|
||||
wantKeys: keysTPMSeal,
|
||||
},
|
||||
{
|
||||
desc: "tpmseal-to-plaintext",
|
||||
path: storePath,
|
||||
wantKeys: keysPlaintext,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
s, err := store.New(t.Logf, tt.path)
|
||||
if err != nil {
|
||||
t.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
gotContent := maps.Collect(s.All())
|
||||
if diff := cmp.Diff(content, gotContent); diff != "" {
|
||||
t.Errorf("unexpected content after migration, diff:\n%s", diff)
|
||||
}
|
||||
|
||||
buf, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(buf, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotKeys := slices.Collect(maps.Keys(data))
|
||||
slices.Sort(gotKeys)
|
||||
slices.Sort(tt.wantKeys)
|
||||
if diff := cmp.Diff(gotKeys, tt.wantKeys); diff != "" {
|
||||
t.Errorf("unexpected content keys after migration, diff:\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func tpmSupported() bool {
|
||||
tpm, err := open()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
tpm.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
type mockTPMSealProvider struct {
|
||||
path string
|
||||
mem.Store
|
||||
}
|
||||
|
||||
func newMockTPMSeal(logf logger.Logf, path string) (ipn.StateStore, error) {
|
||||
path, ok := strings.CutPrefix(path, store.TPMPrefix)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q missing tpmseal: prefix", path)
|
||||
}
|
||||
s := &mockTPMSealProvider{path: path, Store: mem.Store{}}
|
||||
buf, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, s.flushState()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data struct {
|
||||
Key string
|
||||
Nonce string
|
||||
Data map[ipn.StateKey][]byte
|
||||
}
|
||||
if err := json.Unmarshal(buf, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.Key == "" || data.Nonce == "" {
|
||||
return nil, fmt.Errorf("%q missing key or nonce", path)
|
||||
}
|
||||
for k, v := range data.Data {
|
||||
s.Store.WriteState(k, v)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (p *mockTPMSealProvider) WriteState(k ipn.StateKey, v []byte) error {
|
||||
if err := p.Store.WriteState(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.flushState()
|
||||
}
|
||||
|
||||
func (p *mockTPMSealProvider) flushState() error {
|
||||
data := map[string]any{
|
||||
"key": "foo",
|
||||
"nonce": "bar",
|
||||
"data": maps.Collect(p.Store.All()),
|
||||
}
|
||||
buf, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p.path, buf, 0600)
|
||||
}
|
||||
|
@ -1013,17 +1013,13 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
type testStateStorage struct {
|
||||
mem mem.Store
|
||||
mem.Store
|
||||
written atomic.Bool
|
||||
}
|
||||
|
||||
func (s *testStateStorage) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
return s.mem.ReadState(id)
|
||||
}
|
||||
|
||||
func (s *testStateStorage) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
s.written.Store(true)
|
||||
return s.mem.WriteState(id, bs)
|
||||
return s.Store.WriteState(id, bs)
|
||||
}
|
||||
|
||||
// awaitWrite clears the "I've seen writes" bit, in prep for a future
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
@ -83,6 +84,11 @@ type StateStore interface {
|
||||
// instead, which only writes if the value is different from what's
|
||||
// already in the store.
|
||||
WriteState(id StateKey, bs []byte) error
|
||||
// All returns an iterator over all StateStore 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[StateKey, []byte]
|
||||
}
|
||||
|
||||
// WriteState is a wrapper around store.WriteState that only writes if
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
@ -253,3 +254,7 @@ func (s *awsStore) persistState() error {
|
||||
_, err = s.ssmClient.PutParameter(context.TODO(), in)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *awsStore) All() iter.Seq2[ipn.StateKey, []byte] {
|
||||
return s.memory.All()
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ package kubestore
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
@ -428,3 +429,7 @@ func sanitizeKey[T ~string](k T) string {
|
||||
return '_'
|
||||
}, string(k))
|
||||
}
|
||||
|
||||
func (s *Store) All() iter.Seq2[ipn.StateKey, []byte] {
|
||||
return s.memory.All()
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ package mem
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"iter"
|
||||
"sync"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
@ -85,3 +86,16 @@ func (s *Store) ExportToJSON() ([]byte, error) {
|
||||
}
|
||||
return json.MarshalIndent(s.cache, "", " ")
|
||||
}
|
||||
|
||||
func (s *Store) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,14 @@ package store
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@ -20,6 +24,7 @@ import (
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/testenv"
|
||||
)
|
||||
|
||||
// Provider returns a StateStore for the provided path.
|
||||
@ -32,6 +37,9 @@ func init() {
|
||||
|
||||
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
|
||||
@ -53,12 +61,23 @@ func New(logf logger.Logf, path string) (ipn.StateStore, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -77,6 +96,29 @@ func Register(prefix string, fn Provider) {
|
||||
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)
|
||||
//
|
||||
@ -179,3 +221,101 @@ func (s *FileStore) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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 from.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
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ package ipn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"iter"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@ -31,6 +32,19 @@ func (s *memStore) WriteState(k StateKey, v []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memStore) All() iter.Seq2[StateKey, []byte] {
|
||||
return func(yield func(StateKey, []byte) bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for k, v := range s.m {
|
||||
if !yield(k, v) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteState(t *testing.T) {
|
||||
var ss StateStore = new(memStore)
|
||||
WriteState(ss, "foo", []byte("bar"))
|
||||
|
@ -908,6 +908,9 @@ type TPMInfo struct {
|
||||
SpecRevision int `json:",omitempty"`
|
||||
}
|
||||
|
||||
// Present reports whether a TPM device is present on this machine.
|
||||
func (t *TPMInfo) Present() bool { return t != nil }
|
||||
|
||||
// ServiceName is the name of a service, of the form `svc:dns-label`. Services
|
||||
// represent some kind of application provided for users of the tailnet with a
|
||||
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),
|
||||
|
@ -574,6 +574,7 @@ type TestNode struct {
|
||||
sockFile string
|
||||
stateFile string
|
||||
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
|
||||
encryptState bool
|
||||
|
||||
mu sync.Mutex
|
||||
onLogLine []func([]byte)
|
||||
@ -640,7 +641,7 @@ func (n *TestNode) diskPrefs() *ipn.Prefs {
|
||||
if _, err := os.ReadFile(n.stateFile); err != nil {
|
||||
t.Fatalf("reading prefs: %v", err)
|
||||
}
|
||||
fs, err := store.NewFileStore(nil, n.stateFile)
|
||||
fs, err := store.New(nil, n.stateFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading prefs, NewFileStore: %v", err)
|
||||
}
|
||||
@ -822,6 +823,9 @@ func (n *TestNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
|
||||
if n.configFile != "" {
|
||||
cmd.Args = append(cmd.Args, "--config="+n.configFile)
|
||||
}
|
||||
if n.encryptState {
|
||||
cmd.Args = append(cmd.Args, "--encrypt-state")
|
||||
}
|
||||
cmd.Env = append(os.Environ(),
|
||||
"TS_DEBUG_PERMIT_HTTP_C2N=1",
|
||||
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@ -32,6 +33,7 @@ import (
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tstun"
|
||||
@ -1470,3 +1472,60 @@ func TestNetstackUDPLoopback(t *testing.T) {
|
||||
|
||||
d1.MustCleanShutdown(t)
|
||||
}
|
||||
|
||||
func TestEncryptStateMigration(t *testing.T) {
|
||||
if !hostinfo.New().TPM.Present() {
|
||||
t.Skip("TPM not available")
|
||||
}
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
|
||||
t.Skip("--encrypt-state for tailscaled state not supported on this platform")
|
||||
}
|
||||
tstest.Shard(t)
|
||||
tstest.Parallel(t)
|
||||
env := NewTestEnv(t)
|
||||
n := NewTestNode(t, env)
|
||||
|
||||
runNode := func(t *testing.T, wantStateKeys []string) {
|
||||
t.Helper()
|
||||
|
||||
// Run the node.
|
||||
d := n.StartDaemon()
|
||||
n.AwaitResponding()
|
||||
n.MustUp()
|
||||
n.AwaitRunning()
|
||||
|
||||
// Check the contents of the state file.
|
||||
buf, err := os.ReadFile(n.stateFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %q: %v", n.stateFile, err)
|
||||
}
|
||||
t.Logf("state file content:\n%s", buf)
|
||||
var content map[string]any
|
||||
if err := json.Unmarshal(buf, &content); err != nil {
|
||||
t.Fatalf("parsing %q: %v", n.stateFile, err)
|
||||
}
|
||||
for _, k := range wantStateKeys {
|
||||
if _, ok := content[k]; !ok {
|
||||
t.Errorf("state file is missing key %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the node.
|
||||
d.MustCleanShutdown(t)
|
||||
}
|
||||
|
||||
wantPlaintextStateKeys := []string{"_machinekey", "_current-profile", "_profiles"}
|
||||
wantEncryptedStateKeys := []string{"key", "nonce", "data"}
|
||||
t.Run("regular-state", func(t *testing.T) {
|
||||
n.encryptState = false
|
||||
runNode(t, wantPlaintextStateKeys)
|
||||
})
|
||||
t.Run("migrate-to-encrypted", func(t *testing.T) {
|
||||
n.encryptState = true
|
||||
runNode(t, wantEncryptedStateKeys)
|
||||
})
|
||||
t.Run("migrate-to-plaintext", func(t *testing.T) {
|
||||
n.encryptState = false
|
||||
runNode(t, wantPlaintextStateKeys)
|
||||
})
|
||||
}
|
||||
|
@ -51,6 +51,7 @@ import (
|
||||
_ "tailscale.com/util/eventbus"
|
||||
_ "tailscale.com/util/multierr"
|
||||
_ "tailscale.com/util/osshare"
|
||||
_ "tailscale.com/util/syspolicy"
|
||||
_ "tailscale.com/version"
|
||||
_ "tailscale.com/version/distro"
|
||||
_ "tailscale.com/wgengine"
|
||||
|
@ -51,6 +51,7 @@ import (
|
||||
_ "tailscale.com/util/eventbus"
|
||||
_ "tailscale.com/util/multierr"
|
||||
_ "tailscale.com/util/osshare"
|
||||
_ "tailscale.com/util/syspolicy"
|
||||
_ "tailscale.com/version"
|
||||
_ "tailscale.com/version/distro"
|
||||
_ "tailscale.com/wgengine"
|
||||
|
@ -51,6 +51,7 @@ import (
|
||||
_ "tailscale.com/util/eventbus"
|
||||
_ "tailscale.com/util/multierr"
|
||||
_ "tailscale.com/util/osshare"
|
||||
_ "tailscale.com/util/syspolicy"
|
||||
_ "tailscale.com/version"
|
||||
_ "tailscale.com/version/distro"
|
||||
_ "tailscale.com/wgengine"
|
||||
|
@ -51,6 +51,7 @@ import (
|
||||
_ "tailscale.com/util/eventbus"
|
||||
_ "tailscale.com/util/multierr"
|
||||
_ "tailscale.com/util/osshare"
|
||||
_ "tailscale.com/util/syspolicy"
|
||||
_ "tailscale.com/version"
|
||||
_ "tailscale.com/version/distro"
|
||||
_ "tailscale.com/wgengine"
|
||||
|
@ -120,6 +120,10 @@ const (
|
||||
LogSCMInteractions Key = "LogSCMInteractions"
|
||||
FlushDNSOnSessionUnlock Key = "FlushDNSOnSessionUnlock"
|
||||
|
||||
// EncryptState is a boolean setting that specifies whether to encrypt the
|
||||
// tailscaled state file with a TPM device.
|
||||
EncryptState Key = "EncryptState"
|
||||
|
||||
// PostureChecking indicates if posture checking is enabled and the client shall gather
|
||||
// posture data.
|
||||
// Key is a string value that specifies an option: "always", "never", "user-decides".
|
||||
@ -186,6 +190,7 @@ var implicitDefinitions = []*setting.Definition{
|
||||
setting.NewDefinition(ExitNodeID, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(EncryptState, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(Hostname, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
|
||||
|
Loading…
x
Reference in New Issue
Block a user