Enhance session relaying to handle delayed IKE packets and implement an automatic retry mechanism

This commit is contained in:
MengMengCode
2026-08-09 16:59:28 +08:00
parent 6f2b7bf395
commit 3939b061af
4 changed files with 334 additions and 11 deletions
+10 -1
View File
@@ -87,6 +87,13 @@ func (relay *sessionRelay) run() {
packet := append([]byte(nil), buffer[:n]...)
if isIKE {
if err := relay.handleIKE(packet); err != nil {
if errors.Is(err, errMismatchedSessionSPIs) {
// A reconnect can reuse the same NAT mapping while the ePDG still
// has packets queued for the previous IKE SA. Those packets are
// unrelated to this authenticated session and must be discarded;
// treating one as fatal tears down the newly established CHILD_SA.
continue
}
relay.fail(err)
return
}
@@ -111,13 +118,15 @@ func (relay *sessionRelay) run() {
}
}
var errMismatchedSessionSPIs = errors.New("ike: session packet has mismatched SPIs")
func (relay *sessionRelay) handleIKE(packet []byte) error {
header, _, err := parseIKEPacket(packet)
if err != nil {
return err
}
if header.InitiatorSPI != relay.spii || header.ResponderSPI != relay.spir {
return errors.New("ike: session packet has mismatched SPIs")
return errMismatchedSessionSPIs
}
if header.Flags&flagResponse != 0 {
return nil
+36
View File
@@ -178,3 +178,39 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) {
t.Fatal("relay did not send a NAT-T keepalive")
}
}
func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) {
transport := newFakeSessionTransport()
spii := [8]byte{1}
spir := [8]byte{2}
relay := newSessionRelay(
transport,
legacyTestSuite(),
ikeKeys{},
spii,
spir,
true,
time.Hour,
)
defer relay.Close()
transport.incoming <- fakeSessionPacket{
ike: true,
data: ikeHeader{
InitiatorSPI: [8]byte{9},
ResponderSPI: [8]byte{8},
Exchange: exchangeInformational,
}.marshal(nil),
}
wantedESP := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa}
transport.incoming <- fakeSessionPacket{data: wantedESP}
buffer := make([]byte, 64)
count, err := relay.ReceiveESP(context.Background(), buffer)
if err != nil {
t.Fatalf("ReceiveESP() after stale IKE packet = %v", err)
}
if !bytes.Equal(buffer[:count], wantedESP) {
t.Fatalf("ESP after stale IKE packet = %x, want %x", buffer[:count], wantedESP)
}
}
+169 -10
View File
@@ -20,7 +20,11 @@ var (
ErrClosed = errors.New("vowifi runtime: manager is closed")
)
const defaultOperationTimeout = 2 * time.Minute
const (
defaultOperationTimeout = 2 * time.Minute
defaultRetryInitial = 2 * time.Second
defaultRetryMaximum = 30 * time.Second
)
type StateHandler func(context.Context, vowifi.State) error
type OrchestratorFactory func(context.Context, string) (*vowifi.Orchestrator, error)
@@ -28,6 +32,8 @@ type OrchestratorFactory func(context.Context, string) (*vowifi.Orchestrator, er
type Options struct {
Logger *slog.Logger
OperationTimeout time.Duration
RetryInitial time.Duration
RetryMaximum time.Duration
OnState StateHandler
Factory OrchestratorFactory
}
@@ -37,6 +43,8 @@ type Manager struct {
cancel context.CancelFunc
logger *slog.Logger
operationTimeout time.Duration
retryInitial time.Duration
retryMaximum time.Duration
onState StateHandler
factory OrchestratorFactory
@@ -50,6 +58,11 @@ type entry struct {
orchestrator *vowifi.Orchestrator
busy bool
reconnectPending bool
disablePending bool
desiredEnabled bool
autoRetryPending bool
retryFailures uint
operationCancel context.CancelFunc
stopWatch func()
}
@@ -60,12 +73,23 @@ func New(options Options) *Manager {
if options.OperationTimeout <= 0 {
options.OperationTimeout = defaultOperationTimeout
}
if options.RetryInitial <= 0 {
options.RetryInitial = defaultRetryInitial
}
if options.RetryMaximum <= 0 {
options.RetryMaximum = defaultRetryMaximum
}
if options.RetryMaximum < options.RetryInitial {
options.RetryMaximum = options.RetryInitial
}
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
ctx: ctx,
cancel: cancel,
logger: options.Logger,
operationTimeout: options.OperationTimeout,
retryInitial: options.RetryInitial,
retryMaximum: options.RetryMaximum,
onState: options.OnState,
factory: options.Factory,
entries: make(map[string]*entry),
@@ -184,6 +208,20 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
return vowifi.State{}, err
}
manager.mu.Lock()
item := manager.entries[deviceID]
item.desiredEnabled = enabled
if !enabled && item.busy {
item.disablePending = true
cancel := item.operationCancel
state := item.orchestrator.State()
manager.mu.Unlock()
if cancel != nil {
cancel()
}
return state, nil
}
manager.mu.Unlock()
return manager.startOperation(deviceID, false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
if enabled {
_, err := orchestrator.Enable(ctx)
@@ -198,6 +236,11 @@ func (manager *Manager) RequestReconnect(deviceID string) (vowifi.State, error)
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
return vowifi.State{}, err
}
manager.mu.Lock()
if item := manager.entries[deviceID]; item != nil {
item.desiredEnabled = true
}
manager.mu.Unlock()
return manager.startOperation(deviceID, true, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Reconnect(ctx)
return err
@@ -270,6 +313,17 @@ func (manager *Manager) runOperations(
defer manager.wg.Done()
for {
ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout)
manager.mu.Lock()
if item.disablePending {
item.disablePending = false
item.reconnectPending = false
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Disable(ctx)
return err
}
}
item.operationCancel = cancel
manager.mu.Unlock()
err := operation(ctx, item.orchestrator)
cancel()
if err != nil &&
@@ -281,23 +335,114 @@ func (manager *Manager) runOperations(
"error", err,
)
}
state := item.orchestrator.State()
manager.mu.Lock()
if manager.closed || !item.reconnectPending {
item.operationCancel = nil
if manager.closed {
item.busy = false
manager.mu.Unlock()
return
}
item.reconnectPending = false
if item.disablePending {
item.disablePending = false
item.reconnectPending = false
manager.mu.Unlock()
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Disable(ctx)
return err
}
continue
}
if item.reconnectPending {
item.reconnectPending = false
manager.mu.Unlock()
// Read the route only when this runs. If the user bound, unbound,
// then rebound while busy, this reconnect uses the final persisted
// binding instead of replaying stale intermediate routes.
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Reconnect(ctx)
return err
}
continue
}
item.busy = false
shouldRetry := item.desiredEnabled && state.Phase == vowifi.PhaseFailed
if !shouldRetry && state.Phase != vowifi.PhaseFailed {
item.retryFailures = 0
}
manager.mu.Unlock()
if shouldRetry {
manager.scheduleAutoRetry(deviceID, item)
}
return
}
}
func (manager *Manager) scheduleAutoRetry(deviceID string, item *entry) {
manager.mu.Lock()
if manager.closed || item.busy || item.autoRetryPending || !item.desiredEnabled {
manager.mu.Unlock()
return
}
delay := manager.retryInitial
for attempt := uint(0); attempt < item.retryFailures && delay < manager.retryMaximum; attempt++ {
if delay > manager.retryMaximum/2 {
delay = manager.retryMaximum
break
}
delay *= 2
}
if delay > manager.retryMaximum {
delay = manager.retryMaximum
}
item.retryFailures++
item.autoRetryPending = true
manager.wg.Add(1)
manager.mu.Unlock()
manager.logger.Info(
"VoWiFi automatic retry scheduled",
"device_id", deviceID,
"retry_in", delay,
)
go func() {
defer manager.wg.Done()
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-manager.ctx.Done():
return
case <-timer.C:
}
manager.mu.Lock()
item.autoRetryPending = false
if manager.closed || manager.entries[deviceID] != item || !item.desiredEnabled {
manager.mu.Unlock()
return
}
state := item.orchestrator.State()
if state.Phase != vowifi.PhaseFailed {
if state.Phase != vowifi.PhaseStopping {
item.retryFailures = 0
}
manager.mu.Unlock()
return
}
if item.busy {
manager.mu.Unlock()
return
}
item.busy = true
manager.wg.Add(1)
manager.mu.Unlock()
// Read the route only when this runs. If the user bound, unbound, then
// rebound while busy, the single reconnect uses the final persisted
// binding instead of replaying stale intermediate routes.
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Reconnect(ctx)
go manager.runOperations(deviceID, item, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Retry(ctx)
return err
}
}
})
}()
}
func (manager *Manager) watch(deviceID string, states <-chan vowifi.State) {
@@ -310,6 +455,20 @@ func (manager *Manager) watch(deviceID string, states <-chan vowifi.State) {
if !ok {
return
}
if state.Phase == vowifi.PhaseFailed {
manager.mu.Lock()
item := manager.entries[deviceID]
manager.mu.Unlock()
if item != nil {
manager.scheduleAutoRetry(deviceID, item)
}
} else if state.Phase == vowifi.PhaseSMSReady || !state.Enabled {
manager.mu.Lock()
if item := manager.entries[deviceID]; item != nil {
item.retryFailures = 0
}
manager.mu.Unlock()
}
if manager.onState == nil {
continue
}
+119
View File
@@ -63,6 +63,31 @@ func (fakeTunnelSession) Evidence() vowifi.TunnelEvidence {
}
func (fakeTunnelSession) Close(context.Context) error { return nil }
type flakyTunnelProvider struct {
mu sync.Mutex
attempts int
failures int
}
func (provider *flakyTunnelProvider) Start(
context.Context,
vowifi.TunnelRequest,
) (vowifi.TunnelSession, error) {
provider.mu.Lock()
defer provider.mu.Unlock()
provider.attempts++
if provider.attempts <= provider.failures {
return nil, errors.New("temporary tunnel failure")
}
return fakeTunnelSession{}, nil
}
func (provider *flakyTunnelProvider) Attempts() int {
provider.mu.Lock()
defer provider.mu.Unlock()
return provider.attempts
}
type fakeIMSProvider struct{}
type fakeIMSSession struct{}
@@ -104,6 +129,27 @@ func testOrchestrator(t *testing.T, id string) *vowifi.Orchestrator {
return orchestrator
}
func testOrchestratorWithTunnel(
t *testing.T,
id string,
tunnel vowifi.TunnelProvider,
) *vowifi.Orchestrator {
t.Helper()
orchestrator, err := vowifi.New(vowifi.Dependencies{
SIM: fakeSIM{},
AKA: fakeAKA{},
Radio: fakeRadio{},
Proxy: fakeProxy{},
Tunnel: tunnel,
IMS: fakeIMSProvider{},
Phones: fakePhones{},
}, vowifi.Options{DeviceID: id})
if err != nil {
t.Fatal(err)
}
return orchestrator
}
func TestManagerRunsAndPublishesEnable(t *testing.T) {
var mu sync.Mutex
var states []vowifi.State
@@ -148,6 +194,79 @@ func TestManagerRunsAndPublishesEnable(t *testing.T) {
t.Fatal("enable did not finish")
}
func TestManagerRetriesEnabledPolicyUntilReady(t *testing.T) {
provider := &flakyTunnelProvider{failures: 2}
manager := New(Options{
OperationTimeout: time.Second,
RetryInitial: 5 * time.Millisecond,
RetryMaximum: 10 * time.Millisecond,
})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestratorWithTunnel(t, "ec20", provider)); err != nil {
t.Fatal(err)
}
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Phase == vowifi.PhaseSMSReady {
if attempts := provider.Attempts(); attempts != 3 {
t.Fatalf("tunnel attempts = %d, want 3", attempts)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("VoWiFi did not become ready after retries; attempts=%d", provider.Attempts())
}
func TestManagerStopsAutomaticRetryWhenPolicyIsDisabled(t *testing.T) {
provider := &flakyTunnelProvider{failures: 100}
manager := New(Options{
OperationTimeout: time.Second,
RetryInitial: 100 * time.Millisecond,
RetryMaximum: 100 * time.Millisecond,
})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestratorWithTunnel(t, "ec20", provider)); err != nil {
t.Fatal(err)
}
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
manager.mu.Lock()
pending := manager.entries["ec20"].autoRetryPending
manager.mu.Unlock()
if pending {
break
}
time.Sleep(time.Millisecond)
}
if _, err := manager.RequestEnabled("ec20", false); err != nil {
t.Fatal(err)
}
time.Sleep(150 * time.Millisecond)
if attempts := provider.Attempts(); attempts != 1 {
t.Fatalf("tunnel attempts after disable = %d, want 1", attempts)
}
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Enabled || state.Phase != vowifi.PhaseIdle {
t.Fatalf("state after disabling retry policy = %+v", state)
}
}
func TestManagerRejectsUnknownDevice(t *testing.T) {
manager := New(Options{})
t.Cleanup(func() {