feat: add runtime support for IMS SMS and USSD handling including SIP transaction management

This commit is contained in:
MengMengCode
2026-08-19 23:39:33 +08:00
parent 3b8f32f591
commit 9c39e15bcf
8 changed files with 2142 additions and 4 deletions
+1
View File
@@ -759,6 +759,7 @@ func newVoWiFiOrchestrator(
"service_center_timestamp": message.ServiceCenterTimestamp,
"raw_rpdu": message.RawRPDU,
"raw_tpdu": message.RawTPDU,
"decode_error": message.DecodeError,
})
partsTotal := 1
if message.Concat != nil && message.Concat.Total > 0 {
+18 -2
View File
@@ -766,13 +766,29 @@ func readTPAddress(cursor *pduCursor) (string, error) {
if err != nil {
return "", err
}
byteCount := (int(length) + 1) / 2
var byteCount int
var septetCount int
if toa&0x70 == 0x50 {
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
// However, some non-standard sources specify length as the direct count of septets
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
if length >= 7 {
byteCount = (int(length) + 1) / 2
septetCount = int(length) * 4 / 7
} else {
byteCount = (int(length)*7 + 7) / 8
septetCount = int(length)
}
} else {
byteCount = (int(length) + 1) / 2
}
value, err := cursor.bytes(byteCount)
if err != nil {
return "", err
}
if toa&0x70 == 0x50 {
septetCount := int(length) * 4 / 7
septets, unpackErr := unpackSeptets(value, septetCount, 0)
if unpackErr != nil {
return "", unpackErr
+59
View File
@@ -264,6 +264,65 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
}
}
func TestDecodeAlphanumericTPAddress(t *testing.T) {
// "TEST" encoded as 4 GSM-7 septets packed into 4 bytes (non-standard septet count format: length=4).
cursor := &pduCursor{data: []byte{0x04, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
address, err := readTPAddress(cursor)
if err != nil {
t.Fatalf("readTPAddress error = %v", err)
}
if address != "TEST" {
t.Fatalf("readTPAddress = %q, want TEST", address)
}
if cursor.index != len(cursor.data) {
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
}
}
func TestDecodeAlphanumericTPAddressStandard3GPP(t *testing.T) {
// "Google" (6 chars) encoded per 3GPP TS 23.040 §9.1.2.5:
// length = 0x0B (11 useful semi-octets), TOA = 0xD0 (Alphanumeric),
// 6 bytes payload: C7 F7 FB CC 2E 03
cursor := &pduCursor{data: []byte{0x0b, 0xd0, 0xc7, 0xf7, 0xfb, 0xcc, 0x2e, 0x03}}
address, err := readTPAddress(cursor)
if err != nil {
t.Fatalf("readTPAddress standard 3GPP error = %v", err)
}
if address != "Google" {
t.Fatalf("readTPAddress standard 3GPP = %q, want Google", address)
}
if cursor.index != len(cursor.data) {
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
}
// "TEST" (4 chars) with standard 3GPP semi-octets (length = 0x08, 8 semi-octets -> 4 bytes)
cursorTest := &pduCursor{data: []byte{0x08, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
addressTest, err := readTPAddress(cursorTest)
if err != nil {
t.Fatalf("readTPAddress standard 3GPP TEST error = %v", err)
}
if addressTest != "TEST" {
t.Fatalf("readTPAddress standard 3GPP TEST = %q, want TEST", addressTest)
}
}
func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
// SMS-DELIVER with alphanumeric originator "VoCat" and empty user data.
// SMSC length=0, first octet=0x04, OA length=0x05, OA TON=0xD0,
// OA bytes pack "VoCat" (5 septets -> 5 bytes), PID=0x00, DCS=0x00,
// SCTS=7 bytes, UDL=0x00.
message, err := decodeSMSPDU("000405D0D6F7304C0700004210203040500000")
if err != nil {
t.Fatalf("decodeSMSPDU error = %v", err)
}
if message.From != "VoCat" {
t.Fatalf("From = %q, want VoCat", message.From)
}
if message.Direction != SMSDirectionReceived {
t.Fatalf("Direction = %q", message.Direction)
}
}
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
+67 -1
View File
@@ -1,6 +1,9 @@
package vowifi
import "testing"
import (
"strings"
"testing"
)
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
tests := []struct {
@@ -154,4 +157,67 @@ func TestResolveCarrierProfileDITOPhilippinesUsesLegacyIKE(t *testing.T) {
if profile.IKEProposal != IKEProposalLegacy {
t.Fatalf("DITO IKE proposal = %q, want %q", profile.IKEProposal, IKEProposalLegacy)
}
if !profile.AllowSMSWithoutContactConfirmation {
t.Fatalf("DITO profile should allow SMS without contact confirmation")
}
}
func TestResolveCarrierProfileATTRegisterOptions(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"})
if profile.ID != "att-us" {
t.Fatalf("AT&T profile = %#v", profile)
}
if profile.IMSRegisterOptions.ContactFormat != IMSContactFormatATT {
t.Fatalf("AT&T contact format = %q, want %q", profile.IMSRegisterOptions.ContactFormat, IMSContactFormatATT)
}
if profile.IMSRegisterOptions.ExpirySeconds != 18400 {
t.Fatalf("AT&T expiry = %d, want 18400", profile.IMSRegisterOptions.ExpirySeconds)
}
if profile.IMSRegisterOptions.UserAgent != "SimAdmin VoWiFi" {
t.Fatalf("AT&T user agent = %q", profile.IMSRegisterOptions.UserAgent)
}
if profile.IMSRegisterOptions.PVisitedNetworkID != "one.att.net" {
t.Fatalf("AT&T P-Visited-Network-ID = %q", profile.IMSRegisterOptions.PVisitedNetworkID)
}
if len(profile.IMSRegisterOptions.AcceptContactTags) != 2 {
t.Fatalf("AT&T Accept-Contact tags = %v", profile.IMSRegisterOptions.AcceptContactTags)
}
}
func TestResolveCarrierProfileO2GermanyRegisterOptions(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: "03"})
if profile.ID != "o2-germany" {
t.Fatalf("O2 Germany profile = %#v", profile)
}
if profile.IMSRegisterOptions.ContactFormat != "" {
t.Fatalf("O2 Germany contact format = %q, want empty", profile.IMSRegisterOptions.ContactFormat)
}
if profile.IMSRegisterOptions.SupportedHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.SupportedHeader, "sec-agree") {
t.Fatalf("O2 Germany Supported header = %v", profile.IMSRegisterOptions.SupportedHeader)
}
if profile.IMSRegisterOptions.AllowHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.AllowHeader, "MESSAGE") {
t.Fatalf("O2 Germany Allow header = %v", profile.IMSRegisterOptions.AllowHeader)
}
if !profile.IMSRegisterOptions.PPreferredIdentity {
t.Fatal("O2 Germany should add P-Preferred-Identity")
}
}
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "001", HomeMNC: "01"})
if profile.ID != CarrierProfileStandard {
t.Fatalf("profile = %q", profile.ID)
}
if profile.IMSRegisterOptions.ExpirySeconds != 0 {
t.Fatalf("standard expiry = %d", profile.IMSRegisterOptions.ExpirySeconds)
}
if profile.IMSRegisterOptions.ContactFormat != "" {
t.Fatalf("standard contact format = %q", profile.IMSRegisterOptions.ContactFormat)
}
if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
}
if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation")
}
}
+1 -1
View File
@@ -519,7 +519,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
DecodeError: errorString(decodeErr),
}
if status.MessageReference == 0 && status.StatusCode == 0 && decodeErr == nil {
if (message.MessageReference == nil || message.StatusCode == nil) && decodeErr == nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
"stage", "tpdu", "rp_reference", int(rpdu.reference))
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
+98
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"log/slog"
"mime/multipart"
"net"
"net/textproto"
@@ -721,6 +722,86 @@ func serveInboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<-
return err
}
func TestSessionReceivesMalformedSMSBestEffort(t *testing.T) {
request := &sipRequest{
Headers: map[string][]string{
"content-type": {smsContentType},
"content-transfer-encoding": {"binary"},
"call-id": {"malformed-test"},
"p-asserted-identity": {"<sip:[email protected]>"},
},
Body: []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff},
}
received := make(chan ReceivedSMS, 1)
session := &Session{
provider: &Provider{config: Config{
Logger: slog.Default(),
OnSMS: func(_ context.Context, message ReceivedSMS) error {
received <- message
return nil
},
}},
request: vowifi.IMSRequest{DeviceID: "ec20", Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}},
conn: &fakeConn{},
transactions: make(map[sipTransactionKey]chan *sipResponse),
fromTag: "tag",
nextRPReference: 1,
}
session.processSMSMessage(request)
select {
case message := <-received:
if message.DecodeError == "" {
t.Fatal("expected DecodeError to be set")
}
if message.RawRPDU == "" || message.RawTPDU == "" {
t.Fatalf("expected raw payloads to be preserved, got %#v", message)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for best-effort SMS callback")
}
}
func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing.T) {
session := &Session{
provider: &Provider{config: Config{Logger: slog.Default()}},
request: vowifi.IMSRequest{
Identity: vowifi.SIMIdentity{HomeMCC: "515", HomeMNC: "66"},
},
evidence: vowifi.IMSEvidence{
Registered: true,
RegistrationState: "registered",
},
expiresAt: time.Now().Add(time.Hour),
}
evidence, err := session.EnableSMS(context.Background())
if err != nil || !evidence.Ready {
t.Fatalf("EnableSMS() = (%#v, %v), want ready for DITO profile", evidence, err)
}
}
func TestSessionRequiresSMSContactConfirmationByDefault(t *testing.T) {
session := &Session{
provider: &Provider{config: Config{Logger: slog.Default()}},
request: vowifi.IMSRequest{
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
},
evidence: vowifi.IMSEvidence{
Registered: true,
RegistrationState: "registered",
},
expiresAt: time.Now().Add(time.Hour),
}
evidence, err := session.EnableSMS(context.Background())
if !errors.Is(err, ErrSMSCapabilityNotConfirmed) || evidence.Ready {
t.Fatalf("EnableSMS() = (%#v, %v), want not-ready", evidence, err)
}
}
func buildUSSDBody(text string) []byte {
encoded, dcs, err := encodeUSSDBody(text)
if err != nil {
@@ -858,3 +939,20 @@ func serveOutboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<-
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
return err
}
// fakeConn is a minimal net.Conn useful for tests that only need LocalAddr
// to succeed and do not care about the actual SIP MESSAGE delivery report.
type fakeConn struct{}
func (*fakeConn) Read([]byte) (int, error) { return 0, errors.New("fakeConn: closed") }
func (*fakeConn) Write(source []byte) (int, error) { return len(source), nil }
func (*fakeConn) Close() error { return nil }
func (*fakeConn) LocalAddr() net.Addr {
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
}
func (*fakeConn) RemoteAddr() net.Addr {
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
}
func (*fakeConn) SetDeadline(time.Time) error { return nil }
func (*fakeConn) SetReadDeadline(time.Time) error { return nil }
func (*fakeConn) SetWriteDeadline(time.Time) error { return nil }
+1896
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false