ipn/ipnlocal,cmd/tailscale: persist tailnet name in user profile

This PR starts to persist the NetMap tailnet name in SetPrefs so that tailscaled
clients can use this value to disambiguate fast user switching from one tailnet
to another that are under the same exact login. We will also try to backfill
this information during backend starts and profile switches so that users don't
have to re-authenticate their profile. The first client to use this new
information is the CLI in 'tailscale switch -list' which now uses text/tabwriter
to display the ID, Tailnet, and Account. Since account names are ambiguous, we
allow the user to pass 'tailscale switch ID' to specify the exact tailnet they
want to switch to.

Updates #9286

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
pull/10305/head
Marwan Sulaiman 12 months ago committed by Marwan Sulaiman
parent e75be017e4
commit 2dc0645368

@ -8,6 +8,8 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"strings"
"text/tabwriter"
"time" "time"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
@ -25,10 +27,14 @@ var switchCmd = &ffcli.Command{
Exec: switchProfile, Exec: switchProfile,
UsageFunc: func(*ffcli.Command) string { UsageFunc: func(*ffcli.Command) string {
return `USAGE return `USAGE
switch <name> switch <id>
switch --list switch --list
"tailscale switch" switches between logged in accounts. "tailscale switch" switches between logged in accounts. You can
use the ID that's returned from 'tailnet switch -list'
to pick which profile you want to switch to. Alternatively, you
can use the Tailnet or the account names to switch as well.
This command is currently in alpha and may change in the future.` This command is currently in alpha and may change in the future.`
}, },
} }
@ -42,12 +48,22 @@ func listProfiles(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
tw := tabwriter.NewWriter(os.Stdout, 2, 2, 2, ' ', 0)
defer tw.Flush()
printRow := func(vals ...string) {
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
printRow("ID", "Tailnet", "Account")
for _, prof := range all { for _, prof := range all {
name := prof.Name
if prof.ID == curP.ID { if prof.ID == curP.ID {
fmt.Printf("%s *\n", prof.Name) name += "*"
} else {
fmt.Println(prof.Name)
} }
printRow(
string(prof.ID),
prof.NetworkProfile.DomainName,
name,
)
} }
return nil return nil
} }
@ -66,12 +82,30 @@ func switchProfile(ctx context.Context, args []string) error {
os.Exit(1) os.Exit(1)
} }
var profID ipn.ProfileID var profID ipn.ProfileID
// Allow matching by ID, Tailnet, or Account
// in that order.
for _, p := range all { for _, p := range all {
if p.Name == args[0] { if p.ID == ipn.ProfileID(args[0]) {
profID = p.ID profID = p.ID
break break
} }
} }
if profID == "" {
for _, p := range all {
if p.NetworkProfile.DomainName == args[0] {
profID = p.ID
break
}
}
}
if profID == "" {
for _, p := range all {
if p.Name == args[0] {
profID = p.ID
break
}
}
}
if profID == "" { if profID == "" {
errf("No profile named %q\n", args[0]) errf("No profile named %q\n", args[0])
os.Exit(1) os.Exit(1)

@ -341,7 +341,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
return nil, err return nil, err
} }
p.ApplyEdits(&mp) p.ApplyEdits(&mp)
if err := pm.SetPrefs(p.View(), ""); err != nil { if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
return nil, err return nil, err
} }
} }
@ -1105,10 +1105,19 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
prefsChanged = true prefsChanged = true
} }
// Until recently, we did not store the account's tailnet name. So check if this is the case,
// and backfill it on incoming status update.
if b.pm.requiresBackfill() && st.NetMap != nil && st.NetMap.Domain != "" {
prefsChanged = true
}
// Perform all mutations of prefs based on the netmap here. // Perform all mutations of prefs based on the netmap here.
if prefsChanged { if prefsChanged {
// Prefs will be written out if stale; this is not safe unless locked or cloned. // Prefs will be written out if stale; this is not safe unless locked or cloned.
if err := b.pm.SetPrefs(prefs.View(), st.NetMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(prefs.View(), ipn.NetworkProfile{
MagicDNSName: st.NetMap.MagicDNSSuffix(),
DomainName: st.NetMap.DomainName(),
}); err != nil {
b.logf("Failed to save new controlclient state: %v", err) b.logf("Failed to save new controlclient state: %v", err)
} }
} }
@ -1164,7 +1173,10 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.mu.Lock() b.mu.Lock()
prefs.WantRunning = false prefs.WantRunning = false
p := prefs.View() p := prefs.View()
if err := b.pm.SetPrefs(p, st.NetMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(p, ipn.NetworkProfile{
MagicDNSName: st.NetMap.MagicDNSSuffix(),
DomainName: st.NetMap.DomainName(),
}); err != nil {
b.logf("Failed to save new controlclient state: %v", err) b.logf("Failed to save new controlclient state: %v", err)
} }
b.mu.Unlock() b.mu.Unlock()
@ -1573,7 +1585,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
newPrefs := opts.UpdatePrefs.Clone() newPrefs := opts.UpdatePrefs.Clone()
newPrefs.Persist = oldPrefs.Persist().AsStruct() newPrefs.Persist = oldPrefs.Persist().AsStruct()
pv := newPrefs.View() pv := newPrefs.View()
if err := b.pm.SetPrefs(pv, b.netMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(pv, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
b.logf("failed to save UpdatePrefs state: %v", err) b.logf("failed to save UpdatePrefs state: %v", err)
} }
b.setAtomicValuesFromPrefsLocked(pv) b.setAtomicValuesFromPrefsLocked(pv)
@ -2479,7 +2494,10 @@ func (b *LocalBackend) migrateStateLocked(prefs *ipn.Prefs) (err error) {
// Backend owns the state, but frontend is trying to migrate // Backend owns the state, but frontend is trying to migrate
// state into the backend. // state into the backend.
b.logf("importing frontend prefs into backend store; frontend prefs: %s", prefs.Pretty()) b.logf("importing frontend prefs into backend store; frontend prefs: %s", prefs.Pretty())
if err := b.pm.SetPrefs(prefs.View(), b.netMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(prefs.View(), ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
return fmt.Errorf("store.WriteState: %v", err) return fmt.Errorf("store.WriteState: %v", err)
} }
} }
@ -3060,7 +3078,10 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
} }
prefs := newp.View() prefs := newp.View()
if err := b.pm.SetPrefs(prefs, b.netMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(prefs, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
b.logf("failed to save new controlclient state: %v", err) b.logf("failed to save new controlclient state: %v", err)
} }
b.lastProfileID = b.pm.CurrentProfile().ID b.lastProfileID = b.pm.CurrentProfile().ID

