mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-30 07:43:42 +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 {
|
if err := f.Close(); err != nil {
|
||||||
return err
|
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/clientmetric"
|
||||||
"tailscale.com/util/multierr"
|
"tailscale.com/util/multierr"
|
||||||
"tailscale.com/util/osshare"
|
"tailscale.com/util/osshare"
|
||||||
|
"tailscale.com/util/syspolicy"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
"tailscale.com/version/distro"
|
"tailscale.com/version/distro"
|
||||||
"tailscale.com/wgengine"
|
"tailscale.com/wgengine"
|
||||||
@ -126,6 +127,7 @@ var args struct {
|
|||||||
debug string
|
debug string
|
||||||
port uint16
|
port uint16
|
||||||
statepath string
|
statepath string
|
||||||
|
encryptState bool
|
||||||
statedir string
|
statedir string
|
||||||
socketpath string
|
socketpath string
|
||||||
birdSocketPath 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.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.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.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.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.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
|
||||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird 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 {
|
if args.disableLogs {
|
||||||
envknob.SetNoLogsNoSupport()
|
envknob.SetNoLogsNoSupport()
|
||||||
}
|
}
|
||||||
@ -315,13 +340,17 @@ func trySynologyMigration(p string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statePathOrDefault() string {
|
func statePathOrDefault() string {
|
||||||
|
var path string
|
||||||
if args.statepath != "" {
|
if args.statepath != "" {
|
||||||
return args.statepath
|
path = args.statepath
|
||||||
}
|
}
|
||||||
if args.statedir != "" {
|
if path == "" && args.statedir != "" {
|
||||||
return filepath.Join(args.statedir, "tailscaled.state")
|
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.
|
// 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) {
|
getState(id) {
|
||||||
return window.sessionStorage[`ipn-state-${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 {
|
interface IPNStateStorage {
|
||||||
setState(id: string, value: string): void
|
setState(id: string, value: string): void
|
||||||
getState(id: string): string
|
getState(id: string): string
|
||||||
|
all(): string
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPNConfig = {
|
type IPNConfig = {
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
@ -579,6 +580,29 @@ func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
|
|||||||
return nil
|
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 {
|
func mapSlice[T any, M any](a []T, f func(T) M) []M {
|
||||||
n := make([]M, len(a))
|
n := make([]M, len(a))
|
||||||
for i, e := range 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_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_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_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="Tailscale_Category">Tailscale</string>
|
||||||
<string id="UI_Category">UI customization</string>
|
<string id="UI_Category">UI customization</string>
|
||||||
<string id="Settings_Category">Settings</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 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>
|
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>
|
</stringTable>
|
||||||
<presentationTable>
|
<presentationTable>
|
||||||
<presentation id="LoginURL">
|
<presentation id="LoginURL">
|
||||||
|
@ -66,6 +66,10 @@
|
|||||||
displayName="$(string.SINCE_V1_84)">
|
displayName="$(string.SINCE_V1_84)">
|
||||||
<and><reference ref="TAILSCALE_PRODUCT"/></and>
|
<and><reference ref="TAILSCALE_PRODUCT"/></and>
|
||||||
</definition>
|
</definition>
|
||||||
|
<definition name="SINCE_V1_86"
|
||||||
|
displayName="$(string.SINCE_V1_86)">
|
||||||
|
<and><reference ref="TAILSCALE_PRODUCT"/></and>
|
||||||
|
</definition>
|
||||||
</definitions>
|
</definitions>
|
||||||
</supportedOn>
|
</supportedOn>
|
||||||
<categories>
|
<categories>
|
||||||
@ -365,5 +369,15 @@
|
|||||||
<text id="KeyExpirationNoticePrompt" valueName="KeyExpirationNotice" required="true" />
|
<text id="KeyExpirationNoticePrompt" valueName="KeyExpirationNotice" required="true" />
|
||||||
</elements>
|
</elements>
|
||||||
</policy>
|
</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>
|
</policies>
|
||||||
</policyDefinitions>
|
</policyDefinitions>
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -37,7 +38,7 @@ func init() {
|
|||||||
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
|
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
|
||||||
hi.TPM = infoOnce()
|
hi.TPM = infoOnce()
|
||||||
})
|
})
|
||||||
store.Register(storePrefix, newStore)
|
store.Register(store.TPMPrefix, newStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func info() *tailcfg.TPMInfo {
|
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 > '~' }))
|
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) {
|
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 {
|
if err := paths.MkStateDir(filepath.Dir(path)); err != nil {
|
||||||
return nil, fmt.Errorf("creating state directory: %w", err)
|
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)
|
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
|
// The nested levels of encoding and encryption are confusing, so here's what's
|
||||||
// going on in plain English.
|
// going on in plain English.
|
||||||
//
|
//
|
||||||
|
@ -6,13 +6,22 @@ package tpm
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store"
|
"tailscale.com/ipn/store"
|
||||||
|
"tailscale.com/ipn/store/mem"
|
||||||
|
"tailscale.com/types/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPropToString(t *testing.T) {
|
func TestPropToString(t *testing.T) {
|
||||||
@ -29,11 +38,9 @@ func TestPropToString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func skipWithoutTPM(t testing.TB) {
|
func skipWithoutTPM(t testing.TB) {
|
||||||
tpm, err := open()
|
if !tpmSupported() {
|
||||||
if err != nil {
|
|
||||||
t.Skip("TPM not available")
|
t.Skip("TPM not available")
|
||||||
}
|
}
|
||||||
tpm.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSealUnseal(t *testing.T) {
|
func TestSealUnseal(t *testing.T) {
|
||||||
@ -67,7 +74,7 @@ func TestSealUnseal(t *testing.T) {
|
|||||||
func TestStore(t *testing.T) {
|
func TestStore(t *testing.T) {
|
||||||
skipWithoutTPM(t)
|
skipWithoutTPM(t)
|
||||||
|
|
||||||
path := storePrefix + filepath.Join(t.TempDir(), "state")
|
path := store.TPMPrefix + filepath.Join(t.TempDir(), "state")
|
||||||
store, err := newStore(t.Logf, path)
|
store, err := newStore(t.Logf, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
type testStateStorage struct {
|
||||||
mem mem.Store
|
mem.Store
|
||||||
written atomic.Bool
|
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 {
|
func (s *testStateStorage) WriteState(id ipn.StateKey, bs []byte) error {
|
||||||
s.written.Store(true)
|
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
|
// awaitWrite clears the "I've seen writes" bit, in prep for a future
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
@ -83,6 +84,11 @@ type StateStore interface {
|
|||||||
// instead, which only writes if the value is different from what's
|
// instead, which only writes if the value is different from what's
|
||||||
// already in the store.
|
// already in the store.
|
||||||
WriteState(id StateKey, bs []byte) error
|
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
|
// WriteState is a wrapper around store.WriteState that only writes if
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@ -253,3 +254,7 @@ func (s *awsStore) persistState() error {
|
|||||||
_, err = s.ssmClient.PutParameter(context.TODO(), in)
|
_, err = s.ssmClient.PutParameter(context.TODO(), in)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *awsStore) All() iter.Seq2[ipn.StateKey, []byte] {
|
||||||
|
return s.memory.All()
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ package kubestore
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@ -428,3 +429,7 @@ func sanitizeKey[T ~string](k T) string {
|
|||||||
return '_'
|
return '_'
|
||||||
}, string(k))
|
}, string(k))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) All() iter.Seq2[ipn.StateKey, []byte] {
|
||||||
|
return s.memory.All()
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ package mem
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"iter"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
xmaps "golang.org/x/exp/maps"
|
xmaps "golang.org/x/exp/maps"
|
||||||
@ -85,3 +86,16 @@ func (s *Store) ExportToJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return json.MarshalIndent(s.cache, "", " ")
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@ -20,6 +24,7 @@ import (
|
|||||||
"tailscale.com/paths"
|
"tailscale.com/paths"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
|
"tailscale.com/util/testenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider returns a StateStore for the provided path.
|
// Provider returns a StateStore for the provided path.
|
||||||
@ -32,6 +37,9 @@ func init() {
|
|||||||
|
|
||||||
var knownStores map[string]Provider
|
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
|
// New returns a StateStore based on the provided arg
|
||||||
// and registered stores.
|
// and registered stores.
|
||||||
// The arg is of the form "prefix:rest", where prefix was previously
|
// 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) {
|
if strings.HasPrefix(path, prefix) {
|
||||||
// We can't strip the prefix here as some NewStoreFunc (like arn:)
|
// We can't strip the prefix here as some NewStoreFunc (like arn:)
|
||||||
// expect the prefix.
|
// 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)
|
return sf(logf, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
path = TryWindowsAppDataMigration(logf, path)
|
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)
|
return NewFileStore(logf, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +96,29 @@ func Register(prefix string, fn Provider) {
|
|||||||
mak.Set(&knownStores, prefix, fn)
|
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
|
// TryWindowsAppDataMigration attempts to copy the Windows state file
|
||||||
// from its old location to the new location. (Issue 2856)
|
// 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)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"iter"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -31,6 +32,19 @@ func (s *memStore) WriteState(k StateKey, v []byte) error {
|
|||||||
return nil
|
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) {
|
func TestWriteState(t *testing.T) {
|
||||||
var ss StateStore = new(memStore)
|
var ss StateStore = new(memStore)
|
||||||
WriteState(ss, "foo", []byte("bar"))
|
WriteState(ss, "foo", []byte("bar"))
|
||||||
|
@ -908,6 +908,9 @@ type TPMInfo struct {
|
|||||||
SpecRevision int `json:",omitempty"`
|
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
|
// 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
|
// represent some kind of application provided for users of the tailnet with a
|
||||||
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),
|
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),
|
||||||
|
@ -574,6 +574,7 @@ type TestNode struct {
|
|||||||
sockFile string
|
sockFile string
|
||||||
stateFile string
|
stateFile string
|
||||||
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
|
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
|
||||||
|
encryptState bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
onLogLine []func([]byte)
|
onLogLine []func([]byte)
|
||||||
@ -640,7 +641,7 @@ func (n *TestNode) diskPrefs() *ipn.Prefs {
|
|||||||
if _, err := os.ReadFile(n.stateFile); err != nil {
|
if _, err := os.ReadFile(n.stateFile); err != nil {
|
||||||
t.Fatalf("reading prefs: %v", err)
|
t.Fatalf("reading prefs: %v", err)
|
||||||
}
|
}
|
||||||
fs, err := store.NewFileStore(nil, n.stateFile)
|
fs, err := store.New(nil, n.stateFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reading prefs, NewFileStore: %v", err)
|
t.Fatalf("reading prefs, NewFileStore: %v", err)
|
||||||
}
|
}
|
||||||
@ -822,6 +823,9 @@ func (n *TestNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
|
|||||||
if n.configFile != "" {
|
if n.configFile != "" {
|
||||||
cmd.Args = append(cmd.Args, "--config="+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(),
|
cmd.Env = append(os.Environ(),
|
||||||
"TS_DEBUG_PERMIT_HTTP_C2N=1",
|
"TS_DEBUG_PERMIT_HTTP_C2N=1",
|
||||||
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
|
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
|
||||||
|
@ -21,6 +21,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@ -32,6 +33,7 @@ import (
|
|||||||
"tailscale.com/client/tailscale"
|
"tailscale.com/client/tailscale"
|
||||||
"tailscale.com/clientupdate"
|
"tailscale.com/clientupdate"
|
||||||
"tailscale.com/cmd/testwrapper/flakytest"
|
"tailscale.com/cmd/testwrapper/flakytest"
|
||||||
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/net/tstun"
|
"tailscale.com/net/tstun"
|
||||||
@ -1470,3 +1472,60 @@ func TestNetstackUDPLoopback(t *testing.T) {
|
|||||||
|
|
||||||
d1.MustCleanShutdown(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/eventbus"
|
||||||
_ "tailscale.com/util/multierr"
|
_ "tailscale.com/util/multierr"
|
||||||
_ "tailscale.com/util/osshare"
|
_ "tailscale.com/util/osshare"
|
||||||
|
_ "tailscale.com/util/syspolicy"
|
||||||
_ "tailscale.com/version"
|
_ "tailscale.com/version"
|
||||||
_ "tailscale.com/version/distro"
|
_ "tailscale.com/version/distro"
|
||||||
_ "tailscale.com/wgengine"
|
_ "tailscale.com/wgengine"
|
||||||
|
@ -51,6 +51,7 @@ import (
|
|||||||
_ "tailscale.com/util/eventbus"
|
_ "tailscale.com/util/eventbus"
|
||||||
_ "tailscale.com/util/multierr"
|
_ "tailscale.com/util/multierr"
|
||||||
_ "tailscale.com/util/osshare"
|
_ "tailscale.com/util/osshare"
|
||||||
|
_ "tailscale.com/util/syspolicy"
|
||||||
_ "tailscale.com/version"
|
_ "tailscale.com/version"
|
||||||
_ "tailscale.com/version/distro"
|
_ "tailscale.com/version/distro"
|
||||||
_ "tailscale.com/wgengine"
|
_ "tailscale.com/wgengine"
|
||||||
|
@ -51,6 +51,7 @@ import (
|
|||||||
_ "tailscale.com/util/eventbus"
|
_ "tailscale.com/util/eventbus"
|
||||||
_ "tailscale.com/util/multierr"
|
_ "tailscale.com/util/multierr"
|
||||||
_ "tailscale.com/util/osshare"
|
_ "tailscale.com/util/osshare"
|
||||||
|
_ "tailscale.com/util/syspolicy"
|
||||||
_ "tailscale.com/version"
|
_ "tailscale.com/version"
|
||||||
_ "tailscale.com/version/distro"
|
_ "tailscale.com/version/distro"
|
||||||
_ "tailscale.com/wgengine"
|
_ "tailscale.com/wgengine"
|
||||||
|
@ -51,6 +51,7 @@ import (
|
|||||||
_ "tailscale.com/util/eventbus"
|
_ "tailscale.com/util/eventbus"
|
||||||
_ "tailscale.com/util/multierr"
|
_ "tailscale.com/util/multierr"
|
||||||
_ "tailscale.com/util/osshare"
|
_ "tailscale.com/util/osshare"
|
||||||
|
_ "tailscale.com/util/syspolicy"
|
||||||
_ "tailscale.com/version"
|
_ "tailscale.com/version"
|
||||||
_ "tailscale.com/version/distro"
|
_ "tailscale.com/version/distro"
|
||||||
_ "tailscale.com/wgengine"
|
_ "tailscale.com/wgengine"
|
||||||
|
@ -120,6 +120,10 @@ const (
|
|||||||
LogSCMInteractions Key = "LogSCMInteractions"
|
LogSCMInteractions Key = "LogSCMInteractions"
|
||||||
FlushDNSOnSessionUnlock Key = "FlushDNSOnSessionUnlock"
|
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
|
// PostureChecking indicates if posture checking is enabled and the client shall gather
|
||||||
// posture data.
|
// posture data.
|
||||||
// Key is a string value that specifies an option: "always", "never", "user-decides".
|
// 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(ExitNodeID, setting.DeviceSetting, setting.StringValue),
|
||||||
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
|
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
|
||||||
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
|
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
|
||||||
|
setting.NewDefinition(EncryptState, setting.DeviceSetting, setting.BooleanValue),
|
||||||
setting.NewDefinition(Hostname, setting.DeviceSetting, setting.StringValue),
|
setting.NewDefinition(Hostname, setting.DeviceSetting, setting.StringValue),
|
||||||
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
|
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
|
||||||
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
|
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user