99 "syscall"
1010
1111 "github.com/vishvananda/netlink"
12+ "golang.org/x/sys/unix"
1213)
1314
1415const (
@@ -45,6 +46,15 @@ type RouteState struct {
4546 // instead of restoring originals.
4647 origDefaults []netlink.Route
4748 tunRouteAdded bool
49+
50+ // IPv6 fail-closed state. The gateway only tunnels IPv4; to stop IPv6 from
51+ // leaking past the exit node on a dual-stack host we fence it with an
52+ // `unreachable ::/0` route, and exempt marked libp2p sockets via a v6
53+ // fwmark rule + a copy of the host's IPv6 default(s) into tableID. Mirrors
54+ // the IPv4 fields above. origDefaultsV6 may be empty (host has no IPv6).
55+ origDefaultsV6 []netlink.Route
56+ v6RuleAdded bool
57+ v6UnreachAdded bool
4858}
4959
5060// SetupGatewayRoutes configures the system to route all traffic through the
@@ -77,6 +87,16 @@ func SetupGatewayRoutes(tunIfName string, fwmark uint32) (*RouteState, error) {
7787 logger .Warnf ("recovered from leftover gateway route state (previous run was likely killed before teardown)" )
7888 }
7989
90+ // TODO(gateway-route-staleness): origDefaults (and origDefaultsV6 below) are
91+ // snapshotted once here and copied into tableID; they are never refreshed.
92+ // Applies to BOTH families. If the host default changes mid-session (IPv4:
93+ // DHCP renew, Wi-Fi<->Ethernet roaming; IPv6: RA re-advertising a new
94+ // router/prefix — far more frequent), the copy in tableID goes stale and
95+ // marked libp2p sockets lose their physical-NIC exit until the gateway is
96+ // re-toggled. This does NOT cause a leak — the catch-all TUN default / v6
97+ // unreachable route is static — only degraded p2p connectivity. Fix:
98+ // subscribe to netlink RTM_NEWROUTE/RTM_DELROUTE and re-copy the live
99+ // default(s) into tableID for both families.
80100 origDefaults , err := getDefaultRoutes ()
81101 if err != nil {
82102 return nil , fmt .Errorf ("get default routes: %w" , err )
@@ -127,9 +147,88 @@ func SetupGatewayRoutes(tunIfName string, fwmark uint32) (*RouteState, error) {
127147 }
128148 state .tunRouteAdded = true
129149
150+ // IPv6 fail-closed fence (`unreachable ::/0` + libp2p exemption). Installed
151+ // unconditionally — see setupIPv6Fence for why that is safe even when IPv6 is
152+ // disabled via sysctl, and how a genuinely absent IPv6 stack is tolerated.
153+ if err := setupIPv6Fence (state , fwmark ); err != nil {
154+ _ = TeardownGatewayRoutes (state )
155+ return nil , err
156+ }
157+
130158 return state , nil
131159}
132160
161+ // setupIPv6Fence installs the IPv6 fail-closed fencing onto state: a v6
162+ // fwmark->tableID rule, a copy of the host's current IPv6 default(s) into
163+ // tableID (the libp2p exemption path), and an `unreachable ::/0` route that wins
164+ // LPM over any host default so locally generated IPv6 connect()s fail fast with
165+ // EHOSTUNREACH and apps fall back to IPv4 through the tunnel (Happy Eyeballs,
166+ // RFC 8305). Without it a dual-stack host egresses IPv6 straight out its
167+ // physical interface, exposing the real address past the exit node.
168+ //
169+ // It is applied UNCONDITIONALLY, even when the host has no IPv6 default right
170+ // now. The unreachable route and the rule install fine even with IPv6
171+ // administratively disabled via sysctl (`disable_ipv6=1` only blocks address
172+ // assignment, not route/rule additions — verified on Linux 6.8). Installing it
173+ // regardless means IPv6 that appears later — a hot-plugged uplink, a runtime
174+ // sysctl flip, a fresh RA — is already fenced and loses LPM to our metric-5
175+ // unreachable, rather than leaking. An empty IPv6 default set is therefore not
176+ // an error (unlike the IPv4 default above).
177+ //
178+ // The one case where the IPv6 stack genuinely isn't there is a kernel-level
179+ // disable (`ipv6.disable=1` on the cmdline): the module is absent, AF_INET6 ops
180+ // fail with EAFNOSUPPORT, and there is nothing to leak. We detect that from the
181+ // netlink ops and skip the fence (leaving state.v6* unset) rather than failing
182+ // the otherwise-working IPv4 gateway setup.
183+ func setupIPv6Fence (state * RouteState , fwmark uint32 ) error {
184+ origDefaultsV6 , err := getDefaultRoutesV6 ()
185+ if err != nil {
186+ if ipv6Unavailable (err ) {
187+ logger .Infof ("IPv6 stack unavailable (%v); skipping IPv6 leak fence" , err )
188+ return nil
189+ }
190+ return fmt .Errorf ("get IPv6 default routes: %w" , err )
191+ }
192+
193+ if err := netlink .RuleAdd (buildFwmarkRuleV6 (fwmark )); err != nil {
194+ if ipv6Unavailable (err ) {
195+ logger .Infof ("IPv6 stack unavailable (%v); skipping IPv6 leak fence" , err )
196+ return nil
197+ }
198+ return fmt .Errorf ("add IPv6 ip rule: %w" , err )
199+ }
200+ state .v6RuleAdded = true
201+ state .origDefaultsV6 = origDefaultsV6
202+
203+ for i := range origDefaultsV6 {
204+ tableRoute := origDefaultsV6 [i ]
205+ tableRoute .Table = tableID
206+ if err := netlink .RouteAdd (& tableRoute ); err != nil {
207+ return fmt .Errorf ("add original IPv6 default to table %d: %w" , tableID , err )
208+ }
209+ }
210+
211+ if err := netlink .RouteAdd (buildV6UnreachableRoute ()); err != nil {
212+ if errors .Is (err , syscall .EEXIST ) {
213+ return fmt .Errorf ("add IPv6 unreachable default route: %w — a ::/0 route at metric %d " +
214+ "already exists (likely another VPN or a manual route, not awl: cleanupStaleRoutes " +
215+ "already removed any of ours); inspect with `ip -6 route show` and resolve the conflict" ,
216+ err , tunRouteMetric )
217+ }
218+ return fmt .Errorf ("add IPv6 unreachable default route: %w" , err )
219+ }
220+ state .v6UnreachAdded = true
221+
222+ return nil
223+ }
224+
225+ // ipv6Unavailable reports whether a netlink error means the IPv6 stack is not
226+ // present at all (kernel-level ipv6.disable=1), as opposed to a real failure to
227+ // be surfaced. When it is, there is nothing to fence.
228+ func ipv6Unavailable (err error ) bool {
229+ return errors .Is (err , syscall .EAFNOSUPPORT ) || errors .Is (err , syscall .EPROTONOSUPPORT )
230+ }
231+
133232// cleanupStaleRoutes removes leftover state from a previous SetupGatewayRoutes
134233// call: the fwmark→tableID ip rule and every route currently in tableID. All
135234// errors are intentionally swallowed — this is a best-effort pre-clean before
@@ -159,10 +258,14 @@ func cleanupStaleRoutes(fwmark uint32) bool {
159258 // 2. Every route currently in tableID. We own the table by convention
160259 // (its value is "awl" in ASCII), so anything inside it is leftover.
161260 // Filter on Table only — LinkIndex of the original routes may differ
162- // from run to run if the physical NIC was renumbered.
163- routesInTable , err := netlink .RouteListFiltered (netlink .FAMILY_V4 ,
164- & netlink.Route {Table : tableID }, netlink .RT_FILTER_TABLE )
165- if err == nil {
261+ // from run to run if the physical NIC was renumbered. Both families share
262+ // the table, so sweep it for v4 and v6.
263+ for _ , family := range []int {netlink .FAMILY_V4 , netlink .FAMILY_V6 } {
264+ routesInTable , err := netlink .RouteListFiltered (family ,
265+ & netlink.Route {Table : tableID }, netlink .RT_FILTER_TABLE )
266+ if err != nil {
267+ continue
268+ }
166269 for i := range routesInTable {
167270 r := routesInTable [i ]
168271 if delErr := netlink .RouteDel (& r ); delErr == nil {
@@ -171,6 +274,19 @@ func cleanupStaleRoutes(fwmark uint32) bool {
171274 }
172275 }
173276
277+ // 3. Stale IPv6 fwmark rule and the `unreachable ::/0` fence. Unlike the
278+ // IPv4 TUN default route (which the kernel auto-removes when the TUN fd dies
279+ // with the process), the v6 unreachable route is not bound to any interface,
280+ // so a SIGKILL'd run leaves it behind — fencing off IPv6 host-wide until it
281+ // is removed. It IS owner-tagged (its low metric + ::/0 + RTN_UNREACHABLE
282+ // shape is ours), so we clean it here rather than surfacing an EEXIST.
283+ if err := netlink .RuleDel (buildFwmarkRuleV6 (fwmark )); err == nil {
284+ cleaned = true
285+ }
286+ if err := netlink .RouteDel (buildV6UnreachableRoute ()); err == nil {
287+ cleaned = true
288+ }
289+
174290 return cleaned
175291}
176292
@@ -202,6 +318,27 @@ func TeardownGatewayRoutes(state *RouteState) error {
202318 errs = append (errs , fmt .Errorf ("del ip rule: %w" , err ))
203319 }
204320
321+ // IPv6 fail-closed teardown, reverse order of setup: unreachable fence,
322+ // copied defaults, then the v6 fwmark rule. Guarded by the per-step flags so
323+ // a rollback from a partially-applied setup doesn't generate spurious errors.
324+ if state .v6UnreachAdded {
325+ if err := netlink .RouteDel (buildV6UnreachableRoute ()); err != nil {
326+ errs = append (errs , fmt .Errorf ("del IPv6 unreachable default route: %w" , err ))
327+ }
328+ }
329+ for i := range state .origDefaultsV6 {
330+ tableRoute := state .origDefaultsV6 [i ]
331+ tableRoute .Table = tableID
332+ if err := netlink .RouteDel (& tableRoute ); err != nil {
333+ errs = append (errs , fmt .Errorf ("del IPv6 route from table %d: %w" , tableID , err ))
334+ }
335+ }
336+ if state .v6RuleAdded {
337+ if err := netlink .RuleDel (buildFwmarkRuleV6 (state .fwmark )); err != nil {
338+ errs = append (errs , fmt .Errorf ("del IPv6 ip rule: %w" , err ))
339+ }
340+ }
341+
205342 if len (errs ) > 0 {
206343 return fmt .Errorf ("teardown gateway routes: %w" , errors .Join (errs ... ))
207344 }
@@ -220,6 +357,19 @@ func buildFwmarkRule(fwmark uint32) *netlink.Rule {
220357 return r
221358}
222359
360+ // buildFwmarkRuleV6 is the IPv6 counterpart of buildFwmarkRule: it steers
361+ // fwmark-tagged IPv6 packets (libp2p sockets) into tableID so they reach the
362+ // physical NIC instead of hitting the `unreachable ::/0` fence. SO_MARK is set
363+ // on every socket regardless of family, so the same fwmark value applies.
364+ func buildFwmarkRuleV6 (fwmark uint32 ) * netlink.Rule {
365+ r := netlink .NewRule ()
366+ r .Mark = fwmark
367+ r .Table = tableID
368+ r .Priority = rulePriority
369+ r .Family = netlink .FAMILY_V6
370+ return r
371+ }
372+
223373// buildTunDefaultRoute constructs the default route via the TUN. Scope is
224374// SCOPE_LINK because the TUN is a point-to-point device with no gateway —
225375// this matches what `ip route add default dev awl0` would produce, and using
@@ -241,6 +391,26 @@ func buildTunDefaultRoute(tunLinkIndex int) *netlink.Route {
241391 }
242392}
243393
394+ // buildV6UnreachableRoute constructs the `unreachable ::/0` fence installed
395+ // while the gateway is on. RTN_UNREACHABLE (not RTN_BLACKHOLE) so locally
396+ // generated IPv6 connect()s fail fast with EHOSTUNREACH and apps fall back to
397+ // IPv4 through the tunnel (Happy Eyeballs, RFC 8305) instead of timing out.
398+ // Same low metric as the IPv4 TUN default so it wins LPM over the host's
399+ // RA/DHCPv6 default. The identical shape is used for Add, stale-cleanup Del and
400+ // teardown Del so they can't drift. No LinkIndex: an unreachable route is not
401+ // attached to any interface.
402+ func buildV6UnreachableRoute () * netlink.Route {
403+ return & netlink.Route {
404+ Type : unix .RTN_UNREACHABLE ,
405+ Dst : & net.IPNet {
406+ IP : net .IPv6zero ,
407+ Mask : net .CIDRMask (0 , 128 ),
408+ },
409+ Priority : tunRouteMetric ,
410+ Family : netlink .FAMILY_V6 ,
411+ }
412+ }
413+
244414// getDefaultRoutes returns every IPv4 default route currently in the main
245415// routing table. Hosts with multiple uplinks (Wi-Fi + Ethernet) typically
246416// have several; we copy all of them into the policy-routing table.
@@ -273,3 +443,38 @@ func isIPv4DefaultDst(dst *net.IPNet) bool {
273443 bits , _ := dst .Mask .Size ()
274444 return bits == 0
275445}
446+
447+ // getDefaultRoutesV6 returns every IPv6 default route (::/0) currently in the
448+ // main routing table, to be copied into tableID as the libp2p exemption path.
449+ // Unlike getDefaultRoutes, an empty result is NOT an error: the gateway installs
450+ // the `unreachable ::/0` fence unconditionally, so a host with no IPv6 uplink is
451+ // simply fenced against IPv6 that may appear later via RA.
452+ func getDefaultRoutesV6 () ([]netlink.Route , error ) {
453+ allRoutes , err := netlink .RouteList (nil , netlink .FAMILY_V6 )
454+ if err != nil {
455+ return nil , fmt .Errorf ("list IPv6 routes: %w" , err )
456+ }
457+
458+ var defaults []netlink.Route
459+ for i := range allRoutes {
460+ r := allRoutes [i ]
461+ if ! isIPv6DefaultDst (r .Dst ) {
462+ continue
463+ }
464+ defaults = append (defaults , r )
465+ }
466+ return defaults , nil
467+ }
468+
469+ // isIPv6DefaultDst reports whether dst represents the IPv6 default route
470+ // (nil, or ::/0 expressed as a *net.IPNet with a /0 mask).
471+ func isIPv6DefaultDst (dst * net.IPNet ) bool {
472+ if dst == nil {
473+ return true
474+ }
475+ if ! dst .IP .Equal (net .IPv6zero ) {
476+ return false
477+ }
478+ bits , _ := dst .Mask .Size ()
479+ return bits == 0
480+ }
0 commit comments