| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811 |
- package vowifi
- import (
- "context"
- "errors"
- "fmt"
- "net"
- "strings"
- "sync"
- "time"
- )
- const defaultCleanupTimeout = 10 * time.Second
- type runtimeResources struct {
- cancel context.CancelFunc
- radio RadioSnapshot
- radioChanged bool
- tunnel TunnelSession
- ims IMSSession
- }
- // Orchestrator serializes lifecycle mutations while allowing concurrent state
- // readers and subscribers. Disable cancels an in-flight Enable before waiting
- // for the mutation lock, so a blocked provider cannot deadlock shutdown.
- type Orchestrator struct {
- deps Dependencies
- options Options
- operation chan struct{}
- mu sync.Mutex
- state State
- resources *runtimeResources
- subscribers map[uint64]chan State
- nextSubscriber uint64
- }
- func New(deps Dependencies, options Options) (*Orchestrator, error) {
- if err := deps.validate(); err != nil {
- return nil, err
- }
- if err := options.validate(); err != nil {
- return nil, err
- }
- if options.CleanupTimeout == 0 {
- options.CleanupTimeout = defaultCleanupTimeout
- }
- options.DeviceID = strings.TrimSpace(options.DeviceID)
- now := time.Now().UTC()
- orchestrator := &Orchestrator{
- deps: deps,
- options: options,
- operation: make(chan struct{}, 1),
- state: State{
- DeviceID: strings.TrimSpace(options.DeviceID),
- Phase: PhaseIdle,
- Sequence: 1,
- UpdatedAt: now,
- Security: SecurityAudit{
- ResponderAUTH: ResponderAUTHUnknown,
- },
- },
- subscribers: make(map[uint64]chan State),
- }
- orchestrator.operation <- struct{}{}
- return orchestrator, nil
- }
- // State returns a detached snapshot safe for mutation by the caller.
- func (orchestrator *Orchestrator) State() State {
- orchestrator.mu.Lock()
- defer orchestrator.mu.Unlock()
- return orchestrator.state.clone()
- }
- // Subscribe returns the current state immediately and then the newest state on
- // every mutation. Slow subscribers lose intermediate snapshots rather than
- // blocking the modem lifecycle.
- func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) {
- if buffer < 1 {
- buffer = 1
- }
- channel := make(chan State, buffer)
- orchestrator.mu.Lock()
- id := orchestrator.nextSubscriber
- orchestrator.nextSubscriber++
- orchestrator.subscribers[id] = channel
- channel <- orchestrator.state.clone()
- orchestrator.mu.Unlock()
- var once sync.Once
- cancel := func() {
- once.Do(func() {
- orchestrator.mu.Lock()
- if existing, ok := orchestrator.subscribers[id]; ok {
- delete(orchestrator.subscribers, id)
- close(existing)
- }
- orchestrator.mu.Unlock()
- })
- }
- return channel, cancel
- }
- // Enable executes one evidence-backed transaction. The order intentionally
- // follows the working Linux/QMI path: live identity and home PLMN, AKA
- // availability, ePDG derivation, runtime-owned RF off, cellular-data stop,
- // country proxy resolution, SWu tunnel, IMS registration, and SMS readiness.
- func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- if err := orchestrator.lockOperation(ctx); err != nil {
- return orchestrator.State(), err
- }
- defer orchestrator.unlockOperation()
- current := orchestrator.State()
- switch current.Phase {
- case PhaseSIMReady, PhaseAccessReady, PhaseTunnelReady, PhaseIMSReady, PhaseSMSReady, PhaseStopping:
- return current, ErrAlreadyEnabled
- }
- now := time.Now().UTC()
- orchestrator.mutate(func(state *State) {
- attempt := state.Attempt + 1
- sequence := state.Sequence
- *state = State{
- DeviceID: orchestrator.options.DeviceID,
- Phase: PhaseIdle,
- Enabled: true,
- Attempt: attempt,
- Sequence: sequence,
- StartedAt: &now,
- UpdatedAt: now,
- LastReason: "enable_requested",
- Security: SecurityAudit{
- ResponderAUTH: ResponderAUTHUnknown,
- },
- }
- })
- runtimeContext, runtimeCancel := context.WithCancel(context.Background())
- resources := &runtimeResources{cancel: runtimeCancel}
- orchestrator.mu.Lock()
- orchestrator.resources = resources
- orchestrator.mu.Unlock()
- setupContext, stopSetup := mergedContext(ctx, runtimeContext)
- defer stopSetup()
- fail := func(stage Phase, cause error) (State, error) {
- runtimeCancel()
- cleanupErrors := orchestrator.cleanup(resources)
- orchestrator.mu.Lock()
- orchestrator.resources = nil
- orchestrator.mu.Unlock()
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseFailed
- state.Active = false
- state.TunnelReady = false
- state.IMSReady = false
- state.SMSReady = false
- state.LastErrorClass = classifyError(stage, cause)
- state.LastError = cause.Error()
- state.LastReason = "enable_failed"
- state.CleanupErrors = append([]string(nil), cleanupErrors...)
- })
- stageError := error(&StageError{Stage: stage, Err: cause})
- if len(cleanupErrors) > 0 {
- stageError = errors.Join(
- stageError,
- fmt.Errorf("vowifi cleanup: %s", strings.Join(cleanupErrors, "; ")),
- )
- }
- return orchestrator.State(), stageError
- }
- identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID)
- if err != nil {
- return fail(PhaseSIMReady, err)
- }
- if err := identity.validate(); err != nil {
- return fail(PhaseSIMReady, err)
- }
- akaEvidence, err := orchestrator.deps.AKA.CheckReady(setupContext, identity)
- if err != nil {
- return fail(PhaseSIMReady, err)
- }
- if !akaEvidence.Ready {
- return fail(PhaseSIMReady, errors.New("AKA application is not ready"))
- }
- if reader, ok := orchestrator.deps.SIM.(SMSCenterReader); ok {
- if smsc, smscErr := reader.ReadSMSCenter(setupContext, orchestrator.options.DeviceID); smscErr == nil {
- identity.SMSC = strings.TrimSpace(smsc)
- } else {
- orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
- }
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseSIMReady
- state.SIMReady = true
- state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
- state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
- state.LastReason = "sim_and_aka_ready"
- })
- epdg, err := DeriveEPDG(identity)
- if err != nil {
- return fail(PhaseAccessReady, err)
- }
- resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
- if err != nil {
- return fail(PhaseAccessReady, err)
- }
- orchestrator.mutate(func(state *State) {
- state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
- })
- // Mark the radio transaction before the first mutating call: a provider
- // may return an error after partially changing the modem.
- resources.radioChanged = true
- // Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20
- // firmware automatically owns CID 1 while CFUN=1 and rejects a direct
- // CGACT=0 command even though the Linux data interface is down. CFUN=4
- // tears down packet service at the baseband; StopCellularData then acts as
- // a fail-closed verification and removes any context that unexpectedly
- // survived RF-off.
- if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
- return fail(PhaseAccessReady, err)
- }
- if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
- return fail(PhaseAccessReady, err)
- }
- proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{
- DeviceID: orchestrator.options.DeviceID,
- HomeMCC: strings.TrimSpace(identity.HomeMCC),
- HomeMNC: strings.TrimSpace(identity.HomeMNC),
- CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)),
- })
- if err != nil {
- return fail(PhaseAccessReady, err)
- }
- proxy, err = normalizeProxyRoute(proxy)
- if err != nil {
- return fail(PhaseAccessReady, err)
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseAccessReady
- state.AccessReady = true
- state.EPDG = epdg
- state.ProxyMode = proxy.Mode
- state.ProxyID = proxy.ID
- state.LastReason = "epdg_access_ready"
- })
- tunnel, err := orchestrator.deps.Tunnel.Start(setupContext, TunnelRequest{
- DeviceID: orchestrator.options.DeviceID,
- Identity: identity,
- EPDG: epdg,
- Proxy: proxy,
- AKA: orchestrator.deps.AKA,
- Security: TunnelSecurityPolicy{
- AllowMissingResponderAUTH: orchestrator.options.AllowMissingResponderAUTH,
- },
- })
- if err != nil {
- return fail(PhaseTunnelReady, err)
- }
- if tunnel == nil {
- return fail(PhaseTunnelReady, errors.New("tunnel provider returned a nil session"))
- }
- resources.tunnel = tunnel
- tunnelEvidence := tunnel.Evidence()
- if !tunnelEvidence.Established {
- orchestrator.mutate(func(state *State) {
- state.Security = securityAuditFromEvidence(tunnelEvidence)
- })
- return fail(PhaseTunnelReady, ErrTunnelNotEstablished)
- }
- securityAudit, err := orchestrator.validateTunnelEvidence(tunnelEvidence)
- orchestrator.mutate(func(state *State) {
- state.Security = securityAudit
- })
- if err != nil {
- return fail(PhaseTunnelReady, err)
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseTunnelReady
- state.Active = true
- state.TunnelReady = true
- state.TunnelName = strings.TrimSpace(tunnelEvidence.Name)
- state.DataplaneMode = strings.TrimSpace(tunnelEvidence.DataplaneMode)
- state.LastReason = "ipsec_tunnel_ready"
- })
- orchestrator.watchRuntimeTunnel(runtimeContext, resources, tunnel)
- ims, err := orchestrator.deps.IMS.Start(setupContext, IMSRequest{
- DeviceID: orchestrator.options.DeviceID,
- Identity: identity,
- Tunnel: tunnel,
- })
- if err != nil {
- return fail(PhaseIMSReady, err)
- }
- if ims == nil {
- return fail(PhaseIMSReady, errors.New("IMS provider returned a nil session"))
- }
- resources.ims = ims
- orchestrator.watchRuntimeIMS(runtimeContext, resources, ims)
- imsEvidence := ims.Evidence()
- if !imsEvidence.Registered {
- return fail(PhaseIMSReady, ErrIMSNotRegistered)
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseIMSReady
- state.IMSReady = true
- state.IMSRegistration = strings.TrimSpace(imsEvidence.RegistrationState)
- state.LastReason = "ims_registered"
- })
- if number, source, ok := ExtractAssociatedMSISDN(imsEvidence); ok {
- record := PhoneRecord{
- ICCID: strings.TrimSpace(identity.ICCID),
- Number: number,
- Source: source,
- UpdatedAt: time.Now().UTC(),
- }
- if err := orchestrator.deps.Phones.SaveAssociatedNumber(setupContext, record); err != nil {
- orchestrator.addWarning("IMS associated number is valid but could not be persisted: " + err.Error())
- } else {
- orchestrator.mutate(func(state *State) {
- state.PhoneNumber = number
- state.PhoneNumberSource = source
- })
- }
- } else {
- orchestrator.addWarning("IMS did not publish an associated MSISDN; the number was not inferred from IMSI")
- }
- smsEvidence, err := ims.EnableSMS(setupContext)
- if err != nil {
- if orchestrator.options.AllowIMSWithoutSMS {
- orchestrator.addWarning("IMS is registered but SMS capability was not confirmed: " + err.Error())
- orchestrator.mutate(func(state *State) {
- state.LastReason = "ims_registered_sms_unavailable"
- state.LastError = ""
- state.LastErrorClass = ""
- state.CleanupErrors = nil
- })
- return orchestrator.State(), nil
- }
- return fail(PhaseSMSReady, err)
- }
- if !smsEvidence.Ready {
- if orchestrator.options.AllowIMSWithoutSMS {
- orchestrator.addWarning("IMS is registered but SMS capability was not confirmed")
- orchestrator.mutate(func(state *State) {
- state.LastReason = "ims_registered_sms_unavailable"
- state.LastError = ""
- state.LastErrorClass = ""
- state.CleanupErrors = nil
- })
- return orchestrator.State(), nil
- }
- return fail(PhaseSMSReady, ErrSMSNotReady)
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseSMSReady
- state.SMSReady = true
- state.LastReason = "sms_ready"
- state.LastError = ""
- state.LastErrorClass = ""
- state.CleanupErrors = nil
- })
- return orchestrator.State(), nil
- }
- // Disable is idempotent. It interrupts setup when necessary and closes IMS,
- // tunnel, then restores the captured radio state.
- func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- orchestrator.cancelCurrentRuntime()
- if err := orchestrator.lockOperation(ctx); err != nil {
- return orchestrator.State(), err
- }
- defer orchestrator.unlockOperation()
- orchestrator.mu.Lock()
- resources := orchestrator.resources
- orchestrator.mu.Unlock()
- current := orchestrator.State()
- if resources == nil && current.Phase == PhaseIdle {
- return current, nil
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseStopping
- state.Enabled = false
- state.LastReason = "disable_requested"
- })
- if resources != nil && resources.cancel != nil {
- resources.cancel()
- }
- cleanupErrors := orchestrator.cleanup(resources)
- orchestrator.mu.Lock()
- orchestrator.resources = nil
- orchestrator.mu.Unlock()
- if len(cleanupErrors) > 0 {
- cause := fmt.Errorf("%w: %s", ErrCleanupIncomplete, strings.Join(cleanupErrors, "; "))
- orchestrator.mutate(func(state *State) {
- // cleanup() has already released every local resource and restored
- // the radio. A rejected best-effort SIP deregistration is useful
- // diagnostic evidence, but it must not leave a disabled runtime in
- // Failed/Stopping or prevent a later cellular/VoWiFi transition.
- state.Phase = PhaseIdle
- state.Enabled = false
- state.Active = false
- state.SIMReady = false
- state.AccessReady = false
- state.TunnelReady = false
- state.IMSReady = false
- state.SMSReady = false
- state.TunnelName = ""
- state.DataplaneMode = ""
- state.IMSRegistration = ""
- state.LastErrorClass = "cleanup_warning"
- state.LastError = cause.Error()
- state.LastReason = "disabled_with_cleanup_errors"
- state.CleanupErrors = append([]string(nil), cleanupErrors...)
- state.StartedAt = nil
- })
- return orchestrator.State(), cause
- }
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseIdle
- state.Enabled = false
- state.Active = false
- state.SIMReady = false
- state.AccessReady = false
- state.TunnelReady = false
- state.IMSReady = false
- state.SMSReady = false
- state.TunnelName = ""
- state.DataplaneMode = ""
- state.IMSRegistration = ""
- state.LastErrorClass = ""
- state.LastError = ""
- state.LastReason = "disabled"
- state.CleanupErrors = nil
- state.StartedAt = nil
- })
- return orchestrator.State(), nil
- }
- func (orchestrator *Orchestrator) Retry(ctx context.Context) (State, error) {
- if orchestrator.State().Phase != PhaseFailed {
- return orchestrator.State(), ErrRetryRequiresFailure
- }
- return orchestrator.Enable(ctx)
- }
- func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error) {
- current := orchestrator.State()
- if !current.Enabled && current.Phase == PhaseIdle {
- return current, ErrNotRunning
- }
- // Teardown during a reconnect is best-effort. Disable already releases the
- // local IMS, tunnel, and radio resources, so a non-fatal cleanup error
- // (e.g. the network rejecting SIP deregistration) must not block the
- // rebuild — otherwise the device wedges in PhaseFailed. Only propagate
- // errors that prevented the teardown itself (e.g. the operation lock).
- if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) {
- return orchestrator.State(), err
- }
- return orchestrator.Enable(ctx)
- }
- // SendSMS submits through the currently registered IMS session. The lifecycle
- // operation lock prevents teardown from closing the session mid-transaction.
- func (orchestrator *Orchestrator) SendSMS(
- ctx context.Context,
- request SMSSubmitRequest,
- ) (SMSSubmitResult, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- if err := orchestrator.lockOperation(ctx); err != nil {
- return SMSSubmitResult{}, err
- }
- defer orchestrator.unlockOperation()
- orchestrator.mu.Lock()
- resources := orchestrator.resources
- ready := orchestrator.state.IMSReady && orchestrator.state.SMSReady
- orchestrator.mu.Unlock()
- if resources == nil || resources.ims == nil || !ready {
- return SMSSubmitResult{}, ErrSMSNotReady
- }
- sender, ok := resources.ims.(SMSSender)
- if !ok {
- return SMSSubmitResult{}, ErrSMSNotReady
- }
- return sender.SendSMS(ctx, request)
- }
- func (orchestrator *Orchestrator) Close(ctx context.Context) error {
- _, err := orchestrator.Disable(ctx)
- return err
- }
- // DeriveEPDG uses an explicitly provided carrier endpoint or the 3GPP standard
- // home-PLMN form. It never derives a phone number or MNC length from IMSI.
- func DeriveEPDG(identity SIMIdentity) (string, error) {
- if configured := strings.TrimSpace(identity.EPDG); configured != "" {
- if strings.ContainsAny(configured, " \t\r\n/:") || len(configured) > 253 {
- return "", errors.New("vowifi: configured ePDG must be a hostname")
- }
- return strings.ToLower(configured), nil
- }
- if err := identity.validate(); err != nil {
- return "", err
- }
- mnc := strings.TrimSpace(identity.HomeMNC)
- for len(mnc) < 3 {
- mnc = "0" + mnc
- }
- return fmt.Sprintf(
- "epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
- mnc,
- strings.TrimSpace(identity.HomeMCC),
- ), nil
- }
- func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
- if route.Mode == "" {
- route.Mode = ProxyModeDirect
- }
- switch route.Mode {
- case ProxyModeDirect:
- route.Address = ""
- route.Username = ""
- route.Password = ""
- case ProxyModeSOCKS5:
- if strings.TrimSpace(route.Address) == "" {
- return ProxyRoute{}, errors.New("vowifi: SOCKS5 proxy address is empty")
- }
- default:
- return ProxyRoute{}, fmt.Errorf("vowifi: unsupported proxy mode %q", route.Mode)
- }
- route.ID = strings.TrimSpace(route.ID)
- route.Address = strings.TrimSpace(route.Address)
- return route, nil
- }
- func (orchestrator *Orchestrator) validateTunnelEvidence(evidence TunnelEvidence) (SecurityAudit, error) {
- audit := securityAuditFromEvidence(evidence)
- switch evidence.ResponderAUTH {
- case ResponderAUTHVerified:
- return audit, nil
- case ResponderAUTHMissing:
- if !orchestrator.options.AllowMissingResponderAUTH {
- return audit, ErrResponderAUTHRequired
- }
- audit.CompatibilityOverride = true
- audit.HighRisk = true
- audit.Level = AuditLevelHigh
- audit.Code = AuditCodeMissingResponderAUTH
- audit.Message = "IKE responder AUTH was missing and accepted by explicit compatibility policy"
- return audit, nil
- case ResponderAUTHInvalid:
- return audit, fmt.Errorf("%w: responder AUTH is invalid", ErrResponderAUTHRequired)
- default:
- return audit, fmt.Errorf("%w: responder AUTH evidence is unknown", ErrResponderAUTHRequired)
- }
- }
- func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit {
- return SecurityAudit{
- ResponderAUTH: evidence.ResponderAUTH,
- IKEEncryption: strings.TrimSpace(evidence.IKEEncryption),
- IKEIntegrity: strings.TrimSpace(evidence.IKEIntegrity),
- IKEDHGroup: strings.TrimSpace(evidence.IKEDHGroup),
- ESPEncryption: strings.TrimSpace(evidence.ESPEncryption),
- ESPIntegrity: strings.TrimSpace(evidence.ESPIntegrity),
- }
- }
- func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string {
- if resources == nil {
- return nil
- }
- var cleanupErrors []string
- if resources.ims != nil {
- if err := orchestrator.cleanupCall(resources.ims.Close); err != nil {
- cleanupErrors = append(cleanupErrors, "close IMS: "+err.Error())
- }
- resources.ims = nil
- }
- if resources.tunnel != nil {
- if err := orchestrator.cleanupCall(resources.tunnel.Close); err != nil {
- cleanupErrors = append(cleanupErrors, "close tunnel: "+err.Error())
- }
- resources.tunnel = nil
- }
- if resources.radioChanged {
- if err := orchestrator.cleanupCall(func(ctx context.Context) error {
- return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
- }); err != nil {
- cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
- }
- resources.radioChanged = false
- }
- return cleanupErrors
- }
- func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error {
- ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout)
- defer cancel()
- return call(ctx)
- }
- func (orchestrator *Orchestrator) cancelCurrentRuntime() {
- orchestrator.mu.Lock()
- resources := orchestrator.resources
- orchestrator.mu.Unlock()
- if resources != nil && resources.cancel != nil {
- resources.cancel()
- }
- }
- func (orchestrator *Orchestrator) watchRuntimeTunnel(
- runtimeContext context.Context,
- resources *runtimeResources,
- tunnel TunnelSession,
- ) {
- notifier, ok := tunnel.(RuntimeFailureNotifier)
- if !ok {
- return
- }
- orchestrator.watchRuntimeFailure(
- runtimeContext,
- resources,
- notifier,
- "tunnel_runtime",
- "runtime_tunnel_failed",
- )
- }
- func (orchestrator *Orchestrator) watchRuntimeIMS(
- runtimeContext context.Context,
- resources *runtimeResources,
- ims IMSSession,
- ) {
- notifier, ok := ims.(RuntimeFailureNotifier)
- if !ok {
- return
- }
- orchestrator.watchRuntimeFailure(
- runtimeContext,
- resources,
- notifier,
- "ims_runtime",
- "runtime_ims_failed",
- )
- }
- func (orchestrator *Orchestrator) watchRuntimeFailure(
- runtimeContext context.Context,
- resources *runtimeResources,
- notifier RuntimeFailureNotifier,
- errorClass string,
- reason string,
- ) {
- failures := notifier.Failures()
- if failures == nil {
- return
- }
- go func() {
- select {
- case <-runtimeContext.Done():
- return
- case cause := <-failures:
- if cause == nil {
- cause = errors.New("VoWiFi runtime session stopped")
- }
- // Interrupt any still-running IMS setup before waiting for the
- // serialized lifecycle lock.
- if resources.cancel != nil {
- resources.cancel()
- }
- if err := orchestrator.lockOperation(context.Background()); err != nil {
- return
- }
- defer orchestrator.unlockOperation()
- orchestrator.mu.Lock()
- current := orchestrator.resources == resources
- orchestrator.mu.Unlock()
- if !current {
- return
- }
- cleanupErrors := orchestrator.cleanup(resources)
- orchestrator.mu.Lock()
- if orchestrator.resources == resources {
- orchestrator.resources = nil
- }
- orchestrator.mu.Unlock()
- orchestrator.mutate(func(state *State) {
- state.Phase = PhaseFailed
- state.Active = false
- state.TunnelReady = false
- state.IMSReady = false
- state.SMSReady = false
- state.LastErrorClass = errorClass
- state.LastError = cause.Error()
- state.LastReason = reason
- state.CleanupErrors = append([]string(nil), cleanupErrors...)
- })
- }
- }()
- }
- func (orchestrator *Orchestrator) lockOperation(ctx context.Context) error {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-orchestrator.operation:
- return nil
- }
- }
- func (orchestrator *Orchestrator) unlockOperation() {
- orchestrator.operation <- struct{}{}
- }
- func (orchestrator *Orchestrator) mutate(change func(*State)) {
- orchestrator.mu.Lock()
- change(&orchestrator.state)
- orchestrator.state.Sequence++
- orchestrator.state.UpdatedAt = time.Now().UTC()
- snapshot := orchestrator.state.clone()
- for _, subscriber := range orchestrator.subscribers {
- select {
- case subscriber <- snapshot:
- default:
- select {
- case <-subscriber:
- default:
- }
- select {
- case subscriber <- snapshot:
- default:
- }
- }
- }
- orchestrator.mu.Unlock()
- }
- func (orchestrator *Orchestrator) addWarning(warning string) {
- orchestrator.mutate(func(state *State) {
- state.Warnings = append(state.Warnings, warning)
- })
- }
- func classifyError(stage Phase, err error) string {
- switch {
- case errors.Is(err, context.Canceled):
- return "canceled"
- case errors.Is(err, context.DeadlineExceeded):
- return "timeout"
- case isTimeoutError(err):
- return "network_timeout"
- case errors.Is(err, ErrInvalidIdentity):
- return "sim_identity"
- case errors.Is(err, ErrEAPAuthenticationRejected):
- return "eap_authentication_rejected"
- case errors.Is(err, ErrResponderAUTHRequired):
- return "responder_auth"
- case errors.Is(err, ErrTunnelNotEstablished):
- return "tunnel"
- case errors.Is(err, ErrIMSNotRegistered):
- return "ims_registration"
- case errors.Is(err, ErrSMSNotReady):
- return "sms"
- default:
- return string(stage)
- }
- }
- func isTimeoutError(err error) bool {
- var networkError net.Error
- return errors.As(err, &networkError) && networkError.Timeout()
- }
- func mergedContext(caller context.Context, runtime context.Context) (context.Context, func()) {
- merged, cancel := context.WithCancel(caller)
- stop := context.AfterFunc(runtime, cancel)
- return merged, func() {
- stop()
- cancel()
- }
- }
|