Enforce bind direction, cap segments per message and pin the README examples

This commit is contained in:
2026-08-27 14:07:42 +02:00
parent 8d245c82c4
commit 366fa26b1c
14 changed files with 568 additions and 31 deletions
+4 -2
View File
@@ -1,7 +1,10 @@
import type { ConnectionOptions } from 'node:tls';
import type { Result, VoidResult } from './result.ts';
import type { BindType } from './session-options.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
export type { BindType };
import { Session } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { connect as netConnect } from 'node:net';
@@ -9,8 +12,6 @@ import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
import { silentLog } from './log.ts';
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
export type ClientOptions = {
addressRange?: string;
addrNpi?: number;
@@ -125,6 +126,7 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
session.boundAs = bindType;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
+10
View File
@@ -48,6 +48,16 @@ export class IncomingRequests {
async handle(pduObj: PduObject): Promise<void> {
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
if (!this.session.bindAllows(pduObj.cmdName)) {
this.log.info('session - command the peer\'s bind direction does not carry', {
bindType: this.session.boundAs ?? '',
cmdName: pduObj.cmdName,
});
await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
return;
}
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
+40 -21
View File
@@ -15,6 +15,8 @@ export type SendSmsOptions = {
encoding?: EncodingName;
flash?: boolean;
from: string;
/** Refuse before sending anything if the message needs more than this many segments. */
maxSegments?: number;
message: string;
scheduleDeliveryTime?: Date | number | string;
sourceAddrNpi?: number;
@@ -79,31 +81,26 @@ export function submitSmParams(
}
/** Puts a message on the wire as one submit_sm per segment. */
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
if (segments.length === 0) {
return {
err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`),
pduObjs: [],
smsIds: [],
};
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
function checkSegments(allowed: number, segments: number): Error | undefined {
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
return new Error(`maxSegments must be between 1 and ${String(maxSegments)}, got ${String(allowed)}`);
}
const multipart = segments.length > 1;
if (segments === 0) {
return new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`);
}
if (segments > allowed) {
return new Error(`Message needs ${String(segments)} segments, more than the ${String(allowed)} allowed`);
}
return undefined;
}
function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult {
const pduObjs: PduObject[] = [];
const smsIds: string[] = [];
deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
// Segments go out together rather than one-after-a-response: a receiver that waits for every
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { encoding, multipart }),
})));
let failure: Error | undefined;
for (const one of sent) {
@@ -121,3 +118,25 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds };
}
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
const allowed = sms.maxSegments ?? maxSegments;
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
const refused = checkSegments(allowed, segments.length);
if (refused) return { err: refused, pduObjs: [], smsIds: [] };
const multipart = segments.length > 1;
deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
// Segments go out together rather than one-after-a-response: a receiver that waits for every
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { encoding, multipart }),
})));
return collectSent(sent);
}
+2 -1
View File
@@ -5,7 +5,7 @@ import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -147,6 +147,7 @@ async function acceptBind(
): Promise<void> {
const declared = pduObj.params.interface_version;
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
+22
View File
@@ -25,6 +25,28 @@ export const bindCommands: readonly string[] = [
'bind_transmitter',
];
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
if (cmdName === 'bind_receiver') return 'receiver';
if (cmdName === 'bind_transceiver') return 'transceiver';
if (cmdName === 'bind_transmitter') return 'transmitter';
return undefined;
}
/**
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
* has not bound carries everything, since nothing has declared a direction yet.
*/
export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean {
if (bindType === 'receiver') return cmdName !== 'submit_sm';
if (bindType === 'transmitter') return cmdName !== 'deliver_sm';
return true;
}
export type SendOptions = { signal?: AbortSignal | undefined };
/**
+14 -2
View File
@@ -2,7 +2,7 @@ import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { BindType, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
@@ -16,7 +16,7 @@ import { PendingRequests } from './pending-requests.ts';
import { ReconnectLoop } from './reconnect-loop.ts';
import { SendWindow } from './send-window.ts';
import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts';
@@ -30,6 +30,7 @@ export type {
SessionEvents,
SessionOptions,
};
export type { BindType };
export { bindCommands, defaultSystemId };
export class Session extends EventEmitter<SessionEvents> {
@@ -37,6 +38,8 @@ export class Session extends EventEmitter<SessionEvents> {
sock: Socket;
readonly log: SmppLog;
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
@@ -110,6 +113,11 @@ export class Session extends EventEmitter<SessionEvents> {
this.resetTimers();
}
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName);
}
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
acceptsOptionalParams(): boolean {
return this.peerInterfaceVersion === undefined
@@ -165,6 +173,10 @@ export class Session extends EventEmitter<SessionEvents> {
}
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
if (!this.bindAllows('submit_sm')) {
return { err: new Error('A receiver-bound session does not carry submit_sm'), pduObjs: [], smsIds: [] };
}
const sent = await submitSms({
log: this.log,
reference: this.nextConcatReference(),
+4
View File
@@ -126,6 +126,10 @@ async function sendDlr(
sms: Sms,
status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> {
if (!sms.session.bindAllows('deliver_sm')) {
return { err: new Error('A transmitter-bound session does not carry deliver_sm') };
}
const total = sms.pduObjs.length;
const pduObjs: PduObject[] = [];