Skip to content

Commit c36e0a1

Browse files
committed
WIP 14 vpn: add gateway support
1 parent 979991c commit c36e0a1

3 files changed

Lines changed: 256 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ In addition to the per-application SOCKS5 proxy, awl can route **all** of your I
243243
- **Linux:** supported (this is the platform we have tested on).
244244
- **Android:** client mode is supported via the awl Android app (uses `VpnService` for routing). Serving as an exit node from Android is **not** supported.
245245
- **macOS / Windows / other:** **not supported yet** — awl will refuse to start with VPN gateway enabled. Windows-side code exists but is unfinished; see `vpn/sockmark/sockmark_windows.go` and `vpn/routes/nat_windows.go` for the open work items.
246-
- IPv6 traffic is not tunnelled through the gateway in either direction (it is forwarded as a regular awl peer packet instead).
246+
- **IPv6 is not tunnelled; it is fenced off (fail-closed) while the gateway is on.** The gateway only carries IPv4. To stop IPv6 from leaking your real address past the exit node, the Linux client installs an `unreachable ::/0` route while gateway mode is on, so IPv6 connect()s fail fast and apps fall back to IPv4 through the tunnel. This is applied unconditionally — even if the host has no IPv6 right now, and even when IPv6 is disabled via sysctl (`disable_ipv6=1` only blocks address assignment, not routes), so that IPv6 appearing later (a hot-plugged uplink, a fresh router advertisement) is already fenced. It is skipped only when the IPv6 stack is absent entirely (kernel `ipv6.disable=1`), where nothing can leak. On Android the `VpnService` captures `::/0` into the TUN where it is dropped, so IPv6 is fenced there too. Full IPv6 tunnelling through the gateway is not implemented yet.
247+
- **Host default-route changes mid-session are not tracked.** The original default route(s) are snapshotted when the gateway is enabled and copied into the policy table for libp2p's use; if the host default later changes (DHCP renew, Wi-Fi↔Ethernet roaming, or an IPv6 RA advertising a new router/prefix), libp2p's physical-NIC exit can go stale until the gateway is toggled off and on. This degrades p2p connectivity but does not leak — the fail-closed routes are static.
247248
- Both client and exit-node sides require **CAP_NET_ADMIN** on Linux. AWL needs that already to bring up the TUN, so there is no extra capability to grant.
248249
- The exit node sets up NAT through the host's `iptables` binary, which must be installed (on modern distros it resolves to `iptables-nft`). AWL only sees rules on whatever backend that binary uses; rules created against the other backend by unrelated software are invisible to it.
249250

vpn/routes/routes_linux.go

Lines changed: 209 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"syscall"
1010

1111
"github.com/vishvananda/netlink"
12+
"golang.org/x/sys/unix"
1213
)
1314

1415
const (
@@ -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+
}

vpn/routes/vpn_hostnet_integration_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"fmt"
3636
"os"
3737
"os/exec"
38+
"regexp"
3839
"sort"
3940
"strconv"
4041
"strings"
@@ -252,6 +253,23 @@ func assertRoutesApplied(t *testing.T) {
252253

253254
table := strings.TrimSpace(cmdOut(t, "ip", "-4", "route", "show", "table", strconv.Itoa(tableID)))
254255
require.NotEmpty(t, table, "original default(s) must be copied into the awl table")
256+
257+
// IPv6 fail-closed fence. Installed unconditionally — even with IPv6 disabled
258+
// via sysctl (disable_ipv6=1 blocks addresses, not routes). It is skipped
259+
// only when the IPv6 stack is absent entirely (kernel ipv6.disable=1 →
260+
// /proc/sys/net/ipv6 missing), matching setupIPv6Fence's EAFNOSUPPORT path.
261+
if _, err := os.Stat("/proc/sys/net/ipv6"); os.IsNotExist(err) {
262+
return
263+
}
264+
rules6 := cmdOut(t, "ip", "-6", "rule", "show")
265+
require.Contains(t, rules6, fmt.Sprintf("fwmark 0x%x", testFWMark()), "v6 fwmark ip rule")
266+
require.Contains(t, rules6, fmt.Sprintf("lookup %d", tableID), "v6 ip rule must steer to the awl table")
267+
268+
// Anchor the metric to the unreachable line so an unrelated host route can't
269+
// satisfy it.
270+
main6 := cmdOut(t, "ip", "-6", "route", "show")
271+
require.Regexp(t, fmt.Sprintf(`unreachable default.*metric %d`, tunRouteMetric), main6,
272+
"IPv6 unreachable fence present at the expected metric")
255273
}
256274

257275
// ---------------------------------------------------------------------------
@@ -275,11 +293,25 @@ func snapshotNet(t *testing.T) string {
275293
section("ip rule", cmdOut(t, "ip", "rule", "show"))
276294
section("route main", cmdOut(t, "ip", "-4", "route", "show"))
277295
section("route awl-table", routeTableDump(t, tableID))
296+
// v6 route dumps are sanitized: RA-originated defaults carry an `expires
297+
// Nsec` countdown that ticks between snapshots and would make before/after
298+
// equality flaky on a dual-stack host.
299+
section("ip -6 rule", cmdOut(t, "ip", "-6", "rule", "show"))
300+
section("route6 main", stripVolatile(cmdOut(t, "ip", "-6", "route", "show")))
301+
section("route6 awl-table", stripVolatile(route6TableDump(t, tableID)))
278302
section("iptables filter", cmdOut(t, "iptables", "-S"))
279303
section("iptables nat", cmdOut(t, "iptables", "-t", "nat", "-S"))
280304
return b.String()
281305
}
282306

307+
// volatileExpires matches the `expires Nsec` attribute that the kernel prints
308+
// for RA-learned IPv6 routes; its countdown changes between snapshots.
309+
var volatileExpires = regexp.MustCompile(`expires \d+sec`)
310+
311+
func stripVolatile(s string) string {
312+
return volatileExpires.ReplaceAllString(s, "expires")
313+
}
314+
283315
// ---------------------------------------------------------------------------
284316
// helpers
285317
// ---------------------------------------------------------------------------
@@ -369,6 +401,19 @@ func routeTableDump(t *testing.T, table int) string {
369401
return string(out)
370402
}
371403

404+
// route6TableDump is the IPv6 counterpart of routeTableDump.
405+
func route6TableDump(t *testing.T, table int) string {
406+
t.Helper()
407+
out, err := exec.Command("ip", "-6", "route", "show", "table", strconv.Itoa(table)).CombinedOutput()
408+
if err != nil {
409+
if strings.Contains(string(out), "does not exist") {
410+
return ""
411+
}
412+
require.NoErrorf(t, err, "ip -6 route show table %d: %s", table, out)
413+
}
414+
return string(out)
415+
}
416+
372417
func lines(s string) []string {
373418
var out []string
374419
for _, l := range strings.Split(s, "\n") {

0 commit comments

Comments
 (0)