Address the CodeRabbit review: inbound SMS, binary payloads and untrusted input
This commit is contained in:
@@ -13,6 +13,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
|
||||
@@ -5,11 +5,16 @@ on:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
@@ -27,6 +32,8 @@ jobs:
|
||||
node: ['18', '20', '22', '24']
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
|
||||
@@ -43,6 +43,7 @@ src/
|
||||
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
|
||||
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
||||
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
|
||||
log.ts silentLog — the default when the application passes none
|
||||
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 |
|
||||
| `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 |
|
||||
| `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` |
|
||||
|
||||
## 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
|
||||
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".
|
||||
- **Only the server feeds `peerInterfaceVersion`.** `acceptBind()` records what the peer declared;
|
||||
the client never reads `sc_interface_version` out of its bind response, so a client session is
|
||||
permissive. That is not a defect today — this library's ESME direction sends no TLVs at all — but
|
||||
anyone adding a client-side TLV owes the other half of the feed.
|
||||
- **Both ends feed `peerInterfaceVersion`, and a peer that declared nothing is pre-3.4.**
|
||||
`acceptBind()` records what the ESME declared in its bind request; the client's `bind()` records
|
||||
the `sc_interface_version` the SMSC answered with. A peer that declared no version is recorded as
|
||||
`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.**
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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`,
|
||||
`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
|
||||
|
||||
| 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
|
||||
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.
|
||||
|
||||
## Working with PDUs directly
|
||||
|
||||
+10
-6
@@ -3,7 +3,7 @@ import type { LogInt } from '@larvit/log';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
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 tlsConnect } from 'node:tls';
|
||||
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> {
|
||||
const bindType = options.bindType ?? defaults.bindType;
|
||||
const systemId = options.username ?? defaults.username;
|
||||
const sent = await session.send({
|
||||
cmdName: `bind_${bindType}`,
|
||||
params: bindParams(options, systemId),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
const sent = await session.send(
|
||||
{ cmdName: `bind_${bindType}`, params: bindParams(options, systemId) },
|
||||
options.signal ? { signal: options.signal } : {},
|
||||
);
|
||||
|
||||
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'}`) };
|
||||
}
|
||||
|
||||
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
|
||||
|
||||
session.loggedIn = true;
|
||||
session.peerInterfaceVersion = typeof declared === 'number'
|
||||
? declared
|
||||
: undeclaredInterfaceVersion;
|
||||
session.log.info('client - bound', { bindType, systemId });
|
||||
|
||||
return {};
|
||||
|
||||
+27
-11
@@ -87,7 +87,10 @@ const latin1: Encoding = {
|
||||
|
||||
const ucs2: Encoding = {
|
||||
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) {
|
||||
@@ -113,22 +116,35 @@ export function detect(value: string): EncodingName {
|
||||
return 'UCS2';
|
||||
}
|
||||
|
||||
/**
|
||||
* SMPP data_coding is a flat table for 0x00-0x0E, but the 0x1X and 0xFX ranges carry a GSM message
|
||||
* 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 {
|
||||
/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
|
||||
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
||||
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) {
|
||||
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';
|
||||
|
||||
return 'ASCII';
|
||||
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
||||
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ const specs = tlvSpecs({
|
||||
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_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 },
|
||||
payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
|
||||
additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
|
||||
|
||||
+35
-3
@@ -142,6 +142,15 @@ function wantUnsuccessSmes(value: ParamValue): Result<{ smes: UnsuccessSme[] }>
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 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. */
|
||||
export const string: WireType<string> = {
|
||||
default: '',
|
||||
@@ -513,9 +545,9 @@ export const tlv = {
|
||||
return err ? { err } : writeCstring(text, buf, offset);
|
||||
},
|
||||
} satisfies WireType<string>,
|
||||
int8,
|
||||
int16,
|
||||
int32,
|
||||
int8: tlvInt(int8),
|
||||
int16: tlvInt(int16),
|
||||
int32: tlvInt(int32),
|
||||
string: {
|
||||
default: '',
|
||||
read(buf: Buffer, offset: number, length = 0) {
|
||||
|
||||
+10
-2
@@ -82,8 +82,7 @@ function receiptDate(value: string | undefined): Date | undefined {
|
||||
|
||||
const [, years, months, days, hours, minutes, seconds] = match;
|
||||
const century = Math.floor(new Date().getUTCFullYear() / 100) * 100;
|
||||
|
||||
return new Date(Date.UTC(
|
||||
const date = new Date(Date.UTC(
|
||||
century + Number(years),
|
||||
Number(months) - 1,
|
||||
Number(days),
|
||||
@@ -91,6 +90,15 @@ function receiptDate(value: string | undefined): Date | undefined {
|
||||
Number(minutes),
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,15 @@ export class Reassembler {
|
||||
collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined {
|
||||
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 group = this.groups.get(key) ?? this.open(key, concat.total);
|
||||
const replaced = group.parts.get(concat.part);
|
||||
|
||||
+16
-1
@@ -90,7 +90,7 @@ export class ReconnectLoop {
|
||||
return false;
|
||||
}
|
||||
|
||||
const up = await this.options.onConnected(opened.sock);
|
||||
const up = await this.bringUp(opened.sock);
|
||||
|
||||
if (up.err) {
|
||||
this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
|
||||
@@ -102,4 +102,19 @@ export class ReconnectLoop {
|
||||
|
||||
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
@@ -5,7 +5,7 @@ import type { Server as NetServer, Socket } from 'node:net';
|
||||
import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
||||
import { EventEmitter } from 'node:events';
|
||||
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 createTlsServer } from 'node:tls';
|
||||
import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||
@@ -148,7 +148,9 @@ async function acceptBind(
|
||||
const declared = pduObj.params.interface_version;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
+11
-6
@@ -27,6 +27,13 @@ export const bindCommands: readonly string[] = [
|
||||
|
||||
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
|
||||
* 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;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | 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;
|
||||
onRequest?: OnRequest | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
reconnect?: ReconnectOptions | undefined;
|
||||
responseTimeout?: number | undefined;
|
||||
@@ -61,6 +63,9 @@ export type SessionOptions = {
|
||||
|
||||
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 = {
|
||||
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
||||
dlrMergeTimeout: 86_400_000,
|
||||
|
||||
+19
-103
@@ -9,19 +9,15 @@ import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { DlrMerger } from './dlr-merger.ts';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { IncomingRequests } from './incoming-requests.ts';
|
||||
import { LinkTimers } from './link-timers.ts';
|
||||
import { PduFramer } from './pdu-framer.ts';
|
||||
import { PendingRequests } from './pending-requests.ts';
|
||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||
import { SendWindow } from './send-window.ts';
|
||||
import { concatInfo } from './udh.ts';
|
||||
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
|
||||
import { createSms } from './sms.ts';
|
||||
import { optionalParamsMinVersion } from './defs/constants.ts';
|
||||
import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
|
||||
import { dlrFromPdu } from './dlr.ts';
|
||||
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { silentLog } from './log.ts';
|
||||
import { submitSms } from './send-sms.ts';
|
||||
|
||||
@@ -42,14 +38,14 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
readonly log: LogInt;
|
||||
|
||||
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;
|
||||
userData: unknown = undefined;
|
||||
|
||||
private readonly dlrMerger: DlrMerger;
|
||||
private readonly incoming: IncomingRequests;
|
||||
private readonly options: SessionOptions;
|
||||
private readonly pending: PendingRequests;
|
||||
private readonly reassembler: Reassembler;
|
||||
private readonly reconnectLoop: ReconnectLoop | undefined;
|
||||
private readonly timers: LinkTimers;
|
||||
private readonly window: SendWindow;
|
||||
@@ -87,13 +83,17 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
max: defaults.maxDlrMerges,
|
||||
timeout: defaults.dlrMergeTimeout,
|
||||
});
|
||||
this.pending = new PendingRequests(this.log);
|
||||
this.reassembler = new Reassembler({
|
||||
this.incoming = new IncomingRequests({
|
||||
dlrMerger: this.dlrMerger,
|
||||
log: this.log,
|
||||
max: options.maxReassembly ?? defaults.maxReassembly,
|
||||
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.sock = options.sock;
|
||||
this.timers = new LinkTimers({
|
||||
@@ -243,6 +243,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
input: PduObjectInput,
|
||||
options: SendOptions,
|
||||
): 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 built = objToPdu({ ...input, seqNr });
|
||||
|
||||
@@ -270,7 +275,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
this.timers.clear();
|
||||
this.pending.settleAll(new Error('Session closed before a response arrived'));
|
||||
this.dlrMerger.clear();
|
||||
this.reassembler.clear();
|
||||
this.incoming.clear();
|
||||
this.sock.destroy();
|
||||
this.emit('close');
|
||||
}
|
||||
@@ -343,7 +348,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
|
||||
this.emit('incomingPduObj', pduObj);
|
||||
// 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));
|
||||
|
||||
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 {
|
||||
if (this.closed) return;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
assert.equal(dlrFromPdu(deliverSm('just a normal sms')), undefined);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,12 @@ describe('LATIN1', () => {
|
||||
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', () => {
|
||||
@@ -78,6 +84,15 @@ describe('UCS2', () => {
|
||||
|
||||
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()', () => {
|
||||
@@ -111,6 +126,12 @@ describe('encodingByDataCoding()', () => {
|
||||
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', () => {
|
||||
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
||||
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('the reference encoder against our parser', () => {
|
||||
});
|
||||
|
||||
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 refServer = reference.createServer({}, (session: ReferenceSession) => {
|
||||
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(); }); });
|
||||
|
||||
const port = refServer.address()?.port ?? 0;
|
||||
const { err, session } = await client({ port });
|
||||
|
||||
t.after(() => { session?.close(); });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
@@ -243,14 +246,13 @@ describe('a live session against the reference implementation', () => {
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.deepEqual(sent.smsIds, ['ref-id']);
|
||||
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 });
|
||||
|
||||
t.after(async () => { await smpp?.close(); });
|
||||
|
||||
assert.equal(serverErr, undefined);
|
||||
assert.ok(smpp);
|
||||
|
||||
@@ -262,6 +264,8 @@ describe('a live session against the reference implementation', () => {
|
||||
url: `smpp://localhost:${String(smpp.port)}`,
|
||||
});
|
||||
|
||||
t.after(() => { refSession.close(); });
|
||||
|
||||
await new Promise<void>(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.message, 'from the reference client');
|
||||
await sms.sendResp();
|
||||
|
||||
refSession.close();
|
||||
await smpp.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,16 @@ describe('encodeMessage() and decodeMessage()', () => {
|
||||
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', () => {
|
||||
const withUdh = Buffer.concat([
|
||||
Buffer.from([0x05, 0x00, 0x03, 0x01, 0x02, 0x01]),
|
||||
|
||||
@@ -28,8 +28,18 @@ async function startServer(options: Parameters<typeof server>[0] = {}): Promise<
|
||||
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> {
|
||||
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', () => {
|
||||
@@ -345,6 +355,16 @@ describe('reassembly bounds', () => {
|
||||
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.
|
||||
test('drops the oldest incomplete message once the cap is reached', () => {
|
||||
const reassembler = new Reassembler({ log: silentLog, max: 2, now: () => 0, timeout: 60_000 });
|
||||
|
||||
@@ -5,15 +5,19 @@ import type { Dlr } from '../src/dlr.ts';
|
||||
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
||||
import type { Sms } from '../src/sms.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 { PduFramer } from '../src/pdu-framer.ts';
|
||||
import { ReconnectLoop } from '../src/reconnect-loop.ts';
|
||||
import { Session, bindCommands } from '../src/session.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
||||
import { paramText } from '../src/defs/types.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
import { silentLog } from '../src/log.ts';
|
||||
import { splitMessage } from '../src/message.ts';
|
||||
|
||||
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
|
||||
const { err, server: smpp } = await server({ ...options, port: 0 });
|
||||
@@ -391,6 +395,33 @@ describe('bind', () => {
|
||||
peer.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', () => {
|
||||
@@ -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', () => {
|
||||
test('reaches the sender as a dlr event', async () => {
|
||||
const smpp = await startServer();
|
||||
@@ -860,6 +969,75 @@ describe('robustness', () => {
|
||||
assert.ok(await closed);
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
|
||||
import { tlvs } from '../src/defs/tlvs.ts';
|
||||
import { types } from '../src/defs/types.ts';
|
||||
|
||||
describe('integers', () => {
|
||||
@@ -102,6 +103,42 @@ describe('cstring (C-Octet String)', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ rules there constrain every item below.
|
||||
|
||||
## 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.
|
||||
|
||||
```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';
|
||||
|
||||
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();
|
||||
|
||||
const { err, server: smpp } = await server({ authenticate, port });
|
||||
const { err: serverErr, server: smpp } = await server({ authenticate, port });
|
||||
smpp.on('session', session => {
|
||||
session.on('sms', async sms => {
|
||||
await sms.sendResp();
|
||||
|
||||
Reference in New Issue
Block a user