diff --git a/ipn/ipnext/ipnext.go b/ipn/ipnext/ipnext.go index bf8d8a7a6..a628e4e2a 100644 --- a/ipn/ipnext/ipnext.go +++ b/ipn/ipnext/ipnext.go @@ -204,6 +204,16 @@ type Host interface { // NodeBackend returns the [NodeBackend] for the currently active node // (which is approximately the same as the current profile). NodeBackend() NodeBackend + + // AuthReconfigAsync asynchronously pushes a new configuration into wgengine, + // if engine updates are not currently blocked, based on the cached netmap and + // user prefs. The reconfiguration is applied to [ipnlocal.LocalBackend]'s currently + // active node at the time of execution. + // + // AuthReconfigAsync should not be called at a high rate (i.e., more often + // than prefs and netmap changes), except in experimental or proof-of-concept + // contexts, since reconfiguration is known to be slow. + AuthReconfigAsync() } // SafeBackend is a subset of the [ipnlocal.LocalBackend] type's methods that diff --git a/ipn/ipnlocal/extension_host.go b/ipn/ipnlocal/extension_host.go index 7264d7407..1cacb5353 100644 --- a/ipn/ipnlocal/extension_host.go +++ b/ipn/ipnlocal/extension_host.go @@ -124,6 +124,8 @@ type Backend interface { NodeBackend() ipnext.NodeBackend + authReconfig() + ipnext.SafeBackend } @@ -541,6 +543,16 @@ func (h *ExtensionHost) Shutdown() { h.shutdownOnce.Do(h.shutdown) } +// AuthReconfigAsync implements [ipnext.Host.AuthReconfigAsync]. +func (h *ExtensionHost) AuthReconfigAsync() { + if h == nil { + return + } + h.enqueueBackendOperation(func(b Backend) { + b.authReconfig() + }) +} + func (h *ExtensionHost) shutdown() { h.shuttingDown.Store(true) // Prevent any queued but not yet started operations from running, diff --git a/ipn/ipnlocal/extension_host_test.go b/ipn/ipnlocal/extension_host_test.go index a22c5156c..58955dc6c 100644 --- a/ipn/ipnlocal/extension_host_test.go +++ b/ipn/ipnlocal/extension_host_test.go @@ -1375,6 +1375,7 @@ func (b *testBackend) Sys() *tsd.System { func (b *testBackend) SendNotify(ipn.Notify) { panic("not implemented") } func (b *testBackend) NodeBackend() ipnext.NodeBackend { panic("not implemented") } func (b *testBackend) TailscaleVarRoot() string { panic("not implemented") } +func (b *testBackend) authReconfig() { panic("not implemented") } func (b *testBackend) SwitchToBestProfile(reason string) { b.mu.Lock() diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 1ff409b76..28fb48fa6 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -410,6 +410,12 @@ type LocalBackend struct { // getCertForTest is used to retrieve TLS certificates in tests. // See [LocalBackend.ConfigureCertsForTest]. getCertForTest func(hostname string) (*TLSCertKeyPair, error) + + // existsPendingAuthReconfig tracks if a goroutine is waiting to + // acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig]. + // It is used to prevent goroutines from piling up to do the same + // work of [LocalBackend.authReconfigLocked]. + existsPendingAuthReconfig atomic.Bool } // SetHardwareAttested enables hardware attestation key signatures in map @@ -5066,10 +5072,26 @@ func (b *LocalBackend) readvertiseAppConnectorRoutes() { // authReconfig pushes a new configuration into wgengine, if engine // updates are not currently blocked, based on the cached netmap and -// user prefs. +// user prefs. Callers may experience an early return with no work +// done if another goroutine is waiting for the mutex inside this method. +// If there is no other goroutine waiting, the calling goroutine will +// proceed to reconfiguration after acquiring the mutex. + +// Reconfiguration may run asynchronously and may not complete +// before the call returns. func (b *LocalBackend) authReconfig() { + // If there's already a pending auth reconfig from another + // goroutine, exit early. If not, this goroutine becomes the pending. + if b.existsPendingAuthReconfig.Swap(true) { + return + } + b.mu.Lock() defer b.mu.Unlock() + + // Allow another goroutine to become pending. + b.existsPendingAuthReconfig.Store(false) + b.authReconfigLocked() } @@ -5356,7 +5378,7 @@ func (b *LocalBackend) initPeerAPIListenerLocked() { cn := b.currentNode() nm := cn.NetMap() if nm == nil { - // We're called from authReconfig which checks that + // We're called from authReconfigLocked which checks that // netMap is non-nil, but if a concurrent Logout, // ResetForClientDisconnect, or Start happens when its // mutex was released, the netMap could be @@ -5801,7 +5823,7 @@ func (b *LocalBackend) enterStateLocked(newState ipn.State) { case ipn.NeedsLogin: feature.SystemdStatus("Needs login: %s", authURL) // always block updates on NeedsLogin even if seamless renewal is enabled, - // to prevent calls to authReconfig from reconfiguring the engine when our + // to prevent calls to authReconfigLocked from reconfiguring the engine when our // key has expired and we're waiting to authenticate to use the new key. b.blockEngineUpdatesLocked(true) fallthrough