fix: add VPN wake rebind hook (#26739)

Add a CoderVPN WakeRequest RPC so Coder Desktop can trigger the
existing link-change recovery path (Rebind + ReSTUN) immediately on
OS wake, instead of waiting for magicsock's idle re-STUN timer. Wake
events are debounced to at most one rebind per 5s to avoid duplicate
resets of peer path trust.

Closes #26736
This commit is contained in:
Ehab Younes
2026-07-13 15:14:00 +03:00
committed by GitHub
parent 1497ba14fe
commit e11147ec66
8 changed files with 659 additions and 368 deletions
+6
View File
@@ -495,6 +495,12 @@ func (c *Conn) MagicsockSetDebugLoggingEnabled(enabled bool) {
c.magicConn.SetDebugLoggingEnabled(enabled)
}
// Rebind resets local network bindings and rediscovers peer paths.
func (c *Conn) Rebind() {
c.magicConn.Rebind()
c.magicConn.ReSTUN("wake")
}
func (c *Conn) SetAddresses(ips []netip.Prefix) error {
c.configMaps.setAddresses(ips)
c.nodeUpdater.setAddresses(ips)
+1
View File
@@ -32,6 +32,7 @@ type Conn interface {
Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error)
Node() *tailnet.Node
DERPMap() *tailcfg.DERPMap
Rebind()
Close() error
}
+1 -1
View File
@@ -22,7 +22,7 @@ func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
}
const expectedHandshake = "codervpn tunnel 1.2\n"
const expectedHandshake = "codervpn tunnel 1.3\n"
// TestSpeaker_RawPeer tests the speaker with a peer that we simulate by directly making reads and
// writes to the other end of the pipe. There should be at least one test that does this, rather
+38
View File
@@ -164,6 +164,15 @@ func (t *Tunnel) handleRPC(req *request[*TunnelMessage, *ManagerMessage]) {
ErrorMessage: errStr,
},
}
case *ManagerMessage_Wake:
if t.updater.rebind() {
t.logger.Info(t.ctx, "handling system wake; rebinding")
} else {
t.logger.Debug(t.ctx, "ignoring system wake; tunnel is not running or a rebind happened recently")
}
resp.Msg = &TunnelMessage_Wake{
Wake: &WakeResponse{Success: true},
}
default:
t.logger.Warn(t.ctx, "unhandled manager request", slog.F("request", msg))
}
@@ -338,6 +347,8 @@ type updater struct {
// workspaces contains the workspaces to which agents are currently connected via the tunnel.
workspaces map[uuid.UUID]tailnet.Workspace
conn Conn
// lastRebind debounces wake-triggered rebinds.
lastRebind time.Time
clock quartz.Clock
}
@@ -551,6 +562,33 @@ func (u *updater) stop() error {
return err
}
// wakeRebindDebounce is the minimum interval between wake-triggered rebinds.
// Rebinding resets peer path trust, so duplicate wake events must not each
// trigger one.
const wakeRebindDebounce = 5 * time.Second
// rebind resets network bindings and rediscovers peer paths. It reports
// whether it ran; calls within wakeRebindDebounce of the previous rebind, or
// while the tunnel is not running, are dropped.
func (u *updater) rebind() bool {
u.mu.Lock()
conn := u.conn
if conn == nil {
u.mu.Unlock()
return false
}
now := u.clock.Now()
if now.Sub(u.lastRebind) < wakeRebindDebounce {
u.mu.Unlock()
return false
}
u.lastRebind = now
u.mu.Unlock()
conn.Rebind()
return true
}
// sendAgentUpdate sends a peer update message to the manager with the current
// state of the agents, including the latest network status.
func (u *updater) sendAgentUpdate() {
+85 -3
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"tailscale.com/ipn/ipnstate"
@@ -53,9 +54,10 @@ func (f *fakeClient) NewConn(context.Context, *url.URL, string, *Options) (Conn,
func newFakeConn(state tailnet.WorkspaceUpdate, hsTime time.Time) *fakeConn {
return &fakeConn{
closed: make(chan struct{}),
state: state,
hsTime: hsTime,
closed: make(chan struct{}),
rebinds: make(chan struct{}, 1),
state: state,
hsTime: hsTime,
}
}
@@ -69,6 +71,7 @@ type fakeConn struct {
returnPing chan struct{}
hsTime time.Time
closed chan struct{}
rebinds chan struct{}
doClose sync.Once
}
@@ -122,6 +125,10 @@ func (f *fakeConn) GetPeerDiagnostics(uuid.UUID) tailnet.PeerDiagnostics {
}
}
func (f *fakeConn) Rebind() {
f.rebinds <- struct{}{}
}
func (f *fakeConn) Close() error {
f.doClose.Do(func() {
close(f.closed)
@@ -188,6 +195,81 @@ func TestTunnel_StartStop(t *testing.T) {
require.NoError(t, err)
}
func TestTunnel_Wake(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
client := newFakeClient(ctx, t)
conn := newFakeConn(tailnet.WorkspaceUpdate{}, time.Time{})
mClock := quartz.NewMock(t)
_, mgr := setupTunnel(t, ctx, client, mClock)
sendWake := func() *TunnelMessage {
wakeCh := make(chan *TunnelMessage, 1)
go func() {
defer close(wakeCh)
r, err := mgr.unaryRPC(ctx, &ManagerMessage{
Msg: &ManagerMessage_Wake{
Wake: &WakeRequest{},
},
})
assert.NoError(t, err)
wakeCh <- r
}()
return testutil.TryReceive(ctx, t, wakeCh)
}
requireNoRebind := func() {
// Rebinds happen before the RPC reply, so any rebind would
// already be buffered by now.
select {
case <-conn.rebinds:
t.Fatal("unexpected rebind")
default:
}
}
// Waking before the tunnel is started must not rebind.
wakeResp := sendWake()
require.True(t, wakeResp.GetWake().GetSuccess())
requireNoRebind()
startCh := make(chan *TunnelMessage, 1)
go func() {
defer close(startCh)
r, err := mgr.unaryRPC(ctx, &ManagerMessage{
Msg: &ManagerMessage_Start{
Start: &StartRequest{
TunnelFileDescriptor: 2,
CoderUrl: "https://coder.example.com",
ApiToken: "fakeToken",
},
},
})
assert.NoError(t, err)
startCh <- r
}()
testutil.RequireSend(ctx, t, client.ch, conn)
startResp := testutil.TryReceive(ctx, t, startCh)
require.NotNil(t, startResp.GetStart())
// The first wake after start triggers a rebind.
wakeResp = sendWake()
require.True(t, wakeResp.GetWake().GetSuccess())
testutil.TryReceive(ctx, t, conn.rebinds)
// A wake within the debounce window is dropped but still succeeds.
wakeResp = sendWake()
require.True(t, wakeResp.GetWake().GetSuccess())
requireNoRebind()
// After the debounce window elapses, a wake rebinds again.
mClock.Advance(wakeRebindDebounce).MustWait(ctx)
wakeResp = sendWake()
require.True(t, wakeResp.GetWake().GetSuccess())
testutil.TryReceive(ctx, t, conn.rebinds)
}
func TestTunnel_PeerUpdate(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -23,7 +23,8 @@ var CurrentSupportedVersions = RPCVersionList{
// - preferred_derp: The server that DERP relayed connections are
// using, if they're not using P2P.
// - preferred_derp_latency: The latency to the preferred DERP
{Major: 1, Minor: 2},
// 1.3 adds WakeRequest and WakeResponse.
{Major: 1, Minor: 3},
},
}
+516 -363
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -30,6 +30,7 @@ message ManagerMessage {
NetworkSettingsResponse network_settings = 3;
StartRequest start = 4;
StopRequest stop = 5;
WakeRequest wake = 6;
}
}
@@ -42,6 +43,7 @@ message TunnelMessage {
NetworkSettingsRequest network_settings = 4;
StartResponse start = 5;
StopResponse stop = 6;
WakeResponse wake = 7;
}
}
@@ -290,3 +292,11 @@ message Status {
// be populated.
PeerUpdate peer_update = 3;
}
// WakeRequest is sent by the manager after the system wakes from sleep. The
// tunnel uses this as a hint to rediscover network paths.
message WakeRequest {}
message WakeResponse {
bool success = 1;
}