ipn/ipnlocal: preserve b.loginFlags in auto-login cc.Login calls

LocalBackend stores loginFlags at construction so that per-instance
properties (e.g. LoginEphemeral set by tsnet.Server.Ephemeral) persist
for the session. StartLoginInteractiveAs already merges b.loginFlags
into its cc.Login call, but the two auto-login call sites pass bare
controlclient.LoginDefault, silently dropping any stored flags.

Merge b.loginFlags at both auto-login call sites to match the existing
StartLoginInteractiveAs pattern. LoginDefault is zero so this is a
no-op when loginFlags is empty, and restores the documented behavior
when it isn't.

Fixes #15852

Signed-off-by: Scott Graham <scott.github@h4ck3r.net>
This commit is contained in:
Scott Graham 2026-04-17 16:22:18 -07:00 committed by Nick Khyl
parent 618dfd4081
commit cb5a53c424
3 changed files with 61 additions and 2 deletions

View File

@ -2767,7 +2767,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
// Without this, the state machine transitions to "NeedsLogin" implying
// that user interaction is required, which is not the case and can
// regress tsnet.Server restarts.
cc.Login(controlclient.LoginDefault)
cc.Login(b.loginFlags)
}
b.stateMachineLocked()
@ -4842,7 +4842,7 @@ func (b *LocalBackend) setPrefsLocked(newp *ipn.Prefs) ipn.PrefsView {
if !oldp.WantRunning() && newp.WantRunning && cc != nil {
b.logf("transitioning to running; doing Login...")
cc.Login(controlclient.LoginDefault)
cc.Login(b.loginFlags)
}
if oldp.WantRunning() != newp.WantRunning {

View File

@ -8101,3 +8101,60 @@ func TestNoSNATWithAdvertisedExitNodeWarning(t *testing.T) {
}
})
}
// TestStartPreservesLoginFlags is a regression test for a bug where the
// LoginEphemeral flag stored on LocalBackend was silently dropped by the
// auto-login paths in Start() and setPrefsLocked(). The user-visible symptom
// was tsnet.Server.Ephemeral=true being ignored when combined with an auth
// key, because the resulting RegisterRequest.Ephemeral was false.
//
// The test manually constructs the LocalBackend to be able set
// loginFlags=LoginEphemeral, and then checks that at least one cc.Login call
// carried the LoginEphemeral bit.
func TestStartPreservesLoginFlags(t *testing.T) {
logf := tstest.WhileTestRunningLogger(t)
sys := tsd.NewSystem()
sys.Set(new(mem.Store))
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
}
t.Cleanup(e.Close)
sys.Set(e)
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, controlclient.LoginEphemeral)
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
var cc *mockControl
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
cc = newClient(t, opts)
return cc, nil
})
if err := b.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: "https://controlplane.example.com",
WantRunning: false,
},
AuthKey: "tskey-auth-test",
}); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
}); err != nil {
t.Fatalf("EditPrefs: %v", err)
}
cc.mu.Lock()
flags := cc.loginFlags
cc.mu.Unlock()
if flags&controlclient.LoginEphemeral == 0 {
t.Errorf("cc.Login was never called with LoginEphemeral; got flags=%v", flags)
}
}

View File

@ -137,6 +137,7 @@ type mockControl struct {
calls []string
authBlocked bool
shutdown chan struct{}
loginFlags controlclient.LoginFlags
hi *tailcfg.Hostinfo
}
@ -274,6 +275,7 @@ func (cc *mockControl) Login(flags controlclient.LoginFlags) {
cc.mu.Lock()
defer cc.mu.Unlock()
cc.authBlocked = interact || newKeys
cc.loginFlags |= flags
}
func (cc *mockControl) Logout(ctx context.Context) error {