@ -578,7 +578,10 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here. newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here.
newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID) newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID)
if err := b.pm.SetPrefs(newPrefs.View(), b.netMap.MagicDNSSuffix()); err != nil { if err := b.pm.SetPrefs(newPrefs.View(), ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
return fmt.Errorf("saving prefs: %w", err) return fmt.Errorf("saving prefs: %w", err)
} }

@ -151,7 +151,7 @@ func TestTKAEnablementFlow(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
b := LocalBackend{ b := LocalBackend{
capTailnetLock: true, capTailnetLock: true,
varRoot: temp, varRoot: temp,
@ -191,7 +191,7 @@ func TestTKADisablementFlow(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
temp := t.TempDir() temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@ -383,7 +383,7 @@ func TestTKASync(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
// Setup the tka authority on the control plane. // Setup the tka authority on the control plane.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
@ -605,7 +605,7 @@ func TestTKADisable(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
temp := t.TempDir() temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@ -696,7 +696,7 @@ func TestTKASign(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state. // Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32) disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@ -785,7 +785,7 @@ func TestTKAForceDisable(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
temp := t.TempDir() temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@ -880,7 +880,7 @@ func TestTKAAffectedSigs(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state. // Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32) disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@ -1013,7 +1013,7 @@ func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv, NetworkLockKey: nlPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state. // Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32) disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@ -1104,7 +1104,7 @@ func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
PrivateNodeKey: nodePriv, PrivateNodeKey: nodePriv,
NetworkLockKey: cosignPriv, NetworkLockKey: cosignPriv,
}, },
}).View(), "")) }).View(), ipn.NetworkProfile{}))
b := LocalBackend{ b := LocalBackend{
varRoot: temp, varRoot: temp,
logf: t.Logf, logf: t.Logf,

@ -657,7 +657,7 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"), netip.MustParsePrefix("::/0"),
}, },
}).View(), "") }).View(), ipn.NetworkProfile{})
if !h.ps.b.OfferingExitNode() { if !h.ps.b.OfferingExitNode() {
t.Fatal("unexpectedly not offering exit node") t.Fatal("unexpectedly not offering exit node")
} }

