375 lines
15 KiB
Go
375 lines
15 KiB
Go
package inbound
|
|
|
|
import (
|
|
"cmpp-platform/gateway/internal/metrics"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
cmpp "github.com/bigwhite/gocmpp"
|
|
cmpputils "github.com/bigwhite/gocmpp/utils"
|
|
"log"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// One client Submit may contain multiple destinations. The API result retains
|
|
// one internal message mapping per destination while CMPP receives one response.
|
|
|
|
type submitRequest struct {
|
|
RequestID string `json:"requestId,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"`
|
|
RegisteredDelivery uint8 `json:"registeredDelivery"`
|
|
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 {
|
|
PhoneNumber string `json:"phoneNumber"`
|
|
MessageID string `json:"messageId"`
|
|
}
|
|
|
|
type submitResponse struct {
|
|
Accepted bool `json:"accepted"`
|
|
Result uint32 `json:"result,omitempty"`
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
ApplicationID string `json:"applicationId,omitempty"`
|
|
MessageID string `json:"messageId"`
|
|
Messages []submitResponseMessage `json:"messages,omitempty"`
|
|
}
|
|
|
|
func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
|
|
req, ok := normalizeInboundSubmit(packet.Packer)
|
|
if !ok {
|
|
return true, nil
|
|
}
|
|
handlerStartedAt := time.Now()
|
|
session := findSessionByConn(packet.Conn)
|
|
if session == nil || strings.TrimSpace(session.account) == "" {
|
|
logger.Printf(
|
|
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s remote=%s seq=%d result=9 stage=session reason=%q",
|
|
req.protocol, req.protocol, packet.Conn.Conn.RemoteAddr(), req.sequenceID, "authenticated connection session not found",
|
|
)
|
|
setInboundSubmitResponse(response.Packer, 0, 9)
|
|
responseReadyAt := time.Now()
|
|
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, false, s.submitResponseProtocolLogger("", req.protocol, req.sequenceID, "", "", 0, 9))
|
|
return false, nil
|
|
}
|
|
releaseInboundSlot := beginInboundSubmit(session)
|
|
defer releaseInboundSlot()
|
|
account := session.account
|
|
enterpriseCode := strings.TrimRight(req.msgSrc, "\x00")
|
|
if session.enterpriseCode != "" && enterpriseCode != session.enterpriseCode {
|
|
logger.Printf(
|
|
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d result=9 stage=protocol reason=%q",
|
|
defaultString(session.protocol, req.protocol), req.protocol, account, enterpriseCode, packet.Conn.Conn.RemoteAddr(), req.sequenceID,
|
|
fmt.Sprintf("enterprise code mismatch: expected %s", session.enterpriseCode),
|
|
)
|
|
setInboundSubmitResponse(response.Packer, 0, 9)
|
|
responseReadyAt := time.Now()
|
|
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, false, s.submitResponseProtocolLogger(account, defaultString(session.protocol, req.protocol), req.sequenceID, "", "", 0, 9))
|
|
return false, nil
|
|
}
|
|
phones := make([]string, len(req.destTerminalIDs))
|
|
for index, destination := range req.destTerminalIDs {
|
|
phones[index] = strings.TrimSpace(strings.TrimRight(destination, "\x00"))
|
|
}
|
|
phone := ""
|
|
if len(phones) > 0 {
|
|
phone = phones[0]
|
|
}
|
|
remote := packet.Conn.Conn.RemoteAddr()
|
|
clientProtocol := defaultString(session.protocol, req.protocol)
|
|
logger.Printf(
|
|
"cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d",
|
|
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),
|
|
)
|
|
decodeStartedAt := time.Now()
|
|
content, longMessage, err := decodeInboundSubmitContent(req)
|
|
metrics.ObserveInboundStage("decode", err == nil, time.Since(decodeStartedAt))
|
|
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",
|
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, err,
|
|
)
|
|
setInboundSubmitResponse(response.Packer, 0, 9)
|
|
responseReadyAt := time.Now()
|
|
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, false, s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, "", 0, 9))
|
|
return false, nil
|
|
}
|
|
contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content)))
|
|
startedAt := time.Now()
|
|
releaseSubmitBarrier := beginDownstreamSubmitBarrier(packet.Conn)
|
|
apiStartedAt := time.Now()
|
|
result, err := s.submit(remote, submitRequest{
|
|
RequestID: inboundSubmitRequestID(session.connectionID, req.sequenceID, phones, content, req.srcID, longMessage),
|
|
Account: account,
|
|
PhoneNumber: phone,
|
|
PhoneNumbers: phones,
|
|
Content: content,
|
|
SrcID: req.srcID,
|
|
DestID: phone,
|
|
SequenceID: req.sequenceID,
|
|
RegisteredDelivery: req.registeredDelivery,
|
|
RemoteIP: remoteIP(remote),
|
|
LongMessage: longMessage,
|
|
})
|
|
metrics.ObserveInboundStage("api_roundtrip", err == nil, time.Since(apiStartedAt))
|
|
if err != nil || !result.Accepted {
|
|
reason := "api returned accepted=false"
|
|
if err != nil {
|
|
reason = err.Error()
|
|
}
|
|
responseResult := result.Result
|
|
if responseResult == 0 {
|
|
responseResult = 9
|
|
}
|
|
logger.Printf(
|
|
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=%d stage=business duration_ms=%d content_chars=%d content_hash=%s reason=%q",
|
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, responseResult, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, reason,
|
|
)
|
|
setInboundSubmitResponse(response.Packer, 0, responseResult)
|
|
protocolLogger := s.submitResponseProtocolLogger(account, clientProtocol, req.sequenceID, phone, result.MessageID, 0, responseResult)
|
|
responseReadyAt := time.Now()
|
|
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, false, func(sendErr error) {
|
|
releaseSubmitBarrier()
|
|
protocolLogger(sendErr)
|
|
})
|
|
return false, nil
|
|
}
|
|
gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID)
|
|
setInboundSubmitResponse(response.Packer, gatewayMsgID, 0)
|
|
responseMessages := result.Messages
|
|
if len(responseMessages) == 0 {
|
|
responseMessages = []submitResponseMessage{{PhoneNumber: phone, MessageID: result.MessageID}}
|
|
}
|
|
for index, acceptedMessage := range responseMessages {
|
|
acceptedPhone := strings.TrimSpace(acceptedMessage.PhoneNumber)
|
|
if acceptedPhone == "" && index < len(phones) {
|
|
acceptedPhone = phones[index]
|
|
}
|
|
rememberDownstream(downstreamSession{
|
|
messageID: acceptedMessage.MessageID,
|
|
account: account,
|
|
tenantID: result.TenantID,
|
|
applicationID: result.ApplicationID,
|
|
enterpriseCode: session.enterpriseCode,
|
|
protocol: clientProtocol,
|
|
windowSize: session.windowSize,
|
|
submitInFlight: session.submitInFlight,
|
|
srcID: strings.TrimSpace(req.srcID),
|
|
phoneNumber: acceptedPhone,
|
|
gatewayMsgID: gatewayMsgID,
|
|
remoteIP: remoteIP(remote),
|
|
connectedAt: time.Now().UTC(),
|
|
connectionID: session.connectionID,
|
|
conn: packet.Conn,
|
|
mu: &sync.Mutex{},
|
|
presence: s.PresenceStore,
|
|
instanceID: s.gatewayInstanceID(),
|
|
report: session.report,
|
|
deliveryReport: session.deliveryReport,
|
|
protocolLog: session.protocolLog,
|
|
})
|
|
}
|
|
if current := findSessionByConn(packet.Conn); current != nil && current.report != nil {
|
|
go current.report(current, "submit", "")
|
|
}
|
|
responseReadyAt := time.Now()
|
|
response.AfterSend = observeInboundSubmitResponse(handlerStartedAt, responseReadyAt, true, func(sendErr error) {
|
|
releaseSubmitBarrier()
|
|
s.emitProtocolLog(protocolLogEvent{
|
|
Protocol: "cmpp",
|
|
Direction: "platform_to_client",
|
|
EventType: "submit_resp",
|
|
Status: protocolSendStatus(sendErr),
|
|
TenantID: result.TenantID,
|
|
ApplicationID: result.ApplicationID,
|
|
Account: account,
|
|
MessageID: result.MessageID,
|
|
GatewayMessageID: fmt.Sprint(gatewayMsgID),
|
|
Phone: phone,
|
|
ResultCode: protocolSendResultCode(sendErr, 0),
|
|
Detail: protocolSubmitResponseDetail(req.sequenceID, sendErr),
|
|
})
|
|
if sendErr != nil {
|
|
return
|
|
}
|
|
s.schedulePendingFlush(account, logger)
|
|
})
|
|
logger.Printf(
|
|
"cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s dest_count=%d accepted_count=%d result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s",
|
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, len(phones), len(responseMessages), result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash,
|
|
)
|
|
return false, nil
|
|
}
|
|
|
|
func inboundSubmitRequestID(connectionID string, sequenceID uint32, phones []string, content string, srcID string, longMessage *inboundLongMessageFragment) string {
|
|
// The key is stable for an API retry of the same packet but scoped to the authenticated
|
|
// connection, so a later client session may intentionally reuse the CMPP Sequence_Id.
|
|
payload := fmt.Sprintf("%s\x00%d\x00%s\x00%s\x00%s\x00%v", connectionID, sequenceID, strings.Join(phones, ","), strings.TrimSpace(srcID), content, longMessage)
|
|
digest := sha256.Sum256([]byte(payload))
|
|
return fmt.Sprintf("cmpp-inbound:%x", digest[:])
|
|
}
|
|
|
|
func observeInboundSubmitResponse(handlerStartedAt time.Time, responseReadyAt time.Time, accepted bool, next func(error)) func(error) {
|
|
return func(sendErr error) {
|
|
metrics.ObserveInboundStage("response_write", sendErr == nil, time.Since(responseReadyAt))
|
|
metrics.ObserveInboundStage("handler_total", accepted && sendErr == nil, time.Since(handlerStartedAt))
|
|
if next != nil {
|
|
next(sendErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
type inboundSubmitPacket struct {
|
|
protocol string
|
|
pkTotal uint8
|
|
pkNumber uint8
|
|
tpUdhi uint8
|
|
msgFmt uint8
|
|
msgSrc string
|
|
srcID string
|
|
destTerminalIDs []string
|
|
msgContent string
|
|
sequenceID uint32
|
|
registeredDelivery uint8
|
|
}
|
|
|
|
func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
|
|
switch req := packet.(type) {
|
|
case *cmpp.Cmpp2SubmitReqPkt:
|
|
return inboundSubmitPacket{
|
|
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, registeredDelivery: req.RegisteredDelivery,
|
|
}, true
|
|
case *cmpp.Cmpp3SubmitReqPkt:
|
|
return inboundSubmitPacket{
|
|
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, registeredDelivery: req.RegisteredDelivery,
|
|
}, true
|
|
default:
|
|
return inboundSubmitPacket{}, false
|
|
}
|
|
}
|
|
|
|
func setInboundSubmitResponse(packet any, messageID uint64, result uint32) {
|
|
switch resp := packet.(type) {
|
|
case *cmpp.Cmpp2SubmitRspPkt:
|
|
resp.MsgId = messageID
|
|
resp.Result = uint8(result)
|
|
case *cmpp.Cmpp3SubmitRspPkt:
|
|
resp.MsgId = messageID
|
|
resp.Result = result
|
|
}
|
|
}
|
|
|
|
func (s Server) submit(remote net.Addr, payload submitRequest) (submitResponse, error) {
|
|
payload.RemoteIP = remoteIP(remote)
|
|
var result submitResponse
|
|
client := s.SubmitHTTPClient
|
|
if client == nil {
|
|
client = s.HTTPClient
|
|
}
|
|
// Submit has a dedicated transport so protocol logs, receipt recovery and
|
|
// presence traffic cannot occupy the connections needed for SubmitResp.
|
|
err := s.postWithClient(context.Background(), client, "/gateway/events/inbound/submit", payload, &result)
|
|
return result, err
|
|
}
|
|
|
|
func decodeContent(format uint8, content string) (string, error) {
|
|
switch format {
|
|
case 8:
|
|
return cmpputils.Ucs2ToUtf8(content)
|
|
case 15:
|
|
return cmpputils.GB18030ToUtf8(content)
|
|
default:
|
|
return content, nil
|
|
}
|
|
}
|
|
|
|
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 messageIDFrom(value string, seq uint32) uint64 {
|
|
hash := md5.Sum([]byte(value))
|
|
result := uint64(seq)
|
|
for _, item := range hash[:6] {
|
|
result = (result << 8) + uint64(item)
|
|
}
|
|
if result == 0 {
|
|
return uint64(time.Now().UnixNano())
|
|
}
|
|
return result
|
|
}
|