From ea7040eea217affd6be75a990d19aa7d13fd0993 Mon Sep 17 00:00:00 2001 From: Michael Ben-Ami Date: Wed, 18 Mar 2026 16:25:09 -0400 Subject: [PATCH] ipn/{ipnext,ipnlocal}: expose authReconfig in ipnext.Host as AuthReconfigAsync Also implement a limit of one on the number of goroutines that can be waiting to do a reconfig via AuthReconfig, to prevent extensions from calling too fast and taxing resources. Even with the protection, the new method should only be used in experimental or proof-of-concept contexts. The current intended use is for an extension to be able force a reconfiguration of WireGuard, and have the reconfiguration call back into the extension for extra Allowed IPs. If in the future if WireGuard is able to reconfigure individual peers more dynamically, an extension might be able to hook into that process, and this method on ipnext.Host may be deprecated. Fixes tailscale/corp#38120 Updates tailscale/corp#38124 Updates tailscale/corp#38125 Signed-off-by: Michael Ben-Ami --- ipn/ipnext/ipnext.go | 10 ++++++++++ ipn/ipnlocal/extension_host.go | 12 ++++++++++++ ipn/ipnlocal/extension_host_test.go | 1 + ipn/ipnlocal/local.go | 28 +++++++++++++++++++++++++--- 4 files changed, 48 insertions(+), 3 deletions(-) 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