@ -207,11 +207,10 @@ func init() {
// It also saves the prefs to the StateStore. It stores a copy of the // It also saves the prefs to the StateStore. It stores a copy of the
// provided prefs, which may be accessed via CurrentPrefs. // provided prefs, which may be accessed via CurrentPrefs.
// //
// If tailnetMagicDNSName is provided non-empty, it will be used to // NetworkProfile stores additional information about the tailnet the user
// enrich the profile with the tailnet's MagicDNS name. The MagicDNS // is logged into so that we can keep track of things like their domain name
// name cannot be pulled from prefsIn directly because it is not saved // across user switches to disambiguate the same account but a different tailnet.
// on ipn.Prefs (since it's not a field that is configurable by nodes). func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error {
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, tailnetMagicDNSName string) error {
prefs := prefsIn.AsStruct() prefs := prefsIn.AsStruct()
newPersist := prefs.Persist newPersist := prefs.Persist
if newPersist == nil || newPersist.NodeID == "" || newPersist.UserProfile.LoginName == "" { if newPersist == nil || newPersist.NodeID == "" || newPersist.UserProfile.LoginName == "" {
@ -255,9 +254,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, tailnetMagicDNSName st
cp.ControlURL = prefs.ControlURL cp.ControlURL = prefs.ControlURL
cp.UserProfile = newPersist.UserProfile cp.UserProfile = newPersist.UserProfile
cp.NodeID = newPersist.NodeID cp.NodeID = newPersist.NodeID
if tailnetMagicDNSName != "" { cp.NetworkProfile = np
cp.TailnetMagicDNSName = tailnetMagicDNSName
}
pm.knownProfiles[cp.ID] = cp pm.knownProfiles[cp.ID] = cp
pm.currentProfile = cp pm.currentProfile = cp
if err := pm.writeKnownProfiles(); err != nil { if err := pm.writeKnownProfiles(); err != nil {
@ -601,7 +598,7 @@ func (pm *profileManager) migrateFromLegacyPrefs() error {
return fmt.Errorf("load legacy prefs: %w", err) return fmt.Errorf("load legacy prefs: %w", err)
} }
pm.dlogf("loaded legacy preferences; sentinel=%q", sentinel) pm.dlogf("loaded legacy preferences; sentinel=%q", sentinel)
if err := pm.SetPrefs(prefs, ""); err != nil { if err := pm.SetPrefs(prefs, ipn.NetworkProfile{}); err != nil {
metricMigrationError.Add(1) metricMigrationError.Add(1)
return fmt.Errorf("migrating _daemon profile: %w", err) return fmt.Errorf("migrating _daemon profile: %w", err)
} }
@ -611,6 +608,12 @@ func (pm *profileManager) migrateFromLegacyPrefs() error {
return nil return nil
} }
func (pm *profileManager) requiresBackfill() bool {
return pm != nil &&
pm.currentProfile != nil &&
pm.currentProfile.NetworkProfile.RequiresBackfill()
}
var ( var (
metricNewProfile = clientmetric.NewCounter("profiles_new") metricNewProfile = clientmetric.NewCounter("profiles_new")
metricSwitchProfile = clientmetric.NewCounter("profiles_switch") metricSwitchProfile = clientmetric.NewCounter("profiles_switch")

@ -41,7 +41,7 @@ func TestProfileCurrentUserSwitch(t *testing.T) {
LoginName: loginName, LoginName: loginName,
}, },
} }
if err := pm.SetPrefs(p.View(), ""); err != nil { if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return p.View() return p.View()
@ -96,7 +96,7 @@ func TestProfileList(t *testing.T) {
LoginName: loginName, LoginName: loginName,
}, },
} }
if err := pm.SetPrefs(p.View(), ""); err != nil { if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return p.View() return p.View()
@ -157,7 +157,7 @@ func TestProfileDupe(t *testing.T) {
reauth := func(pm *profileManager, p *persist.Persist) { reauth := func(pm *profileManager, p *persist.Persist) {
prefs := ipn.NewPrefs() prefs := ipn.NewPrefs()
prefs.Persist = p prefs.Persist = p
must.Do(pm.SetPrefs(prefs.View(), "")) must.Do(pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}))
} }
login := func(pm *profileManager, p *persist.Persist) { login := func(pm *profileManager, p *persist.Persist) {
pm.NewProfile() pm.NewProfile()
@ -379,7 +379,7 @@ func TestProfileManagement(t *testing.T) {
}, },
NodeID: nid, NodeID: nid,
} }
if err := pm.SetPrefs(p.View(), ""); err != nil { if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return p.View() return p.View()
@ -506,7 +506,7 @@ func TestProfileManagementWindows(t *testing.T) {
}, },
NodeID: tailcfg.StableNodeID(strconv.Itoa(int(id))), NodeID: tailcfg.StableNodeID(strconv.Itoa(int(id))),
} }
if err := pm.SetPrefs(p.View(), ""); err != nil { if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return p.View() return p.View()

@ -923,7 +923,7 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
LegacyFrontendPrivateMachineKey: key.NewMachine(), LegacyFrontendPrivateMachineKey: key.NewMachine(),
}, },
}).View(), "") }).View(), ipn.NetworkProfile{})
if p := b.pm.CurrentPrefs().Persist(); !p.Valid() || p.PrivateNodeKey().IsZero() { if p := b.pm.CurrentPrefs().Persist(); !p.Valid() || p.PrivateNodeKey().IsZero() {
t.Fatalf("PrivateNodeKey not set") t.Fatalf("PrivateNodeKey not set")
} }

