diff --git a/internal/vowifi/ike/relay.go b/internal/vowifi/ike/relay.go index 7691f04..9206f54 100644 --- a/internal/vowifi/ike/relay.go +++ b/internal/vowifi/ike/relay.go @@ -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 diff --git a/internal/vowifi/ike/relay_test.go b/internal/vowifi/ike/relay_test.go index 44c54a0..0a61a69 100644 --- a/internal/vowifi/ike/relay_test.go +++ b/internal/vowifi/ike/relay_test.go @@ -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) + } +} diff --git a/internal/vowifi/runtime/manager.go b/internal/vowifi/runtime/manager.go index 6355b00..9245b4f 100644 --- a/internal/vowifi/runtime/manager.go +++ b/internal/vowifi/runtime/manager.go @@ -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 } diff --git a/internal/vowifi/runtime/manager_test.go b/internal/vowifi/runtime/manager_test.go index 75724e6..4a0809f 100644 --- a/internal/vowifi/runtime/manager_test.go +++ b/internal/vowifi/runtime/manager_test.go @@ -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() {