Address the CodeRabbit review: inbound SMS, binary payloads and untrusted input

This commit is contained in:
2026-08-27 11:52:43 +02:00
parent 884afdb87b
commit 8a182604b7
23 changed files with 603 additions and 152 deletions
+2
View File
@@ -13,6 +13,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
cache: npm cache: npm
+7
View File
@@ -5,11 +5,16 @@ on:
branches: ['**'] branches: ['**']
pull_request: pull_request:
permissions:
contents: read
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
cache: npm cache: npm
@@ -27,6 +32,8 @@ jobs:
node: ['18', '20', '22', '24'] node: ['18', '20', '22', '24']
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
cache: npm cache: npm
+9 -4
View File
@@ -43,6 +43,7 @@ src/
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts silentLog — the default when the application passes none log.ts silentLog — the default when the application passes none
message.ts Encoding detection, splitting, bit counting, SMPP date formatting message.ts Encoding detection, splitting, bit counting, SMPP date formatting
@@ -118,6 +119,8 @@ implementation (see todo.md).
| Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node | | Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node |
| `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command | | `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command |
| Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 | | Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 |
| `source_telematics_id` width | Defined as a 2-octet integer; SMPP 3.4 5.3.2.8 makes it 1 octet, unlike `dest_telematics_id`, which really is 2 |
| Binary payloads decoded as text | `data_coding` 0x02, 0x04, 0x14 and 0xF4-0xF7 are 8-bit binary and land on the GSM 03.38 table, which rewrites every octet outside it. They resolve to LATIN1 now, so the payload survives as bytes |
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` | | `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
## Multipart sends and the send window ## Multipart sends and the send window
@@ -170,10 +173,12 @@ exactly 140.
because silently stripping a caller's explicit TLVs off a deliberately public low-level surface because silently stripping a caller's explicit TLVs off a deliberately public low-level surface
would be worse than sending them. The guarantee is "what this library sends honours the rule", would be worse than sending them. The guarantee is "what this library sends honours the rule",
never "the session cannot send optional parameters to an old peer". never "the session cannot send optional parameters to an old peer".
- **Only the server feeds `peerInterfaceVersion`.** `acceptBind()` records what the peer declared; - **Both ends feed `peerInterfaceVersion`, and a peer that declared nothing is pre-3.4.**
the client never reads `sc_interface_version` out of its bind response, so a client session is `acceptBind()` records what the ESME declared in its bind request; the client's `bind()` records
permissive. That is not a defect today — this library's ESME direction sends no TLVs at all — but the `sc_interface_version` the SMSC answered with. A peer that declared no version is recorded as
anyone adding a client-side TLV owes the other half of the feed. `undeclaredInterfaceVersion` (0x00) and is sent no optional parameters — the spec reads an absent
`sc_interface_version` as an SMSC that supports none. `undefined` is left to mean one thing only:
no bind has been accepted on this session yet.
- **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.** - **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.**
Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec
parse and build whatever a peer sends. The declared version is an option on both `client()` and parse and build whatever a peer sends. The declared version is an option on both `client()` and
+19 -1
View File
@@ -116,6 +116,21 @@ to reconcile against a later receipt, not enough to resend the rest, so treat a
failed message. A message needing more than 255 segments is refused before anything is sent, since failed message. A message needing more than 255 segments is refused before anything is sent, since
the concatenation header numbers segments in a single octet. the concatenation header numbers segments in a single octet.
### Receiving
A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events — the same
handle the server side gets, answered the same way:
```javascript
session.on('sms', async sms => {
// sms.from, sms.to, sms.message
await sms.sendResp();
});
```
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
to tell the two apart.
## Server ## Server
The simplest possible server — no authentication, listening on port 2775: The simplest possible server — no authentication, listening on port 2775:
@@ -168,6 +183,9 @@ await smpp.close(); // stop listening and close every live session
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`.
A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.from(sms.message,
'latin1')` gives you back the original octets.
### Server options ### Server options
| Option | Default | | | Option | Default | |
@@ -229,7 +247,7 @@ const { err, pduObj } = await session.send({
`acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version `acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version
at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is
the raw value it declared. The library's own senders consult the first before attaching a TLV — a the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a
`send()` you build yourself is passed through as written, so consult it too when you attach TLVs. `send()` you build yourself is passed through as written, so consult it too when you attach TLVs.
## Working with PDUs directly ## Working with PDUs directly
+10 -6
View File
@@ -3,7 +3,7 @@ import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { Session } from './session.ts'; import { Session } from './session.ts';
import { checkSessionOptions } from './session-options.ts'; import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { connect as netConnect } from 'node:net'; import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls'; import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -107,11 +107,10 @@ function bindParams(options: ClientOptions, systemId: string) {
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> { async function bind(session: Session, options: ClientOptions): Promise<VoidResult> {
const bindType = options.bindType ?? defaults.bindType; const bindType = options.bindType ?? defaults.bindType;
const systemId = options.username ?? defaults.username; const systemId = options.username ?? defaults.username;
const sent = await session.send({ const sent = await session.send(
cmdName: `bind_${bindType}`, { cmdName: `bind_${bindType}`, params: bindParams(options, systemId) },
params: bindParams(options, systemId), options.signal ? { signal: options.signal } : {},
...(options.signal ? { signal: options.signal } : {}), );
});
if (sent.err) return { err: sent.err }; if (sent.err) return { err: sent.err };
@@ -124,7 +123,12 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) }; return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
} }
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
session.loggedIn = true; session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
session.log.info('client - bound', { bindType, systemId }); session.log.info('client - bound', { bindType, systemId });
return {}; return {};
+27 -11
View File
@@ -87,7 +87,10 @@ const latin1: Encoding = {
const ucs2: Encoding = { const ucs2: Encoding = {
decode(buffer) { decode(buffer) {
return Buffer.from(buffer).swap16().toString('utf16le'); // A peer-controlled sm_length can cut a character in half, and swap16() refuses odd lengths.
const whole = buffer.length - (buffer.length % 2);
return Buffer.from(buffer.subarray(0, whole)).swap16().toString('utf16le');
}, },
encode(value) { encode(value) {
@@ -113,22 +116,35 @@ export function detect(value: string): EncodingName {
return 'UCS2'; return 'UCS2';
} }
/** /** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
* SMPP data_coding is a flat table for 0x00-0x0E, but the 0x1X and 0xFX ranges carry a GSM message function messageClassEncoding(dataCoding: number): EncodingName | undefined {
* class and encode the alphabet in bits 3-2 (or bit 2) instead — which is how a flash UCS2 message
* arrives as 0x18. Alphabets with no codec here fall back to ASCII.
*/
export function encodingByDataCoding(dataCoding: number): EncodingName {
if ((dataCoding & 0xF0) === 0x10) { if ((dataCoding & 0xF0) === 0x10) {
return ((dataCoding >> 2) & 0x03) === 0x02 ? 'UCS2' : 'ASCII'; const alphabet = (dataCoding >> 2) & 0x03;
if (alphabet === 0x01) return 'LATIN1';
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
} }
if ((dataCoding & 0xF0) === 0xF0) { if ((dataCoding & 0xF0) === 0xF0) {
return 'ASCII'; return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
} }
if (dataCoding === 0x03) return 'LATIN1'; return undefined;
}
/**
* SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2
* message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps
* every octet to a code point and back unchanged, so a binary payload survives; alphabets with no
* codec fall back to ASCII.
*/
export function encodingByDataCoding(dataCoding: number): EncodingName {
const messageClass = messageClassEncoding(dataCoding);
if (messageClass) return messageClass;
if (dataCoding === 0x08) return 'UCS2'; if (dataCoding === 0x08) return 'UCS2';
return 'ASCII'; // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
} }
+1 -1
View File
@@ -22,7 +22,7 @@ const specs = tlvSpecs({
source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 }, source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 },
source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 }, source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 },
source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 }, source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 },
source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int16 }, source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int8 },
qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 }, qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 },
payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 }, payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring }, additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
+35 -3
View File
@@ -142,6 +142,15 @@ function wantUnsuccessSmes(value: ParamValue): Result<{ smes: UnsuccessSme[] }>
} }
function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number; value: string }> { function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number; value: string }> {
// An offset at the end exactly is an absent trailing field, which real peers do send.
if (outOfRange(buffer, offset, 0)) {
return {
err: new Error(
`C-Octet String starts at offset ${String(offset)}, past a ${String(buffer.length)} octet buffer`,
),
};
}
let length = 0; let length = 0;
while (buffer[offset + length]) { while (buffer[offset + length]) {
@@ -197,6 +206,29 @@ export const int8 = intType(1, 0xFF, (b, o) => b.readUInt8(o), (b, v, o) => b.wr
export const int16 = intType(2, 0xFFFF, (b, o) => b.readUInt16BE(o), (b, v, o) => b.writeUInt16BE(v, o)); export const int16 = intType(2, 0xFFFF, (b, o) => b.readUInt16BE(o), (b, v, o) => b.writeUInt16BE(v, o));
export const int32 = intType(4, 0xFFFFFFFF, (b, o) => b.readUInt32BE(o), (b, v, o) => b.writeUInt32BE(v, o)); export const int32 = intType(4, 0xFFFFFFFF, (b, o) => b.readUInt32BE(o), (b, v, o) => b.writeUInt32BE(v, o));
const intByOctets: Record<number, WireType<number>> = { 1: int8, 2: int16, 4: int32 };
/**
* The TLV header's length is what the parser skips past, so it is also the width the value is read
* at — a peer that types a tag one octet wider than the table says still gets the value it meant.
*/
function tlvInt(declared: WireType<number>): WireType<number> {
return {
...declared,
read(buffer, offset, length) {
if (length === undefined) return declared.read(buffer, offset);
const width = intByOctets[length];
if (!width) {
return { err: new Error(`Integer TLV declares ${String(length)} octets, expected 1, 2 or 4`) };
}
return width.read(buffer, offset);
},
};
}
/** Octet String: a length octet followed by that many octets. */ /** Octet String: a length octet followed by that many octets. */
export const string: WireType<string> = { export const string: WireType<string> = {
default: '', default: '',
@@ -513,9 +545,9 @@ export const tlv = {
return err ? { err } : writeCstring(text, buf, offset); return err ? { err } : writeCstring(text, buf, offset);
}, },
} satisfies WireType<string>, } satisfies WireType<string>,
int8, int8: tlvInt(int8),
int16, int16: tlvInt(int16),
int32, int32: tlvInt(int32),
string: { string: {
default: '', default: '',
read(buf: Buffer, offset: number, length = 0) { read(buf: Buffer, offset: number, length = 0) {
+10 -2
View File
@@ -82,8 +82,7 @@ function receiptDate(value: string | undefined): Date | undefined {
const [, years, months, days, hours, minutes, seconds] = match; const [, years, months, days, hours, minutes, seconds] = match;
const century = Math.floor(new Date().getUTCFullYear() / 100) * 100; const century = Math.floor(new Date().getUTCFullYear() / 100) * 100;
const date = new Date(Date.UTC(
return new Date(Date.UTC(
century + Number(years), century + Number(years),
Number(months) - 1, Number(months) - 1,
Number(days), Number(days),
@@ -91,6 +90,15 @@ function receiptDate(value: string | undefined): Date | undefined {
Number(minutes), Number(minutes),
Number(seconds ?? 0), Number(seconds ?? 0),
)); ));
// Date.UTC rolls 31 February over into March rather than refusing it.
const rolled = date.getUTCMonth() !== Number(months) - 1
|| date.getUTCDate() !== Number(days)
|| date.getUTCHours() !== Number(hours)
|| date.getUTCMinutes() !== Number(minutes)
|| date.getUTCSeconds() !== Number(seconds ?? 0);
return rolled ? undefined : date;
} }
/** /**
+137
View File
@@ -0,0 +1,137 @@
import type { DlrMerger } from './dlr-merger.ts';
import type { LogInt } from '@larvit/log';
import type { OnRequest } from './session-options.ts';
import type { PduObject } from './pdu.ts';
import type { Session } from './session.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts';
import { concatInfo } from './udh.ts';
import { consts } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts';
export type IncomingRequestsOptions = {
dlrMerger: DlrMerger;
log: LogInt;
maxOctets?: number | undefined;
maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined;
reassemblyTimeout?: number | undefined;
session: Session;
systemId?: string | undefined;
};
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests {
private readonly dlrMerger: DlrMerger;
private readonly log: LogInt;
private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler;
private readonly session: Session;
private readonly systemId: string;
constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger;
this.log = options.log;
this.onRequest = options.onRequest;
this.reassembler = new Reassembler({
log: options.log,
max: options.maxReassembly ?? defaults.maxReassembly,
maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
});
this.session = options.session;
this.systemId = options.systemId ?? defaults.systemId;
}
async handle(pduObj: PduObject): Promise<void> {
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
break;
case 'enquire_link':
await this.session.sendReturn(pduObj);
break;
case 'submit_sm':
this.onMessage(pduObj);
break;
case 'unbind':
await this.session.sendReturn(pduObj);
this.session.close();
break;
default:
await this.unhandled(pduObj);
}
}
/** Drops the segments of every message that never became whole. */
clear(): void {
this.reassembler.clear();
}
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
}
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
private async onDeliverSm(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj);
if (!dlr) {
this.onMessage(pduObj);
return;
}
this.session.emit('dlr', dlr, pduObj);
const merged = this.dlrMerger.collect(dlr);
if (merged) this.session.emit('messageDlr', merged);
await this.session.sendReturn(pduObj);
}
private onMessage(pduObj: PduObject): void {
const message = pduObj.params.short_message;
const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
return;
}
const whole = this.reassembler.collect(pduObj, concat);
if (whole) this.emitSms(whole);
}
private emitSms(pduObjs: PduObject[]): void {
const first = pduObjs[0];
if (!first) return;
this.session.emit('sms', createSms({
from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs),
pduObjs,
session: this.session,
to: paramText(first.params.destination_addr),
}));
}
}
+9
View File
@@ -122,6 +122,15 @@ export class Reassembler {
collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined { collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined {
this.sweep(); this.sweep();
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', {
part: concat.part,
total: concat.total,
});
return undefined;
}
const key = groupKey(pduObj, concat.reference); const key = groupKey(pduObj, concat.reference);
const group = this.groups.get(key) ?? this.open(key, concat.total); const group = this.groups.get(key) ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part); const replaced = group.parts.get(concat.part);
+16 -1
View File
@@ -90,7 +90,7 @@ export class ReconnectLoop {
return false; return false;
} }
const up = await this.options.onConnected(opened.sock); const up = await this.bringUp(opened.sock);
if (up.err) { if (up.err) {
this.options.log.warn('reconnect - could not come back up', { message: up.err.message }); this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
@@ -102,4 +102,19 @@ export class ReconnectLoop {
return false; return false;
} }
/** The loop owns the socket until the owner is up on it, so a failed handover must not leak it. */
private async bringUp(sock: Socket): Promise<VoidResult> {
try {
const up = await this.options.onConnected(sock);
if (up.err) sock.destroy();
return up;
} catch (thrown: unknown) {
sock.destroy();
return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) };
}
}
} }
+4 -2
View File
@@ -5,7 +5,7 @@ import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { Server as TlsServer, TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts'; import { Session, bindCommands, defaultSystemId } from './session.ts';
import { checkSessionOptions } from './session-options.ts'; import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { createServer as createNetServer } from 'node:net'; import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls'; import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -148,7 +148,9 @@ async function acceptBind(
const declared = pduObj.params.interface_version; const declared = pduObj.params.interface_version;
session.loggedIn = true; session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number' ? declared : undefined; session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
} }
+11 -6
View File
@@ -27,6 +27,13 @@ export const bindCommands: readonly string[] = [
export type SendOptions = { signal?: AbortSignal | undefined }; export type SendOptions = { signal?: AbortSignal | undefined };
/**
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean>;
/** /**
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller * How to come back after an unexpected disconnect. The session owns the retry loop; the caller
* supplies how to open a socket and what to do once it is open (bind, for a client). * supplies how to open a socket and what to do once it is open (bind, for a client).
@@ -45,12 +52,7 @@ export type SessionOptions = {
maxOctets?: number | undefined; maxOctets?: number | undefined;
maxOutstanding?: number | undefined; maxOutstanding?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
/** onRequest?: OnRequest | undefined;
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined; reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined; responseTimeout?: number | undefined;
@@ -61,6 +63,9 @@ export type SessionOptions = {
export const defaultSystemId = ''; export const defaultSystemId = '';
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00;
export const defaults = { export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
+19 -103
View File
@@ -9,19 +9,15 @@ import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts'; import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { IncomingRequests } from './incoming-requests.ts';
import { LinkTimers } from './link-timers.ts'; import { LinkTimers } from './link-timers.ts';
import { PduFramer } from './pdu-framer.ts'; import { PduFramer } from './pdu-framer.ts';
import { PendingRequests } from './pending-requests.ts'; import { PendingRequests } from './pending-requests.ts';
import { ReconnectLoop } from './reconnect-loop.ts'; import { ReconnectLoop } from './reconnect-loop.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { SendWindow } from './send-window.ts'; import { SendWindow } from './send-window.ts';
import { concatInfo } from './udh.ts'; import { optionalParamsMinVersion } from './defs/constants.ts';
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { bindCommands, defaultSystemId, defaults } from './session-options.ts'; import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { dlrFromPdu } from './dlr.ts';
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts'; import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts'; import { submitSms } from './send-sms.ts';
@@ -42,14 +38,14 @@ export class Session extends EventEmitter<SessionEvents> {
readonly log: LogInt; readonly log: LogInt;
loggedIn = false; loggedIn = false;
/** The interface_version the peer declared when binding; undefined until a bind is accepted. */ /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined; peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined; userData: unknown = undefined;
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly incoming: IncomingRequests;
private readonly options: SessionOptions; private readonly options: SessionOptions;
private readonly pending: PendingRequests; private readonly pending: PendingRequests;
private readonly reassembler: Reassembler;
private readonly reconnectLoop: ReconnectLoop | undefined; private readonly reconnectLoop: ReconnectLoop | undefined;
private readonly timers: LinkTimers; private readonly timers: LinkTimers;
private readonly window: SendWindow; private readonly window: SendWindow;
@@ -87,13 +83,17 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges, max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout, timeout: defaults.dlrMergeTimeout,
}); });
this.pending = new PendingRequests(this.log); this.incoming = new IncomingRequests({
this.reassembler = new Reassembler({ dlrMerger: this.dlrMerger,
log: this.log, log: this.log,
max: options.maxReassembly ?? defaults.maxReassembly,
maxOctets: options.maxOctets, maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, maxReassembly: options.maxReassembly,
onRequest: options.onRequest,
reassemblyTimeout: options.reassemblyTimeout,
session: this,
systemId: options.systemId,
}); });
this.pending = new PendingRequests(this.log);
this.reconnectLoop = this.loopFor(options.reconnect); this.reconnectLoop = this.loopFor(options.reconnect);
this.sock = options.sock; this.sock = options.sock;
this.timers = new LinkTimers({ this.timers = new LinkTimers({
@@ -243,6 +243,11 @@ export class Session extends EventEmitter<SessionEvents> {
input: PduObjectInput, input: PduObjectInput,
options: SendOptions, options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
// pending.wait() alone settles the caller while the request still goes out to the peer.
if (options.signal?.aborted === true) {
return { err: new Error('Aborted before the request was sent') };
}
const seqNr = this.pending.nextSeqNr(); const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr }); const built = objToPdu({ ...input, seqNr });
@@ -270,7 +275,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.timers.clear(); this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived')); this.pending.settleAll(new Error('Session closed before a response arrived'));
this.dlrMerger.clear(); this.dlrMerger.clear();
this.reassembler.clear(); this.incoming.clear();
this.sock.destroy(); this.sock.destroy();
this.emit('close'); this.emit('close');
} }
@@ -343,7 +348,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.emit('incomingPduObj', pduObj); this.emit('incomingPduObj', pduObj);
// Every application hook and listener reached from an incoming PDU funnels through here. // Every application hook and listener reached from an incoming PDU funnels through here.
void this.handle(pduObj).catch((thrown: unknown) => { void this.incoming.handle(pduObj).catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown)); const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.log.error('session - a handler threw', { message: err.message }); this.log.error('session - a handler threw', { message: err.message });
@@ -351,95 +356,6 @@ export class Session extends EventEmitter<SessionEvents> {
}); });
} }
private async handle(pduObj: PduObject): Promise<void> {
const onRequest = this.options.onRequest;
if (onRequest && await onRequest(this, pduObj)) return;
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
break;
case 'enquire_link':
await this.sendReturn(pduObj);
break;
case 'submit_sm':
this.onSubmitSm(pduObj);
break;
case 'unbind':
await this.sendReturn(pduObj);
this.close();
break;
default:
await this.unhandled(pduObj);
}
}
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
await this.sendReturn(pduObj, 'ESME_RALYBND', {
system_id: this.options.systemId ?? defaults.systemId,
});
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.sendReturn(pduObj, 'ESME_RINVCMDID');
}
private onSubmitSm(pduObj: PduObject): void {
const message = pduObj.params.short_message;
const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
return;
}
const whole = this.reassembler.collect(pduObj, concat);
if (whole) this.emitSms(whole);
}
private emitSms(pduObjs: PduObject[]): void {
const first = pduObjs[0];
if (!first) return;
this.emit('sms', createSms({
from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs),
pduObjs,
session: this,
to: paramText(first.params.destination_addr),
}));
}
private async onDeliverSm(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj);
if (!dlr) {
this.log.info('session - deliver_sm carries no delivery report', { seqNr: pduObj.seqNr });
await this.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
return;
}
this.emit('dlr', dlr, pduObj);
const merged = this.dlrMerger.collect(dlr);
if (merged) this.emit('messageDlr', merged);
await this.sendReturn(pduObj);
}
private resetTimers(): void { private resetTimers(): void {
if (this.closed) return; if (this.closed) return;
+8
View File
@@ -104,6 +104,14 @@ describe('dlrFromPdu()', () => {
} }
}); });
test('leaves an impossible receipt date undefined rather than rolling it over', () => {
const rolled = dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:9902310000'));
assert.ok(rolled);
assert.equal(rolled.doneDate, undefined);
assert.equal(dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:2501012560'))?.doneDate, undefined);
});
test('returns nothing when the PDU identifies no message', () => { test('returns nothing when the PDU identifies no message', () => {
assert.equal(dlrFromPdu(deliverSm('just a normal sms')), undefined); assert.equal(dlrFromPdu(deliverSm('just a normal sms')), undefined);
}); });
+21
View File
@@ -50,6 +50,12 @@ describe('LATIN1', () => {
assert.equal(encodings.LATIN1.decode(Buffer.from(bytes)), str); assert.equal(encodings.LATIN1.decode(Buffer.from(bytes)), str);
} }
}); });
test('carries every octet through unchanged, which is what makes it the binary codec', () => {
const every = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte));
assert.deepEqual(encodings.LATIN1.encode(encodings.LATIN1.decode(every)), every);
});
}); });
describe('UCS2', () => { describe('UCS2', () => {
@@ -78,6 +84,15 @@ describe('UCS2', () => {
assert.deepEqual(buffer, Buffer.from([0x00, 0x20])); assert.deepEqual(buffer, Buffer.from([0x00, 0x20]));
}); });
// swap16() throws ERR_INVALID_BUFFER_SIZE on an odd octet count, and sm_length is peer-controlled.
test('drops an incomplete trailing octet instead of throwing', () => {
const odd = Buffer.from([0x00, 0x41, 0x00, 0x42, 0x00]);
assert.equal(encodings.UCS2.decode(odd), 'AB');
assert.equal(encodings.UCS2.decode(Buffer.from([0x41])), '');
assert.deepEqual(odd, Buffer.from([0x00, 0x41, 0x00, 0x42, 0x00]));
});
}); });
describe('detect()', () => { describe('detect()', () => {
@@ -111,6 +126,12 @@ describe('encodingByDataCoding()', () => {
assert.equal(encodingByDataCoding(0xF0), 'ASCII'); assert.equal(encodingByDataCoding(0xF0), 'ASCII');
}); });
test('resolves the 8-bit binary codings to the codec that keeps every octet', () => {
for (const dataCoding of [0x02, 0x04, 0x14, 0xF4, 0xF7]) {
assert.equal(encodingByDataCoding(dataCoding), 'LATIN1');
}
});
test('falls back to ASCII for alphabets it has no codec for', () => { test('falls back to ASCII for alphabets it has no codec for', () => {
assert.equal(encodingByDataCoding(0x05), 'ASCII'); assert.equal(encodingByDataCoding(0x05), 'ASCII');
assert.equal(encodingByDataCoding(0x0E), 'ASCII'); assert.equal(encodingByDataCoding(0x0E), 'ASCII');
+9 -8
View File
@@ -204,7 +204,7 @@ describe('the reference encoder against our parser', () => {
}); });
describe('a live session against the reference implementation', () => { describe('a live session against the reference implementation', () => {
test('our client binds to a reference server and delivers an SMS', async () => { test('our client binds to a reference server and delivers an SMS', async t => {
const received: { from: string; message: string }[] = []; const received: { from: string; message: string }[] = [];
const refServer = reference.createServer({}, (session: ReferenceSession) => { const refServer = reference.createServer({}, (session: ReferenceSession) => {
session.on('bind_transceiver', pdu => { session.on('bind_transceiver', pdu => {
@@ -226,11 +226,14 @@ describe('a live session against the reference implementation', () => {
}); });
}); });
t.after(() => new Promise<void>(resolve => { refServer.close(() => { resolve(); }); }));
await new Promise<void>(resolve => { refServer.listen(0, () => { resolve(); }); }); await new Promise<void>(resolve => { refServer.listen(0, () => { resolve(); }); });
const port = refServer.address()?.port ?? 0; const port = refServer.address()?.port ?? 0;
const { err, session } = await client({ port }); const { err, session } = await client({ port });
t.after(() => { session?.close(); });
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
@@ -243,14 +246,13 @@ describe('a live session against the reference implementation', () => {
assert.equal(sent.err, undefined); assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['ref-id']); assert.deepEqual(sent.smsIds, ['ref-id']);
assert.deepEqual(received, [{ from: 'MyBrand', message: 'interop check' }]); assert.deepEqual(received, [{ from: 'MyBrand', message: 'interop check' }]);
session.close();
await new Promise<void>(resolve => { refServer.close(() => { resolve(); }); });
}); });
test('a reference client binds to our server and delivers an SMS', async () => { test('a reference client binds to our server and delivers an SMS', async t => {
const { err: serverErr, server: smpp } = await server({ port: 0 }); const { err: serverErr, server: smpp } = await server({ port: 0 });
t.after(async () => { await smpp?.close(); });
assert.equal(serverErr, undefined); assert.equal(serverErr, undefined);
assert.ok(smpp); assert.ok(smpp);
@@ -262,6 +264,8 @@ describe('a live session against the reference implementation', () => {
url: `smpp://localhost:${String(smpp.port)}`, url: `smpp://localhost:${String(smpp.port)}`,
}); });
t.after(() => { refSession.close(); });
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
refSession.bind_transceiver({ password: 'bar', system_id: 'foo' }, () => { resolve(); }); refSession.bind_transceiver({ password: 'bar', system_id: 'foo' }, () => { resolve(); });
}); });
@@ -277,8 +281,5 @@ describe('a live session against the reference implementation', () => {
assert.equal(sms.from, '46701113311'); assert.equal(sms.from, '46701113311');
assert.equal(sms.message, 'from the reference client'); assert.equal(sms.message, 'from the reference client');
await sms.sendResp(); await sms.sendResp();
refSession.close();
await smpp.close();
}); });
}); });
+10
View File
@@ -110,6 +110,16 @@ describe('encodeMessage() and decodeMessage()', () => {
assert.equal(decodeMessage(buffer, 0x08).message, 'hej 一'); assert.equal(decodeMessage(buffer, 0x08).message, 'hej 一');
}); });
test('keeps a binary payload octet for octet instead of running it through GSM 03.38', () => {
const payload = Buffer.from([0x00, 0x1B, 0x60, 0x80, 0xFF]);
assert.deepEqual(Buffer.from(decodeMessage(payload, 0x04).message, 'latin1'), payload);
});
test('decodes the whole characters of a UCS2 payload cut in half by sm_length', () => {
assert.equal(decodeMessage(Buffer.from([0x00, 0x68, 0x00, 0x65, 0x00]), 0x08).message, 'he');
});
test('strips a UDH when the esm_class says one is present', () => { test('strips a UDH when the esm_class says one is present', () => {
const withUdh = Buffer.concat([ const withUdh = Buffer.concat([
Buffer.from([0x05, 0x00, 0x03, 0x01, 0x02, 0x01]), Buffer.from([0x05, 0x00, 0x03, 0x01, 0x02, 0x01]),
+21 -1
View File
@@ -28,8 +28,18 @@ async function startServer(options: Parameters<typeof server>[0] = {}): Promise<
return smpp; return smpp;
} }
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> { function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>(resolve => { register(resolve); }); return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('waited 5000 ms for an event that never fired'));
}, 5000);
register(value => {
clearTimeout(timer);
resolve(value);
});
});
} }
describe('merged delivery reports', () => { describe('merged delivery reports', () => {
@@ -345,6 +355,16 @@ describe('reassembly bounds', () => {
assert.equal(reassembler.size, 0); assert.equal(reassembler.size, 0);
}); });
// The UDH is peer-controlled, and the default authenticate() accepts every peer.
test('refuses a segment whose concatenation metadata cannot be honoured', () => {
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
assert.equal(collect(reassembler, 1, 1, 0), undefined);
assert.equal(collect(reassembler, 2, 0, 3), undefined);
assert.equal(collect(reassembler, 3, 4, 3), undefined);
assert.equal(reassembler.size, 0);
});
// 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived. // 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived.
test('drops the oldest incomplete message once the cap is reached', () => { test('drops the oldest incomplete message once the cap is reached', () => {
const reassembler = new Reassembler({ log: silentLog, max: 2, now: () => 0, timeout: 60_000 }); const reassembler = new Reassembler({ log: silentLog, max: 2, now: () => 0, timeout: 60_000 });
+178
View File
@@ -5,15 +5,19 @@ import type { Dlr } from '../src/dlr.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Sms } from '../src/sms.ts'; import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts'; import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
import type { VoidResult } from '../src/result.ts';
import { DlrMerger } from '../src/dlr-merger.ts'; import { DlrMerger } from '../src/dlr-merger.ts';
import { PduFramer } from '../src/pdu-framer.ts'; import { PduFramer } from '../src/pdu-framer.ts';
import { ReconnectLoop } from '../src/reconnect-loop.ts'; import { ReconnectLoop } from '../src/reconnect-loop.ts';
import { Session, bindCommands } from '../src/session.ts'; import { Session, bindCommands } from '../src/session.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { consts } from '../src/defs/constants.ts';
import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts'; import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts'; import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts'; import { silentLog } from '../src/log.ts';
import { splitMessage } from '../src/message.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> { async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
const { err, server: smpp } = await server({ ...options, port: 0 }); const { err, server: smpp } = await server({ ...options, port: 0 });
@@ -391,6 +395,33 @@ describe('bind', () => {
peer.close(); peer.close();
await smpp.close(); await smpp.close();
}); });
test('records the version the SMSC declared in its bind response', async t => {
const smpp = await startServer({ interfaceVersion: 0x50 });
t.after(() => smpp.close());
const { session } = await connect(smpp);
assert.ok(session);
t.after(() => { session.close(); });
assert.equal(session.peerInterfaceVersion, 0x50);
assert.ok(session.acceptsOptionalParams());
});
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
test('takes an SMSC that declares no version as older than 3.4', async t => {
const peer = await bindOnlyPeer();
t.after(() => peer.close());
const { session } = await client({ port: peer.port });
assert.ok(session);
t.after(() => { session.close(); });
assert.equal(session.peerInterfaceVersion, 0x00);
assert.equal(session.acceptsOptionalParams(), false);
});
}); });
describe('sending', () => { describe('sending', () => {
@@ -517,6 +548,84 @@ describe('sending', () => {
}); });
}); });
describe('receiving', () => {
async function inbound(t: TestContext): Promise<{ peer: Session; session: Session }> {
const smpp = await startServer();
t.after(() => smpp.close());
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
const { session } = await connect(smpp);
assert.ok(session);
t.after(() => { session.close(); });
return { peer: await bound, session };
}
test('hands a client a deliver_sm that is not a delivery receipt', async t => {
const { peer, session } = await inbound(t);
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
short_message: 'inbound hello',
source_addr: '46701113311',
},
});
const sms = await raceWithin(2000, incoming);
assert.ok(sms, 'a deliver_sm that carries no receipt is an inbound SMS');
assert.equal(sms.from, '46701113311');
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'inbound hello');
await sms.sendResp({ smsId: 'inbound-id' });
const answered = await delivered;
assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp');
assert.equal(answered.pduObj.params.message_id, 'inbound-id');
});
test('reassembles a multipart inbound SMS before the sms event', async t => {
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
const { peer, session } = await inbound(t);
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
const segments = splitMessage(message, { reference: 42 });
assert.equal(segments.length, 2);
const delivered = Promise.all(segments.map(segment => peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
short_message: segment,
source_addr: '46701113311',
},
})));
const sms = await raceWithin(2000, incoming);
assert.ok(sms, 'both segments should reassemble into one message');
assert.equal(sms.message, message);
assert.equal(sms.pduObjs.length, 2);
await sms.sendResp({ smsId: 'inbound-long' });
const ids: string[] = [];
for (const answered of await delivered) {
assert.ok(answered.pduObj);
ids.push(paramText(answered.pduObj.params.message_id));
}
assert.deepEqual(ids, ['inbound-long-1', 'inbound-long-2']);
});
});
describe('delivery reports', () => { describe('delivery reports', () => {
test('reaches the sender as a dlr event', async () => { test('reaches the sender as a dlr event', async () => {
const smpp = await startServer(); const smpp = await startServer();
@@ -860,6 +969,75 @@ describe('robustness', () => {
assert.ok(await closed); assert.ok(await closed);
await smpp.close(); await smpp.close();
}); });
// An aborted send that still reaches the SMSC bills a message the caller believes never went out.
test('puts nothing on the wire for a signal that is already aborted', async t => {
const smpp = await startServer();
t.after(() => smpp.close());
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
const { session } = await connect(smpp);
assert.ok(session);
t.after(() => { session.close(); });
const peer = await bound;
const controller = new AbortController();
const seen: string[] = [];
peer.on('incomingPduObj', pduObj => { seen.push(pduObj.cmdName); });
controller.abort();
const sent = await session.sendSms({
from: '46701113311',
message: 'must never reach the peer',
to: '46709771337',
}, { signal: controller.signal });
assert.ok(sent.err instanceof Error);
await delay(50);
assert.deepEqual(seen, []);
});
// A socket the loop opened and never handed over is one leaked per retry, forever.
test('leaves no socket open when coming back up fails', async () => {
const opened: net.Socket[] = [];
function onConnected(): Promise<VoidResult> {
if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') });
throw new Error('bind exploded');
}
const loop = new ReconnectLoop({
connect: () => {
const sock = new net.Socket();
opened.push(sock);
return Promise.resolve({ sock });
},
log: silentLog,
maxDelay: 10,
minDelay: 1,
onConnected,
});
loop.schedule();
const destroyed = await waitFor(() => opened.length >= 2
&& opened[0]?.destroyed === true
&& opened[1]?.destroyed === true);
loop.stop();
for (const sock of opened) {
sock.destroy();
}
assert.ok(destroyed, 'a failed setup should leave no socket open');
});
}); });
describe('application hooks that throw', () => { describe('application hooks that throw', () => {
+37
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts'; import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
import { tlvs } from '../src/defs/tlvs.ts';
import { types } from '../src/defs/types.ts'; import { types } from '../src/defs/types.ts';
describe('integers', () => { describe('integers', () => {
@@ -102,6 +103,42 @@ describe('cstring (C-Octet String)', () => {
test('refuses a string with no terminator rather than running off the end', () => { test('refuses a string with no terminator rather than running off the end', () => {
assert.ok(types.cstring.read(Buffer.from('abcd'), 0).err instanceof Error); assert.ok(types.cstring.read(Buffer.from('abcd'), 0).err instanceof Error);
}); });
test('refuses one that starts past the end instead of inventing an empty value', () => {
assert.ok(types.cstring.read(encoded, encoded.length + 1).err instanceof Error);
assert.ok(types.cstring.read(encoded, -1).err instanceof Error);
// At the end exactly the field is absent, not corrupt: peers truncate a NULL-only body.
assert.deepEqual(types.cstring.read(encoded, encoded.length), { bytesRead: 1, value: '' });
});
});
describe('integer TLVs', () => {
const encoded = Buffer.from([0x00, 0x00, 0x01, 0x02]);
// The TLV header's length is what the parser skips, so it is what the value must be read at.
test('read the width the TLV header declares', () => {
assert.deepEqual(types.tlv.int8.read(encoded, 0, 4), { bytesRead: 4, value: 0x00000102 });
assert.deepEqual(types.tlv.int16.read(encoded, 2, 2), { bytesRead: 2, value: 0x0102 });
assert.deepEqual(types.tlv.int32.read(encoded, 3, 1), { bytesRead: 1, value: 0x02 });
assert.deepEqual(types.tlv.int16.read(encoded, 2), { bytesRead: 2, value: 0x0102 });
});
test('refuse a length no integer field can have', () => {
assert.ok(types.tlv.int8.read(encoded, 0, 0).err instanceof Error);
assert.ok(types.tlv.int16.read(encoded, 0, 3).err instanceof Error);
assert.ok(types.tlv.int32.read(encoded, 0, 8).err instanceof Error);
});
test('stay bounds-checked at the declared width', () => {
assert.ok(types.tlv.int8.read(Buffer.alloc(2), 0, 4).err instanceof Error);
});
// SMPP 3.4 5.3.2.7-8: the two telematics ids are deliberately different widths.
test('are the width the spec gives each tag', () => {
assert.equal(tlvs.source_telematics_id.type, types.tlv.int8);
assert.equal(tlvs.dest_telematics_id.type, types.tlv.int16);
});
}); });
describe('buffer', () => { describe('buffer', () => {
+3 -3
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status ## Status
The rewrite is **feature complete and green**: 190 tests, lint and typecheck clean, verified on Node The rewrite is **feature complete and green**: 208 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash ```bash
@@ -22,10 +22,10 @@ public surface is documented in [README.md](README.md); this is the short form.
import { client, server } from '@larvit/smpp'; import { client, server } from '@larvit/smpp';
const { err, session } = await client({ host, password, port, username }); const { err, session } = await client({ host, password, port, username });
const { err, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to });
await session.unbind(); await session.unbind();
const { err, server: smpp } = await server({ authenticate, port }); const { err: serverErr, server: smpp } = await server({ authenticate, port });
smpp.on('session', session => { smpp.on('session', session => {
session.on('sms', async sms => { session.on('sms', async sms => {
await sms.sendResp(); await sms.sendResp();