The v1 give-up rebind heals a dead 5-tuple only at ~90s, while users probe within the first seconds after device wake. Two new triggers over the same action and shared debounce window: - early: >=3 unanswered initiations against a provably dead session (no live keypair, or last handshake older than RejectAfterTime) rebind at ~15s from the retry branch; a live session keeps byte-for-byte upstream behaviour; - nudge: public Device.RebindIfSessionStale() lets the consumer report a device wake-up and heal stale peers immediately, without traffic demand. The whole mechanism now lives in device/lx_giveup_rebind.go (moved from device.go to keep the upstream file delta minimal); the rebind log line carries the trigger label.
143 lines
5.9 KiB
Go
143 lines
5.9 KiB
Go
/* SPDX-License-Identifier: MIT
|
|
*
|
|
* lx: SPEC 041 — passive self-heal for a dead per-flow path (an expired NAT
|
|
* mapping or a poisoned DPI flow entry that pins every retry to the same dead
|
|
* 5-tuple until a manual reconnect). One mechanism — reopen the bind (fresh
|
|
* ephemeral port when allowed) and immediately re-initiate — with three
|
|
* triggers sharing one debounce window:
|
|
*
|
|
* giveup — the handshake retry cycle exhausted (~90s of unanswered
|
|
* initiations under traffic demand); safety net, covers every path;
|
|
* early — >=3 unanswered initiations against a provably dead session
|
|
* (see sessionProvablyDead): no point waiting out the rest of the
|
|
* cycle, rebind at ~15s instead of ~90s;
|
|
* nudge — the consumer reports "device woke up" via
|
|
* Device.RebindIfSessionStale (wired through sing-box libbox);
|
|
* heals without waiting for traffic demand at all.
|
|
*
|
|
* Zero cost while healthy: no timers, no goroutines — triggers 1-2 live in
|
|
* the existing retry cycle, trigger 3 is paid by the caller. The state lives
|
|
* in Device.giveUpRebind (device.go); enabled defaults to true in NewDevice,
|
|
* sing-box decides freshPort from whether the user pinned listen_port.
|
|
*/
|
|
|
|
package device
|
|
|
|
import "time"
|
|
|
|
// earlyGiveUpMinAttempts is the number of unanswered initiations (retry timer
|
|
// expiries) after which a provably dead session is rebound early instead of
|
|
// waiting out the full RekeyAttemptTime cycle: ~15s at RekeyTimeout=5s.
|
|
const earlyGiveUpMinAttempts = 3
|
|
|
|
// SetGiveUpRebind configures the self-heal (see the giveUpRebind field
|
|
// comment in device.go). freshPort must be false when the user pinned an
|
|
// explicit listen_port: the pinned port is preserved, at the cost of the
|
|
// rebind not changing the 5-tuple.
|
|
func (device *Device) SetGiveUpRebind(enabled, freshPort bool) {
|
|
device.giveUpRebind.enabled.Store(enabled)
|
|
device.giveUpRebind.freshPort.Store(freshPort)
|
|
}
|
|
|
|
// handleHandshakeGiveUp is invoked from the give-up branch of
|
|
// expiredRetransmitHandshake: ~90s of initiations went unanswered, so the
|
|
// current socket's 5-tuple is proven dead.
|
|
func (device *Device) handleHandshakeGiveUp(peer *Peer) {
|
|
device.selfHealRebind("giveup", peer)
|
|
}
|
|
|
|
// maybeEarlyGiveUpRebind is invoked from the RETRY branch of
|
|
// expiredRetransmitHandshake. Once enough initiations went unanswered AND the
|
|
// session is provably dead there is nothing left to protect — rebind now, at
|
|
// ~15s instead of ~90s. The retry cycle itself continues untouched: this only
|
|
// moves the socket under it. A live session with transient packet loss fails
|
|
// sessionProvablyDead and keeps byte-for-byte upstream behaviour; the shared
|
|
// debounce means this also suppresses the giveup rebind of the same series.
|
|
func (device *Device) maybeEarlyGiveUpRebind(peer *Peer) {
|
|
if peer.timers.handshakeAttempts.Load() < earlyGiveUpMinAttempts {
|
|
return
|
|
}
|
|
if !device.sessionProvablyDead(peer) {
|
|
return
|
|
}
|
|
device.selfHealRebind("early", peer)
|
|
}
|
|
|
|
// sessionProvablyDead reports whether the peer's session is beyond saving: no
|
|
// live keypair, or the last successful handshake is older than
|
|
// RejectAfterTime (the keys are invalid after that, so a rebind loses
|
|
// nothing). The stale predicate shared by the early and nudge triggers.
|
|
func (device *Device) sessionProvablyDead(peer *Peer) bool {
|
|
if peer.keypairs.Current() == nil {
|
|
return true
|
|
}
|
|
return time.Since(time.Unix(0, peer.lastHandshakeNano.Load())) > RejectAfterTime
|
|
}
|
|
|
|
// RebindIfSessionStale is the wake-nudge entry (trigger 3): the consumer
|
|
// observed a device wake-up and asks for an immediate heal instead of waiting
|
|
// for traffic demand to walk the retry cycle. If any running peer's session
|
|
// is provably dead the bind is reopened once (shared debounce) and every such
|
|
// peer re-initiates immediately; a healthy device is a no-op. Returns whether
|
|
// a rebind was actually scheduled. Never blocks on the rebind itself — the
|
|
// heavy part runs in a goroutine (see selfHealRebind). On a down or closed
|
|
// device it is a no-op, so callers racing idle-suspend or Close are safe.
|
|
func (device *Device) RebindIfSessionStale() bool {
|
|
if !device.giveUpRebind.enabled.Load() || !device.isUp() {
|
|
return false
|
|
}
|
|
var stale []*Peer
|
|
device.peers.RLock()
|
|
for _, peer := range device.peers.keyMap {
|
|
if peer.isRunning.Load() && device.sessionProvablyDead(peer) {
|
|
stale = append(stale, peer)
|
|
}
|
|
}
|
|
device.peers.RUnlock()
|
|
if len(stale) == 0 {
|
|
return false
|
|
}
|
|
return device.selfHealRebind("nudge", stale...)
|
|
}
|
|
|
|
// selfHealRebind is the shared action behind all three triggers. Runs the
|
|
// heavy part in a goroutine so a timer callback (or a nudge caller) never
|
|
// blocks on BindUpdate's worker drain. Debounced to one rebind per
|
|
// RekeyAttemptTime per device across ALL triggers (CAS on `last` settles
|
|
// concurrent multi-peer races): an early rebind at ~15s suppresses the giveup
|
|
// rebind of the same failed series at ~90s. On a down or closed device
|
|
// BindUpdate does not reopen the socket, so a rebind racing idle-suspend
|
|
// (SPEC 020) or Close degrades to a no-op.
|
|
func (device *Device) selfHealRebind(trigger string, peers ...*Peer) bool {
|
|
if !device.giveUpRebind.enabled.Load() {
|
|
return false
|
|
}
|
|
if device.isClosed() {
|
|
return false
|
|
}
|
|
now := time.Now().Unix()
|
|
last := device.giveUpRebind.last.Load()
|
|
if now-last < int64(RekeyAttemptTime/time.Second) {
|
|
return false
|
|
}
|
|
if !device.giveUpRebind.last.CompareAndSwap(last, now) {
|
|
return false
|
|
}
|
|
fresh := device.giveUpRebind.freshPort.Load()
|
|
go func() {
|
|
if fresh {
|
|
device.net.Lock()
|
|
device.net.port = 0
|
|
device.net.Unlock()
|
|
}
|
|
if err := device.BindUpdate(); err != nil {
|
|
device.log.Errorf("%v - Failed self-heal rebind (trigger=%s): %v", peers[0], trigger, err)
|
|
return
|
|
}
|
|
device.log.Verbosef("%v - Rebound socket for self-heal (trigger=%s, fresh port=%v)", peers[0], trigger, fresh)
|
|
for _, peer := range peers {
|
|
peer.SendHandshakeInitiation(false)
|
|
}
|
|
}()
|
|
return true
|
|
}
|