@ -790,6 +790,23 @@ type ProfileID string
// tests. // tests.
type WindowsUserID string type WindowsUserID string
// NetworkProfile is a subset of netmap.NetworkMap
// that should be saved with each user profile.
type NetworkProfile struct {
MagicDNSName string
DomainName string
}
// RequiresBackfill returns whether this object does not have all the data
// expected. This is because this struct is a later addition to LoginProfile and
// this method can be checked to see if it's been backfilled to the current
// expectation or not. Note that for now, it just checks if the struct is empty.
// In the future, if we have new optional fields, this method can be changed to
// do more explicit checks to return whether it's apt for a backfill or not.
func (n NetworkProfile) RequiresBackfill() bool {
return n == NetworkProfile{}
}
// LoginProfile represents a single login profile as managed // LoginProfile represents a single login profile as managed
// by the ProfileManager. // by the ProfileManager.
type LoginProfile struct { type LoginProfile struct {
@ -804,13 +821,12 @@ type LoginProfile struct {
// It is filled in from the UserProfile.LoginName field. // It is filled in from the UserProfile.LoginName field.
Name string Name string
// TailnetMagicDNSName is filled with the MagicDNS suffix for this // NetworkProfile is a subset of netmap.NetworkMap that we
// profile's node (even if MagicDNS isn't necessarily in use). // store to remember information about the tailnet that this
// It will neither start nor end with a period. // profile was logged in with.
// //
// TailnetMagicDNSName is only filled from 2023-09-09 forward, // This field was added on 2023-11-17.
// and will only get backfilled when a profile is the current profile. NetworkProfile NetworkProfile
TailnetMagicDNSName string
// Key is the StateKey under which the profile is stored. // Key is the StateKey under which the profile is stored.
// It is assigned once at profile creation time and never changes. // It is assigned once at profile creation time and never changes.

@ -177,6 +177,16 @@ func (nm *NetworkMap) MagicDNSSuffix() string {
return MagicDNSSuffixOfNodeName(nm.Name) return MagicDNSSuffixOfNodeName(nm.Name)
} }
// DomainName returns the name of the NetworkMap's
// current tailnet. If the map is nil, it returns
// an empty string.
func (nm *NetworkMap) DomainName() string {
if nm == nil {
return ""
}
return nm.Domain
}
// SelfCapabilities returns SelfNode.Capabilities if nm and nm.SelfNode are // SelfCapabilities returns SelfNode.Capabilities if nm and nm.SelfNode are
// non-nil. This is a method so we can use it in envknob/logknob without a // non-nil. This is a method so we can use it in envknob/logknob without a
// circular dependency. // circular dependency.

Loading…
Cancel
Save