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.
This commit is contained in:
parent
37bc7b9f55
commit
c4e0bcf768
4 changed files with 330 additions and 0 deletions
172
device/lx_giveup_selfheal_test.go
Normal file
172
device/lx_giveup_selfheal_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue