@@ -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 (
1722const 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.
2837type 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+ }
0 commit comments