mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-28 23:04:10 +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>
|
||||
@ -67,7 +68,7 @@ If you disable or do not configure this policy setting, an interactive user logi
|
||||
See https://tailscale.com/kb/1315/mdm-keys#set-an-auth-key for more details.]]></string>
|
||||
<string id="ExitNodeID">Require using a specific Exit Node</string>
|
||||
<string id="ExitNodeID_Help"><![CDATA[This policy can be used to require always using the specified Exit Node whenever the Tailscale client is connected.
|
||||
|
||||
|
||||
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected. Alternatively, you can set it to "auto:any" (without quotes), which allows the Tailscale client to automatically select the most suitable exit node.
|
||||
|
||||
If you disable this policy or supply an empty exit node ID, then usage of exit nodes will be disallowed.
|
||||
@ -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),
|
||||
|
@ -569,11 +569,12 @@ type TestNode struct {
|
||||
env *TestEnv
|
||||
tailscaledParser *nodeOutputParser
|
||||
|
||||
dir string // temp dir for sock & state
|
||||
configFile string // or empty for none
|
||||
sockFile string
|
||||
stateFile string
|
||||
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
|
||||
dir string // temp dir for sock & state
|
||||
configFile string // or empty for none
|
||||
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