Skip to content

Commit 79e660c

Browse files
committed
vpn/netstate: allow enabling the gateway client offline
Soften the no-IPv4-uplink gates on both platforms from a hard error to a warning: on Linux setupGatewayRoutes proceeds with an empty exemption table (the route monitor copies the default in when one appears), on Windows EnableClientRoutes no longer refuses at index4 == 0 (the /1 routes are TUN-bound, marking is a no-op until the watcher re-binds). An offline boot with a persisted ClientEnabled now self-heals instead of failing Init.
1 parent 88fa336 commit 79e660c

5 files changed

Lines changed: 101 additions & 24 deletions

File tree

vpn/netstate/manager_windows.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package netstate
44

55
import (
66
"context"
7-
"errors"
87
"fmt"
98
"sync"
109
"sync/atomic"
@@ -192,23 +191,21 @@ func (m *Manager) onNetworkChange() {
192191

193192
// EnableClientRoutes installs the gateway client routes on the TUN (the
194193
// default-route capture plus the IPv6 fail-closed fence). Idempotent: a
195-
// second call while routes are installed is a no-op. It refuses while no
196-
// IPv4 uplink is known (marking could not exempt libp2p traffic — routing
197-
// loop); the condition is self-healing (the watcher re-binds sockets when
198-
// connectivity appears), so that is "try again once online", not a permanent
199-
// failure. IPv6 is not required: the tunnel is IPv4-only and gateway mode
200-
// fences IPv6.
194+
// second call while routes are installed is a no-op. Offline enable is
195+
// allowed with a warning: the /1 routes are bound to the TUN LUID and need
196+
// no uplink, marking with index 0 is a no-op, and the watcher re-binds
197+
// registered sockets once connectivity appears — the gateway self-heals
198+
// without a re-enable. IPv6 is not required: the tunnel is IPv4-only and
199+
// gateway mode fences IPv6.
201200
func (m *Manager) EnableClientRoutes(tunIfName string) error {
202201
m.mu.Lock()
203202
defer m.mu.Unlock()
204203

205204
if m.routeState != nil {
206205
return nil
207206
}
208-
// TODO(gateway-offline-start): soften this gate to a warning so gateway
209-
// client mode can be enabled/booted offline and self-heal when connectivity appears
210207
if m.index4.Load() == 0 {
211-
return errors.New("cannot enable VPN gateway: no active network connection (no IPv4 default route)")
208+
logger.Warnf("gateway client enabled with no IPv4 uplink; internet will flow when network appears")
212209
}
213210

214211
state, err := m.setupGatewayRoutes(tunIfName)

vpn/netstate/routes_linux.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ func (m *Manager) setupGatewayRoutes(tunIfName string) (*routeState, error) {
102102
return nil, fmt.Errorf("get default routes: %w", err)
103103
}
104104
if len(origDefaults) == 0 {
105-
// TODO(gateway-offline-start): soften to a warning and proceed with an
106-
// empty exemption table — the route monitor below already re-syncs it
107-
// when a default appears (DHCP), so an offline boot with a persisted
108-
// ClientEnabled would self-heal instead of failing Init. Needs a
109-
// reconcile-from-empty verification + doc updates
110-
return nil, fmt.Errorf("no IPv4 default route present, cannot configure VPN gateway")
105+
// Offline enable is allowed: proceed with an empty exemption table. The
106+
// route monitor (alive since Manager.Start) copies the default into
107+
// tableID once one appears (DHCP), so an offline boot with a persisted
108+
// ClientEnabled self-heals instead of failing Init. Until that reconcile
109+
// (≤ one monitor tick) marked libp2p sockets fall through to the TUN
110+
// default — a routing loop, but with no uplink there is no traffic to
111+
// leak, only a connect delay.
112+
logger.Warnf("gateway client enabled with no IPv4 uplink; internet will flow when network appears")
111113
}
112114

113115
state := &routeState{

vpn/netstate/sockmark_windows.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ func (m *Manager) ControlFunc() func(network, address string, c syscall.RawConn)
7474
// index is unknown — the option simply stays
7575
// unset. rebind=true (uplink change) always writes, because writing 0 is the
7676
// documented way to clear a stale binding (the socket falls back to regular
77-
// routing, which is safe: with no uplink there is nothing to leak to, and
78-
// EnableClientRoutes refuses to enable the gateway while offline).
77+
// routing, which is safe: with no uplink there is no traffic at all, and once
78+
// one appears the watcher re-binds the registry within the debounce window —
79+
// the brief unmarked-libp2p loop into the TUN in between is the accepted
80+
// offline-enable transient).
7981
//
8082
// Returns the Control-level error (dead socket — eviction signal for the
8183
// registry) separately from setsockopt errors (live socket, wrong option —

vpn/netstate/vpn_hostnet_integration_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,80 @@ func TestGatewayHostNetRoutesStalenessReconcileV6(t *testing.T) {
338338
assertRoutesApplied(t)
339339
}
340340

341+
// ---- R6: offline enable proceeds and self-heals when a default appears ----
342+
//
343+
// Enabling the gateway client with NO IPv4 default route must succeed (warn +
344+
// empty exemption table) with the TUN default and the v6 fence installed, and
345+
// the monitor must copy the host default into tableID once one appears — the
346+
// §8.4 offline-boot self-heal. We simulate "offline" by removing the host's
347+
// real IPv4 defaults and restoring them afterwards; while they are gone the
348+
// host's v4 egress is the TUN black hole, which is within this suite's risk
349+
// profile (every routes test black-holes egress mid-flight anyway).
350+
func TestGatewayHostNetRoutesOfflineEnableSelfHeals(t *testing.T) {
351+
verifyNoLeaks(t)
352+
requireRoot(t)
353+
// A default is required not by the enable under test (that is the point)
354+
// but as material: we snapshot the real defaults to remove and re-add.
355+
requireDefaultRoute(t)
356+
setupDummyTun(t)
357+
358+
origDefaults, err := getDefaultRoutes()
359+
require.NoError(t, err)
360+
require.NotEmpty(t, origDefaults)
361+
for i := range origDefaults {
362+
r := origDefaults[i]
363+
require.NoError(t, netlink.RouteDel(&r), "remove host default %v", r)
364+
}
365+
t.Cleanup(func() {
366+
// RouteReplace, not RouteAdd: the mid-test re-add below leaves one of
367+
// them already present on the success path.
368+
for i := range origDefaults {
369+
r := origDefaults[i]
370+
_ = netlink.RouteReplace(&r)
371+
}
372+
})
373+
374+
before := snapshotNet(t)
375+
376+
// See R4 for why Start + cancel are needed here.
377+
mgr := NewManager()
378+
ctx, cancel := context.WithCancel(context.Background())
379+
t.Cleanup(cancel)
380+
require.NoError(t, mgr.Start(ctx))
381+
require.NoError(t, mgr.EnableClientRoutes(testTunIf),
382+
"offline enable must proceed with a warning, not fail")
383+
t.Cleanup(func() { _ = mgr.DisableClientRoutes() })
384+
require.True(t, mgr.ClientRoutesActive())
385+
386+
// Offline shape: TUN default captured, exemption table empty (nothing to
387+
// exempt yet). assertRoutesApplied is not usable here — it requires a
388+
// non-empty exemption table.
389+
main := cmdOut(t, "ip", "-4", "route", "show")
390+
require.Contains(t, main, "dev "+testTunIf, "TUN default must be installed even offline")
391+
require.Empty(t, strings.TrimSpace(routeTableDump(t, tableID)),
392+
"the v4 exemption table must start empty when enabled offline")
393+
394+
// The network comes back: restore one real default in the main table.
395+
restored := origDefaults[0]
396+
require.NoError(t, netlink.RouteReplace(&restored))
397+
398+
require.Eventually(t, func() bool {
399+
return strings.Contains(routeTableDump(t, tableID), "default")
400+
}, 5*time.Second, 100*time.Millisecond,
401+
"the monitor must copy the appeared host default into the awl exemption table")
402+
403+
// The reconcile must have left the TUN default and the IPv6 fence alone.
404+
assertRoutesApplied(t)
405+
406+
require.NoError(t, mgr.DisableClientRoutes())
407+
require.False(t, mgr.ClientRoutesActive())
408+
409+
// Back out the mid-test re-add so the state is comparable with the
410+
// "no defaults" snapshot; teardown must have removed everything else.
411+
require.NoError(t, netlink.RouteDel(&restored))
412+
require.Equal(t, before, snapshotNet(t), "teardown must restore the exact pre-setup routing state")
413+
}
414+
341415
// ---------------------------------------------------------------------------
342416
// assertions
343417
// ---------------------------------------------------------------------------

vpn/netstate/vpn_hostnet_windows_integration_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,20 @@ func requireAdmin(t *testing.T) {
9494
}
9595
}
9696

97-
// startManager runs Manager.Start with a test-scoped context, so the client
98-
// tests pass the IPv4-uplink gate in EnableClientRoutes the same way
99-
// production does. The watch goroutine dies on the context cancel registered
100-
// here; verifyNoLeaks (registered before this, so running after) sees it gone.
101-
// Skips when the host is offline — the gate would legitimately refuse.
97+
// startManager runs Manager.Start with a test-scoped context, populating the
98+
// uplink indexes the same way production does. The watch goroutine dies on
99+
// the context cancel registered here; verifyNoLeaks (registered before this,
100+
// so running after) sees it gone. Skips when the host is offline: enabling
101+
// offline is allowed (warn + self-heal), but the tests below assert against
102+
// a real uplink index (e.g. MarkerRebind), which an offline runner cannot
103+
// provide.
102104
func startManager(t *testing.T, mgr *Manager) {
103105
t.Helper()
104106
ctx, cancel := context.WithCancel(context.Background())
105107
t.Cleanup(cancel)
106108
require.NoError(t, mgr.Start(ctx))
107109
if mgr.index4.Load() == 0 {
108-
t.Skip("no IPv4 uplink on this host; EnableClientRoutes refuses while offline")
110+
t.Skip("no IPv4 uplink on this host; these tests assert against a real uplink index")
109111
}
110112
}
111113

0 commit comments

Comments
 (0)