feat: add cmpp inbound gateway listener

This commit is contained in:
hectorzhao
2026-07-07 16:19:14 +08:00
parent ad3b86ba0a
commit cc628d0214
14 changed files with 631 additions and 17 deletions
@@ -1,6 +1,8 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
GatewayInboundAuthDto,
GatewayInboundSubmitDto,
GatewayReceiptEventDto,
GatewaySubmitResultDto,
GatewayUplinkEventDto,
@@ -26,5 +28,14 @@ export class GatewayEventsController {
uplink(@Body() body: GatewayUplinkEventDto) {
return this.sendChain.handleUplink(body);
}
}
@Post('inbound/authenticate')
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
return this.sendChain.authenticateInboundApplication(body);
}
@Post('inbound/submit')
submitInbound(@Body() body: GatewayInboundSubmitDto) {
return this.sendChain.submitInboundMessage(body);
}
}
+19 -1
View File
@@ -59,7 +59,17 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
findFirst: jest.fn().mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
secretHash: 'secret-hash',
status: 'active',
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
}),
},
smsTemplate: {
findUnique: jest.fn().mockResolvedValue({
@@ -69,6 +79,14 @@ function createPrismaMock() {
auditStatus: 'approved',
signature: { auditStatus: 'approved', reportStatus: 'approved' },
}),
findFirst: jest.fn().mockResolvedValue({
id: 'tpl-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello',
auditStatus: 'approved',
signature: { auditStatus: 'approved', reportStatus: 'approved' },
}),
},
smsBatchTask: {
create: jest.fn().mockResolvedValue(task),
+170 -1
View File
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException, OnModuleDestroy, On
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { isIP } from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -20,6 +22,25 @@ export interface CreateBatchTaskDto {
createdById?: string;
sourceIp?: string;
userAgent?: string;
sourceType?: 'client' | 'api' | 'cmpp';
}
export interface GatewayInboundAuthDto {
account: string;
password?: string;
authSource?: string;
timestamp?: number;
remoteIp?: string;
}
export interface GatewayInboundSubmitDto {
account: string;
phoneNumber: string;
content: string;
srcId?: string;
destId?: string;
sequenceId?: number;
remoteIp?: string;
}
export interface GatewaySubmitResultDto {
@@ -176,7 +197,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
applicationId: data.applicationId,
templateId: data.templateId,
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceType: 'client',
sourceType: data.sourceType ?? 'client',
content: data.content,
category: data.category,
phoneTotal: phones.length,
@@ -628,6 +649,63 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.findInboundApplication(data.account);
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
passwordCipher: application.secretHash,
status: 'authenticated',
};
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
const application = await this.findInboundApplication(data.account);
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
throw new BadRequestException('CMPP submit phone number is invalid');
}
const template = await this.resolveInboundTemplate(application.id, data.content);
const task = await this.createBatchTask({
tenantId: application.tenantId,
applicationId: application.id,
templateId: template.id,
content: data.content,
phones: [data.phoneNumber],
sourceType: 'cmpp',
sourceIp: data.remoteIp,
userAgent: 'cmpp-gateway',
});
const message = task?.messages?.[0];
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
taskId: task?.id,
messageId: message?.messageId,
messageRecordId: message?.id,
status: message?.status ?? task?.status,
};
}
async markUnknownTimeout(data: TimeoutUnknownDto) {
const olderThanHours = data.olderThanHours ?? 72;
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
@@ -911,6 +989,36 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return normalizeQueuePriority(application.queuePriority);
}
private findInboundApplication(account: string) {
return this.prisma.smsApplication.findFirst({
where: { cmppAccount: account },
include: {
tenant: true,
ipAllowlist: true,
},
});
}
private async resolveInboundTemplate(applicationId: string, content: string) {
const template = await this.prisma.smsTemplate.findFirst({
where: {
applicationId,
content,
auditStatus: 'approved',
signature: {
auditStatus: 'approved',
reportStatus: 'approved',
},
},
include: { signature: true },
orderBy: { updatedAt: 'desc' },
});
if (!template) {
throw new BadRequestException('CMPP submit content does not match an approved template and signature');
}
return template;
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.status !== 'active') {
@@ -1270,3 +1378,64 @@ function bullmqConnection() {
maxRetriesPerRequest: null,
};
}
function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
if (data.authSource && data.timestamp !== undefined) {
const expected = createHash('md5')
.update(Buffer.concat([
Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.alloc(9),
Buffer.from(secretHash),
Buffer.from(String(data.timestamp).padStart(10, '0')),
]))
.digest('base64');
return expected === data.authSource;
}
if (!data.password) {
return false;
}
return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash;
}
function octetString(value: string, fixedLength: number) {
if (value.length === fixedLength) {
return value;
}
if (value.length > fixedLength) {
return value.slice(value.length - fixedLength);
}
return value + '\0'.repeat(fixedLength - value.length);
}
function isApplicationIpAllowed(remoteIp: string, allowlist: string[]) {
const normalizedRemoteIp = normalizeIp(remoteIp);
if (allowlist.length === 0) {
return true;
}
return allowlist.some((rule) => ipMatchesRule(normalizedRemoteIp, rule));
}
function ipMatchesRule(remoteIp: string, rule: string) {
const normalizedRule = normalizeIp(rule.trim());
if (!normalizedRule) {
return false;
}
if (!normalizedRule.includes('/')) {
return remoteIp === normalizedRule;
}
const [network, prefixText] = normalizedRule.split('/');
const prefix = Number(prefixText);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32 || isIP(remoteIp) !== 4 || isIP(network) !== 4) {
return false;
}
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (ipv4ToInt(remoteIp) & mask) === (ipv4ToInt(network) & mask);
}
function normalizeIp(value: string) {
return value.replace(/^::ffff:/, '').trim();
}
function ipv4ToInt(value: string) {
return value.split('.').reduce((result, part) => ((result << 8) + Number(part)) >>> 0, 0);
}