From 4d330bac14e131a9187536b6deb6bbd543303fe9 Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Wed, 9 Nov 2022 10:58:10 +0500 Subject: [PATCH] ipn/ipnlocal: add support for multiple user profiles Signed-off-by: Maisem Ali --- cmd/tailscale/cli/up.go | 21 +- cmd/tailscale/cli/web.go | 4 +- cmd/tailscaled/tailscaled.go | 4 +- cmd/tsconnect/wasm/wasm_js.go | 4 +- control/controlclient/auto.go | 4 +- control/controlclient/direct.go | 10 +- ipn/backend.go | 50 +-- ipn/fake_test.go | 4 +- ipn/ipnlocal/local.go | 474 ++++++++++++------------- ipn/ipnlocal/local_test.go | 6 +- ipn/ipnlocal/loglines_test.go | 4 +- ipn/ipnlocal/network-lock.go | 15 +- ipn/ipnlocal/network-lock_test.go | 49 ++- ipn/ipnlocal/peerapi_test.go | 11 +- ipn/ipnlocal/profiles.go | 439 +++++++++++++++++++++++ ipn/ipnlocal/profiles_test.go | 221 ++++++++++++ ipn/ipnlocal/ssh_test.go | 4 +- ipn/ipnlocal/state_test.go | 35 +- ipn/ipnserver/server.go | 68 +--- ipn/prefs.go | 18 + ipn/store.go | 32 +- ssh/tailssh/tailssh_test.go | 2 +- tsnet/tsnet.go | 3 +- tstest/integration/integration_test.go | 11 +- types/persist/persist.go | 5 +- types/persist/persist_clone.go | 2 + types/persist/persist_test.go | 22 +- types/persist/persist_view.go | 3 + wgengine/netstack/netstack_test.go | 17 +- 29 files changed, 1106 insertions(+), 436 deletions(-) create mode 100644 ipn/ipnlocal/profiles.go create mode 100644 ipn/ipnlocal/profiles_test.go diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 87091252c..6d4b28359 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -634,27 +634,10 @@ func runUp(ctx context.Context, args []string) (retErr error) { if err != nil { return err } - opts := ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, + bc.Start(ipn.Options{ AuthKey: authKey, UpdatePrefs: prefs, - } - // On Windows, we still run in mostly the "legacy" way that - // predated the server's StateStore. That is, we send an empty - // StateKey and send the prefs directly. Although the Windows - // supports server mode, though, the transition to StateStore - // is only half complete. Only server mode uses it, and the - // Windows service (~tailscaled) is the one that computes the - // StateKey based on the connection identity. So for now, just - // do as the Windows GUI's always done: - if effectiveGOOS() == "windows" { - // The Windows service will set this as needed based - // on our connection's identity. - opts.StateKey = "" - opts.Prefs = prefs - } - - bc.Start(opts) + }) if upArgs.forceReauth { startLoginInteractive() } diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index 746e33861..0700bd938 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -496,9 +496,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU bc.SetPrefs(prefs) - bc.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - }) + bc.Start(ipn.Options{}) if forceReauth { bc.StartLoginInteractive() } diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index bea9d0a57..1279f2448 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -33,7 +33,6 @@ "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/control/controlclient" "tailscale.com/envknob" - "tailscale.com/ipn" "tailscale.com/ipn/ipnserver" "tailscale.com/ipn/store" "tailscale.com/logpolicy" @@ -306,7 +305,6 @@ func ipnServerOpts() (o ipnserver.Options) { fallthrough default: o.SurviveDisconnects = true - o.AutostartStateKey = ipn.GlobalDaemonStateKey case "windows": // Not those. } @@ -452,7 +450,7 @@ func run() error { if err != nil { return fmt.Errorf("store.New: %w", err) } - srv, err := ipnserver.New(logf, pol.PublicID.String(), store, e, dialer, nil, opts) + srv, err := ipnserver.New(logf, pol.PublicID.String(), store, e, dialer, opts) if err != nil { return fmt.Errorf("ipnserver.New: %w", err) } diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 5ff163398..df20f5169 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -124,9 +124,10 @@ func newIPN(jsConfig js.Value) map[string]any { return ns.DialContextTCP(ctx, dst) } - srv, err := ipnserver.New(logf, lpc.PublicID.String(), store, eng, dialer, nil, ipnserver.Options{ + srv, err := ipnserver.New(logf, lpc.PublicID.String(), store, eng, dialer, ipnserver.Options{ SurviveDisconnects: true, LoginFlags: controlclient.LoginEphemeral, + AutostartStateKey: "wasm", }) if err != nil { log.Fatalf("ipnserver.New: %v", err) @@ -284,7 +285,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) { go func() { err := i.lb.Start(ipn.Options{ - StateKey: "wasm", UpdatePrefs: &ipn.Prefs{ ControlURL: i.controlURL, RouteAll: false, diff --git a/control/controlclient/auto.go b/control/controlclient/auto.go index 715fe8a10..d1929a809 100644 --- a/control/controlclient/auto.go +++ b/control/controlclient/auto.go @@ -590,7 +590,7 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM } if nm != nil && loggedIn && synced { pp := c.direct.GetPersist() - p = &pp + p = pp.AsStruct() } else { // don't send netmap status, as it's misleading when we're // not logged in. @@ -708,7 +708,7 @@ func (c *Auto) Shutdown() { // used exclusively in tests. func (c *Auto) TestOnlyNodePublicKey() key.NodePublic { priv := c.direct.GetPersist() - return priv.PrivateNodeKey.Public() + return priv.PrivateNodeKey().Public() } func (c *Auto) TestOnlySetAuthKey(authkey string) { diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 020397142..666dc7ab7 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -333,10 +333,10 @@ func (c *Direct) SetTKAHead(tkaHead string) bool { return true } -func (c *Direct) GetPersist() persist.Persist { +func (c *Direct) GetPersist() persist.PersistView { c.mu.Lock() defer c.mu.Unlock() - return c.persist + return c.persist.View() } func (c *Direct) TryLogout(ctx context.Context) error { @@ -633,6 +633,12 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new if resp.Login.LoginName != "" { persist.LoginName = resp.Login.LoginName } + persist.UserProfile = tailcfg.UserProfile{ + ID: resp.User.ID, + DisplayName: resp.Login.DisplayName, + ProfilePicURL: resp.Login.ProfilePicURL, + LoginName: resp.Login.LoginName, + } // TODO(crawshaw): RegisterResponse should be able to mechanically // communicate some extra instructions from the server: diff --git a/ipn/backend.go b/ipn/backend.go index afea73b6e..6e092115d 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -20,13 +20,13 @@ type State int const ( - NoState = State(iota) - InUseOtherUser - NeedsLogin - NeedsMachineAuth - Stopped - Starting - Running + NoState State = 0 + InUseOtherUser State = 1 + NeedsLogin State = 2 + NeedsMachineAuth State = 3 + Stopped State = 4 + Starting State = 5 + Running State = 6 ) // GoogleIDToken Type is the tailcfg.Oauth2Token.TokenType for the Google @@ -153,21 +153,8 @@ type PartialFile struct { } // StateKey is an opaque identifier for a set of LocalBackend state -// (preferences, private keys, etc.). -// -// The reason we need this is that the Tailscale agent may be running -// on a multi-user machine, in a context where a single daemon is -// shared by several consecutive users. Ideally we would just use the -// username of the connected frontend as the StateKey. -// -// Various platforms currently set StateKey in different ways: -// -// - the macOS/iOS GUI apps set it to "ipn-go-bridge" -// - the Android app sets it to "ipn-android" -// - on Windows, it's the empty string (in client mode) or, via -// LocalBackend.userID, a string like "user-$USER_ID" (used in -// server mode). -// - on Linux/etc, it's always "_daemon" (ipn.GlobalDaemonStateKey) +// (preferences, private keys, etc.). It is also used as a key for +// the various LoginProfiles that the instance may be signed into. // // Additionally, the StateKey can be debug setting name: // @@ -178,21 +165,10 @@ type PartialFile struct { type Options struct { // FrontendLogID is the public logtail id used by the frontend. FrontendLogID string - // StateKey and Prefs together define the state the backend should - // use: - // - StateKey=="" && Prefs!=nil: use Prefs for internal state, - // don't persist changes in the backend, except for the machine key - // for migration purposes. - // - StateKey!="" && Prefs==nil: load the given backend-side - // state and use/update that. - // - StateKey!="" && Prefs!=nil: like the previous case, but do - // an initial overwrite of backend state with Prefs. - // - // NOTE(apenwarr): The above means that this Prefs field does not do - // what you probably think it does. It will overwrite your encryption - // keys. Do not use unless you know what you're doing. - StateKey StateKey - Prefs *Prefs + // LegacyMigrationPrefs are used to migrate preferences from the + // frontend to the backend. + // If non-nil, they are imported as a new profile. + LegacyMigrationPrefs *Prefs `json:"Prefs"` // UpdatePrefs, if provided, overrides Options.Prefs *and* the Prefs // already stored in the backend state, *except* for the Persist // Persist member. If you just want to provide prefs, this is diff --git a/ipn/fake_test.go b/ipn/fake_test.go index 8f0ff7253..92912903c 100644 --- a/ipn/fake_test.go +++ b/ipn/fake_test.go @@ -16,13 +16,13 @@ type FakeBackend struct { } func (b *FakeBackend) Start(opts Options) error { - b.serverURL = opts.Prefs.ControlURLOrDefault() + b.serverURL = opts.LegacyMigrationPrefs.ControlURLOrDefault() if b.notify == nil { panic("FakeBackend.Start: SetNotifyCallback not called") } nl := NeedsLogin if b.notify != nil { - p := opts.Prefs.View() + p := opts.LegacyMigrationPrefs.View() b.notify(Notify{Prefs: &p}) b.notify(Notify{State: &nl}) } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index ad9614d70..afbd437a0 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -124,6 +124,7 @@ type LocalBackend struct { keyLogf logger.Logf // for printing list of peers on change statsLogf logger.Logf // for printing peers stats on change e wgengine.Engine + pm *profileManager store ipn.StateStore dialer *tsdial.Dialer // non-nil backendLogID string @@ -138,6 +139,10 @@ type LocalBackend struct { sshAtomicBool atomic.Bool shutdownCalled bool // if Shutdown has been called + // lastProfileID tracks the last profile we've seen from the ProfileManager. + // It's used to detect when the user has changed their profile. + lastProfileID ipn.ProfileID + filterAtomic atomic.Pointer[filter.Filter] containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool] shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool] @@ -151,9 +156,6 @@ type LocalBackend struct { notify func(ipn.Notify) cc controlclient.Client ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto - stateKey ipn.StateKey // computed in part from user-provided value - userID string // current controlling user ID (for Windows, primarily) - prefs ipn.PrefsView // may not be Valid. inServerMode bool machinePrivKey key.MachinePrivate nlPrivKey key.NLPrivate @@ -226,11 +228,16 @@ type LocalBackend struct { // but is not actually running. // // If dialer is nil, a new one is made. -func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, dialer *tsdial.Dialer, e wgengine.Engine, loginFlags controlclient.LoginFlags) (*LocalBackend, error) { +func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, stateKey ipn.StateKey, dialer *tsdial.Dialer, e wgengine.Engine, loginFlags controlclient.LoginFlags) (*LocalBackend, error) { if e == nil { panic("ipn.NewLocalBackend: engine must not be nil") } + pm, err := newProfileManager(store, logf, stateKey) + if err != nil { + return nil, err + } + hi := hostinfo.New() logf.JSON(1, "Hostinfo", hi) envknob.LogCurrent(logf) @@ -253,7 +260,8 @@ func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, diale keyLogf: logger.LogOnChange(logf, 5*time.Minute, time.Now), statsLogf: logger.LogOnChange(logf, 5*time.Minute, time.Now), e: e, - store: store, + pm: pm, + store: pm.Store(), dialer: dialer, backendLogID: logid, state: ipn.NoState, @@ -292,7 +300,7 @@ func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, diale for _, component := range debuggableComponents { key := componentStateKey(component) - if ut, err := ipn.ReadStoreInt(store, key); err == nil { + if ut, err := ipn.ReadStoreInt(pm.Store(), key); err == nil { if until := time.Unix(ut, 0); until.After(time.Now()) { // conditional to avoid log spam at start when off b.SetComponentDebugLogging(component, until) @@ -450,7 +458,7 @@ func (b *LocalBackend) linkChange(major bool, ifst *interfaces.State) { // If the local network configuration has changed, our filter may // need updating to tweak default routes. - b.updateFilterLocked(b.netMap, b.prefs) + b.updateFilterLocked(b.netMap, b.pm.CurrentPrefs()) if peerAPIListenAsync && b.netMap != nil && b.state == ipn.Running { want := len(b.netMap.Addresses) @@ -520,7 +528,7 @@ func stripKeysFromPrefs(p ipn.PrefsView) ipn.PrefsView { func (b *LocalBackend) Prefs() ipn.PrefsView { b.mu.Lock() defer b.mu.Unlock() - return stripKeysFromPrefs(b.prefs) + return stripKeysFromPrefs(b.pm.CurrentPrefs()) } // Status returns the latest status of the backend and its @@ -577,14 +585,14 @@ func (b *LocalBackend) updateStatus(sb *ipnstate.StatusBuilder, extraLocked func s.CurrentTailnet.MagicDNSSuffix = b.netMap.MagicDNSSuffix() s.CurrentTailnet.MagicDNSEnabled = b.netMap.DNS.Proxied s.CurrentTailnet.Name = b.netMap.Domain - if b.prefs.Valid() && !b.prefs.ExitNodeID().IsZero() { - if exitPeer, ok := b.netMap.PeerWithStableID(b.prefs.ExitNodeID()); ok { + if prefs := b.pm.CurrentPrefs(); prefs.Valid() && !prefs.ExitNodeID().IsZero() { + if exitPeer, ok := b.netMap.PeerWithStableID(prefs.ExitNodeID()); ok { var online = false if exitPeer.Online != nil { online = *exitPeer.Online } s.ExitNodeStatus = &ipnstate.ExitNodeStatus{ - ID: b.prefs.ExitNodeID(), + ID: prefs.ExitNodeID(), Online: online, TailscaleIPs: exitPeer.Addresses, } @@ -628,6 +636,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) { for id, up := range b.netMap.UserProfiles { sb.AddUser(id, up) } + exitNodeID := b.pm.CurrentPrefs().ExitNodeID() for _, p := range b.netMap.Peers { var lastSeen time.Time if p.LastSeen != nil { @@ -650,7 +659,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) { LastSeen: lastSeen, Online: p.Online != nil && *p.Online, ShareeNode: p.Hostinfo.ShareeNode(), - ExitNode: p.StableID != "" && p.StableID == b.prefs.ExitNodeID(), + ExitNode: p.StableID != "" && p.StableID == exitNodeID, SSH_HostKeys: p.Hostinfo.SSH_HostKeys().AsSlice(), } peerStatusFromNode(ps, p) @@ -796,8 +805,7 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { b.e.SetNetworkMap(new(netmap.NetworkMap)) } - prefs := b.prefs.AsStruct() - stateKey := b.stateKey + prefs := b.pm.CurrentPrefs().AsStruct() netMap := b.netMap interact := b.interact @@ -834,9 +842,6 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { prefsChanged = true } // Prefs will be written out; this is not safe unless locked or cloned. - if prefsChanged { - b.prefs = prefs.View() - } if st.NetMap != nil { b.mu.Unlock() // respect locking rules for tkaSyncIfNeeded if err := b.tkaSyncIfNeeded(st.NetMap); err != nil { @@ -858,23 +863,22 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { b.tkaFilterNetmapLocked(st.NetMap) } b.setNetMapLocked(st.NetMap) - b.updateFilterLocked(st.NetMap, b.prefs) + b.updateFilterLocked(st.NetMap, prefs.View()) + } + + if prefsChanged { + if err := b.pm.SetPrefs(prefs.View()); err != nil { + b.logf("Failed to save new controlclient state: %v", err) + } } - userID := b.userID b.mu.Unlock() // Now complete the lock-free parts of what we started while locked. if prefsChanged { - if stateKey != "" { - if err := b.store.WriteState(stateKey, prefs.ToBytes()); err != nil { - b.logf("Failed to save new controlclient state: %v", err) - } - } - b.writeServerModeStartState(userID, prefs.View()) - p := prefs.View() b.send(ipn.Notify{Prefs: &p}) } + if st.NetMap != nil { if netMap != nil { diff := st.NetMap.ConciseDiffFrom(netMap) @@ -1064,8 +1068,7 @@ func (b *LocalBackend) startIsNoopLocked(opts ipn.Options) bool { return b.state == ipn.Running && b.hostinfo != nil && b.hostinfo.FrontendLogID == opts.FrontendLogID && - b.stateKey == opts.StateKey && - opts.Prefs == nil && + opts.LegacyMigrationPrefs == nil && opts.UpdatePrefs == nil && opts.AuthKey == "" } @@ -1081,29 +1084,30 @@ func (b *LocalBackend) startIsNoopLocked(opts ipn.Options) bool { // actually a supported operation (it should be, but it's very unclear // from the following whether or not that is a safe transition). func (b *LocalBackend) Start(opts ipn.Options) error { - if opts.Prefs == nil && opts.StateKey == "" { - return errors.New("no state key or prefs provided") + if opts.LegacyMigrationPrefs == nil && !b.pm.CurrentPrefs().Valid() { + return errors.New("no prefs provided") } - if opts.Prefs != nil { - b.logf("Start: %v", opts.Prefs.Pretty()) + if opts.LegacyMigrationPrefs != nil { + b.logf("Start: %v", opts.LegacyMigrationPrefs.Pretty()) } else { b.logf("Start") } b.mu.Lock() + profileID := b.pm.CurrentProfile().ID // The iOS client sends a "Start" whenever its UI screen comes // up, just because it wants a netmap. That should be fixed, // but meanwhile we can make Start cheaper here for such a // case and not restart the world (which takes a few seconds). // Instead, just send a notify with the state that iOS needs. - if b.startIsNoopLocked(opts) { + if b.startIsNoopLocked(opts) && profileID == b.lastProfileID { b.logf("Start: already running; sending notify") nm := b.netMap state := b.state b.mu.Unlock() - p := b.prefs + p := b.pm.CurrentPrefs() b.send(ipn.Notify{ State: &state, NetMap: nm, @@ -1139,26 +1143,24 @@ func (b *LocalBackend) Start(opts ipn.Options) error { b.hostinfo = hostinfo b.state = ipn.NoState - if err := b.loadStateLocked(opts.StateKey, opts.Prefs); err != nil { + if err := b.migrateStateLocked(opts.LegacyMigrationPrefs); err != nil { b.mu.Unlock() return fmt.Errorf("loading requested state: %v", err) } if opts.UpdatePrefs != nil { - newPrefs := opts.UpdatePrefs - newPrefs.Persist = b.prefs.Persist() - b.prefs = newPrefs.View() - - if opts.StateKey != "" { - if err := b.store.WriteState(opts.StateKey, b.prefs.ToBytes()); err != nil { - b.logf("failed to save UpdatePrefs state: %v", err) - } + oldPrefs := b.pm.CurrentPrefs() + newPrefs := opts.UpdatePrefs.Clone() + newPrefs.Persist = oldPrefs.Persist() + pv := newPrefs.View() + if err := b.pm.SetPrefs(pv); err != nil { + b.logf("failed to save UpdatePrefs state: %v", err) } - b.setAtomicValuesFromPrefs(b.prefs) - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + b.setAtomicValuesFromPrefs(pv) } - wantRunning := b.prefs.WantRunning() + prefs := b.pm.CurrentPrefs() + wantRunning := prefs.WantRunning() if wantRunning { if err := b.initMachineKeyLocked(); err != nil { return fmt.Errorf("initMachineKeyLocked: %w", err) @@ -1168,17 +1170,20 @@ func (b *LocalBackend) Start(opts ipn.Options) error { return fmt.Errorf("initNLKeyLocked: %w", err) } - loggedOut := b.prefs.LoggedOut() + loggedOut := prefs.LoggedOut() - b.inServerMode = b.prefs.ForceDaemon() - b.serverURL = b.prefs.ControlURLOrDefault() + b.inServerMode = prefs.ForceDaemon() + b.serverURL = prefs.ControlURLOrDefault() if b.inServerMode || runtime.GOOS == "windows" { b.logf("Start: serverMode=%v", b.inServerMode) } - b.applyPrefsToHostinfo(hostinfo, b.prefs) + b.applyPrefsToHostinfo(hostinfo, prefs) b.setNetMapLocked(nil) - persistv := b.prefs.Persist() + persistv := prefs.Persist() + if persistv == nil { + persistv = new(persist.Persist) + } b.updateFilterLocked(nil, ipn.PrefsView{}) b.mu.Unlock() @@ -1206,10 +1211,6 @@ func (b *LocalBackend) Start(opts ipn.Options) error { discoPublic := b.e.DiscoPublicKey() var err error - if persistv == nil { - // let controlclient initialize it - persistv = &persist.Persist{} - } isNetstack := wgengine.IsNetstackRouter(b.e) debugFlags := controlDebugFlags @@ -1272,10 +1273,6 @@ func (b *LocalBackend) Start(opts ipn.Options) error { b.e.SetNetInfoCallback(b.setNetInfo) - b.mu.Lock() - prefs := b.prefs - b.mu.Unlock() - blid := b.backendLogID b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID) b.send(ipn.Notify{BackendLogID: &blid}) @@ -1779,8 +1776,8 @@ func (b *LocalBackend) initMachineKeyLocked() (err error) { } var legacyMachineKey key.MachinePrivate - if b.prefs.Persist() != nil { - legacyMachineKey = b.prefs.Persist().LegacyFrontendPrivateMachineKey + if p := b.pm.CurrentPrefs().Persist(); p != nil { + legacyMachineKey = p.LegacyFrontendPrivateMachineKey } keyText, err := b.store.ReadState(ipn.MachineKeyStateKey) @@ -1804,11 +1801,6 @@ func (b *LocalBackend) initMachineKeyLocked() (err error) { // have a legacy machine key, use that. Otherwise generate a // new one. if !legacyMachineKey.IsZero() { - if b.stateKey == "" { - b.logf("using frontend-provided legacy machine key") - } else { - b.logf("using legacy machine key from state key %q", b.stateKey) - } b.machinePrivKey = legacyMachineKey } else { b.logf("generating new machine key") @@ -1865,110 +1857,23 @@ func (b *LocalBackend) initNLKeyLocked() (err error) { return nil } -// writeServerModeStartState stores the ServerModeStartKey value based on the current -// user and prefs. If userID is blank or prefs is blank, no work is done. -// -// b.mu may either be held or not. -func (b *LocalBackend) writeServerModeStartState(userID string, prefs ipn.PrefsView) { - if userID == "" || !prefs.Valid() { - return +// migrateStateLocked migrates state from the frontend to the backend. +// It is a no-op if prefs is nil +// b.mu must be held. +func (b *LocalBackend) migrateStateLocked(prefs *ipn.Prefs) (err error) { + if prefs == nil && !b.pm.CurrentPrefs().Valid() { + return fmt.Errorf("no prefs provided and no current profile") } - - if prefs.ForceDaemon() { - stateKey := ipn.StateKey("user-" + userID) - if err := b.store.WriteState(ipn.ServerModeStartKey, []byte(stateKey)); err != nil { - b.logf("WriteState error: %v", err) - } - // It's important we do this here too, even if it looks - // redundant with the one in the 'if stateKey != ""' - // check block above. That one won't fire in the case - // where the Windows client started up in client mode. - // This happens when we transition into server mode: - if err := b.store.WriteState(stateKey, prefs.ToBytes()); err != nil { - b.logf("WriteState error: %v", err) - } - } else { - if err := b.store.WriteState(ipn.ServerModeStartKey, nil); err != nil { - b.logf("WriteState error: %v", err) - } - } -} - -// loadStateLocked sets b.prefs and b.stateKey based on a complex -// combination of key, prefs, and legacyPath. b.mu must be held when -// calling. -func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err error) { - if prefs == nil && key == "" { - panic("state key and prefs are both unset") - } - - // Optimistically set stateKey (for initMachineKeyLocked's - // logging), but revert it if we return an error so a later SetPrefs - // call can't pick it up if it's bogus. - b.stateKey = key - defer func() { - if err != nil { - b.stateKey = "" - } - }() - - if key == "" { - // Frontend owns the state, we just need to obey it. - // - // If the frontend (e.g. on Windows) supplied the - // optional/legacy machine key then it's used as the - // value instead of making up a new one. - b.logf("using frontend prefs: %s", prefs.Pretty()) - b.prefs = prefs.Clone().View() - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() - b.writeServerModeStartState(b.userID, b.prefs) - return nil - } - if prefs != nil { // Backend owns the state, but frontend is trying to migrate // state into the backend. b.logf("importing frontend prefs into backend store; frontend prefs: %s", prefs.Pretty()) - if err := b.store.WriteState(key, prefs.ToBytes()); err != nil { + if err := b.pm.SetPrefs(prefs.View()); err != nil { return fmt.Errorf("store.WriteState: %v", err) } } - bs, err := b.store.ReadState(key) - switch { - case errors.Is(err, ipn.ErrStateNotExist): - prefs := ipn.NewPrefs() - prefs.WantRunning = false - b.logf("using backend prefs; created empty state for %q: %s", key, prefs.Pretty()) - b.prefs = prefs.View() - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() - return nil - case err != nil: - return fmt.Errorf("backend prefs: store.ReadState(%q): %v", key, err) - } - prefs, err = ipn.PrefsFromBytes(bs) - if err != nil { - b.logf("using backend prefs for %q", key) - return fmt.Errorf("PrefsFromBytes: %v", err) - } - - // Ignore any old stored preferences for https://login.tailscale.com - // as the control server that would override the new default of - // controlplane.tailscale.com. - // This makes sure that mobile clients go through the new - // frontends where we're (2021-10-02) doing battery - // optimization work ahead of turning down the old backends. - if prefs != nil && prefs.ControlURL != "" && - prefs.ControlURL != ipn.DefaultControlURL && - ipn.IsLoginServerSynonym(prefs.ControlURL) { - prefs.ControlURL = "" - } - - b.logf("using backend prefs for %q: %s", key, prefs.Pretty()) - b.prefs = prefs.View() - - b.setAtomicValuesFromPrefs(b.prefs) - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + b.setAtomicValuesFromPrefs(b.pm.CurrentPrefs()) return nil } @@ -2010,8 +1915,8 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) { b.shouldInterceptTCPPortAtomic.Store(f) } -// setAtomicValuesFromPrefs populates sshAtomicBool and containsViaIPFuncAtomic -// from the prefs p, which may be nil. +// setAtomicValuesFromPrefs populates sshAtomicBool, containsViaIPFuncAtomic +// and shouldInterceptTCPPortAtomic from the prefs p, which may be !Valid(). func (b *LocalBackend) setAtomicValuesFromPrefs(p ipn.PrefsView) { b.sshAtomicBool.Store(p.Valid() && p.RunSSH() && envknob.CanSSHD()) @@ -2020,6 +1925,7 @@ func (b *LocalBackend) setAtomicValuesFromPrefs(p ipn.PrefsView) { b.setTCPPortsIntercepted(nil) } else { b.containsViaIPFuncAtomic.Store(tsaddr.NewContainsIPFunc(p.AdvertiseRoutes().Filter(tsaddr.IsViaPrefix))) + b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(p) } } @@ -2175,15 +2081,16 @@ func (b *LocalBackend) shouldUploadServices() bool { b.mu.Lock() defer b.mu.Unlock() - if !b.prefs.Valid() || b.netMap == nil { + p := b.pm.CurrentPrefs() + if !p.Valid() || b.netMap == nil { return false // default to safest setting } - return !b.prefs.ShieldsUp() && b.netMap.CollectServices + return !p.ShieldsUp() && b.netMap.CollectServices } func (b *LocalBackend) SetCurrentUserID(uid string) { b.mu.Lock() - b.userID = uid + b.pm.SetCurrentUser(uid) b.mu.Unlock() } @@ -2248,7 +2155,7 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error { } func (b *LocalBackend) sshOnButUnusableHealthCheckMessageLocked() (healthMessage string) { - if !b.prefs.Valid() || !b.prefs.RunSSH() { + if p := b.pm.CurrentPrefs(); !p.Valid() || !p.RunSSH() { return "" } if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" { @@ -2274,10 +2181,11 @@ func (b *LocalBackend) sshOnButUnusableHealthCheckMessageLocked() (healthMessage } func (b *LocalBackend) isDefaultServerLocked() bool { - if !b.prefs.Valid() { + prefs := b.pm.CurrentPrefs() + if !prefs.Valid() { return true // assume true until set otherwise } - return b.prefs.ControlURLOrDefault() == ipn.DefaultControlURL + return prefs.ControlURLOrDefault() == ipn.DefaultControlURL } func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) { @@ -2287,8 +2195,8 @@ func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) { b.egg = true go b.doSetHostinfoFilterServices(b.hostinfo.Clone()) } - p0 := b.prefs - p1 := b.prefs.AsStruct() + p0 := b.pm.CurrentPrefs() + p1 := b.pm.CurrentPrefs().AsStruct() p1.ApplyEdits(mp) if err := b.checkPrefsLocked(p1); err != nil { b.mu.Unlock() @@ -2330,66 +2238,61 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) { // It returns a readonly copy of the new prefs. func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn.PrefsView { netMap := b.netMap - stateKey := b.stateKey - oldp := b.prefs - newp.Persist = oldp.Persist() // caller isn't allowed to override this + b.setAtomicValuesFromPrefs(newp.View()) + oldp := b.pm.CurrentPrefs() + if oldp.Valid() { + newp.Persist = oldp.Persist().Clone() // caller isn't allowed to override this + } // findExitNodeIDLocked returns whether it updated b.prefs, but // everything in this function treats b.prefs as completely new // anyway. No-op if no exit node resolution is needed. findExitNodeIDLocked(newp, netMap) - b.prefs = newp.View() - b.setAtomicValuesFromPrefs(b.prefs) - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() - b.inServerMode = b.prefs.ForceDaemon() // We do this to avoid holding the lock while doing everything else. + b.inServerMode = newp.ForceDaemon oldHi := b.hostinfo newHi := oldHi.Clone() - b.applyPrefsToHostinfo(newHi, b.prefs) + b.applyPrefsToHostinfo(newHi, newp.View()) b.hostinfo = newHi hostInfoChanged := !oldHi.Equal(newHi) - userID := b.userID cc := b.cc // [GRINDER STATS LINE] - please don't remove (used for log parsing) if caller == "SetPrefs" { - b.logf("SetPrefs: %v", b.prefs.Pretty()) + b.logf("SetPrefs: %v", newp.Pretty()) } - b.updateFilterLocked(netMap, b.prefs) + b.updateFilterLocked(netMap, newp.View()) - if oldp.ShouldSSHBeRunning() && !b.prefs.ShouldSSHBeRunning() { + if oldp.ShouldSSHBeRunning() && !newp.ShouldSSHBeRunning() { if b.sshServer != nil { go b.sshServer.Shutdown() b.sshServer = nil } } - prefs := b.prefs // We can grab the view before unlocking. It can't be mutated. - b.mu.Unlock() - - if stateKey != "" { - if err := b.store.WriteState(stateKey, prefs.ToBytes()); err != nil { - b.logf("failed to save new controlclient state: %v", err) - } - } - b.writeServerModeStartState(userID, prefs) - if netMap != nil { - if login := netMap.UserProfiles[netMap.User].LoginName; login != "" { - if prefs.Persist() == nil { + up := netMap.UserProfiles[netMap.User] + if login := up.LoginName; login != "" { + if newp.Persist == nil { b.logf("active login: %s", login) - } else if prefs.Persist().LoginName != login { - // Corp issue 461: sometimes the wrong prefs are - // logged; the frontend isn't always getting - // notified (to update its prefs/persist) on - // account switch. Log this while we figure it - // out. - b.logf("active login: %q ([unexpected] corp#461, not %q)", prefs.Persist().LoginName, login) + } else { + if newp.Persist.LoginName != login { + b.logf("active login: %q (changed from %q)", login, newp.Persist.LoginName) + newp.Persist.LoginName = login + } + newp.Persist.UserProfile = up } } } - if oldp.ShieldsUp() != prefs.ShieldsUp() || hostInfoChanged { + prefs := newp.View() + if err := b.pm.SetPrefs(prefs); err != nil { + b.logf("failed to save new controlclient state: %v", err) + } + b.lastProfileID = b.pm.CurrentProfile().ID + b.mu.Unlock() + + if oldp.ShieldsUp() != newp.ShieldsUp || hostInfoChanged { b.doSetHostinfoFilterServices(newHi) } @@ -2397,12 +2300,12 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn b.e.SetDERPMap(netMap.DERPMap) } - if !oldp.WantRunning() && prefs.WantRunning() { + if !oldp.WantRunning() && newp.WantRunning { b.logf("transitioning to running; doing Login...") cc.Login(nil, controlclient.LoginDefault) } - if oldp.WantRunning() != prefs.WantRunning() { + if oldp.WantRunning() != newp.WantRunning { b.stateMachine() } else { b.authReconfig() @@ -2536,7 +2439,7 @@ func (b *LocalBackend) blockEngineUpdates(block bool) { func (b *LocalBackend) authReconfig() { b.mu.Lock() blocked := b.blocked - prefs := b.prefs + prefs := b.pm.CurrentPrefs() nm := b.netMap hasPAC := b.prevIfState.HasPAC() disableSubnetsIfPAC := nm != nil && nm.Debug != nil && nm.Debug.DisableSubnetsIfPAC.EqualBool(true) @@ -3139,9 +3042,15 @@ func (b *LocalBackend) applyPrefsToHostinfo(hi *tailcfg.Hostinfo, prefs ipn.Pref // happen". func (b *LocalBackend) enterState(newState ipn.State) { b.mu.Lock() + b.enterStateLockedOnEntry(newState) +} + +// enterStateLockedOnEntry is like enterState but requires b.mu be held to call +// it, but it unlocks b.mu when done. +func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State) { oldState := b.state b.state = newState - prefs := b.prefs + prefs := b.pm.CurrentPrefs() netMap := b.netMap activeLogin := b.activeLogin authURL := b.authURL @@ -3157,12 +3066,12 @@ func (b *LocalBackend) enterState(newState ipn.State) { // prefs may change irrespective of state; WantRunning should be explicitly // set before potential early return even if the state is unchanged. - health.SetIPNState(newState.String(), prefs.WantRunning()) + health.SetIPNState(newState.String(), prefs.Valid() && prefs.WantRunning()) if oldState == newState { return } b.logf("Switching ipn state %v -> %v (WantRunning=%v, nm=%v)", - oldState, newState, prefs.WantRunning, netMap != nil) + oldState, newState, prefs.WantRunning(), netMap != nil) b.send(ipn.Notify{State: &newState}) switch newState { @@ -3189,6 +3098,8 @@ func (b *LocalBackend) enterState(newState ipn.State) { addrs = append(addrs, addr.Addr().String()) } systemd.Status("Connected; %s; %s", activeLogin, strings.Join(addrs, " ")) + case ipn.NoState: + // Do nothing. default: b.logf("[unexpected] unknown newState %#v", newState) } @@ -3199,8 +3110,8 @@ func (b *LocalBackend) hasNodeKey() bool { // we can't use b.Prefs(), because it strips the keys, oops! b.mu.Lock() defer b.mu.Unlock() - - return b.prefs.Valid() && b.prefs.Persist() != nil && !b.prefs.Persist().PrivateNodeKey.IsZero() + p := b.pm.CurrentPrefs() + return p.Valid() && p.Persist() != nil && !p.Persist().PrivateNodeKey.IsZero() } // nextState returns the state the backend seems to be in, based on @@ -3209,15 +3120,20 @@ func (b *LocalBackend) nextState() ipn.State { b.mu.Lock() b.assertClientLocked() var ( - cc = b.cc - netMap = b.netMap - state = b.state - blocked = b.blocked - wantRunning = b.prefs.WantRunning() - loggedOut = b.prefs.LoggedOut() - st = b.engineStatus - keyExpired = b.keyExpired + cc = b.cc + netMap = b.netMap + state = b.state + blocked = b.blocked + st = b.engineStatus + keyExpired = b.keyExpired + + wantRunning = false + loggedOut = false ) + if p := b.pm.CurrentPrefs(); p.Valid() { + wantRunning = p.WantRunning() + loggedOut = p.LoggedOut() + } b.mu.Unlock() switch { @@ -3328,15 +3244,13 @@ func (b *LocalBackend) ResetForClientDisconnect() { go b.cc.Shutdown() b.cc = nil } - b.stateKey = "" - b.userID = "" b.setNetMapLocked(nil) - b.prefs = new(ipn.Prefs).View() + b.pm.Reset() b.keyExpired = false b.authURL = "" b.authURLSticky = "" b.activeLogin = "" - b.setAtomicValuesFromPrefs(b.prefs) + b.setAtomicValuesFromPrefs(ipn.PrefsView{}) b.setTCPPortsIntercepted(nil) } @@ -3434,10 +3348,19 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { b.dialer.SetNetMap(nm) var login string if nm != nil { - login = nm.UserProfiles[nm.User].LoginName + up := nm.UserProfiles[nm.User] + login = up.LoginName if login == "" { login = "" } + if cp := b.pm.CurrentProfile(); cp.ID != "" && cp.UserProfile.ID == 0 { + // Migration to profiles: we didn't use to persist + // the UserProfile, so if we don't have one, fill it + // in from the NetworkMap. + prefs := b.pm.CurrentPrefs().AsStruct() + prefs.Persist.UserProfile = up + b.pm.SetPrefs(prefs.View()) + } } b.netMap = nm if login != b.activeLogin { @@ -3459,7 +3382,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { } b.capFileSharing = fs - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs()) if nm == nil { b.nodeByAddr = nil return @@ -3498,17 +3421,16 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { // the ports that tailscaled should handle as a function of b.netMap and b.prefs. // // b.mu must be held. -func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked() { +func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) { handlePorts := make([]uint16, 0, 4) - prefs := b.prefs if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() { handlePorts = append(handlePorts, 22) } nm := b.netMap if nm != nil && nm.SelfNode != nil { - profileID := fmt.Sprintf("node-%s", nm.SelfNode.StableID) // TODO(maisem,bradfitz): something else? + profileID := b.pm.CurrentProfile().ID confKey := ipn.ServeConfigKey(profileID) if confj, err := b.store.ReadState(confKey); err == nil { if !b.lastServeConfJSON.Equal(mem.B(confj)) { @@ -3546,7 +3468,7 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error { if nm.SelfNode == nil { return errors.New("netMap SelfNode is nil") } - profileID := fmt.Sprintf("node-%s", nm.SelfNode.StableID) // TODO(maisem,bradfitz): something else? + profileID := b.pm.CurrentProfile().ID confKey := ipn.ServeConfigKey(profileID) var bs []byte @@ -3561,7 +3483,7 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error { return fmt.Errorf("writing ServeConfig to StateStore: %w", err) } - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs()) return nil } @@ -3579,10 +3501,11 @@ func (b *LocalBackend) ServeConfig() ipn.ServeConfigView { func (b *LocalBackend) operatorUserName() string { b.mu.Lock() defer b.mu.Unlock() - if !b.prefs.Valid() { + prefs := b.pm.CurrentPrefs() + if !prefs.Valid() { return "" } - return b.prefs.OperatorUser() + return prefs.OperatorUser() } // OperatorUserID returns the current pref's OperatorUser's ID (in @@ -3605,8 +3528,8 @@ func (b *LocalBackend) OperatorUserID() string { // in the test harness. func (b *LocalBackend) TestOnlyPublicKeys() (machineKey key.MachinePublic, nodeKey key.NodePublic) { b.mu.Lock() - prefs := b.prefs machinePrivKey := b.machinePrivKey + prefs := b.pm.CurrentPrefs() b.mu.Unlock() if !prefs.Valid() || machinePrivKey.IsZero() { @@ -3723,8 +3646,8 @@ func (b *LocalBackend) SetDNS(ctx context.Context, name, value string) error { b.mu.Lock() cc := b.ccAuto - if b.prefs.Valid() { - req.NodeKey = b.prefs.Persist().PublicNodeKey() + if prefs := b.pm.CurrentPrefs(); prefs.Valid() { + req.NodeKey = prefs.Persist().PrivateNodeKey.Public() } b.mu.Unlock() if cc == nil { @@ -3836,11 +3759,11 @@ func (b *LocalBackend) DERPMap() *tailcfg.DERPMap { func (b *LocalBackend) OfferingExitNode() bool { b.mu.Lock() defer b.mu.Unlock() - if !b.prefs.Valid() { + if !b.pm.CurrentPrefs().Valid() { return false } var def4, def6 bool - ar := b.prefs.AdvertiseRoutes() + ar := b.pm.CurrentPrefs().AdvertiseRoutes() for i := 0; i < ar.Len(); i++ { r := ar.At(i) if r.Bits() != 0 { @@ -4027,7 +3950,8 @@ func (b *LocalBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) func (b *LocalBackend) tailscaleSSHEnabled() bool { b.mu.Lock() defer b.mu.Unlock() - return b.prefs.Valid() && b.prefs.RunSSH() + p := b.pm.CurrentPrefs() + return p.Valid() && p.RunSSH() } func (b *LocalBackend) sshServerOrInit() (_ SSHServer, err error) { @@ -4121,7 +4045,7 @@ func (b *LocalBackend) SetDevStateStore(key, value string) error { b.mu.Lock() defer b.mu.Unlock() - b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs()) return nil } @@ -4132,3 +4056,75 @@ func (b *LocalBackend) SetDevStateStore(key, value string) error { func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool { return b.shouldInterceptTCPPortAtomic.Load()(port) } + +// SwitchProfile switches to the profile with the given id. +// It will restart the backend on success. +// If the profile is not known, it returns an errProfileNotFound. +func (b *LocalBackend) SwitchProfile(profile ipn.ProfileID) error { + if b.CurrentProfile().ID == profile { + return nil + } + b.mu.Lock() + if err := b.pm.SwitchProfile(profile); err != nil { + b.mu.Unlock() + return err + } + return b.resetForProfileChangeLockedOnEntry() +} + +// resetForProfileChangeLockedOnEntry resets the backend for a profile change. +func (b *LocalBackend) resetForProfileChangeLockedOnEntry() error { + b.setNetMapLocked(nil) // Reset netmap. + // Reset the NetworkMap in the engine + b.e.SetNetworkMap(new(netmap.NetworkMap)) + b.enterStateLockedOnEntry(ipn.NoState) // Reset state. + return b.Start(ipn.Options{}) +} + +// DeleteProfile deletes a profile with the given ID. +// If the profile is not known, it is a no-op. +func (b *LocalBackend) DeleteProfile(p ipn.ProfileID) error { + b.mu.Lock() + defer b.mu.Unlock() + needToRestart := b.pm.CurrentProfile().ID == p + if err := b.pm.DeleteProfile(p); err != nil { + if err == errProfileNotFound { + return nil + } + return err + } + if !needToRestart { + return nil + } + return b.resetForProfileChangeLockedOnEntry() +} + +// CurrentProfile returns the current LoginProfile. +// The value may be zero if the profile is not persisted. +func (b *LocalBackend) CurrentProfile() ipn.LoginProfile { + b.mu.Lock() + defer b.mu.Unlock() + return b.pm.CurrentProfile() +} + +// NewProfile creates and switches to the new profile. +func (b *LocalBackend) NewProfile() error { + b.mu.Lock() + b.pm.NewProfile() + return b.resetForProfileChangeLockedOnEntry() +} + +// ListProfiles returns a list of all LoginProfiles. +func (b *LocalBackend) ListProfiles() []ipn.LoginProfile { + b.mu.Lock() + defer b.mu.Unlock() + return b.pm.Profiles() +} + +// CurrentUser returns the current server mode user ID. It is only non-empty on +// Windows where we have a multi-user system. +func (b *LocalBackend) CurrentUser() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.pm.CurrentUser() +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 303efb70a..77c6aa549 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -489,7 +489,7 @@ func TestLazyMachineKeyGeneration(t *testing.T) { t.Fatalf("NewFakeUserspaceEngine: %v", err) } t.Cleanup(eng.Close) - lb, err := NewLocalBackend(logf, "logid", store, nil, eng, 0) + lb, err := NewLocalBackend(logf, "logid", store, "default", nil, eng, 0) if err != nil { t.Fatalf("NewLocalBackend: %v", err) } @@ -498,9 +498,7 @@ func TestLazyMachineKeyGeneration(t *testing.T) { Transport: panicOnUseTransport{}, // validate we don't send HTTP requests }) - if err := lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - }); err != nil { + if err := lb.Start(ipn.Options{}); err != nil { t.Fatalf("Start: %v", err) } diff --git a/ipn/ipnlocal/loglines_test.go b/ipn/ipnlocal/loglines_test.go index 4a3511dfb..1c6cb7c27 100644 --- a/ipn/ipnlocal/loglines_test.go +++ b/ipn/ipnlocal/loglines_test.go @@ -55,14 +55,12 @@ func TestLocalLogLines(t *testing.T) { } t.Cleanup(e.Close) - lb, err := NewLocalBackend(logf, idA.String(), store, nil, e, 0) + lb, err := NewLocalBackend(logf, idA.String(), store, "", nil, e, 0) if err != nil { t.Fatal(err) } defer lb.Shutdown() - // custom adjustments for required non-nil fields - lb.prefs = ipn.NewPrefs().View() lb.hostinfo = &tailcfg.Hostinfo{} // hacky manual override of the usual log-on-change behaviour of keylogf lb.keyLogf = logListen.Logf diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 2956310f1..29503f0fa 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -109,7 +109,7 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap) error { b.mu.Lock() // take mu to protect access to synchronized fields. defer b.mu.Unlock() - ourNodeKey := b.prefs.Persist().PublicNodeKey() + ourNodeKey := b.pm.CurrentPrefs().Persist().PublicNodeKey() isEnabled := b.tka != nil wantEnabled := nm.TKAEnabled @@ -362,8 +362,8 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt var ourNodeKey key.NodePublic b.mu.Lock() - if b.prefs.Valid() { - ourNodeKey = b.prefs.Persist().PublicNodeKey() + if p := b.pm.CurrentPrefs(); p.Valid() { + ourNodeKey = p.Persist().PublicNodeKey() } b.mu.Unlock() if ourNodeKey.IsZero() { @@ -465,7 +465,8 @@ func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic [] if err != nil { return key.NodePublic{}, tka.NodeKeySignature{}, fmt.Errorf("signature failed: %w", err) } - return b.prefs.Persist().PublicNodeKey(), sig, nil + + return b.pm.CurrentPrefs().Persist().PublicNodeKey(), sig, nil }(nodeKey, rotationPublic) if err != nil { return err @@ -518,7 +519,7 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err return nil } - ourNodeKey := b.prefs.Persist().PublicNodeKey() + ourNodeKey := b.pm.CurrentPrefs().Persist().PublicNodeKey() head := b.tka.authority.Head() b.mu.Unlock() resp, err := b.tkaDoSyncSend(ourNodeKey, head, aums, true) @@ -553,8 +554,8 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error { ) b.mu.Lock() - if b.prefs.Valid() { - ourNodeKey = b.prefs.Persist().PublicNodeKey() + if p := b.pm.CurrentPrefs(); p.Valid() { + ourNodeKey = p.Persist().PublicNodeKey() } if b.tka == nil { err = errNetworkLockNotActive diff --git a/ipn/ipnlocal/network-lock_test.go b/ipn/ipnlocal/network-lock_test.go index 8f027aa46..bbe1dab15 100644 --- a/ipn/ipnlocal/network-lock_test.go +++ b/ipn/ipnlocal/network-lock_test.go @@ -20,12 +20,14 @@ "tailscale.com/envknob" "tailscale.com/hostinfo" "tailscale.com/ipn" + "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/types/netmap" "tailscale.com/types/persist" "tailscale.com/types/tkatype" + "tailscale.com/util/must" ) func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto { @@ -117,14 +119,17 @@ func TestTKAEnablementFlow(t *testing.T) { temp := t.TempDir() cc := fakeControlClient(t, client) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View())) b := LocalBackend{ varRoot: temp, cc: cc, ccAuto: cc, logf: t.Logf, - prefs: (&ipn.Prefs{ - Persist: &persist.Persist{PrivateNodeKey: nodePriv}, - }).View(), + pm: pm, + store: pm.Store(), } err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ @@ -210,6 +215,10 @@ func TestTKADisablementFlow(t *testing.T) { defer ts.Close() cc := fakeControlClient(t, client) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View())) b := LocalBackend{ varRoot: temp, cc: cc, @@ -219,9 +228,8 @@ func TestTKADisablementFlow(t *testing.T) { authority: authority, storage: chonk, }, - prefs: (&ipn.Prefs{ - Persist: &persist.Persist{PrivateNodeKey: nodePriv}, - }).View(), + pm: pm, + store: pm.Store(), } // Test that the wrong disablement secret does not shut down the authority. @@ -456,18 +464,21 @@ type tkaSyncScenario struct { // Setup the client. cc := fakeControlClient(t, client) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View())) b := LocalBackend{ varRoot: temp, cc: cc, ccAuto: cc, logf: t.Logf, + pm: pm, + store: pm.Store(), tka: &tkaState{ authority: nodeAuthority, storage: nodeStorage, }, - prefs: (&ipn.Prefs{ - Persist: &persist.Persist{PrivateNodeKey: nodePriv}, - }).View(), } // Finally, lets trigger a sync. @@ -607,6 +618,11 @@ func TestTKADisable(t *testing.T) { defer ts.Close() cc := fakeControlClient(t, client) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View())) + b := LocalBackend{ varRoot: temp, cc: cc, @@ -616,9 +632,8 @@ func TestTKADisable(t *testing.T) { authority: authority, storage: chonk, }, - prefs: (&ipn.Prefs{ - Persist: &persist.Persist{PrivateNodeKey: nodePriv}, - }).View(), + pm: pm, + store: pm.Store(), } // Test that we get an error for an incorrect disablement secret. @@ -688,7 +703,10 @@ func TestTKASign(t *testing.T) { } })) defer ts.Close() - + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View())) cc := fakeControlClient(t, client) b := LocalBackend{ varRoot: temp, @@ -699,9 +717,8 @@ func TestTKASign(t *testing.T) { authority: authority, storage: chonk, }, - prefs: (&ipn.Prefs{ - Persist: &persist.Persist{PrivateNodeKey: nodePriv}, - }).View(), + pm: pm, + store: pm.Store(), nlPrivKey: nlPriv, } diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go index bb0ea34b7..886181f3a 100644 --- a/ipn/ipnlocal/peerapi_test.go +++ b/ipn/ipnlocal/peerapi_test.go @@ -21,9 +21,11 @@ "go4.org/netipx" "tailscale.com/ipn" + "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/logger" + "tailscale.com/util/must" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" ) @@ -585,20 +587,23 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) { h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) h.ps = &peerAPIServer{ b: &LocalBackend{ - e: eng, + e: eng, + pm: pm, + store: pm.Store(), }, } if h.ps.b.OfferingExitNode() { t.Fatal("unexpectedly offering exit node") } - h.ps.b.prefs = (&ipn.Prefs{ + h.ps.b.pm.SetPrefs((&ipn.Prefs{ AdvertiseRoutes: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, - }).View() + }).View()) if !h.ps.b.OfferingExitNode() { t.Fatal("unexpectedly not offering exit node") } diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go new file mode 100644 index 000000000..c26753d82 --- /dev/null +++ b/ipn/ipnlocal/profiles.go @@ -0,0 +1,439 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package ipnlocal + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "runtime" + "time" + + "golang.org/x/exp/slices" + "tailscale.com/ipn" + "tailscale.com/types/logger" + "tailscale.com/util/strs" + "tailscale.com/version" +) + +// profileManager is a wrapper around a StateStore that manages +// multiple profiles and the current profile. +type profileManager struct { + store ipn.StateStore + logf logger.Logf + + currentUserID string // only used on Windows + knownProfiles map[ipn.ProfileID]*ipn.LoginProfile + currentProfile *ipn.LoginProfile + prefs ipn.PrefsView + + // isNewProfile is a sentinel value that indicates that the + // current profile is new and has not been saved to disk yet. + // It is reset to false after a call to SetPrefs with a filled + // in LoginName. + isNewProfile bool +} + +// CurrentUser returns the current user ID. It is only non-empty on +// Windows where we have a multi-user system. +func (pm *profileManager) CurrentUser() string { + return pm.currentUserID +} + +// SetCurrentUser sets the current user ID. The uid is only non-empty +// on Windows where we have a multi-user system. +func (pm *profileManager) SetCurrentUser(uid string) error { + if pm.currentUserID == uid { + return nil + } + cpk := ipn.CurrentProfileKey(uid) + if b, err := pm.store.ReadState(cpk); err == nil { + pk := ipn.StateKey(string(b)) + prefs, err := pm.loadSavedPrefs(pk) + if err != nil { + return err + } + pm.currentProfile = pm.findProfileByKey(pk) + pm.prefs = prefs + pm.isNewProfile = false + } else if err == ipn.ErrStateNotExist { + pm.NewProfile() + } else { + return err + } + pm.currentUserID = uid + return nil +} + +func (pm *profileManager) findProfileByName(name string) *ipn.LoginProfile { + for _, p := range pm.knownProfiles { + if p.Name == name { + return p + } + } + return nil +} + +func (pm *profileManager) findProfileByKey(key ipn.StateKey) *ipn.LoginProfile { + for _, p := range pm.knownProfiles { + if p.Key == key { + return p + } + } + return nil +} + +func (pm *profileManager) setUnattendedModeAsConfigured() error { + if pm.currentUserID == "" { + return nil + } + + if pm.prefs.ForceDaemon() { + return pm.store.WriteState(ipn.ServerModeStartKey, []byte(pm.currentProfile.Key)) + } else { + return pm.store.WriteState(ipn.ServerModeStartKey, nil) + } +} + +// Reset unloads the current profile, if any. +func (pm *profileManager) Reset() { + pm.currentUserID = "" + pm.NewProfile() +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// SetPrefs sets the current profile's prefs to the provided value. +// It also saves the prefs to the StateStore. It stores a copy of the +// provided prefs, which may be accessed via CurrentPrefs. +func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView) error { + prefs := prefsIn.AsStruct().View() + ps := prefs.Persist() + if ps == nil || ps.LoginName == "" { + return pm.setPrefsLocked(prefs) + } + up := ps.UserProfile + if up.LoginName == "" { + up.LoginName = ps.LoginName + } + if up.DisplayName == "" { + up.DisplayName = up.LoginName + } + cp := pm.currentProfile + if pm.isNewProfile { + pm.isNewProfile = false + cp.ID, cp.Key = newUnusedID(pm.knownProfiles) + cp.Name = ps.LoginName + cp.UserProfile = ps.UserProfile + cp.LocalUserID = pm.currentUserID + } else { + cp.UserProfile = ps.UserProfile + } + pm.knownProfiles[cp.ID] = cp + if err := pm.writeKnownProfiles(); err != nil { + return err + } + if err := pm.setAsUserSelectedProfileLocked(); err != nil { + return err + } + if err := pm.setPrefsLocked(prefs); err != nil { + return err + } + return nil +} + +func newUnusedID(knownProfiles map[ipn.ProfileID]*ipn.LoginProfile) (ipn.ProfileID, ipn.StateKey) { + var idb [2]byte + for { + rand.Read(idb[:]) + id := ipn.ProfileID(fmt.Sprintf("%x", idb)) + if _, ok := knownProfiles[id]; ok { + continue + } + return id, ipn.StateKey("profile-" + id) + } +} + +// setPrefsLocked sets the current profile's prefs to the provided value. +// It also saves the prefs to the StateStore, if the current profile +// is not new. +func (pm *profileManager) setPrefsLocked(clonedPrefs ipn.PrefsView) error { + pm.prefs = clonedPrefs + if pm.isNewProfile { + return nil + } + if err := pm.writePrefsToStore(pm.currentProfile.Key, pm.prefs); err != nil { + return err + } + return pm.setUnattendedModeAsConfigured() +} + +func (pm *profileManager) writePrefsToStore(key ipn.StateKey, prefs ipn.PrefsView) error { + if key == "" { + return nil + } + if err := pm.store.WriteState(key, prefs.ToBytes()); err != nil { + pm.logf("WriteState(%q): %v", key, err) + return err + } + return nil +} + +// Profiles returns the list of known profiles. +func (pm *profileManager) Profiles() []ipn.LoginProfile { + var profiles []ipn.LoginProfile + for _, p := range pm.knownProfiles { + if p.LocalUserID == pm.currentUserID { + profiles = append(profiles, *p) + } + } + slices.SortFunc(profiles, func(a, b ipn.LoginProfile) bool { + return a.Name < b.Name + }) + return profiles +} + +// SwitchProfile switches to the profile with the given id. +// If the profile is not known, it returns an errProfileNotFound. +func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error { + kp, ok := pm.knownProfiles[id] + if !ok { + return errProfileNotFound + } + + if pm.currentProfile != nil && kp.ID == pm.currentProfile.ID && pm.prefs.Valid() { + return nil + } + if kp.LocalUserID != pm.currentUserID { + return fmt.Errorf("profile %q is not owned by current user", id) + } + prefs, err := pm.loadSavedPrefs(kp.Key) + if err != nil { + return err + } + pm.prefs = prefs + pm.currentProfile = kp + pm.isNewProfile = false + return pm.setAsUserSelectedProfileLocked() +} + +func (pm *profileManager) setAsUserSelectedProfileLocked() error { + k := ipn.CurrentProfileKey(pm.currentUserID) + return pm.store.WriteState(k, []byte(pm.currentProfile.Key)) +} + +func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error) { + bs, err := pm.store.ReadState(key) + if err != nil { + if err == ipn.ErrStateNotExist { + return emptyPrefs, nil + } + return ipn.PrefsView{}, err + } + savedPrefs, err := ipn.PrefsFromBytes(bs) + if err != nil { + return ipn.PrefsView{}, fmt.Errorf("PrefsFromBytes: %v", err) + } + pm.logf("using backend prefs for %q: %v", key, savedPrefs.Pretty()) + + // Ignore any old stored preferences for https://login.tailscale.com + // as the control server that would override the new default of + // controlplane.tailscale.com. + if savedPrefs.ControlURL != "" && + savedPrefs.ControlURL != ipn.DefaultControlURL && + ipn.IsLoginServerSynonym(savedPrefs.ControlURL) { + savedPrefs.ControlURL = "" + } + return savedPrefs.View(), nil +} + +// CurrentProfile returns the name and ID of the current profile, or "" if the profile +// is not named. +func (pm *profileManager) CurrentProfile() ipn.LoginProfile { + return *pm.currentProfile +} + +// errProfileNotFound is returned by methods that accept a ProfileID. +var errProfileNotFound = errors.New("profile not found") + +// DeleteProfile removes the profile with the given id. It returns +// errProfileNotFound if the profile does not exist. +// If the profile is the current profile, it is the equivalent of +// calling NewProfile() followed by DeleteProfile(id). This is +// useful for deleting the last profile. In other cases, it is +// recommended to call SwitchProfile() first. +func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error { + kp, ok := pm.knownProfiles[id] + if !ok { + return errProfileNotFound + } + if kp.ID == pm.currentProfile.ID { + pm.NewProfile() + } + if err := pm.store.WriteState(kp.Key, nil); err != nil { + return err + } + delete(pm.knownProfiles, id) + return pm.writeKnownProfiles() +} + +func (pm *profileManager) writeKnownProfiles() error { + b, err := json.Marshal(pm.knownProfiles) + if err != nil { + return err + } + return pm.store.WriteState(ipn.KnownProfilesStateKey, b) +} + +// NewProfile creates and switches to a new unnamed profile. The new profile is +// not persisted until SetPrefs is called with a logged-in user. +func (pm *profileManager) NewProfile() { + pm.prefs = emptyPrefs + pm.isNewProfile = true + pm.currentProfile = &ipn.LoginProfile{} +} + +// emptyPrefs is the default prefs for a new profile. +var emptyPrefs = func() ipn.PrefsView { + prefs := ipn.NewPrefs() + prefs.WantRunning = false + return prefs.View() +}() + +// Store returns the StateStore used by the ProfileManager. +func (pm *profileManager) Store() ipn.StateStore { + return pm.store +} + +// CurrentPrefs returns a read-only view of the current prefs. +func (pm *profileManager) CurrentPrefs() ipn.PrefsView { + return pm.prefs +} + +// ReadStartupPrefsForTest reads the startup prefs from disk. It is only used for testing. +func ReadStartupPrefsForTest(logf logger.Logf, store ipn.StateStore) (ipn.PrefsView, error) { + pm, err := newProfileManager(store, logf, "") + if err != nil { + return ipn.PrefsView{}, err + } + return pm.CurrentPrefs(), nil +} + +// newProfileManager creates a new ProfileManager using the provided StateStore. +// It also loads the list of known profiles from the StateStore. +// If a state key is provided, it will be used to load the current profile. +func newProfileManager(store ipn.StateStore, logf logger.Logf, stateKey ipn.StateKey) (*profileManager, error) { + return newProfileManagerWithGOOS(store, logf, stateKey, runtime.GOOS) +} + +func readAutoStartKey(store ipn.StateStore, goos string) (ipn.StateKey, error) { + startKey := ipn.CurrentProfileStateKey + if goos == "windows" { + // When tailscaled runs on Windows it is not typically run unattended. + // So we can't use the profile mechanism to load the profile at startup. + startKey = ipn.ServerModeStartKey + } + autoStartKey, err := store.ReadState(startKey) + if err != nil && err != ipn.ErrStateNotExist { + return "", fmt.Errorf("calling ReadState on state store: %w", err) + } + return ipn.StateKey(autoStartKey), nil +} + +func readKnownProfiles(store ipn.StateStore) (map[ipn.ProfileID]*ipn.LoginProfile, error) { + var knownProfiles map[ipn.ProfileID]*ipn.LoginProfile + prfB, err := store.ReadState(ipn.KnownProfilesStateKey) + switch err { + case nil: + if err := json.Unmarshal(prfB, &knownProfiles); err != nil { + return nil, fmt.Errorf("unmarshaling known profiles: %w", err) + } + case ipn.ErrStateNotExist: + knownProfiles = make(map[ipn.ProfileID]*ipn.LoginProfile) + default: + return nil, fmt.Errorf("calling ReadState on state store: %w", err) + } + return knownProfiles, nil +} + +func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, stateKey ipn.StateKey, goos string) (*profileManager, error) { + if stateKey == "" { + var err error + stateKey, err = readAutoStartKey(store, goos) + if err != nil { + return nil, err + } + } + + knownProfiles, err := readKnownProfiles(store) + if err != nil { + return nil, err + } + + pm := &profileManager{ + store: store, + knownProfiles: knownProfiles, + logf: logf, + } + + if stateKey != "" { + for _, v := range knownProfiles { + if v.Key == stateKey { + pm.currentProfile = v + } + } + if pm.currentProfile == nil { + if suf, ok := strs.CutPrefix(string(stateKey), "user-"); ok { + pm.currentUserID = suf + } + pm.NewProfile() + } else { + pm.currentUserID = pm.currentProfile.LocalUserID + } + prefs, err := pm.loadSavedPrefs(stateKey) + if err != nil { + return nil, err + } + if err := pm.setPrefsLocked(prefs); err != nil { + return nil, err + } + } else if len(knownProfiles) == 0 && goos != "windows" { + // No known profiles, try a migration. + if err := pm.migrateFromLegacyPrefs(); err != nil { + return nil, err + } + } else { + pm.NewProfile() + } + + return pm, nil +} + +func (pm *profileManager) migrateFromLegacyPrefs() error { + pm.NewProfile() + k := ipn.LegacyGlobalDaemonStateKey + switch { + case runtime.GOOS == "ios": + k = "ipn-go-bridge" + case version.IsSandboxedMacOS(): + k = "ipn-go-bridge" + case runtime.GOOS == "android": + k = "ipn-android" + } + prefs, err := pm.loadSavedPrefs(k) + if err != nil { + return fmt.Errorf("calling ReadState on state store: %w", err) + } + pm.logf("migrating %q profile to new format", k) + if err := pm.SetPrefs(prefs); err != nil { + return fmt.Errorf("migrating _daemon profile: %w", err) + } + // Do not delete the old state key, as we may be downgraded to an + // older version that still relies on it. + return nil +} diff --git a/ipn/ipnlocal/profiles_test.go b/ipn/ipnlocal/profiles_test.go new file mode 100644 index 000000000..38e631527 --- /dev/null +++ b/ipn/ipnlocal/profiles_test.go @@ -0,0 +1,221 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package ipnlocal + +import ( + "testing" + + "tailscale.com/ipn" + "tailscale.com/ipn/store/mem" + "tailscale.com/types/logger" + "tailscale.com/types/persist" +) + +// TestProfileManagement tests creating, loading, and switching profiles. +func TestProfileManagement(t *testing.T) { + store := new(mem.Store) + + pm, err := newProfileManagerWithGOOS(store, logger.Discard, "", "linux") + if err != nil { + t.Fatal(err) + } + wantCurProfile := "" + wantProfiles := map[string]ipn.PrefsView{ + "": emptyPrefs, + } + checkProfiles := func(t *testing.T) { + t.Helper() + prof := pm.CurrentProfile() + t.Logf("\tCurrentProfile = %q", prof) + if prof.Name != wantCurProfile { + t.Fatalf("CurrentProfile = %q; want %q", prof, wantCurProfile) + } + profiles := pm.Profiles() + wantLen := len(wantProfiles) + if _, ok := wantProfiles[""]; ok { + wantLen-- + } + if len(profiles) != wantLen { + t.Fatalf("Profiles = %v; want %v", profiles, wantProfiles) + } + p := pm.CurrentPrefs() + if !p.Valid() { + t.Fatalf("CurrentPrefs = %v; want valid", p) + } + if !p.Equals(wantProfiles[wantCurProfile]) { + t.Fatalf("CurrentPrefs = %v; want %v", p.Pretty(), wantProfiles[wantCurProfile].Pretty()) + } + for _, p := range profiles { + got, err := pm.loadSavedPrefs(p.Key) + if err != nil { + t.Fatal(err) + } + // Use Hostname as a proxy for all prefs. + if got.Hostname() != wantProfiles[p.Name].Hostname() { + t.Fatalf("Prefs for profile %q = %v; want %v", p, got.Pretty(), wantProfiles[p.Name].Pretty()) + } + } + } + setPrefs := func(t *testing.T, loginName string) ipn.PrefsView { + p := pm.CurrentPrefs().AsStruct() + p.Persist = &persist.Persist{ + LoginName: loginName, + } + if err := pm.SetPrefs(p.View()); err != nil { + t.Fatal(err) + } + return p.View() + } + t.Logf("Check initial state from empty store") + checkProfiles(t) + + { + t.Logf("Set prefs for default profile") + wantProfiles["user@1.example.com"] = setPrefs(t, "user@1.example.com") + wantCurProfile = "user@1.example.com" + delete(wantProfiles, "") + } + checkProfiles(t) + + t.Logf("Create new profile") + pm.NewProfile() + wantCurProfile = "" + wantProfiles[""] = emptyPrefs + checkProfiles(t) + + { + t.Logf("Set prefs for test profile") + wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com") + wantCurProfile = "user@2.example.com" + delete(wantProfiles, "") + } + checkProfiles(t) + + t.Logf("Recreate profile manager from store") + // Recreate the profile manager to ensure that it can load the profiles + // from the store at startup. + pm, err = newProfileManagerWithGOOS(store, logger.Discard, "", "linux") + if err != nil { + t.Fatal(err) + } + checkProfiles(t) + + t.Logf("Delete default profile") + if err := pm.DeleteProfile(pm.findProfileByName("user@1.example.com").ID); err != nil { + t.Fatal(err) + } + delete(wantProfiles, "user@1.example.com") + checkProfiles(t) + + t.Logf("Recreate profile manager from store after deleting default profile") + // Recreate the profile manager to ensure that it can load the profiles + // from the store at startup. + pm, err = newProfileManagerWithGOOS(store, logger.Discard, "", "linux") + if err != nil { + t.Fatal(err) + } + checkProfiles(t) +} + +// TestProfileManagementWindows tests going into and out of Unattended mode on +// Windows. +func TestProfileManagementWindows(t *testing.T) { + store := new(mem.Store) + + pm, err := newProfileManagerWithGOOS(store, logger.Discard, "", "windows") + if err != nil { + t.Fatal(err) + } + wantCurProfile := "" + wantProfiles := map[string]ipn.PrefsView{ + "": emptyPrefs, + } + checkProfiles := func(t *testing.T) { + t.Helper() + prof := pm.CurrentProfile() + t.Logf("\tCurrentProfile = %q", prof) + if prof.Name != wantCurProfile { + t.Fatalf("CurrentProfile = %q; want %q", prof, wantCurProfile) + } + if p := pm.CurrentPrefs(); !p.Equals(wantProfiles[wantCurProfile]) { + t.Fatalf("CurrentPrefs = %+v; want %+v", p.Pretty(), wantProfiles[wantCurProfile].Pretty()) + } + } + setPrefs := func(t *testing.T, loginName string, forceDaemon bool) ipn.PrefsView { + p := pm.CurrentPrefs().AsStruct() + p.ForceDaemon = forceDaemon + p.Persist = &persist.Persist{ + LoginName: loginName, + } + if err := pm.SetPrefs(p.View()); err != nil { + t.Fatal(err) + } + return p.View() + } + t.Logf("Check initial state from empty store") + checkProfiles(t) + + { + t.Logf("Set user1 as logged in user") + if err := pm.SetCurrentUser("user1"); err != nil { + t.Fatal(err) + } + checkProfiles(t) + t.Logf("Save prefs for user1") + wantProfiles["default"] = setPrefs(t, "default", false) + wantCurProfile = "default" + } + checkProfiles(t) + + { + t.Logf("Create new profile") + pm.NewProfile() + wantCurProfile = "" + wantProfiles[""] = emptyPrefs + checkProfiles(t) + + t.Logf("Save as test profile") + wantProfiles["test"] = setPrefs(t, "test", false) + wantCurProfile = "test" + checkProfiles(t) + } + + t.Logf("Recreate profile manager from store, should reset prefs") + // Recreate the profile manager to ensure that it can load the profiles + // from the store at startup. + pm, err = newProfileManagerWithGOOS(store, logger.Discard, "", "windows") + if err != nil { + t.Fatal(err) + } + wantCurProfile = "" + wantProfiles[""] = emptyPrefs + checkProfiles(t) + + { + t.Logf("Set user1 as current user") + if err := pm.SetCurrentUser("user1"); err != nil { + t.Fatal(err) + } + wantCurProfile = "test" + } + checkProfiles(t) + { + t.Logf("set unattended mode") + wantProfiles["test"] = setPrefs(t, "test", true) + } + if pm.CurrentUser() != "user1" { + t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUser(), "user1") + } + + // Recreate the profile manager to ensure that it starts with test profile. + pm, err = newProfileManagerWithGOOS(store, logger.Discard, "", "windows") + if err != nil { + t.Fatal(err) + } + checkProfiles(t) + if pm.CurrentUser() != "user1" { + t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUser(), "user1") + } +} diff --git a/ipn/ipnlocal/ssh_test.go b/ipn/ipnlocal/ssh_test.go index ef8518e20..b82f9b948 100644 --- a/ipn/ipnlocal/ssh_test.go +++ b/ipn/ipnlocal/ssh_test.go @@ -11,6 +11,7 @@ "reflect" "testing" + "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/util/must" ) @@ -49,7 +50,8 @@ type fakeSSHServer struct { } func TestGetSSHUsernames(t *testing.T) { - b := new(LocalBackend) + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + b := &LocalBackend{pm: pm, store: pm.Store()} b.sshServer = fakeSSHServer{} res, err := b.getSSHUsernames(new(tailcfg.C2NSSHUsernamesRequest)) if err != nil { diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index d53d7d59d..0921d7cdd 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -97,7 +97,7 @@ type mockControl struct { mu sync.Mutex calls []string authBlocked bool - persist persist.Persist + persist *persist.Persist machineKey key.MachinePrivate } @@ -125,7 +125,7 @@ func (cc *mockControl) populateKeys() (newKeys bool) { newKeys = true } - if cc.persist.PrivateNodeKey.IsZero() { + if cc.persist != nil && cc.persist.PrivateNodeKey.IsZero() { cc.logf("Generating a new nodekey.") cc.persist.OldPrivateNodeKey = cc.persist.PrivateNodeKey cc.persist.PrivateNodeKey = key.NewNode() @@ -142,7 +142,7 @@ func (cc *mockControl) send(err error, url string, loginFinished bool, nm *netma s := controlclient.Status{ URL: url, NetMap: nm, - Persist: &cc.persist, + Persist: cc.persist, Err: err, } if loginFinished { @@ -290,7 +290,7 @@ func TestStateMachine(t *testing.T) { } t.Cleanup(e.Close) - b, err := NewLocalBackend(logf, "logid", store, nil, e, 0) + b, err := NewLocalBackend(logf, "logid", store, "", nil, e, 0) if err != nil { t.Fatalf("NewLocalBackend: %v", err) } @@ -303,7 +303,7 @@ func TestStateMachine(t *testing.T) { cc.opts = opts cc.logfActual = opts.Logf cc.authBlocked = true - cc.persist = cc.opts.Persist + cc.persist = &cc.opts.Persist cc.mu.Unlock() cc.logf("ccGen: new mockControl.") @@ -335,7 +335,7 @@ func TestStateMachine(t *testing.T) { // but not ask it to do anything yet. t.Logf("\n\nStart") notifies.expect(2) - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { // BUG: strictly, it should pause, not unpause, here, since !WantRunning. cc.assertCalls("New", "unpause") @@ -360,7 +360,7 @@ func TestStateMachine(t *testing.T) { // events as the first time, so UIs always know what to expect. t.Logf("\n\nStart2") notifies.expect(2) - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { // BUG: strictly, it should pause, not unpause, here, since !WantRunning. cc.assertCalls("Shutdown", "unpause", "New", "unpause") @@ -552,7 +552,7 @@ func TestStateMachine(t *testing.T) { t.Logf("\n\nFastpath Start()") notifies.expect(1) b.state = ipn.Running - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { nn := notifies.drain(1) cc.assertCalls() @@ -662,7 +662,7 @@ func TestStateMachine(t *testing.T) { // The frontend restarts! t.Logf("\n\nStart3") notifies.expect(2) - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { // BUG: We already called Shutdown(), no need to do it again. // BUG: don't unpause because we're not logged in. @@ -722,7 +722,7 @@ func TestStateMachine(t *testing.T) { // One more restart, this time with a valid key, but WantRunning=false. t.Logf("\n\nStart4") notifies.expect(2) - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { // NOTE: cc.Shutdown() is correct here, since we didn't call // b.Shutdown() explicitly ourselves. @@ -844,7 +844,7 @@ func TestStateMachine(t *testing.T) { // logged in and WantRunning. t.Logf("\n\nStart5") notifies.expect(1) - c.Assert(b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}), qt.IsNil) + c.Assert(b.Start(ipn.Options{}), qt.IsNil) { // NOTE: cc.Shutdown() is correct here, since we didn't call // b.Shutdown() ourselves. @@ -920,27 +920,26 @@ func TestStateMachine(t *testing.T) { func TestEditPrefsHasNoKeys(t *testing.T) { logf := tstest.WhileTestRunningLogger(t) - store := new(testStateStorage) e, err := wgengine.NewFakeUserspaceEngine(logf, 0) if err != nil { t.Fatalf("NewFakeUserspaceEngine: %v", err) } t.Cleanup(e.Close) - b, err := NewLocalBackend(logf, "logid", store, nil, e, 0) + b, err := NewLocalBackend(logf, "logid", new(mem.Store), "", nil, e, 0) if err != nil { t.Fatalf("NewLocalBackend: %v", err) } b.hostinfo = &tailcfg.Hostinfo{OS: "testos"} - b.prefs = (&ipn.Prefs{ + b.pm.SetPrefs((&ipn.Prefs{ Persist: &persist.Persist{ PrivateNodeKey: key.NewNode(), OldPrivateNodeKey: key.NewNode(), LegacyFrontendPrivateMachineKey: key.NewMachine(), }, - }).View() - if b.prefs.Persist().PrivateNodeKey.IsZero() { + }).View()) + if b.pm.CurrentPrefs().Persist().PrivateNodeKey.IsZero() { t.Fatalf("PrivateNodeKey not set") } p, err := b.EditPrefs(&ipn.MaskedPrefs{ @@ -1005,7 +1004,7 @@ func TestWGEngineStatusRace(t *testing.T) { eng, err := wgengine.NewFakeUserspaceEngine(logf, 0) c.Assert(err, qt.IsNil) t.Cleanup(eng.Close) - b, err := NewLocalBackend(logf, "logid", new(mem.Store), nil, eng, 0) + b, err := NewLocalBackend(logf, "logid", new(mem.Store), "", nil, eng, 0) c.Assert(err, qt.IsNil) cc := newMockControl(t) @@ -1030,7 +1029,7 @@ func TestWGEngineStatusRace(t *testing.T) { wantState(ipn.NoState) // Start the backend. - err = b.Start(ipn.Options{StateKey: ipn.GlobalDaemonStateKey}) + err = b.Start(ipn.Options{}) c.Assert(err, qt.IsNil) wantState(ipn.NeedsLogin) diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index c6bdd3bb3..9c73d7bf2 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -101,8 +101,7 @@ type Server struct { // being run in "client mode" that requires an active GUI // connection (such as on Windows by default). Even if this // is true, the ForceDaemon pref can override this. - resetOnZero bool - autostartStateKey ipn.StateKey + resetOnZero bool bsMu sync.Mutex // lock order: bsMu, then mu bs *ipn.BackendServer @@ -685,26 +684,6 @@ func Run(ctx context.Context, logf logger.Logf, ln net.Listener, store ipn.State }() logf("Listening on %v", ln.Addr()) - var serverModeUser *user.User - if opts.AutostartStateKey == "" { - autoStartKey, err := store.ReadState(ipn.ServerModeStartKey) - if err != nil && err != ipn.ErrStateNotExist { - return fmt.Errorf("calling ReadState on state store: %w", err) - } - key := string(autoStartKey) - if strings.HasPrefix(key, "user-") { - uid := strings.TrimPrefix(key, "user-") - u, err := lookupUserFromID(logf, uid) - if err != nil { - logf("ipnserver: found server mode auto-start key %q; failed to load user: %v", key, err) - } else { - logf("ipnserver: found server mode auto-start key %q (user %s)", key, u.Username) - serverModeUser = u - } - opts.AutostartStateKey = ipn.StateKey(key) - } - } - bo := backoff.NewBackoff("ipnserver", logf, 30*time.Second) var unservedConn net.Conn // if non-nil, accepted, but hasn't served yet @@ -745,7 +724,7 @@ func Run(ctx context.Context, logf logger.Logf, ln net.Listener, store ipn.State } } - server, err := New(logf, logid, store, eng, dialer, serverModeUser, opts) + server, err := New(logf, logid, store, eng, dialer, opts) if err != nil { return err } @@ -761,8 +740,8 @@ func Run(ctx context.Context, logf logger.Logf, ln net.Listener, store ipn.State // New returns a new Server. // // To start it, use the Server.Run method. -func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engine, dialer *tsdial.Dialer, serverModeUser *user.User, opts Options) (*Server, error) { - b, err := ipnlocal.NewLocalBackend(logf, logid, store, dialer, eng, opts.LoginFlags) +func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engine, dialer *tsdial.Dialer, opts Options) (*Server, error) { + b, err := ipnlocal.NewLocalBackend(logf, logid, store, opts.AutostartStateKey, dialer, eng, opts.LoginFlags) if err != nil { return nil, fmt.Errorf("NewLocalBackend: %v", err) } @@ -808,32 +787,23 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi } - if opts.AutostartStateKey == "" { - autoStartKey, err := store.ReadState(ipn.ServerModeStartKey) - if err != nil && err != ipn.ErrStateNotExist { - return nil, fmt.Errorf("calling ReadState on store: %w", err) - } - key := string(autoStartKey) - if strings.HasPrefix(key, "user-") { - uid := strings.TrimPrefix(key, "user-") - u, err := lookupUserFromID(logf, uid) - if err != nil { - logf("ipnserver: found server mode auto-start key %q; failed to load user: %v", key, err) - } else { - logf("ipnserver: found server mode auto-start key %q (user %s)", key, u.Username) - serverModeUser = u - } - opts.AutostartStateKey = ipn.StateKey(key) + var serverModeUser *user.User + if uid := b.CurrentUser(); uid != "" { + u, err := lookupUserFromID(logf, uid) + if err != nil { + logf("ipnserver: found server mode auto-start key; failed to load user: %v", err) + } else { + logf("ipnserver: found server mode auto-start key (user %s)", u.Username) + serverModeUser = u } } server := &Server{ - b: b, - backendLogID: logid, - logf: logf, - resetOnZero: !opts.SurviveDisconnects, - serverModeUser: serverModeUser, - autostartStateKey: opts.AutostartStateKey, + b: b, + backendLogID: logid, + logf: logf, + resetOnZero: !opts.SurviveDisconnects, + serverModeUser: serverModeUser, } server.bs = ipn.NewBackendServer(logf, b, server.writeToClients) return server, nil @@ -859,11 +829,11 @@ func (s *Server) Run(ctx context.Context, ln net.Listener) error { ln.Close() }() - if s.autostartStateKey != "" { + if s.b.Prefs().Valid() { s.bs.GotCommand(ctx, &ipn.Command{ Version: version.Long, Start: &ipn.StartArgs{ - Opts: ipn.Options{StateKey: s.autostartStateKey}, + Opts: ipn.Options{}, }, }) } diff --git a/ipn/prefs.go b/ipn/prefs.go index 05f8bed27..b40e24262 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -681,3 +681,21 @@ func SavePrefs(filename string, p *Prefs) { log.Printf("SavePrefs: %v\n", err) } } + +// ProfileID is an auto-generated system-wide unique identifier for a login +// profile. It is a 4 character hex string like "1ab3". +type ProfileID string + +// LoginProfile represents a single login profile as managed +// by the ProfileManager. +type LoginProfile struct { + ID ProfileID + Name string + Key StateKey + + UserProfile tailcfg.UserProfile + + // LocalUserID is the user ID of the user who created this profile. + // It is only relevant on Windows where we have a multi-user system. + LocalUserID string +} diff --git a/ipn/store.go b/ipn/store.go index 516971742..caf863a81 100644 --- a/ipn/store.go +++ b/ipn/store.go @@ -19,14 +19,21 @@ // in its key.NodePrivate.MarshalText representation. MachineKeyStateKey = StateKey("_machinekey") - // GlobalDaemonStateKey is the ipn.StateKey that tailscaled + // LegacyGlobalDaemonStateKey is the ipn.StateKey that tailscaled // loads on startup. // // We have to support multiple state keys for other OSes (Windows in // particular), but right now Unix daemons run with a single // node-global state. To keep open the option of having per-user state // later, the global state key doesn't look like a username. - GlobalDaemonStateKey = StateKey("_daemon") + // + // As of 2022-10-21, it has been superseded by profiles and is no longer + // written to disk. It is only read at startup when there are no profiles, + // to migrate the state to the "default" profile. + // The existing state is left on disk in case the user downgrades to an + // older version of Tailscale that doesn't support profiles. We can + // remove this in a future release. + LegacyGlobalDaemonStateKey = StateKey("_daemon") // ServerModeStartKey's value, if non-empty, is the value of a // StateKey containing the prefs to start with which to start the @@ -40,8 +47,27 @@ // NLKeyStateKey is the key under which we store the node's // network-lock node key, in its key.NLPrivate.MarshalText representation. NLKeyStateKey = StateKey("_nl-node-key") + + // KnownProfilesStateKey is the key under which we store the list of + // known profiles. The value is a JSON-encoded []LoginProfile. + KnownProfilesStateKey = StateKey("_profiles") + + // CurrentProfileStateKey is the key under which we store the current + // profile. + CurrentProfileStateKey = StateKey("_current-profile") ) +// CurrentProfileID returns the StateKey that stores the +// current profile ID. The value is a JSON-encoded LoginProfile. +// If the userID is empty, the key returned is CurrentProfileStateKey, +// otherwise it is "_current/"+userID. +func CurrentProfileKey(userID string) StateKey { + if userID == "" { + return CurrentProfileStateKey + } + return StateKey("_current/" + userID) +} + // StateStore persists state, and produces it back on request. type StateStore interface { // ReadState returns the bytes associated with ID. Returns (nil, @@ -67,7 +93,7 @@ func PutStoreInt(store StateStore, id StateKey, val int64) error { // ServeConfigKey returns a StateKey that stores the // JSON-encoded ServeConfig for a config profile. -func ServeConfigKey(profileID string) StateKey { +func ServeConfigKey(profileID ProfileID) StateKey { return StateKey("_serve/" + profileID) } diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 16b487383..91bc22016 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -507,7 +507,7 @@ func TestSSH(t *testing.T) { t.Fatal(err) } lb, err := ipnlocal.NewLocalBackend(logf, "", - new(mem.Store), + new(mem.Store), "", new(tsdial.Dialer), eng, 0) if err != nil { diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 96e8f9a75..68e67a76f 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -324,7 +324,7 @@ func (s *Server) start() (reterr error) { if s.Ephemeral { loginFlags = controlclient.LoginEphemeral } - lb, err := ipnlocal.NewLocalBackend(logf, logid, s.Store, s.dialer, eng, loginFlags) + lb, err := ipnlocal.NewLocalBackend(logf, logid, s.Store, "", s.dialer, eng, loginFlags) if err != nil { return fmt.Errorf("NewLocalBackend: %v", err) } @@ -340,7 +340,6 @@ func (s *Server) start() (reterr error) { prefs.WantRunning = true authKey := s.getAuthKey() err = lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, UpdatePrefs: prefs, AuthKey: authKey, }) diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go index f43fa26b5..a7c6dde33 100644 --- a/tstest/integration/integration_test.go +++ b/tstest/integration/integration_test.go @@ -31,6 +31,7 @@ "go4.org/mem" "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnstate" "tailscale.com/ipn/store" "tailscale.com/safesocket" @@ -659,15 +660,11 @@ func (n *testNode) diskPrefs() *ipn.Prefs { if err != nil { t.Fatalf("reading prefs, NewFileStore: %v", err) } - prefBytes, err := fs.ReadState(ipn.GlobalDaemonStateKey) + p, err := ipnlocal.ReadStartupPrefsForTest(t.Logf, fs) if err != nil { - t.Fatalf("reading prefs, ReadState: %v", err) + t.Fatalf("reading prefs, ReadDiskPrefsForTest: %v", err) } - p := new(ipn.Prefs) - if err := json.Unmarshal(prefBytes, p); err != nil { - t.Fatalf("reading prefs, JSON unmarshal: %v", err) - } - return p + return p.AsStruct() } // AwaitResponding waits for n's tailscaled to be up enough to be diff --git a/types/persist/persist.go b/types/persist/persist.go index 031ef3427..ea73b0d81 100644 --- a/types/persist/persist.go +++ b/types/persist/persist.go @@ -8,6 +8,7 @@ import ( "fmt" + "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/structs" ) @@ -34,6 +35,7 @@ type Persist struct { OldPrivateNodeKey key.NodePrivate // needed to request key rotation Provider string LoginName string + UserProfile tailcfg.UserProfile } // PublicNodeKey returns the public key for the node key. @@ -53,7 +55,8 @@ func (p *Persist) Equals(p2 *Persist) bool { p.PrivateNodeKey.Equal(p2.PrivateNodeKey) && p.OldPrivateNodeKey.Equal(p2.OldPrivateNodeKey) && p.Provider == p2.Provider && - p.LoginName == p2.LoginName + p.LoginName == p2.LoginName && + p.UserProfile == p2.UserProfile } func (p *Persist) Pretty() string { diff --git a/types/persist/persist_clone.go b/types/persist/persist_clone.go index 6b2e27638..a6292bd04 100644 --- a/types/persist/persist_clone.go +++ b/types/persist/persist_clone.go @@ -7,6 +7,7 @@ package persist import ( + "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/structs" ) @@ -30,4 +31,5 @@ func (src *Persist) Clone() *Persist { OldPrivateNodeKey key.NodePrivate Provider string LoginName string + UserProfile tailcfg.UserProfile }{}) diff --git a/types/persist/persist_test.go b/types/persist/persist_test.go index c78307c58..e23f82189 100644 --- a/types/persist/persist_test.go +++ b/types/persist/persist_test.go @@ -8,6 +8,7 @@ "reflect" "testing" + "tailscale.com/tailcfg" "tailscale.com/types/key" ) @@ -21,7 +22,7 @@ func fieldsOf(t reflect.Type) (fields []string) { } func TestPersistEqual(t *testing.T) { - persistHandles := []string{"LegacyFrontendPrivateMachineKey", "PrivateNodeKey", "OldPrivateNodeKey", "Provider", "LoginName"} + persistHandles := []string{"LegacyFrontendPrivateMachineKey", "PrivateNodeKey", "OldPrivateNodeKey", "Provider", "LoginName", "UserProfile"} if have := fieldsOf(reflect.TypeOf(Persist{})); !reflect.DeepEqual(have, persistHandles) { t.Errorf("Persist.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, persistHandles) @@ -92,6 +93,25 @@ func TestPersistEqual(t *testing.T) { &Persist{LoginName: "foo@tailscale.com"}, true, }, + { + &Persist{UserProfile: tailcfg.UserProfile{ + ID: tailcfg.UserID(3), + }}, + &Persist{UserProfile: tailcfg.UserProfile{ + ID: tailcfg.UserID(3), + }}, + true, + }, + { + &Persist{UserProfile: tailcfg.UserProfile{ + ID: tailcfg.UserID(3), + }}, + &Persist{UserProfile: tailcfg.UserProfile{ + ID: tailcfg.UserID(3), + DisplayName: "foo", + }}, + false, + }, } for i, test := range tests { if got := test.a.Equals(test.b); got != test.want { diff --git a/types/persist/persist_view.go b/types/persist/persist_view.go index 6e0772706..f4c28187b 100644 --- a/types/persist/persist_view.go +++ b/types/persist/persist_view.go @@ -10,6 +10,7 @@ "encoding/json" "errors" + "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/structs" ) @@ -68,6 +69,7 @@ func (v PersistView) PrivateNodeKey() key.NodePrivate { return v.ж.PrivateNo func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey } func (v PersistView) Provider() string { return v.ж.Provider } func (v PersistView) LoginName() string { return v.ж.LoginName } +func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _PersistViewNeedsRegeneration = Persist(struct { @@ -77,4 +79,5 @@ func (v PersistView) LoginName() string { return v.ж.LoginName OldPrivateNodeKey key.NodePrivate Provider string LoginName string + UserProfile tailcfg.UserProfile }{}) diff --git a/wgengine/netstack/netstack_test.go b/wgengine/netstack/netstack_test.go index 6135ad858..cc6403eea 100644 --- a/wgengine/netstack/netstack_test.go +++ b/wgengine/netstack/netstack_test.go @@ -293,8 +293,7 @@ func TestShouldProcessInbound(t *testing.T) { netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:100/120"), } i.lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - UpdatePrefs: prefs, + LegacyMigrationPrefs: prefs, }) i.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress) @@ -326,8 +325,7 @@ func TestShouldProcessInbound(t *testing.T) { netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:200/120"), } i.lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - UpdatePrefs: prefs, + LegacyMigrationPrefs: prefs, }) }, want: false, @@ -345,8 +343,7 @@ func TestShouldProcessInbound(t *testing.T) { prefs := ipn.NewPrefs() prefs.RunSSH = true i.lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - UpdatePrefs: prefs, + LegacyMigrationPrefs: prefs, }) i.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { return addr.String() == "100.101.102.104" // Dst, above @@ -367,8 +364,7 @@ func TestShouldProcessInbound(t *testing.T) { prefs := ipn.NewPrefs() prefs.RunSSH = false // default, but to be explicit i.lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - UpdatePrefs: prefs, + LegacyMigrationPrefs: prefs, }) i.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { return addr.String() == "100.101.102.104" // Dst, above @@ -427,8 +423,7 @@ func TestShouldProcessInbound(t *testing.T) { netip.MustParsePrefix("10.0.0.1/24"), } i.lb.Start(ipn.Options{ - StateKey: ipn.GlobalDaemonStateKey, - UpdatePrefs: prefs, + LegacyMigrationPrefs: prefs, }) // As if we were running on Linux where netstack isn't used. @@ -458,7 +453,7 @@ func TestShouldProcessInbound(t *testing.T) { } t.Cleanup(e.Close) - lb, err := ipnlocal.NewLocalBackend(logf, "logid", new(mem.Store), new(tsdial.Dialer), e, 0) + lb, err := ipnlocal.NewLocalBackend(logf, "logid", new(mem.Store), "", new(tsdial.Dialer), e, 0) if err != nil { t.Fatalf("NewLocalBackend: %v", err) }