[client] Preserve routed allowed IPs across lazy connection idle transitions

When a peer went idle under lazy connections, the WireGuard peer was
removed and the wake endpoint re-armed with only the overlay allowed
IPs, dropping routed prefixes installed by the route manager. The
asynchronous route-watcher re-add uses update_only, which is a silent
no-op while the peer is absent, so routed subnets stayed black-holed
until the peer was woken by other means.

Split the idle transition from the full close: Conn.Idle tears down
transports, cancels pending delayed endpoint updates and updates the
status without touching the WireGuard peer. The activity listener then
arms the wake endpoint via the new IdlePeerEndpoint operation, which
removes and re-creates the peer in a single transaction: handshake
state is dropped (keeping the handshake-first wake semantics of packet
staging and first-packet capture/reinjection) while the currently
installed allowed IPs are preserved. The read-modify-write runs under
the interface mutex, serializing it against concurrent allowed IP
updates from the route manager.

Conn.Close no longer takes signalToRemote: the idle transition is the
only path that signals GOAWAY to the remote peer.
This commit is contained in:
Zoltan Papp
2026-07-19 21:37:15 +02:00
parent 92a5ed19d3
commit 13433af4a5
21 changed files with 455 additions and 34 deletions
+20 -4
View File
@@ -285,8 +285,20 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
return nil
}
// Close closes this peer Conn issuing a close event to the Conn closeCh
func (conn *Conn) Close(signalToRemote bool) {
// Close closes this peer Conn issuing a close event to the Conn closeCh and removes the WireGuard peer
func (conn *Conn) Close() {
conn.close(false, true)
}
// Idle tears down the connection for the lazy idle state: transports and proxies are
// closed but the WireGuard peer is kept, so its AllowedIPs (including routed prefixes
// installed by the route manager) survive until the activity listener re-points the
// endpoint at the wake listener.
func (conn *Conn) Idle(signalToRemote bool) {
conn.close(signalToRemote, false)
}
func (conn *Conn) close(signalToRemote bool, removeWgPeer bool) {
conn.mu.Lock()
defer conn.wgWatcherWg.Wait()
defer conn.mu.Unlock()
@@ -329,8 +341,12 @@ func (conn *Conn) Close(signalToRemote bool) {
conn.wgProxyICE = nil
}
if err := conn.endpointUpdater.RemoveWgPeer(); err != nil {
conn.Log.Errorf("failed to remove wg endpoint: %v", err)
if removeWgPeer {
if err := conn.endpointUpdater.RemoveWgPeer(); err != nil {
conn.Log.Errorf("failed to remove wg endpoint: %v", err)
}
} else {
conn.endpointUpdater.CancelPendingUpdates()
}
if conn.evalStatus() == StatusConnected && conn.onDisconnected != nil {
+10
View File
@@ -65,6 +65,16 @@ func (e *EndpointUpdater) RemoveWgPeer() error {
return e.wgConfig.WgInterface.RemovePeer(e.wgConfig.RemoteKey)
}
// CancelPendingUpdates stops a scheduled delayed endpoint update without touching the
// WireGuard peer. Used on the idle transition where the peer is kept so a pending
// responder-side update cannot overwrite the wake endpoint later.
func (e *EndpointUpdater) CancelPendingUpdates() {
e.mu.Lock()
defer e.mu.Unlock()
e.waitForCloseTheDelayedUpdate()
}
func (e *EndpointUpdater) RemoveEndpointAddress() error {
e.mu.Lock()
defer e.mu.Unlock()
+99
View File
@@ -0,0 +1,99 @@
package peer
import (
"net"
"net/netip"
"sync"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/iface/wgproxy"
)
type endpointTestWGIface struct {
mu sync.Mutex
updateCalls int
removeCalls int
}
func (m *endpointTestWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
m.mu.Lock()
defer m.mu.Unlock()
m.updateCalls++
return nil
}
func (m *endpointTestWGIface) RemovePeer(string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.removeCalls++
return nil
}
func (m *endpointTestWGIface) GetStats() (map[string]configurer.WGStats, error) { return nil, nil }
func (m *endpointTestWGIface) GetProxy() wgproxy.Proxy { return nil }
func (m *endpointTestWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *endpointTestWGIface) RemoveEndpointAddress(string) error { return nil }
func (m *endpointTestWGIface) counts() (updates, removes int) {
m.mu.Lock()
defer m.mu.Unlock()
return m.updateCalls, m.removeCalls
}
func newTestEndpointUpdater(iface *endpointTestWGIface, initiator bool) *EndpointUpdater {
cfg := WgConfig{
RemoteKey: "remoteKey",
WgInterface: iface,
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.5/32")},
}
return NewEndpointUpdater(log.WithField("peer", "test"), cfg, initiator)
}
// TestEndpointUpdater_CancelPendingUpdates ensures a scheduled responder-side delayed
// update is stopped without removing the WireGuard peer, so an idle transition cannot
// be overwritten later by a stale endpoint update.
func TestEndpointUpdater_CancelPendingUpdates(t *testing.T) {
iface := &endpointTestWGIface{}
e := newTestEndpointUpdater(iface, false)
addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 51820}
require.NoError(t, e.ConfigureWGEndpoint(addr, nil))
updates, _ := iface.counts()
require.Equal(t, 1, updates, "responder must apply the immediate nil-endpoint update")
// CancelPendingUpdates waits for the delayed-update goroutine to exit, so the
// call counts below are final: the 5s fallback update can never fire anymore.
e.CancelPendingUpdates()
updates, removes := iface.counts()
assert.Equal(t, 1, updates, "delayed endpoint update must not fire after cancellation")
assert.Equal(t, 0, removes, "cancellation must not remove the WireGuard peer")
}
// TestEndpointUpdater_CancelPendingUpdatesNoPending ensures cancellation is a safe no-op
// when no delayed update is scheduled (initiator path).
func TestEndpointUpdater_CancelPendingUpdatesNoPending(t *testing.T) {
iface := &endpointTestWGIface{}
e := newTestEndpointUpdater(iface, true)
addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 51820}
require.NoError(t, e.ConfigureWGEndpoint(addr, nil))
e.CancelPendingUpdates()
updates, removes := iface.counts()
assert.Equal(t, 1, updates, "initiator applies exactly one direct update")
assert.Equal(t, 0, removes, "cancellation must not remove the WireGuard peer")
}