After ~90s of unanswered handshake initiations (the give-up branch of expiredRetransmitHandshake) the socket's 5-tuple is proven dead (expired NAT mapping / poisoned DPI flow entry after device sleep) and upstream retries into it forever; only a manual reconnect healed the peer. Reopen the bind once per give-up cycle (fresh ephemeral port unless the user pinned listen_port), then re-initiate immediately. Debounced via CAS; no timers or goroutines while healthy; a rebind racing Down()/Close() degrades to a no-op inside BindUpdate. Red/green e2e + unit tests.
172 lines
5.3 KiB
Go
172 lines
5.3 KiB
Go
/* SPDX-License-Identifier: MIT
|
|
*
|
|
* lx: SPEC 041 — behavioural red/green test for the handshake give-up
|
|
* self-heal. Field failure mode (WARP/AWG after device sleep): the per-flow
|
|
* path state of the socket's 5-tuple dies (expired NAT mapping / poisoned DPI
|
|
* flow entry), every packet sent from the old socket vanishes, and upstream
|
|
* wireguard-go retries into that dead socket forever — only a manual
|
|
* reconnect (new socket, new ephemeral port) heals the peer.
|
|
*
|
|
* The test models the dead 5-tuple with a bind whose FIRST socket generation
|
|
* silently swallows every send; any socket opened after a rebind delivers
|
|
* normally. It then drives the peer into the give-up branch of
|
|
* expiredRetransmitHandshake and expects traffic to flow end to end without
|
|
* any reconnect:
|
|
*
|
|
* - pre-fix (base): give-up only flushes staged packets, the bind is never
|
|
* reopened, every retry keeps dying in the first generation -> timeout;
|
|
* - post-fix: give-up rebinds the socket and re-initiates -> tunnel comes
|
|
* up and the packet arrives.
|
|
*
|
|
* This file deliberately uses NO post-fix API, so it compiles and runs RED on
|
|
* the pre-fix base commit. Reuses the chanBind/chanTun harness from
|
|
* transport_padding_test.go.
|
|
*/
|
|
|
|
package device
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sagernet/wireguard-go/conn"
|
|
)
|
|
|
|
// gateBind wraps chanBind: it records every Open (the port argument the
|
|
// device asked for) and silently swallows sends while the socket generation
|
|
// is at most dropOpens — modelling a dead 5-tuple whose packets vanish on the
|
|
// path without any local error.
|
|
type gateBind struct {
|
|
*chanBind
|
|
mu sync.Mutex
|
|
openPorts []uint16
|
|
opens int
|
|
dropOpens int // swallow sends while opens <= dropOpens
|
|
}
|
|
|
|
func (b *gateBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) {
|
|
fns, actual, err := b.chanBind.Open(port)
|
|
b.mu.Lock()
|
|
b.opens++
|
|
b.openPorts = append(b.openPorts, port)
|
|
b.mu.Unlock()
|
|
return fns, actual, err
|
|
}
|
|
|
|
func (b *gateBind) Send(bufs [][]byte, ep conn.Endpoint, offset int) error {
|
|
b.mu.Lock()
|
|
drop := b.opens <= b.dropOpens
|
|
b.mu.Unlock()
|
|
if drop {
|
|
return nil // the dead 5-tuple: no local error, the packet just vanishes
|
|
}
|
|
return b.chanBind.Send(bufs, ep, offset)
|
|
}
|
|
|
|
func (b *gateBind) openCount() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.opens
|
|
}
|
|
|
|
func (b *gateBind) portsSnapshot() []uint16 {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]uint16(nil), b.openPorts...)
|
|
}
|
|
|
|
type giveUpPair struct {
|
|
devA, devB *Device
|
|
tunA, tunB *chanTun
|
|
bindA *gateBind
|
|
pkB NoisePublicKey
|
|
}
|
|
|
|
// newGiveUpPair builds two Up()'d peered devices; devA sits on a gateBind
|
|
// whose first socket generation optionally blackholes all sends.
|
|
func newGiveUpPair(t *testing.T, dropFirstOpen bool) *giveUpPair {
|
|
t.Helper()
|
|
|
|
skA, err := newPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("newPrivateKey A: %v", err)
|
|
}
|
|
skB, err := newPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("newPrivateKey B: %v", err)
|
|
}
|
|
pkA := skA.publicKey()
|
|
pkB := skB.publicKey()
|
|
|
|
rawA, rawB := newChanBindPair()
|
|
bindA := &gateBind{chanBind: rawA}
|
|
if dropFirstOpen {
|
|
bindA.dropOpens = 1
|
|
}
|
|
tunA := newChanTun()
|
|
tunB := newChanTun()
|
|
|
|
devA := NewDevice(context.Background(), tunA, bindA, NewLogger(LogLevelError, "devA: "), 1)
|
|
devB := NewDevice(context.Background(), tunB, rawB, NewLogger(LogLevelError, "devB: "), 1)
|
|
t.Cleanup(devA.Close)
|
|
t.Cleanup(devB.Close)
|
|
|
|
cfgA := fmt.Sprintf(
|
|
"private_key=%s\nreplace_peers=true\npublic_key=%s\nendpoint=127.0.0.1:2\nallowed_ip=%s/32\n",
|
|
hex.EncodeToString(skA[:]), hex.EncodeToString(pkB[:]), testIPB)
|
|
cfgB := fmt.Sprintf(
|
|
"private_key=%s\nreplace_peers=true\npublic_key=%s\nendpoint=127.0.0.1:1\nallowed_ip=%s/32\n",
|
|
hex.EncodeToString(skB[:]), hex.EncodeToString(pkA[:]), testIPA)
|
|
|
|
if err := devA.IpcSet(cfgA); err != nil {
|
|
t.Fatalf("IpcSet A: %v", err)
|
|
}
|
|
if err := devB.IpcSet(cfgB); err != nil {
|
|
t.Fatalf("IpcSet B: %v", err)
|
|
}
|
|
if err := devA.Up(); err != nil {
|
|
t.Fatalf("Up A: %v", err)
|
|
}
|
|
if err := devB.Up(); err != nil {
|
|
t.Fatalf("Up B: %v", err)
|
|
}
|
|
|
|
return &giveUpPair{devA: devA, devB: devB, tunA: tunA, tunB: tunB, bindA: bindA, pkB: pkB}
|
|
}
|
|
|
|
// triggerGiveUp drives dev's peer into the give-up branch of
|
|
// expiredRetransmitHandshake exactly the way 90s of unanswered retries would:
|
|
// attempts past the limit, last initiation older than RekeyTimeout.
|
|
func triggerGiveUp(t *testing.T, dev *Device, pk NoisePublicKey) {
|
|
t.Helper()
|
|
dev.peers.RLock()
|
|
peer := dev.peers.keyMap[pk]
|
|
dev.peers.RUnlock()
|
|
if peer == nil {
|
|
t.Fatal("peer not found")
|
|
}
|
|
peer.handshake.mutex.Lock()
|
|
peer.handshake.lastSentHandshake = time.Now().Add(-2 * RekeyTimeout)
|
|
peer.handshake.mutex.Unlock()
|
|
peer.timers.handshakeAttempts.Store(MaxTimerHandshakes + 1)
|
|
expiredRetransmitHandshake(peer)
|
|
}
|
|
|
|
// TestHandshakeGiveUpSelfHeal: dead first socket, give-up fires — the tunnel
|
|
// must come up and deliver traffic without any reconnect.
|
|
func TestHandshakeGiveUpSelfHeal(t *testing.T) {
|
|
pair := newGiveUpPair(t, true)
|
|
|
|
pkt := buildIPv4Packet(testIPA, testIPB, 8)
|
|
send := func() { pair.tunA.toDevice <- pkt }
|
|
|
|
// Traffic demand: stages the packet and sends the first (blackholed)
|
|
// initiation, exactly the state a real give-up cycle ends in.
|
|
send()
|
|
triggerGiveUp(t, pair.devA, pair.pkB)
|
|
awaitPacket(t, pair.tunB, pkt, send)
|
|
}
|