wireguard-go-awg2-lx/device/lx_giveup_selfheal_test.go
Leadaxe c4e0bcf768 lx: rebind socket on handshake give-up (sing-box-lx SPEC 041 self-heal)
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.
2026-08-05 16:56:04 +03:00

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)
}