From 95299a0a392c1f732becebd740e529dfcc48a183 Mon Sep 17 00:00:00 2001 From: Rain Seven <128443127+RAiNY7Study@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:50:13 +0800 Subject: [PATCH] fix: align IMS calls with MMTel dialog requirements (#48) * fix: align IMS calls with MMTel routing * fix: originate calls with associated public identity * fix: use carrier-specific IMS dial URIs * fix: acknowledge rejected INVITE transactions * docs: record sanitized IMS call validation * Delete docs/IMS_CALL_INTEROPERABILITY.md * Update README.md --------- Co-authored-by: Meng Meng <227010654+MengMengCode@users.noreply.github.com> --- internal/vowifi/carrier_compat.go | 64 ++- internal/vowifi/carrier_profiles.json | 11 +- internal/vowifi/ims/call_runtime.go | 481 ++++++++++++++++++++--- internal/vowifi/ims/call_runtime_test.go | 165 +++++++- internal/vowifi/ims/security.go | 2 +- internal/vowifi/types.go | 1 + 6 files changed, 668 insertions(+), 56 deletions(-) diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go index 4a13953..fd0ac70 100644 --- a/internal/vowifi/carrier_compat.go +++ b/internal/vowifi/carrier_compat.go @@ -33,6 +33,11 @@ type CarrierProfile struct { IMSRegisterProfile string IMSIPSecEncryption string SMSCenter string + PANICountry string + PANINode string + IMSDialURIScheme string + IMSUserEqPhone bool + IMSVoiceCodecs []string } type carrierProfileDocument struct { @@ -75,11 +80,16 @@ type carrierProfileIKE struct { } type carrierProfileIMS struct { - Transport string `json:"transport"` - IdentityProfile string `json:"identity_profile"` - RegisterProfile string `json:"register_profile"` - IPSecEncryption string `json:"ipsec_encryption"` - SMSCenter string `json:"sms_center"` + Transport string `json:"transport"` + IdentityProfile string `json:"identity_profile"` + RegisterProfile string `json:"register_profile"` + IPSecEncryption string `json:"ipsec_encryption"` + SMSCenter string `json:"sms_center"` + PANICountry string `json:"pani_country"` + PANINode string `json:"pani_node"` + DialURIScheme string `json:"dial_uri_scheme"` + UserEqPhone *bool `json:"user_eq_phone"` + VoiceCodecs []string `json:"voice_codecs"` } //go:embed carrier_profiles.json @@ -140,6 +150,20 @@ func validCarrierProfileRule(rule carrierProfileRule) bool { encryption != "aes-cbc" && encryption != "null" { return false } + if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" && + (len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') { + return false + } + if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" { + return false + } + for _, codec := range rule.IMS.VoiceCodecs { + switch strings.ToUpper(strings.TrimSpace(codec)) { + case "PCMA", "PCMU", "AMR", "AMR-WB": + default: + return false + } + } return true } @@ -155,6 +179,8 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile { IMSIdentityProfile: IMSProfileStandard, IMSRegisterProfile: IMSProfileStandard, IMSIPSecEncryption: "aes-cbc", + IMSDialURIScheme: "tel", + IMSVoiceCodecs: []string{"PCMA", "PCMU"}, } bestScore := -1 for _, rule := range builtinCarrierProfiles { @@ -256,9 +282,37 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc base.IMSIPSecEncryption = value } base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter) + base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)) + base.PANINode = strings.TrimSpace(rule.IMS.PANINode) + if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" { + base.IMSDialURIScheme = value + } + if rule.IMS.UserEqPhone != nil { + base.IMSUserEqPhone = *rule.IMS.UserEqPhone + } + if len(rule.IMS.VoiceCodecs) > 0 { + base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs) + } return base } +func normalizeVoiceCodecs(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + continue + } + if _, duplicate := seen[value]; duplicate { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + func canonicalPLMN(mcc, mnc string) string { mcc = strings.TrimSpace(mcc) mnc = strings.TrimSpace(mnc) diff --git a/internal/vowifi/carrier_profiles.json b/internal/vowifi/carrier_profiles.json index b41556b..7bcfc41 100644 --- a/internal/vowifi/carrier_profiles.json +++ b/internal/vowifi/carrier_profiles.json @@ -18,7 +18,13 @@ "imsi_prefixes": ["23433"], "iccid_prefixes": ["894430"] }, - "route": { "mcc": "234", "mnc": "30" } + "route": { "mcc": "234", "mnc": "30" }, + "ims": { + "pani_country": "GB", + "pani_node": "000000000000", + "dial_uri_scheme": "sip", + "voice_codecs": ["PCMA", "PCMU"] + } }, { "id": "att-us", @@ -58,7 +64,8 @@ "match": { "home_plmns": ["23410"] }, "ims": { "transport": "udp", - "sms_center": "+447802000332" + "sms_center": "+447802000332", + "user_eq_phone": true } }, { diff --git a/internal/vowifi/ims/call_runtime.go b/internal/vowifi/ims/call_runtime.go index 678118f..a7ab818 100644 --- a/internal/vowifi/ims/call_runtime.go +++ b/internal/vowifi/ims/call_runtime.go @@ -20,21 +20,30 @@ var ( const terminalCallRetention = 30 * time.Second +const ( + mmtelServiceURN = "urn:urn-7:3gpp-service.ims.icsi.mmtel" + mmtelFeatureTag = "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel" +) + type imsCall struct { - public vowifi.Call - callID string - target string - from string - to string - branch string - cseq uint32 - invite *sipRequest - respond func([]byte) error - responses chan *sipResponse - remoteTag string - routes []string - terminated bool - media *rtpMedia + public vowifi.Call + callID string + target string + from string + to string + branch string + cseq uint32 + inviteTarget string + invite *sipRequest + respond func([]byte) error + responses chan *sipResponse + remoteTag string + routes []string + terminated bool + media *rtpMedia + pracked map[string]bool + sessionExpires int + sessionCancel context.CancelFunc } func (session *Session) Calls() []vowifi.Call { @@ -67,12 +76,14 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal return vowifi.Call{}, err } callID := callToken + "@" + addressHost(session.conn.LocalAddr()) - target := "tel:" + number + carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity) + target := callTargetURI(number, session.identity.domain, carrierProfile) session.mu.Lock() cseq := session.cseq session.cseq++ routes := append([]string(nil), session.evidence.ServiceRoute...) securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue) + fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile) session.mu.Unlock() media, err := newRTPMedia(session.localMediaIP()) if err != nil { @@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal } body := media.offerSDP(session.localMediaIP()) transportUpper := strings.ToUpper(session.transport) - from := "<" + session.identity.public + ">;tag=" + session.fromTag + from := "<" + fromIdentity + ">;tag=" + session.fromTag to := "<" + target + ">" lines := []string{ "INVITE " + target + " SIP/2.0", @@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal "To: "+to, "Call-ID: "+callID, fmt.Sprintf("CSeq: %d INVITE", cseq), - "Contact: ", - "P-Preferred-Identity: <"+session.identity.public+">", - "Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE", - "Supported: timer", + session.dialogContactHeader(), + "P-Preferred-Identity: <"+preferredIdentity+">", + "P-Preferred-Service: "+mmtelServiceURN, + `Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`, + "P-Access-Network-Info: "+session.pAccessNetworkInfo(), + "User-Agent: "+session.callUserAgent(), + "Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO", + "Supported: 100rel, timer, replaces", + "Session-Expires: 1800;refresher=uac", + "Min-SE: 90", + "Accept: application/sdp", "Content-Type: application/sdp", "Content-Length: "+strconv.Itoa(len(body)), "", "", ) @@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal session.transactionsMu.Unlock() call := &imsCall{ public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()}, - callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses, - routes: routes, media: media, + callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses, + routes: routes, media: media, pracked: make(map[string]bool), } session.callMu.Lock() session.calls[callID] = call session.callMu.Unlock() + if session.provider != nil && session.provider.config.Logger != nil { + session.provider.config.Logger.Info("IMS call started", + "direction", "outgoing", + "identity_source", identitySource, + "target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")), + ) + } session.writeMu.Lock() _, err = session.conn.Write(request) session.writeMu.Unlock() @@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey) if response == nil { continue } + diagnostic := callResponseDiagnostic(response) + session.logCallResponse(response, diagnostic) if response.StatusCode < 200 { - session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason) - if response.StatusCode >= 180 { + session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic) + session.updateCallDialogFromResponse(call, response) + if len(response.Body) > 0 { + if mediaErr := call.media.configureRemote(response.Body); mediaErr == nil { + session.setCallMediaReady(call.callID) + session.setCallState(call.callID, "early_media") + } + } else if response.StatusCode >= 180 { session.setCallState(call.callID, "ringing") } + if reliableProvisional(response) { + go session.sendPRACK(call, response) + } continue } if response.StatusCode >= 200 && response.StatusCode < 300 { - session.callMu.Lock() - call.to = response.value("To") - call.remoteTag = headerParameter(call.to, "tag") - if contact := headerURI(response.value("Contact")); contact != "" { - call.target = contact - } - if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 { - call.routes = reverseStrings(recordRoutes) - } - session.callMu.Unlock() + session.updateCallDialogFromResponse(call, response) mediaErr := call.media.configureRemote(response.Body) _ = session.sendACK(call) if mediaErr != nil { @@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey) } session.setCallMediaReady(call.callID) session.setCallState(call.callID, "active") - } else if session.callWasTerminated(call.callID) { - // CANCEL normally causes the pending INVITE transaction to finish - // with 487 Request Terminated. It is the expected response to our - // local hang-up, not a new network rejection. - session.finishCall(call.callID, "ended", response.StatusCode, response.Reason) + session.startSessionTimer(call, response.value("Session-Expires")) } else { - session.finishCall(call.callID, "failed", response.StatusCode, response.Reason) + if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil { + session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed", + "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, + "sip_status", response.StatusCode, + "error", safeSIPDiagnostic(ackErr.Error()), + ) + } + if session.callWasTerminated(call.callID) { + // CANCEL normally causes the pending INVITE transaction to finish + // with 487 Request Terminated. It is the expected response to our + // local hang-up, not a new network rejection. + session.finishCall(call.callID, "ended", response.StatusCode, diagnostic) + } else { + session.finishCall(call.callID, "failed", response.StatusCode, diagnostic) + } } return } @@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e } request, respond := call.invite, call.respond session.callMu.Unlock() - response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP())) + response, err := buildSIPResponseWithBody( + request, + 200, + session.fromTag, + call.media.answerSDP(session.localMediaIP()), + session.dialogContactHeader(), + ) if err != nil { return vowifi.Call{}, err } @@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e return vowifi.Call{}, err } session.setCallState(id, "active") + session.startSessionTimer(call, request.value("Session-Expires")) if call.media.ready() { session.setCallMediaReady(id) } @@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error { return nil } method := "BYE" - if direction == "outgoing" && (state == "dialing" || state == "ringing") { + if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") { method = "CANCEL" } err := session.sendDialogRequest(ctx, call, method) @@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by if callID == "" { return true } + session.callMu.Lock() + existing := session.calls[callID] + session.callMu.Unlock() + if existing != nil && existing.public.State == "active" { + return session.handleDialogOffer(request, respond, existing) + } number := identityNumber(request.value("From")) target := headerURI(request.value("Contact")) if target == "" { @@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by } call := &imsCall{ public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()}, - callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag, + callID: callID, target: target, inviteTarget: request.URI, from: request.value("To") + ";tag=" + session.fromTag, to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media, + pracked: make(map[string]bool), } session.callMu.Lock() session.calls[callID] = call @@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by _ = respond(response) } return true + case "PRACK": + response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil) + if err == nil { + _ = respond(response) + } + return true + case "UPDATE": + callID := strings.TrimSpace(request.value("Call-ID")) + session.callMu.Lock() + call := session.calls[callID] + session.callMu.Unlock() + if call == nil { + return false + } + return session.handleDialogOffer(request, respond, call) case "ACK": callID := strings.TrimSpace(request.value("Call-ID")) session.callMu.Lock() @@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error { return err } +// sendRejectedInviteACK acknowledges a non-2xx final response using the +// original INVITE transaction branch and request URI. Unlike a 2xx ACK this is +// part of the INVITE transaction; sending a dialog-style ACK with a new branch +// leaves the P-CSCF retransmitting the rejection and leaking transaction state. +func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipResponse) error { + if call == nil || response == nil || response.StatusCode < 300 { + return nil + } + if session == nil || session.conn == nil { + return errors.New("ims: SIP connection unavailable for rejected INVITE ACK") + } + target := call.inviteTarget + if target == "" { + target = call.target + } + to := strings.TrimSpace(response.value("To")) + if to == "" { + to = call.to + } + lines := []string{ + "ACK " + target + " SIP/2.0", + fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), call.branch), + "Max-Forwards: 70", + } + session.mu.Lock() + securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue) + session.mu.Unlock() + lines = append(lines, securityHeaders...) + for _, route := range call.routes { + lines = append(lines, "Route: "+route) + } + lines = append(lines, + "From: "+call.from, + "To: "+to, + "Call-ID: "+call.callID, + fmt.Sprintf("CSeq: %d ACK", call.cseq), + "P-Access-Network-Info: "+session.pAccessNetworkInfo(), + "User-Agent: "+session.callUserAgent(), + "Content-Length: 0", "", "", + ) + session.writeMu.Lock() + _, err := session.conn.Write([]byte(strings.Join(lines, "\r\n"))) + session.writeMu.Unlock() + return err +} + +func reliableProvisional(response *sipResponse) bool { + if response == nil || strings.TrimSpace(response.value("RSeq")) == "" { + return false + } + for _, token := range strings.Split(strings.ToLower(response.value("Require")), ",") { + if strings.TrimSpace(token) == "100rel" { + return true + } + } + return false +} + +func (session *Session) updateCallDialogFromResponse(call *imsCall, response *sipResponse) { + if call == nil || response == nil { + return + } + session.callMu.Lock() + defer session.callMu.Unlock() + call.to = response.value("To") + call.remoteTag = headerParameter(call.to, "tag") + if contact := headerURI(response.value("Contact")); contact != "" { + call.target = contact + } + if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 { + call.routes = reverseStrings(recordRoutes) + } +} + +func (session *Session) sendPRACK(call *imsCall, response *sipResponse) { + rseq := strings.TrimSpace(response.value("RSeq")) + inviteCSeq := strings.TrimSpace(response.value("CSeq")) + if rseq == "" || inviteCSeq == "" { + return + } + key := rseq + "|" + inviteCSeq + session.callMu.Lock() + if call.pracked == nil { + call.pracked = make(map[string]bool) + } + if call.pracked[key] || call.public.EndedAt != nil { + session.callMu.Unlock() + return + } + call.pracked[key] = true + target, to, from := call.target, call.to, call.from + routes := append([]string(nil), call.routes...) + session.callMu.Unlock() + + session.mu.Lock() + cseq := session.cseq + session.cseq++ + session.mu.Unlock() + branch, _ := randomHex(12) + lines := []string{ + "PRACK " + target + " SIP/2.0", + fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch), + "Max-Forwards: 70", + } + session.mu.Lock() + securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue) + session.mu.Unlock() + lines = append(lines, securityHeaders...) + for _, route := range routes { + lines = append(lines, "Route: "+route) + } + lines = append(lines, + "From: "+from, "To: "+to, "Call-ID: "+call.callID, + fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq, + "P-Access-Network-Info: "+session.pAccessNetworkInfo(), + "User-Agent: "+session.callUserAgent(), + "Content-Length: 0", "", "", + ) + ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second) + defer cancel() + result, err := session.exchangeRuntime(ctx, []byte(strings.Join(lines, "\r\n")), sipTransactionKey{callID: call.callID, cseq: cseq, method: "PRACK"}) + if err != nil || result.StatusCode < 200 || result.StatusCode >= 300 { + reason := "reliable provisional response could not be acknowledged" + if err != nil { + reason = err.Error() + } + session.finishCall(call.callID, "failed", 0, reason) + } +} + +func (session *Session) handleDialogOffer(request *sipRequest, respond func([]byte) error, call *imsCall) bool { + var body []byte + if len(request.Body) > 0 { + if err := call.media.configureRemote(request.Body); err != nil { + if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil { + _ = respond(response) + } + return true + } + body = call.media.answerSDP(session.localMediaIP()) + session.setCallMediaReady(call.callID) + } + extraHeaders := []string(nil) + if request.Method == "INVITE" { + extraHeaders = append(extraHeaders, session.dialogContactHeader()) + } + response, err := buildSIPResponseWithBody(request, 200, session.fromTag, body, extraHeaders...) + if err == nil { + _ = respond(response) + session.startSessionTimer(call, request.value("Session-Expires")) + } + return true +} + +func (session *Session) startSessionTimer(call *imsCall, header string) { + value := strings.TrimSpace(strings.Split(header, ";")[0]) + seconds, err := strconv.Atoi(value) + if err != nil || seconds < 90 || seconds > 86400 || call == nil { + return + } + ctx, cancel := context.WithCancel(session.refreshContext) + session.callMu.Lock() + if call.sessionCancel != nil { + call.sessionCancel() + } + call.sessionExpires = seconds + call.sessionCancel = cancel + session.callMu.Unlock() + go func() { + interval := time.Duration(seconds) * time.Second / 2 + timer := time.NewTimer(interval) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + refreshContext, cancelRefresh := context.WithTimeout(ctx, 15*time.Second) + refreshErr := session.sendDialogRequest(refreshContext, call, "UPDATE") + cancelRefresh() + if refreshErr != nil { + session.finishCall(call.callID, "failed", 0, "SIP session refresh failed") + return + } + timer.Reset(interval) + } + } + }() +} + func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error { cseq := call.cseq - if method == "BYE" { + if method == "BYE" || method == "UPDATE" { session.mu.Lock() cseq = session.cseq session.cseq++ @@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui if method == "CANCEL" { branch = call.branch } + target := call.target + if method == "CANCEL" && call.inviteTarget != "" { + target = call.inviteTarget + } to := call.to if to == "" { to = "<" + call.target + ">" } lines := []string{ - method + " " + call.target + " SIP/2.0", + method + " " + target + " SIP/2.0", fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch), "Max-Forwards: 70", } + session.mu.Lock() + securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue) + session.mu.Unlock() + lines = append(lines, securityHeaders...) for _, route := range call.routes { lines = append(lines, "Route: "+route) } @@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui "To: "+to, "Call-ID: "+call.callID, fmt.Sprintf("CSeq: %d %s", cseq, method), - "Content-Length: 0", "", "", + "Supported: 100rel, timer", + "User-Agent: "+session.callUserAgent(), ) + if method != "CANCEL" { + lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo()) + } + if method == "UPDATE" { + lines = append(lines, session.dialogContactHeader()) + } + lines = append(lines, "Content-Length: 0", "", "") + if method == "UPDATE" && call.sessionExpires > 0 { + lines = append(lines[:len(lines)-3], fmt.Sprintf("Session-Expires: %d;refresher=uac", call.sessionExpires), "Content-Length: 0", "", "") + } return []byte(strings.Join(lines, "\r\n")) } @@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP { return addressIP(localAddress) } -func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) { +func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte, extraHeaders ...string) ([]byte, error) { reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"} reason := reasons[status] if reason == "" { @@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body lines = append(lines, "Via: "+value) } lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq) + for _, header := range extraHeaders { + if strings.TrimSpace(header) != "" { + lines = append(lines, header) + } + } + if value := strings.TrimSpace(request.value("Session-Expires")); value != "" && status >= 200 && status < 300 && (request.Method == "INVITE" || request.Method == "UPDATE") { + lines = append(lines, "Supported: timer", "Session-Expires: "+value) + } if len(body) > 0 { lines = append(lines, "Content-Type: application/sdp") } @@ -453,10 +736,110 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body return append([]byte(strings.Join(lines, "\r\n")), body...), nil } +func (session *Session) dialogContactHeader() string { + if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" { + return "" + } + contact := "Contact: " + if strings.TrimSpace(session.instanceID) != "" { + contact += `;+sip.instance="<` + session.instanceID + `>"` + } + return contact + `;audio;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` +} + +func callTargetURI(number, domain string, profile vowifi.CarrierProfile) string { + domain = strings.TrimSpace(domain) + if profile.IMSDialURIScheme == "sip" { + target := "sip:" + number + "@" + domain + if profile.IMSUserEqPhone { + target += ";user=phone" + } + return target + } + if strings.HasPrefix(number, "+") { + return "tel:" + number + } + return "tel:" + number + ";phone-context=" + domain +} + +// callOriginatingIdentitiesLocked selects only a number that IMS explicitly +// associated with this registration. 3GPP originating sessions use that +// public identity in both From and P-Preferred-Identity; some TAS deployments +// accept an IMSI IMPU at the P-CSCF and then terminate the session immediately. +// The fallback deliberately remains the registered IMPU and never derives a +// telephone number from IMSI digits. +func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierProfile) (from, preferred, source string) { + if number, numberSource, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok { + from = "sip:" + number + "@" + session.identity.domain + if profile.IMSUserEqPhone { + from += ";user=phone" + } + return from, "tel:" + number, numberSource + } + return session.identity.public, session.identity.public, "registered_impu" +} + +func (session *Session) pAccessNetworkInfo() string { + profile := vowifi.ResolveCarrierProfile(session.request.Identity) + node := strings.TrimSpace(profile.PANINode) + if node == "" { + node = "000000000000" + } + value := "IEEE-802.11;i-wlan-node-id=" + node + if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { + value += ";country=" + country + } + return value + ";network-provided" +} + +func (session *Session) callUserAgent() string { + if session != nil && session.provider != nil { + if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { + return value + } + } + return "vocat/1" +} + +func callResponseDiagnostic(response *sipResponse) string { + if response == nil { + return "" + } + parts := make([]string, 0, 3) + if reason := safeSIPDiagnostic(response.Reason); reason != "" { + parts = append(parts, reason) + } + for _, name := range []string{"Reason", "Warning"} { + for _, value := range response.values(name) { + if value = safeSIPDiagnostic(value); value != "" { + parts = append(parts, name+": "+value) + } + } + } + return safeSIPDiagnostic(strings.Join(parts, "; ")) +} + +func (session *Session) logCallResponse(response *sipResponse, diagnostic string) { + if session == nil || session.provider == nil || session.provider.config.Logger == nil || response == nil { + return + } + session.provider.config.Logger.Info("IMS call response", + "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, + "sip_status", response.StatusCode, + "diagnostic", diagnostic, + "content_type", safeSIPDiagnostic(response.value("Content-Type")), + "body_bytes", len(response.Body), + ) +} + func (session *Session) setCallState(id, state string) { session.callMu.Lock() if call := session.calls[id]; call != nil { call.public.State = state + if state == "active" && call.public.AnsweredAt == nil { + now := time.Now().UTC() + call.public.AnsweredAt = &now + } if state != "ended" && state != "failed" { call.public.EndedAt = nil } @@ -509,6 +892,10 @@ func (session *Session) finishCall(id, state string, code int, reason string) { call.public.Reason = reason } call.public.EndedAt = &now + if call.sessionCancel != nil { + call.sessionCancel() + call.sessionCancel = nil + } } session.callMu.Unlock() if media != nil { diff --git a/internal/vowifi/ims/call_runtime_test.go b/internal/vowifi/ims/call_runtime_test.go index 174e5b1..4de7b47 100644 --- a/internal/vowifi/ims/call_runtime_test.go +++ b/internal/vowifi/ims/call_runtime_test.go @@ -2,6 +2,8 @@ package ims import ( "context" + "io" + "net" "strings" "testing" "time" @@ -10,7 +12,13 @@ import ( ) func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) { - session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)} + client, peer := net.Pipe() + defer client.Close() + defer peer.Close() + session := &Session{ + fromTag: "local-tag", calls: make(map[string]*imsCall), conn: client, + identity: identitySet{user: "subscriber"}, transport: "udp", + } packet, err := parseSIPPacket([]byte(strings.Join([]string{ "INVITE sip:subscriber@example.test SIP/2.0", "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming", @@ -42,6 +50,9 @@ func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) { if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") { t.Fatalf("answered = %#v, response = %q", answered, responses[1]) } + if !strings.Contains(string(responses[1]), "Contact: ") { + t.Fatalf("answer omitted dialog Contact: %q", responses[1]) + } } func TestIncomingCallCanBeRejected(t *testing.T) { @@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) { t.Fatal("call number validation mismatch") } } + +func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) { + client, peer := net.Pipe() + defer peer.Close() + refreshContext, cancelRefresh := context.WithCancel(context.Background()) + defer cancelRefresh() + session := &Session{ + provider: &Provider{config: Config{ + UserAgent: "VoCat Test", + SecurityMode: SecurityDisabled, + }}, + request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{ + HomeMCC: "234", HomeMNC: "33", IMSI: "234330000000001", ICCID: "8944300000000000000", + }}, + identity: identitySet{ + domain: "ims.mnc033.mcc234.3gppnetwork.org", + public: "sip:234330000000001@ims.mnc033.mcc234.3gppnetwork.org", + user: "234330000000001", + }, + endpoint: pcscfEndpoint{host: "pcscf.test", port: 5060}, + transport: "tcp", + conn: client, + fromTag: "local-tag", + instanceID: "urn:uuid:00000000-0000-4000-8000-000000000001", + cseq: 1, + transactions: make(map[sipTransactionKey]chan *sipResponse), + calls: make(map[string]*imsCall), + refreshContext: refreshContext, + evidence: vowifi.IMSEvidence{ + PAssociatedURI: []string{""}, + ServiceRoute: []string{""}, + }, + } + wireResult := make(chan string, 1) + go func() { + packet, _ := io.ReadAll(peer) + wireResult <- string(packet) + }() + + call, err := session.DialCall(context.Background(), "888") + if err != nil { + t.Fatal(err) + } + session.finishCall(call.ID, "ended", 0, "test complete") + cancelRefresh() + _ = client.Close() + wire := <-wireResult + + for _, expected := range []string{ + "INVITE sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n", + "To: \r\n", + "From: ;tag=local-tag\r\n", + "P-Preferred-Identity: \r\n", + "P-Preferred-Service: " + mmtelServiceURN + "\r\n", + `Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n", + "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n", + "User-Agent: VoCat Test\r\n", + "Accept: application/sdp\r\n", + } { + if !strings.Contains(wire, expected) { + t.Fatalf("INVITE omitted %q:\n%s", expected, wire) + } + } +} + +func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) { + session := &Session{ + identity: identitySet{ + domain: "ims.mnc033.mcc234.3gppnetwork.org", + public: "sip:234330000000001@ims.mnc033.mcc234.3gppnetwork.org", + }, + } + from, preferred, source := session.callOriginatingIdentitiesLocked(vowifi.CarrierProfile{}) + if from != session.identity.public || preferred != session.identity.public || source != "registered_impu" { + t.Fatalf("fallback identities = (%q, %q, %q)", from, preferred, source) + } +} + +func TestCallTargetURIUsesPhoneContextOnlyForLocalNumbers(t *testing.T) { + domain := "ims.mnc033.mcc234.3gppnetwork.org" + if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:888;phone-context="+domain { + t.Fatalf("local target = %q", got) + } + if got := callTargetURI("+447700900123", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:+447700900123" { + t.Fatalf("global target = %q", got) + } + if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip"}); got != "sip:888@"+domain { + t.Fatalf("SIP target = %q", got) + } + if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip", IMSUserEqPhone: true}); got != "sip:888@"+domain+";user=phone" { + t.Fatalf("SIP user=phone target = %q", got) + } +} + +func TestCallResponseDiagnosticIncludesNetworkReason(t *testing.T) { + response := &sipResponse{ + StatusCode: 487, + Reason: "Request Terminated", + Headers: map[string][]string{ + "reason": {`Q.850;cause=31;text="Normal, unspecified"`}, + }, + } + want := `Request Terminated; Reason: Q.850;cause=31;text="Normal, unspecified"` + if got := callResponseDiagnostic(response); got != want { + t.Fatalf("diagnostic = %q, want %q", got, want) + } +} + +func TestRejectedInviteACKUsesOriginalTransaction(t *testing.T) { + client, peer := net.Pipe() + defer client.Close() + defer peer.Close() + session := &Session{ + provider: &Provider{config: Config{UserAgent: "VoCat Test"}}, + request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "33"}}, + identity: identitySet{domain: "ims.mnc033.mcc234.3gppnetwork.org"}, + transport: "tcp", + conn: client, + } + call := &imsCall{ + callID: "call-1", + inviteTarget: "sip:888@ims.mnc033.mcc234.3gppnetwork.org", + from: ";tag=local", + to: "", + branch: "original-branch", + cseq: 41, + routes: []string{""}, + } + response := &sipResponse{StatusCode: 487, Headers: map[string][]string{ + "to": {";tag=tas"}, + }} + ackResult := make(chan string, 1) + go func() { + buffer := make([]byte, 4096) + count, _ := peer.Read(buffer) + ackResult <- string(buffer[:count]) + }() + if err := session.sendRejectedInviteACK(call, response); err != nil { + t.Fatal(err) + } + ack := <-ackResult + for _, expected := range []string{ + "ACK sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n", + "branch=z9hG4bKoriginal-branch;rport", + "To: ;tag=tas\r\n", + "CSeq: 41 ACK\r\n", + } { + if !strings.Contains(ack, expected) { + t.Fatalf("rejected INVITE ACK omitted %q:\n%s", expected, ack) + } + } +} diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go index 9155fb6..2812257 100644 --- a/internal/vowifi/ims/security.go +++ b/internal/vowifi/ims/security.go @@ -783,7 +783,7 @@ func zeroBytes(value []byte) { } func (session *Session) securityOffered() bool { - return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined + return session.provider != nil && session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined } func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) { diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index 3643ddd..55bfa37 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -405,6 +405,7 @@ type Call struct { Direction string `json:"direction"` State string `json:"state"` StartedAt time.Time `json:"started_at"` + AnsweredAt *time.Time `json:"answered_at,omitempty"` SIPCode int `json:"sip_code,omitempty"` Reason string `json:"reason,omitempty"` MediaReady bool `json:"media_ready,omitempty"`