fix: reassemble inbound CMPP long messages
This commit is contained in:
@@ -44,14 +44,22 @@ type authRequest struct {
|
||||
}
|
||||
|
||||
type submitRequest struct {
|
||||
Account string `json:"account"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
PhoneNumbers []string `json:"phoneNumbers,omitempty"`
|
||||
Content string `json:"content"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
DestID string `json:"destId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
Account string `json:"account"`
|
||||
PhoneNumber string `json:"phoneNumber,omitempty"`
|
||||
PhoneNumbers []string `json:"phoneNumbers,omitempty"`
|
||||
Content string `json:"content"`
|
||||
SrcID string `json:"srcId,omitempty"`
|
||||
DestID string `json:"destId,omitempty"`
|
||||
SequenceID uint32 `json:"sequenceId,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
|
||||
}
|
||||
|
||||
type inboundLongMessageFragment struct {
|
||||
Reference int `json:"reference"`
|
||||
Total int `json:"total"`
|
||||
Index int `json:"index"`
|
||||
Format int `json:"format"`
|
||||
}
|
||||
|
||||
type submitResponseMessage struct {
|
||||
@@ -291,7 +299,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
|
||||
clientProtocol, req.protocol, account, enterpriseCode, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt,
|
||||
req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent),
|
||||
)
|
||||
content, err := decodeContent(req.msgFmt, req.msgContent)
|
||||
content, longMessage, err := decodeInboundSubmitContent(req)
|
||||
if err != nil {
|
||||
logger.Printf(
|
||||
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q",
|
||||
@@ -311,6 +319,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
|
||||
DestID: phone,
|
||||
SequenceID: req.sequenceID,
|
||||
RemoteIP: remoteIP(remote),
|
||||
LongMessage: longMessage,
|
||||
})
|
||||
if err != nil || !result.Accepted {
|
||||
reason := "api returned accepted=false"
|
||||
@@ -465,6 +474,7 @@ type inboundSubmitPacket struct {
|
||||
protocol string
|
||||
pkTotal uint8
|
||||
pkNumber uint8
|
||||
tpUdhi uint8
|
||||
msgFmt uint8
|
||||
msgSrc string
|
||||
srcID string
|
||||
@@ -477,13 +487,13 @@ func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
|
||||
switch req := packet.(type) {
|
||||
case *cmpp.Cmpp2SubmitReqPkt:
|
||||
return inboundSubmitPacket{
|
||||
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
|
||||
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||
}, true
|
||||
case *cmpp.Cmpp3SubmitReqPkt:
|
||||
return inboundSubmitPacket{
|
||||
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
|
||||
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||
}, true
|
||||
@@ -697,6 +707,60 @@ func decodeContent(format uint8, content string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeInboundSubmitContent(req inboundSubmitPacket) (string, *inboundLongMessageFragment, error) {
|
||||
raw := []byte(req.msgContent)
|
||||
if req.tpUdhi == 0 && req.pkTotal <= 1 {
|
||||
content, err := decodeContent(req.msgFmt, req.msgContent)
|
||||
return content, nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", nil, errors.New("UDH message content is empty")
|
||||
}
|
||||
|
||||
headerLength := int(raw[0]) + 1
|
||||
if headerLength > len(raw) {
|
||||
return "", nil, fmt.Errorf("UDH length %d exceeds message content length %d", headerLength, len(raw))
|
||||
}
|
||||
|
||||
var reference, total, index int
|
||||
switch {
|
||||
case len(raw) >= 6 && raw[0] == 0x05 && raw[1] == 0x00 && raw[2] == 0x03:
|
||||
reference = int(raw[3])
|
||||
total = int(raw[4])
|
||||
index = int(raw[5])
|
||||
case len(raw) >= 7 && raw[0] == 0x06 && raw[1] == 0x08 && raw[2] == 0x04:
|
||||
reference = int(raw[3])<<8 | int(raw[4])
|
||||
total = int(raw[5])
|
||||
index = int(raw[6])
|
||||
default:
|
||||
if req.pkTotal > 1 {
|
||||
return "", nil, errors.New("concatenated CMPP submit is missing a supported 8-bit or 16-bit UDH")
|
||||
}
|
||||
content, err := decodeContent(req.msgFmt, string(raw[headerLength:]))
|
||||
return content, nil, err
|
||||
}
|
||||
if total < 2 || index < 1 || index > total {
|
||||
return "", nil, fmt.Errorf("invalid concatenated UDH total/index %d/%d", index, total)
|
||||
}
|
||||
if req.pkTotal > 0 && int(req.pkTotal) != total {
|
||||
return "", nil, fmt.Errorf("PkTotal %d does not match UDH total %d", req.pkTotal, total)
|
||||
}
|
||||
if req.pkNumber > 0 && int(req.pkNumber) != index {
|
||||
return "", nil, fmt.Errorf("PkNumber %d does not match UDH index %d", req.pkNumber, index)
|
||||
}
|
||||
|
||||
content, err := decodeContent(req.msgFmt, string(raw[headerLength:]))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return content, &inboundLongMessageFragment{
|
||||
Reference: reference,
|
||||
Total: total,
|
||||
Index: index,
|
||||
Format: int(req.msgFmt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func apiBaseURL(value string) string {
|
||||
if value == "" {
|
||||
return "http://127.0.0.1:3000/api"
|
||||
@@ -733,6 +797,13 @@ func rememberDownstream(session downstreamSession) {
|
||||
}
|
||||
session.touchPresence("connected", true, false)
|
||||
downstreamRegistry.Lock()
|
||||
if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn {
|
||||
// A downstream long message returns one SUBMIT_RESP per fragment but is
|
||||
// persisted as one platform message. Keep the first fragment Msg_Id so
|
||||
// online delivery and restart recovery (which persists the first
|
||||
// Sequence_Id) address the same client-side message.
|
||||
session.gatewayMsgID = existing.gatewayMsgID
|
||||
}
|
||||
downstreamRegistry.byMessageID[session.messageID] = &session
|
||||
downstreamRegistry.byConn[session.conn] = &session
|
||||
if session.account != "" {
|
||||
|
||||
@@ -235,6 +235,144 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInboundLongMessageStripsConcatUDHBeforeUCS2Decode(t *testing.T) {
|
||||
payload, err := cmpputils.Utf8ToUcs2("【深圳市合正物业服务有限公司】第一片正文")
|
||||
if err != nil {
|
||||
t.Fatalf("encode content: %v", err)
|
||||
}
|
||||
raw := append([]byte{0x05, 0x00, 0x03, 0x10, 0x02, 0x01}, []byte(payload)...)
|
||||
|
||||
content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{
|
||||
pkTotal: 2,
|
||||
pkNumber: 1,
|
||||
tpUdhi: 1,
|
||||
msgFmt: 8,
|
||||
msgContent: string(raw),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decode long message: %v", err)
|
||||
}
|
||||
if content != "【深圳市合正物业服务有限公司】第一片正文" {
|
||||
t.Fatalf("decoded content = %q", content)
|
||||
}
|
||||
if fragment == nil || fragment.Reference != 0x10 || fragment.Total != 2 || fragment.Index != 1 {
|
||||
t.Fatalf("unexpected fragment metadata: %+v", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInboundLongMessageSupports16BitConcatReference(t *testing.T) {
|
||||
payload, err := cmpputils.Utf8ToUcs2("第二片正文")
|
||||
if err != nil {
|
||||
t.Fatalf("encode content: %v", err)
|
||||
}
|
||||
raw := append([]byte{0x06, 0x08, 0x04, 0x12, 0x34, 0x02, 0x02}, []byte(payload)...)
|
||||
|
||||
content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{
|
||||
pkTotal: 2,
|
||||
pkNumber: 2,
|
||||
tpUdhi: 1,
|
||||
msgFmt: 8,
|
||||
msgContent: string(raw),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decode long message: %v", err)
|
||||
}
|
||||
if content != "第二片正文" {
|
||||
t.Fatalf("decoded content = %q", content)
|
||||
}
|
||||
if fragment == nil || fragment.Reference != 0x1234 || fragment.Total != 2 || fragment.Index != 2 {
|
||||
t.Fatalf("unexpected fragment metadata: %+v", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachSubmit(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
account := "100001"
|
||||
password := "secret-hash"
|
||||
var mu sync.Mutex
|
||||
submits := make([]submitRequest, 0, 2)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/gateway/events/inbound/authenticate":
|
||||
_ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account})
|
||||
case "/api/gateway/events/inbound/submit":
|
||||
var submit submitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&submit); err != nil {
|
||||
t.Fatalf("decode submit: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
submits = append(submits, submit)
|
||||
count := len(submits)
|
||||
mu.Unlock()
|
||||
status := "fragment_pending"
|
||||
if count == 2 {
|
||||
status = "accepted"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"accepted": true, "messageId": "MSG-LONG-1", "status": status,
|
||||
})
|
||||
case "/api/gateway/events/downstream/pending":
|
||||
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
|
||||
case "/api/gateway/events/inbound/connection":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected api path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
addr := reserveTCPAddr(t)
|
||||
go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
client := cmpp.NewClient(cmpp.V20)
|
||||
defer client.Disconnect()
|
||||
if err := client.Connect(addr, account, password, 2*time.Second); err != nil {
|
||||
t.Fatalf("connect CMPP2 inbound: %v", err)
|
||||
}
|
||||
parts := []string{"【签名】第一片", "第二片正文"}
|
||||
var firstResponseMsgID uint64
|
||||
for index, text := range parts {
|
||||
payload, _ := cmpputils.Utf8ToUcs2(text)
|
||||
raw := append([]byte{0x05, 0x00, 0x03, 0x22, 0x02, byte(index + 1)}, []byte(payload)...)
|
||||
if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{
|
||||
PkTotal: 2, PkNumber: uint8(index + 1), TpUdhi: 1, RegisteredDelivery: 1, MsgLevel: 1,
|
||||
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696",
|
||||
MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000",
|
||||
DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(raw)), MsgContent: string(raw),
|
||||
}); err != nil {
|
||||
t.Fatalf("send long-message fragment %d: %v", index+1, err)
|
||||
}
|
||||
if rsp := recvSubmitRsp20(t, client); rsp.Result != 0 || rsp.MsgId == 0 {
|
||||
t.Fatalf("unexpected fragment %d response: %+v", index+1, rsp)
|
||||
} else if index == 0 {
|
||||
firstResponseMsgID = rsp.MsgId
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(submits) != 2 {
|
||||
t.Fatalf("submit API calls = %d, want 2", len(submits))
|
||||
}
|
||||
for index, submit := range submits {
|
||||
if submit.Content != parts[index] {
|
||||
t.Fatalf("fragment %d content = %q", index+1, submit.Content)
|
||||
}
|
||||
if submit.LongMessage == nil || submit.LongMessage.Reference != 0x22 ||
|
||||
submit.LongMessage.Total != 2 || submit.LongMessage.Index != index+1 || submit.LongMessage.Format != 8 {
|
||||
t.Fatalf("fragment %d metadata = %+v", index+1, submit.LongMessage)
|
||||
}
|
||||
}
|
||||
downstreamRegistry.RLock()
|
||||
session := downstreamRegistry.byMessageID["MSG-LONG-1"]
|
||||
downstreamRegistry.RUnlock()
|
||||
if session == nil || session.gatewayMsgID != firstResponseMsgID {
|
||||
t.Fatalf("stored long-message Msg_Id = %v, want first fragment Msg_Id %v", session, firstResponseMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) {
|
||||
resetDownstreamRegistry()
|
||||
defer resetDownstreamRegistry()
|
||||
|
||||
Reference in New Issue
Block a user