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:
Andrew Lytvynov
2025-06-26 17:09:13 -07:00
committed by GitHub
parent d2c1ed22c3
commit 6feb3c35cb
24 changed files with 546 additions and 26 deletions

View File

@@ -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
}

View File

@@ -10,4 +10,7 @@ export const sessionStateStorage: IPNStateStorage = {
getState(id) {
return window.sessionStorage[`ipn-state-${id}`] || ""
},
all() {
return JSON.stringify(window.sessionStorage)
},
}

View File

@@ -44,6 +44,7 @@ declare global {
interface IPNStateStorage {
setState(id: string, value: string): void
getState(id: string): string
all(): string
}
type IPNConfig = {

View File

@@ -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 {