Skip to content

Commit 18e6cd6

Browse files
committed
vpn/netstate: run the linux route monitor for the whole process lifetime
The netlink route-change monitor moves from routeState (started by every EnableClientRoutes, stopped by teardown) into Manager: Start(ctx) launches it once and it lives until ctx is cancelled. routeState becomes a pure value-holder — its mutex and monitor lifecycle fields are gone, everything is guarded by Manager.mu, so reconcile and Enable/Disable transitions serialise on the same lock. The subscription loop lives in manager_linux.go, the route-diff logic stays in routes_linux.go. An initial subscribe failure no longer disables staleness tracking until the next enable: the monitor starts with a dead subscription and keeps re-subscribing with backoff.
1 parent 89ac8d6 commit 18e6cd6

4 files changed

Lines changed: 232 additions & 226 deletions

File tree

vpn/netstate/manager_linux.go

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import (
66
"context"
77
"fmt"
88
"sync"
9+
"sync/atomic"
910
"syscall"
11+
"time"
12+
13+
"github.com/vishvananda/netlink"
14+
"golang.org/x/sys/unix"
1015
)
1116

1217
// awlMark is the numeric value shared by the SO_MARK fwmark applied to marked
@@ -17,18 +22,29 @@ import (
1722
const awlMark = 0x61776C
1823

1924
// Manager owns the Linux OS network state behind AWL's VPN gateway feature:
20-
// the always-on socket marking (SO_MARK, applied via ControlFunc) and the
21-
// runtime state of gateway mode — the client routes and the server NAT.
25+
// the always-on socket marking (SO_MARK, applied via ControlFunc), the
26+
// runtime state of gateway mode — the client routes and the server NAT —
27+
// and the route-change monitor (started by Start, alive for the whole
28+
// process) that keeps the client routes' exemption table in sync with the
29+
// live host defaults.
2230
//
2331
// Enable/Disable methods are idempotent and safe for concurrent use. The
2432
// internal mutex only guards the Manager's own state; the orchestration
2533
// above (service.VPNGateway) still serialises whole enable/disable
2634
// transactions — config, tunnel binding, DNS and the calls here — under its
27-
// own lock.
35+
// own lock. The monitor's reconcile takes the same mutex, which is what
36+
// makes it exclusive with Enable/Disable transitions.
2837
type Manager struct {
2938
mu sync.Mutex
3039
routeState *routeState
3140
natState *natState
41+
42+
// stopping suppresses the netlink subscription's ErrorCallback on
43+
// shutdown: closing our own live subscription socket (the monitor's
44+
// deferred close(subDone)) provokes a benign "Receive failed" that is
45+
// not worth a warning. Set by the monitor goroutine itself when ctx is
46+
// cancelled; never reset — ctx cancellation is terminal for the process.
47+
stopping atomic.Bool
3248
}
3349

3450
// NewManager returns the Manager for Linux. Setting SO_MARK requires
@@ -38,9 +54,32 @@ func NewManager() *Manager {
3854
return &Manager{}
3955
}
4056

41-
// Start is a no-op on Linux: SO_MARK is interpreted by the kernel per packet,
42-
// there is no per-socket state to keep in sync with the network.
43-
func (m *Manager) Start(_ context.Context) error { return nil }
57+
// Start launches the route-change monitor goroutine, which keeps the tableID
58+
// exemption copies in sync with the live host default(s) for the whole
59+
// process lifetime (see runRouteMonitor); it exits when ctx is cancelled.
60+
// Socket marking itself needs no lifecycle here: SO_MARK is interpreted by
61+
// the kernel per packet, so unlike Windows there is no per-socket state to
62+
// keep in sync with the network.
63+
//
64+
// Always returns nil: the monitor is best-effort staleness tracking — the
65+
// gateway works without it — so a subscribe failure is retried in the
66+
// background forever rather than surfaced. (Contrast with Windows, where
67+
// Start fails Init on a callback-registration error: there the notifications
68+
// drive socket marking itself, which never recovers without them.)
69+
func (m *Manager) Start(ctx context.Context) error {
70+
subDone := make(chan struct{})
71+
updates, err := m.subscribeRouteUpdates(subDone)
72+
if err != nil {
73+
// Hand the monitor an already-closed channel: its subscription-died
74+
// path fires immediately, entering the re-subscribe loop with backoff.
75+
logger.Warnf("gateway route monitor: initial subscribe failed, will keep retrying: %v", err)
76+
closed := make(chan netlink.RouteUpdate)
77+
close(closed)
78+
updates = closed
79+
}
80+
go m.runRouteMonitor(ctx, updates, subDone)
81+
return nil
82+
}
4483

4584
// ControlFunc returns a function compatible with net.Dialer.Control and the
4685
// QUIC ListenUDP override, marking each new socket with SO_MARK so its
@@ -142,3 +181,135 @@ func (m *Manager) ServerNATActive() bool {
142181
defer m.mu.Unlock()
143182
return m.natState != nil
144183
}
184+
185+
// routeMonitorDebounce is the interval at which the monitor checks whether any
186+
// relevant route event has arrived since the last reconcile. A single uplink
187+
// change (DHCP renew, Wi-Fi roam, new RA) emits a burst of
188+
// RTM_NEWROUTE/RTM_DELROUTE messages; the monitor only marks state dirty as they
189+
// come in and reconciles at most once per tick, coalescing the burst into a
190+
// single reconcile (bounded worst-case latency of one interval).
191+
// Our reconcile is two netlink dumps + a small
192+
// diff with no external consumers, so a short interval is fine — and the window
193+
// it bounds is only degraded p2p, never a leak, so faster restoration is a mild
194+
// plus.
195+
const routeMonitorDebounce = 500 * time.Millisecond
196+
197+
// routeMonitorResubscribeBackoff is how long the monitor waits before retrying a
198+
// netlink subscription that died mid-session (see runRouteMonitor).
199+
const routeMonitorResubscribeBackoff = 1 * time.Second
200+
201+
// subscribeRouteUpdates opens a fresh netlink route-change subscription feeding a
202+
// new updates channel. Closing subDone tears down THIS subscription's socket and
203+
// its internal watcher goroutine — the library never closes the socket itself on
204+
// a receive error, so each subscription needs its own cancel channel to avoid
205+
// leaking a socket + goroutine per re-subscription. netlink closes the updates
206+
// channel on any receive error, which the consumer treats as "subscription died".
207+
// The ErrorCallback stays quiet once m.stopping is set: tearing our own socket
208+
// down provokes a benign "Receive failed" that is not worth logging.
209+
func (m *Manager) subscribeRouteUpdates(subDone <-chan struct{}) (chan netlink.RouteUpdate, error) {
210+
updates := make(chan netlink.RouteUpdate, 64)
211+
opts := netlink.RouteSubscribeOptions{
212+
ListExisting: false,
213+
ErrorCallback: func(err error) {
214+
if m.stopping.Load() {
215+
return
216+
}
217+
logger.Warnf("gateway route monitor: %v", err)
218+
},
219+
}
220+
if err := netlink.RouteSubscribeWithOptions(updates, subDone, opts); err != nil {
221+
return nil, err
222+
}
223+
return updates, nil
224+
}
225+
226+
// runRouteMonitor consumes route-change events and reconciles tableID after a
227+
// debounce window. It runs for the whole process lifetime and exits only when
228+
// ctx is cancelled; a mid-session subscription death (netlink socket error →
229+
// updates closed) is recovered by re-subscribing rather than giving up, so
230+
// staleness tracking survives transient netlink failures. Events arriving
231+
// while the gateway client is disabled only cost a dirty flag — reconcile
232+
// bails out early on m.routeState == nil.
233+
//
234+
// subDone is the live subscription's cancel channel. It is closed (and replaced)
235+
// on every re-subscription and once more on exit, so no dead subscription's
236+
// socket or watcher goroutine outlives the event that killed it.
237+
func (m *Manager) runRouteMonitor(ctx context.Context, updates <-chan netlink.RouteUpdate, subDone chan struct{}) {
238+
// Tears down whichever subscription is live when we exit. The closure reads
239+
// the current subDone, which the loop reassigns on each re-subscription.
240+
defer func() { close(subDone) }()
241+
242+
ticker := time.NewTicker(routeMonitorDebounce)
243+
defer ticker.Stop()
244+
245+
dirty := false
246+
for {
247+
select {
248+
case upd, ok := <-updates:
249+
if !ok {
250+
// Subscription died mid-session (or the initial subscribe in
251+
// Start failed). Release the dead subscription's socket +
252+
// watcher goroutine, then re-subscribe on a fresh socket and
253+
// force a catch-up reconcile (events may have been missed while
254+
// it was down). Bail only if we were stopped during backoff.
255+
close(subDone)
256+
subDone = make(chan struct{})
257+
newUpdates, ok := m.resubscribe(ctx, subDone)
258+
if !ok {
259+
return
260+
}
261+
updates = newUpdates
262+
dirty = true
263+
continue
264+
}
265+
if isDefaultRouteUpdate(upd) {
266+
dirty = true
267+
}
268+
case <-ticker.C:
269+
if dirty {
270+
dirty = false
271+
m.reconcile()
272+
}
273+
case <-ctx.Done():
274+
// Set before the deferred close(subDone), which tears down the
275+
// live socket and provokes the benign "Receive failed" that
276+
// stopping suppresses in the ErrorCallback.
277+
m.stopping.Store(true)
278+
return
279+
}
280+
}
281+
}
282+
283+
// resubscribe retries the netlink subscription after a subscription death, with
284+
// a backoff between attempts. It returns the new updates channel, or ok=false if
285+
// ctx was cancelled (shutdown) while waiting/retrying. All attempts share
286+
// subDone: a failed subscribe binds nothing, so reuse leaks nothing, and the one
287+
// that succeeds binds its socket to subDone for the caller to cancel later.
288+
func (m *Manager) resubscribe(ctx context.Context, subDone <-chan struct{}) (<-chan netlink.RouteUpdate, bool) {
289+
for {
290+
select {
291+
case <-ctx.Done():
292+
return nil, false
293+
case <-time.After(routeMonitorResubscribeBackoff):
294+
}
295+
296+
updates, err := m.subscribeRouteUpdates(subDone)
297+
if err != nil {
298+
logger.Warnf("gateway route monitor: re-subscribe failed, retrying in %s: %v", routeMonitorResubscribeBackoff, err)
299+
continue
300+
}
301+
logger.Infof("gateway route monitor: re-subscribed after subscription loss")
302+
return updates, true
303+
}
304+
}
305+
306+
// isDefaultRouteUpdate reports whether a route event concerns a main-table
307+
// default route (either family). Changes in other tables — including awl's own
308+
// tableID edits — are ignored, so reconcile never triggers itself.
309+
func isDefaultRouteUpdate(upd netlink.RouteUpdate) bool {
310+
r := upd.Route
311+
if r.Table != unix.RT_TABLE_MAIN {
312+
return false
313+
}
314+
return isIPv4DefaultDst(r.Dst) || isIPv6DefaultDst(r.Dst)
315+
}

vpn/netstate/manager_windows.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ func NewManager() *Manager {
5555
// start (no default route → both indexes 0) is not an error: the watcher
5656
// picks the uplink up when connectivity appears and re-binds registered
5757
// sockets, so a restart is never needed. Only the change-notification
58-
// registration itself can fail. Must be called before the first libp2p
59-
// socket is created.
58+
// registration itself can fail — and that deliberately fails Init (unlike
59+
// the Linux Start, whose netlink monitor is best-effort staleness tracking):
60+
// the notifications drive socket marking itself, which never recovers
61+
// without them. Must be called before the first libp2p socket is created.
6062
func (m *Manager) Start(ctx context.Context) error {
6163
m.redetect()
6264

0 commit comments

Comments
 (0)