mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 14:53:42 +08:00
feat: add runtime support for IMS SMS and USSD handling including SIP transaction management
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Generated
+1896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
Reference in New Issue
Block a user