Skip to content

Commit d22f6f8

Browse files
committed
vpn/netstate: re-sync exit-node forwarding on uplink change (windows)
The watch cycle moves from sockmark_windows.go into manager_windows.go and gains a second consumer besides the marker re-bind: when the best IPv4 uplink appears or moves, resyncServerForwarding enables IPv4 forwarding on it, so exit-node roaming and an offline server start heal without a server off/on toggle. Forwarding on the abandoned uplink is deliberately left on; teardown reverts only what we enabled. The marker re-bind chain gets a hostnet test on a real Winsock socket, with the stored index poisoned to 0 so the re-bind is observable. Also, from review: WFP session rollback unified through teardownNAT, runPowerShell bounded by a timeout (a hung PowerShell would freeze the watch goroutine via m.mu), kick/redetect/reapply/apply renamed to notifyNetChange/redetectUplinks/rebindSockets/bindSocketToUplink, WFP weight rationale documented.
1 parent 18e6cd6 commit d22f6f8

7 files changed

Lines changed: 497 additions & 198 deletions

vpn/netstate/manager_windows.go

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@ import (
88
"fmt"
99
"sync"
1010
"sync/atomic"
11+
"time"
1112

1213
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
1314
)
1415

16+
const (
17+
// Debounce parameters for network-change notifications, borrowed from
18+
// WireGuard for Windows (tunnel/defaultroutemonitor.go): coalesce bursts
19+
// for 150ms, but never delay a re-detection beyond 2s if the burst keeps
20+
// going (interface storms during docking/undocking).
21+
debounceInterval = 150 * time.Millisecond
22+
debounceBurstMax = 2 * time.Second
23+
24+
// sweepInterval paces the registry liveness sweep. On a stable network
25+
// re-apply (the other cleanup point) may not run for weeks; the sweep
26+
// guarantees closed sockets don't stay pinned by their RawConn either
27+
// way. The registry holds a handful of entries, so this is nearly free.
28+
sweepInterval = 2 * time.Minute
29+
)
30+
1531
// Manager owns the Windows OS network state behind AWL's VPN gateway feature:
1632
// the always-on socket marking (IP_UNICAST_IF binding to the physical uplink
1733
// NIC — mechanism and lifecycle in sockmark_windows.go) and the runtime state
@@ -33,11 +49,11 @@ type Manager struct {
3349
index6 atomic.Uint32
3450

3551
// registry tracks the live long-lived UDP sockets for re-binding on
36-
// uplink changes; kickCh feeds debounced network-change notifications to
37-
// the watch goroutine. Both belong to the marking machinery in
38-
// sockmark_windows.go.
39-
registry sockRegistry
40-
kickCh chan struct{}
52+
// uplink changes (marking machinery, sockmark_windows.go); netChangeCh
53+
// carries network-change notifications from the OS callbacks to the
54+
// watch goroutine, which debounces them.
55+
registry sockRegistry
56+
netChangeCh chan struct{}
4157

4258
mu sync.Mutex
4359
routeState *routeState
@@ -47,7 +63,7 @@ type Manager struct {
4763
// NewManager returns the Manager for Windows. The uplink indexes stay zero
4864
// (marking is a no-op) until Start performs the initial detection.
4965
func NewManager() *Manager {
50-
return &Manager{kickCh: make(chan struct{}, 1)}
66+
return &Manager{netChangeCh: make(chan struct{}, 1)}
5167
}
5268

5369
// Start synchronously detects the current uplink and launches the
@@ -58,14 +74,18 @@ func NewManager() *Manager {
5874
// registration itself can fail — and that deliberately fails Init (unlike
5975
// the Linux Start, whose netlink monitor is best-effort staleness tracking):
6076
// the notifications drive socket marking itself, which never recovers
61-
// without them. Must be called before the first libp2p socket is created.
77+
// without them. Called before the first libp2p socket is created (Init
78+
// guarantees this); the ordering matters mostly for TCP — a UDP socket
79+
// created earlier is registered and re-bound by the initial uplink
80+
// re-detection anyway, but a TCP dial made before Start would stay unmarked
81+
// for its lifetime.
6282
func (m *Manager) Start(ctx context.Context) error {
63-
m.redetect()
83+
m.redetectUplinks()
6484

6585
routeCb, err := winipcfg.RegisterRouteChangeCallback(func(_ winipcfg.MibNotificationType, route *winipcfg.MibIPforwardRow2) {
6686
// Only default-route changes can change the uplink choice.
6787
if route != nil && route.DestinationPrefix.PrefixLength == 0 {
68-
m.kick()
88+
m.notifyNetChange()
6989
}
7090
})
7191
if err != nil {
@@ -75,7 +95,7 @@ func (m *Manager) Start(ctx context.Context) error {
7595
// Parameter changes cover interface metric flips, which reorder
7696
// default routes without touching the route table itself.
7797
if notificationType == winipcfg.MibParameterNotification {
78-
m.kick()
98+
m.notifyNetChange()
7999
}
80100
})
81101
if err != nil {
@@ -93,6 +113,83 @@ func (m *Manager) Start(ctx context.Context) error {
93113
return nil
94114
}
95115

116+
// notifyNetChange signals the watch goroutine that the network changed, which
117+
// reacts after a debounce (onNetworkChange). Non-blocking and coalescing:
118+
// called from OS notification threads.
119+
func (m *Manager) notifyNetChange() {
120+
select {
121+
case m.netChangeCh <- struct{}{}:
122+
default:
123+
}
124+
}
125+
126+
// watch is the Manager's background goroutine: the debounced reaction to
127+
// network change notifications plus the periodic registry sweep. cleanup
128+
// unregisters the OS callbacks when ctx dies.
129+
func (m *Manager) watch(ctx context.Context, cleanup func()) {
130+
defer cleanup()
131+
132+
coalesce := time.NewTimer(time.Hour)
133+
if !coalesce.Stop() {
134+
<-coalesce.C
135+
}
136+
defer coalesce.Stop()
137+
sweep := time.NewTicker(sweepInterval)
138+
defer sweep.Stop()
139+
140+
stopCoalesce := func() {
141+
if !coalesce.Stop() {
142+
select {
143+
case <-coalesce.C:
144+
default:
145+
}
146+
}
147+
}
148+
149+
var burstStart time.Time
150+
pending := false
151+
for {
152+
select {
153+
case <-ctx.Done():
154+
return
155+
case <-m.netChangeCh:
156+
now := time.Now()
157+
switch {
158+
case !pending:
159+
burstStart = now
160+
pending = true
161+
coalesce.Reset(debounceInterval)
162+
case now.Sub(burstStart) >= debounceBurstMax:
163+
// The burst has been going on too long — don't starve the
164+
// re-detection, run it now.
165+
stopCoalesce()
166+
pending = false
167+
m.onNetworkChange()
168+
default:
169+
stopCoalesce()
170+
coalesce.Reset(debounceInterval)
171+
}
172+
case <-coalesce.C:
173+
pending = false
174+
m.onNetworkChange()
175+
case <-sweep.C:
176+
if evicted := m.registry.sweep(); evicted > 0 {
177+
logger.Debugf("registry sweep evicted %d closed sockets", evicted)
178+
}
179+
}
180+
}
181+
}
182+
183+
// onNetworkChange runs the debounced consumers of a network-change event:
184+
// socket-marking re-detection first (lock-free — atomics + the registry's own
185+
// lock), then the exit node's forwarding re-sync, which takes m.mu.
186+
// EnableServerNAT can hold m.mu for seconds of PowerShell, and must never
187+
// delay socket re-binding — only the re-sync itself, which is harmless.
188+
func (m *Manager) onNetworkChange() {
189+
m.redetectUplinks()
190+
m.resyncServerForwarding()
191+
}
192+
96193
// EnableClientRoutes installs the gateway client routes on the TUN (the
97194
// default-route capture plus the IPv6 fail-closed fence). Idempotent: a
98195
// second call while routes are installed is a no-op. It refuses while no

0 commit comments

Comments
 (0)