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 <mzb@tailscale.com>
This commit is contained in:
Michael Ben-Ami
2026-03-18 16:25:09 -04:00
committed by mzbenami
parent 3a5afc3358
commit ea7040eea2
4 changed files with 48 additions and 3 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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()

View File

@@ -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