Stop a thrown listener escaping, bound what a peer can pin, and drop the body from failure responses

This commit is contained in:
2026-08-27 10:55:34 +02:00
parent 5af75a3c57
commit 884afdb87b
17 changed files with 420 additions and 87 deletions
+4
View File
@@ -156,6 +156,10 @@ exactly 140.
## Decisions ## Decisions
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and - **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and
publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that
is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to
+13 -7
View File
@@ -82,7 +82,7 @@ Every one is optional.
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. | | `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. | | `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering; with `reconnect` set, it re-binds. | | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering; with `reconnect` set, it re-binds. |
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it. | | `responseTimeout` | `30000` | How long to wait for a response before giving up on it; `0` waits forever. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. | | `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. | | `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. |
| `log` | silent | A `@larvit/log` instance. | | `log` | silent | A `@larvit/log` instance. |
@@ -111,9 +111,10 @@ const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to });
``` ```
`err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every `err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every
segment goes on the wire together, `smsIds` then holds the ids of the segments the SMSC did accept — segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough
retry only what is missing from it. A message needing more than 255 segments is refused before to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a
anything is sent, since the concatenation header numbers segments in a single octet. 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.
## Server ## Server
@@ -178,6 +179,7 @@ await smpp.close(); // stop listening and close every live session
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. | | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | | `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
@@ -205,7 +207,7 @@ is exactly what this library promises not to do.
| --- | --- | | --- | --- |
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and the `smsId` it was answered with. | | `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and the `smsId` it was answered with. |
| `dlr` | A delivery report arrives, one per segment. | | `dlr` | A delivery report arrives, one per segment. |
| `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on. | | `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. |
| `close` | The connection closed. | | `close` | The connection closed. |
| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). | | `reconnected` | The client re-bound after a drop (only with `reconnect` configured). |
| `sessionError` | Something failed on a live session, including a hook or listener that threw. | | `sessionError` | Something failed on a live session, including a hook or listener that threw. |
@@ -255,8 +257,9 @@ The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `
resolving to a result object with an optional `err`. Nothing rejects. resolving to a result object with an optional `err`. Nothing rejects.
- **`server()` resolves once, when it is listening**, and gives you a handle with `close()`, `port` - **`server()` resolves once, when it is listening**, and gives you a handle with `close()`, `port`
and a `session` event. It no longer calls your callback once per incoming connection. and a `session` event. It no longer calls your callback once per incoming connection.
- **The id a message is answered with goes to `sendResp({ smsId })`**, and `sms.smsId` is read-only. - **The id a message is answered with goes to `sendResp({ smsId })`**, and `sms.smsId` is read-only:
Assigning it no longer works, so set the id where the response is sent rather than before it. it reports what the response actually carried. Delete any `sms.smsId = …` line — assigning to it
throws a `TypeError`, since modules are always strict mode — and pass the id to `sendResp()`.
- **`checkuserpass` is now `authenticate`**, takes `{ password, session, systemId, systemType }` and - **`checkuserpass` is now `authenticate`**, takes `{ password, session, systemId, systemType }` and
returns `false` or `{ userData }`. returns `false` or `{ userData }`.
- **Renamed options:** `enqLinkTiming``enquireLinkInterval`, server `timeout``idleTimeout`. - **Renamed options:** `enqLinkTiming``enquireLinkInterval`, server `timeout``idleTimeout`.
@@ -299,6 +302,9 @@ have worked around any of these, remove the workaround:
- Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME - Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME
speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter, speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter,
including the TLVs delivery receipts are carried in. including the TLVs delivery receipts are carried in.
- A response reporting a failure now carries no body, which is what the spec defines and what other
implementations send. 0.4.0 filled the body with empty defaults, so a refused `submit_sm_resp` went
out with an empty `message_id` a caller could mistake for a real one.
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped. - `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
+20 -3
View File
@@ -3,6 +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 { 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';
@@ -152,9 +153,15 @@ function createSession(options: ClientOptions, log: LogInt, sock: Socket): Sessi
}); });
} }
/** Connects to an SMSC and binds. */ async function connect(options: ClientOptions, log: LogInt): Promise<Result<{ sock: Socket }>> {
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> { const checked = checkSessionOptions(options);
const log = options.log ?? silentLog;
if (checked.err) {
log.warn('client - option out of range', { message: checked.err.message });
return { err: checked.err };
}
const opened = await openSocket(options); const opened = await openSocket(options);
if (opened.err) { if (opened.err) {
@@ -167,6 +174,16 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { err: opened.err }; return { err: opened.err };
} }
return { sock: opened.sock };
}
/** Connects to an SMSC and binds. */
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
const log = options.log ?? silentLog;
const opened = await connect(options, log);
if (opened.err) return { err: opened.err };
const session = createSession(options, log, opened.sock); const session = createSession(options, log, opened.sock);
const signal = options.signal; const signal = options.signal;
+26 -4
View File
@@ -215,13 +215,19 @@ export const string: WireType<string> = {
size(value) { size(value) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
return err ? { err } : { size: text.length + 1 }; if (err) return { err };
return tooLongForLengthOctet(text) ?? { size: text.length + 1 };
}, },
write(value, buffer, offset) { write(value, buffer, offset) {
const { err, text } = wantText(value); const { err, text } = wantText(value);
if (err) return { err }; if (err) return { err };
const lengthErr = tooLongForLengthOctet(text);
if (lengthErr) return lengthErr;
const rangeErr = outOfRange(buffer, offset, text.length + 1); const rangeErr = outOfRange(buffer, offset, text.length + 1);
if (rangeErr) return { err: rangeErr }; if (rangeErr) return { err: rangeErr };
@@ -233,6 +239,12 @@ export const string: WireType<string> = {
}, },
}; };
function tooLongForLengthOctet(text: string): { err: Error } | undefined {
if (text.length <= 0xFF) return undefined;
return { err: new Error(`Octet String is ${String(text.length)} octets, the length octet holds 255`) };
}
/** C-Octet String: NULL-terminated. */ /** C-Octet String: NULL-terminated. */
export const cstring: WireType<string> = { export const cstring: WireType<string> = {
default: '', default: '',
@@ -350,7 +362,11 @@ export const dest_address_array: WireType<DestAddress[]> = {
for (const dest of addresses) { for (const dest of addresses) {
if ('dl_name' in dest) { if ('dl_name' in dest) {
buf.writeUInt8(2, offset++); buf.writeUInt8(2, offset++);
writeCstring(dest.dl_name, buf, offset);
const name = writeCstring(dest.dl_name, buf, offset);
if (name.err) return { err: name.err };
offset += dest.dl_name.length + 1; offset += dest.dl_name.length + 1;
} else { } else {
buf.writeUInt8(1, offset++); buf.writeUInt8(1, offset++);
@@ -363,7 +379,10 @@ export const dest_address_array: WireType<DestAddress[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
writeCstring(dest.destination_addr, buf, offset); const addr = writeCstring(dest.destination_addr, buf, offset);
if (addr.err) return { err: addr.err };
offset += dest.destination_addr.length + 1; offset += dest.destination_addr.length + 1;
} }
} }
@@ -448,7 +467,10 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
if (npi.err) return { err: npi.err }; if (npi.err) return { err: npi.err };
writeCstring(sme.destination_addr, buf, offset); const addr = writeCstring(sme.destination_addr, buf, offset);
if (addr.err) return { err: addr.err };
offset += sme.destination_addr.length + 1; offset += sme.destination_addr.length + 1;
const status = writeInt32(sme.error_status_code, buf, offset); const status = writeInt32(sme.error_status_code, buf, offset);
+25 -1
View File
@@ -1,4 +1,5 @@
import type { Dlr } from './dlr.ts'; import type { Dlr } from './dlr.ts';
import type { MessageState } from './defs/constants.ts';
import type { LogInt } from '@larvit/log'; import type { LogInt } from '@larvit/log';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
@@ -24,6 +25,29 @@ const numbered = /^(.*)-(\d+)$/;
* numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An * numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An
* SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it. * SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it.
*/ */
/**
* MESSAGE_STATE is a flat enum, not a ranking — ACCEPTED is 6 where UNDELIVERABLE is 5 — so reducing
* on the wire value reports a part-failed message as delivered. Rank it deliberately instead.
*/
const severity: Record<MessageState, number> = {
DELIVERED: 0,
ACCEPTED: 1,
ENROUTE: 2,
SCHEDULED: 3,
SKIPPED: 4,
UNKNOWN: 5,
EXPIRED: 6,
DELETED: 7,
REJECTED: 8,
UNDELIVERABLE: 9,
};
function severityOf(dlr: Dlr): number {
const ranked: Record<string, number | undefined> = severity;
return ranked[dlr.statusMsg] ?? severity.UNKNOWN;
}
export class DlrMerger { export class DlrMerger {
private readonly groups: ExpiringGroups<Group>; private readonly groups: ExpiringGroups<Group>;
private readonly log: LogInt; private readonly log: LogInt;
@@ -86,7 +110,7 @@ export class DlrMerger {
this.groups.delete(base); this.groups.delete(base);
const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one); const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
const worst = segments.reduce((carry, one) => (one.statusId > carry.statusId ? one : carry)); const worst = segments.reduce((carry, one) => (severityOf(one) > severityOf(carry) ? one : carry));
return { ...worst, segments, smsId: base }; return { ...worst, segments, smsId: base };
} }
+2 -1
View File
@@ -35,7 +35,7 @@ export { uuidv7 } from './uuid.ts';
export type { BindType, ClientOptions } from './client.ts'; export type { BindType, ClientOptions } from './client.ts';
export type { Dlr, Receipt } from './dlr.ts'; export type { Dlr, Receipt } from './dlr.ts';
export type { Sms, SmsInput } from './sms.ts'; export type { SendRespOptions, Sms, SmsInput } from './sms.ts';
export type { ConcatInfo } from './udh.ts'; export type { ConcatInfo } from './udh.ts';
export type { Result, VoidResult } from './result.ts'; export type { Result, VoidResult } from './result.ts';
export type { export type {
@@ -49,6 +49,7 @@ export type {
ReconnectOptions, ReconnectOptions,
SendOptions, SendOptions,
SendSmsOptions, SendSmsOptions,
SendSmsResult,
SessionEvents, SessionEvents,
SessionOptions, SessionOptions,
} from './session.ts'; } from './session.ts';
+34 -9
View File
@@ -45,8 +45,10 @@ export type PduObject = {
tlvs: Record<string, Tlv>; tlvs: Record<string, Tlv>;
}; };
const respBit = 0x80000000;
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean { export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
return pduObj.cmdId >= 0x80000000; return pduObj.cmdId >= respBit;
} }
/** /**
@@ -169,6 +171,30 @@ function writeTlvs(tlvs: Record<string, TlvInput> | undefined): Result<{ chunks:
return { chunks }; return { chunks };
} }
/**
* SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but
* present" — they are absent, and a peer that reads them anyway reads past the end of the PDU.
*/
function buildBody(
definition: CommandDefinition,
cmdName: CommandName,
cmdStatus: ErrorName,
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
): Result<{ body: Buffer }> {
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
const written = writeParams(definition, resolveShortMessage(params), cmdName);
if (written.err) return { err: written.err };
const writtenTlvs = writeTlvs(tlvs);
if (writtenTlvs.err) return { err: writtenTlvs.err };
return { body: Buffer.concat([...written.chunks, ...writtenTlvs.chunks]) };
}
function buildPdu( function buildPdu(
cmdName: CommandName, cmdName: CommandName,
cmdStatus: ErrorName, cmdStatus: ErrorName,
@@ -190,15 +216,11 @@ function buildPdu(
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) }; return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
} }
const written = writeParams(definition, resolveShortMessage(params), cmdName); const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
if (written.err) return { err: written.err }; if (built.err) return { err: built.err };
const writtenTlvs = writeTlvs(tlvs); const body = built.body;
if (writtenTlvs.err) return { err: writtenTlvs.err };
const body = Buffer.concat([...written.chunks, ...writtenTlvs.chunks]);
const header = Buffer.alloc(16); const header = Buffer.alloc(16);
header.writeUInt32BE(body.length + 16, 0); header.writeUInt32BE(body.length + 16, 0);
@@ -292,7 +314,10 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
return { err: new Error(`Invalid seqNr, exceeds ${String(maxSeqNr)}: ${String(seqNr)}`) }; return { err: new Error(`Invalid seqNr, exceeds ${String(maxSeqNr)}: ${String(seqNr)}`) };
} }
const read = readParams(cmdName, pdu, trailingNull); // SMPP 3.4 4.4.2 and friends: a response with a non-zero status carries no body at all.
const read = cmdStatusId !== 0 && cmdLength === 16
? { offset: 16, params: {} }
: readParams(cmdName, pdu, trailingNull);
if (read.err) return { err: read.err }; if (read.err) return { err: read.err };
+9 -2
View File
@@ -42,15 +42,22 @@ function detach(pduObj: PduObject): PduObject {
return { ...pduObj, params, tlvs }; return { ...pduObj, params, tlvs };
} }
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: unknown): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
function octetsOf(pduObj: PduObject): number { function octetsOf(pduObj: PduObject): number {
let octets = 0; let octets = 0;
for (const value of Object.values(pduObj.params)) { for (const value of Object.values(pduObj.params)) {
if (Buffer.isBuffer(value)) octets += value.length; octets += sizeOf(value);
} }
for (const tlv of Object.values(pduObj.tlvs)) { for (const tlv of Object.values(pduObj.tlvs)) {
if (Buffer.isBuffer(tlv.tagValue)) octets += tlv.tagValue.length; octets += sizeOf(tlv.tagValue);
} }
return octets; return octets;
-1
View File
@@ -61,7 +61,6 @@ export function submitSmParams(
dest_addr_npi: sms.destinationAddrNpi ?? 0, dest_addr_npi: sms.destinationAddrNpi ?? 0,
dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to), dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to),
short_message: segment, short_message: segment,
sm_length: segment.length,
source_addr: sms.from, source_addr: sms.from,
source_addr_npi: sms.sourceAddrNpi ?? 0, source_addr_npi: sms.sourceAddrNpi ?? 0,
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from), source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
+57 -25
View File
@@ -1,10 +1,11 @@
import type { LogInt } from '@larvit/log'; import type { LogInt } from '@larvit/log';
import type { PduObject, TlvInput } from './pdu.ts'; import type { PduObject, TlvInput } from './pdu.ts';
import type { Result } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net'; 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 { 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';
@@ -27,6 +28,7 @@ export type ServerOptions = {
interfaceVersion?: number; interfaceVersion?: number;
log?: LogInt; log?: LogInt;
maxOutstanding?: number; maxOutstanding?: number;
maxOctets?: number;
maxReassembly?: number; maxReassembly?: number;
port?: number; port?: number;
reassemblyTimeout?: number; reassemblyTimeout?: number;
@@ -52,10 +54,12 @@ const defaults = {
export class SmppServer extends EventEmitter<ServerEvents> { export class SmppServer extends EventEmitter<ServerEvents> {
readonly sessions = new Set<Session>(); readonly sessions = new Set<Session>();
private readonly log: LogInt;
private readonly server: NetServer; private readonly server: NetServer;
constructor(server: NetServer) { constructor(server: NetServer, log: LogInt) {
super(); super();
this.log = log;
this.server = server; this.server = server;
} }
@@ -66,11 +70,34 @@ export class SmppServer extends EventEmitter<ServerEvents> {
return typeof address === 'object' && address !== null ? address.port : 0; return typeof address === 'object' && address !== null ? address.port : 0;
} }
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
override emit<K extends keyof ServerEvents>(
event: K,
...args: K extends keyof ServerEvents ? ServerEvents[K] : never
): boolean {
try {
return super.emit(event, ...args);
} catch (thrown: unknown) {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.log.error('server - a listener threw', { event, message: err.message });
// Guarded against the listener that throws being the one listening for this.
if (event !== 'serverError') this.emit('serverError', err);
return false;
}
}
/** Stops listening and closes every live session. */ /** Stops listening and closes every live session. */
close(): Promise<void> { close(): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
for (const session of this.sessions) { for (const session of this.sessions) {
try {
session.close(); session.close();
} catch (thrown: unknown) {
this.emit('serverError', thrown instanceof Error ? thrown : new Error(String(thrown)));
}
} }
this.sessions.clear(); this.sessions.clear();
@@ -173,6 +200,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
idleTimeout: options.idleTimeout ?? defaults.idleTimeout, idleTimeout: options.idleTimeout ?? defaults.idleTimeout,
log, log,
maxOutstanding: options.maxOutstanding, maxOutstanding: options.maxOutstanding,
maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly, maxReassembly: options.maxReassembly,
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options), onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
reassemblyTimeout: options.reassemblyTimeout, reassemblyTimeout: options.reassemblyTimeout,
@@ -203,6 +231,29 @@ function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer {
return listener; return listener;
} }
function checkOptions(options: ServerOptions, log: LogInt, port: number): VoidResult {
const checked = checkSessionOptions(options);
if (checked.err) return { err: checked.err };
if (options.tls === true) {
log.warn('server - tls without a certificate', { port });
return { err: new Error('Listening over TLS needs tls: { cert, key }') };
}
// An int8 TLV on every bind response: out of range here means no ESME can ever bind.
const version = options.interfaceVersion ?? defaults.interfaceVersion;
if (!Number.isInteger(version) || version < 0 || version > 0xFF) {
log.warn('server - interface version out of range', { interfaceVersion: version });
return { err: new Error(`interfaceVersion must be 0-255, got ${String(version)}`) };
}
return {};
}
function createListener( function createListener(
options: ServerOptions, options: ServerOptions,
log: LogInt, log: LogInt,
@@ -210,20 +261,9 @@ function createListener(
): Result<{ listener: NetServer | TlsServer; useTls: boolean }> { ): Result<{ listener: NetServer | TlsServer; useTls: boolean }> {
const useTls = options.tls !== undefined && options.tls !== false; const useTls = options.tls !== undefined && options.tls !== false;
const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined;
const version = options.interfaceVersion ?? defaults.interfaceVersion; const checked = checkOptions(options, log, port);
if (useTls && !tlsOptions) { if (checked.err) return { err: checked.err };
log.warn('server - tls without a certificate', { port });
return { err: new Error('Listening over TLS needs tls: { cert, key }') };
}
// An int8 TLV on every bind response: out of range here means no ESME can ever bind.
if (!Number.isInteger(version) || version < 0 || version > 0xFF) {
log.warn('server - interface version out of range', { interfaceVersion: version });
return { err: new Error(`interfaceVersion must be 0-255, got ${String(version)}`) };
}
return { return {
listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(), listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(),
@@ -242,15 +282,7 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio
log.info('server - listening', { host: options.host ?? '*', port: smpp.port }); log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
// close() runs the application's own 'close' listeners, so a throw from one lands here. options.signal?.addEventListener('abort', () => { void smpp.close(); }, { once: true });
options.signal?.addEventListener('abort', () => {
void smpp.close().catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
log.warn('server - could not close on abort', { message: err.message });
smpp.emit('serverError', err);
});
}, { once: true });
} }
/** Starts listening for SMPP connections. Resolves once the socket is bound. */ /** Starts listening for SMPP connections. Resolves once the socket is bound. */
@@ -262,7 +294,7 @@ export function server(options: ServerOptions = {}): Promise<Result<{ server: Sm
if (created.err) return Promise.resolve({ err: created.err }); if (created.err) return Promise.resolve({ err: created.err });
const listener = created.listener; const listener = created.listener;
const smpp = new SmppServer(listener); const smpp = new SmppServer(listener, log);
listener.on(created.useTls ? 'secureConnection' : 'connection', (sock: Socket) => { listener.on(created.useTls ? 'secureConnection' : 'connection', (sock: Socket) => {
onConnection(sock, options, smpp); onConnection(sock, options, smpp);
+52
View File
@@ -1,9 +1,30 @@
import type { Dlr } from './dlr.ts';
import type { LogInt } from '@larvit/log'; import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import type { Sms } from './sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
export type SessionEvents = {
close: [];
data: [Buffer];
dlr: [Dlr, PduObject];
incomingPdu: [Buffer];
incomingPduObj: [PduObject];
messageDlr: [MessageDlr];
reconnected: [];
sessionError: [Error];
sms: [Sms];
};
export const bindCommands: readonly string[] = [
'bind_receiver',
'bind_transceiver',
'bind_transmitter',
];
export type SendOptions = { signal?: AbortSignal | undefined }; export type SendOptions = { signal?: AbortSignal | undefined };
/** /**
@@ -21,6 +42,7 @@ export type SessionOptions = {
enquireLinkInterval?: number | undefined; enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
log?: LogInt | undefined; log?: LogInt | undefined;
maxOctets?: number | undefined;
maxOutstanding?: number | undefined; maxOutstanding?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
/** /**
@@ -51,3 +73,33 @@ export const defaults = {
responseTimeout: 30_000, responseTimeout: 30_000,
systemId: defaultSystemId, systemId: defaultSystemId,
}; };
/**
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
* queued behind a slot that is never freed, so the call never settles at all.
*/
export function checkSessionOptions(options: SessionCounts): VoidResult {
const limits: [string, number, number][] = [
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
];
for (const [name, value, min] of limits) {
if (!Number.isInteger(value) || value < min) {
return { err: new Error(`${name} must be ${String(min)} or more, got ${String(value)}`) };
}
}
return {};
}
export type SessionCounts = {
idleTimeout?: number | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
reassemblyTimeout?: number | undefined;
responseTimeout?: number | undefined;
};
+33 -24
View File
@@ -1,13 +1,11 @@
import type { Dlr } from './dlr.ts';
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { LogInt } from '@larvit/log'; import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts'; import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { ReconnectOptions, SendOptions, SessionOptions } from './session-options.ts'; import type { ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { Sms } from './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';
@@ -20,33 +18,23 @@ import { SendWindow } from './send-window.ts';
import { concatInfo } from './udh.ts'; import { concatInfo } from './udh.ts';
import { consts, optionalParamsMinVersion } from './defs/constants.ts'; import { consts, optionalParamsMinVersion } from './defs/constants.ts';
import { createSms } from './sms.ts'; import { createSms } from './sms.ts';
import { defaultSystemId, defaults } from './session-options.ts'; import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { dlrFromPdu } from './dlr.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 { 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';
export type { MessageDlr, ReconnectOptions, SendOptions, SendSmsOptions, SessionOptions }; export type {
export { defaultSystemId }; MessageDlr,
ReconnectOptions,
export type SessionEvents = { SendOptions,
close: []; SendSmsOptions,
data: [Buffer]; SendSmsResult,
dlr: [Dlr, PduObject]; SessionEvents,
incomingPdu: [Buffer]; SessionOptions,
incomingPduObj: [PduObject];
messageDlr: [MessageDlr];
reconnected: [];
sessionError: [Error];
sms: [Sms];
}; };
export { bindCommands, defaultSystemId };
export const bindCommands: readonly string[] = [
'bind_receiver',
'bind_transceiver',
'bind_transmitter',
];
export class Session extends EventEmitter<SessionEvents> { export class Session extends EventEmitter<SessionEvents> {
/** Replaced on reconnect, so hold the session rather than this. */ /** Replaced on reconnect, so hold the session rather than this. */
@@ -70,6 +58,25 @@ export class Session extends EventEmitter<SessionEvents> {
private concatReference = 0; private concatReference = 0;
private framer = new PduFramer(); private framer = new PduFramer();
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
override emit<K extends keyof SessionEvents>(
event: K,
...args: K extends keyof SessionEvents ? SessionEvents[K] : never
): boolean {
try {
return super.emit(event, ...args);
} catch (thrown: unknown) {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.log.error('session - a listener threw', { event, message: err.message });
// Guarded against the listener that throws being the one listening for this.
if (event !== 'sessionError') this.emit('sessionError', err);
return false;
}
}
constructor(options: SessionOptions) { constructor(options: SessionOptions) {
super(); super();
@@ -84,6 +91,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.reassembler = new Reassembler({ this.reassembler = new Reassembler({
log: this.log, log: this.log,
max: options.maxReassembly ?? defaults.maxReassembly, max: options.maxReassembly ?? defaults.maxReassembly,
maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
}); });
this.reconnectLoop = this.loopFor(options.reconnect); this.reconnectLoop = this.loopFor(options.reconnect);
@@ -138,7 +146,8 @@ export class Session extends EventEmitter<SessionEvents> {
const built = pduReturn(pdu, status, params, tlvs); const built = pduReturn(pdu, status, params, tlvs);
const sent = built.err ? { err: built.err } : this.write(built.buffer); const sent = built.err ? { err: built.err } : this.write(built.buffer);
if (sent.err) { // A peer that unbinds and drops the link takes our response with it; that is not a failure.
if (sent.err && !this.closed) {
this.log.warn('session - could not answer a request', { this.log.warn('session - could not answer a request', {
cmdName: pdu.cmdName, cmdName: pdu.cmdName,
message: sent.err.message, message: sent.err.message,
+11 -3
View File
@@ -24,7 +24,8 @@ describe('header', () => {
test('writes command length, id, status and sequence number', () => { test('writes command length, id, status and sequence number', () => {
const pdu = encode({ cmdName: 'bind_transceiver_resp', cmdStatus: 'ESME_RALYBND', seqNr: 1 }); const pdu = encode({ cmdName: 'bind_transceiver_resp', cmdStatus: 'ESME_RALYBND', seqNr: 1 });
assert.equal(pdu.readUInt32BE(0), 17); // A failure response is header-only, so 16 rather than 17 with an empty system_id.
assert.equal(pdu.readUInt32BE(0), 16);
assert.equal(pdu.readUInt32BE(4).toString(16), '80000009'); assert.equal(pdu.readUInt32BE(4).toString(16), '80000009');
assert.equal(pdu.readUInt32BE(8), 5); assert.equal(pdu.readUInt32BE(8), 5);
assert.equal(pdu.readUInt32BE(12), 1); assert.equal(pdu.readUInt32BE(12), 1);
@@ -397,7 +398,7 @@ describe('pduReturn()', () => {
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: 'foo' }, params: { destination_addr: '46709771337', short_message: 'hi', source_addr: 'foo' },
seqNr: 9, seqNr: 9,
})); }));
const { buffer, err } = pduReturn(request, 'ESME_RINVDSTADR', { message_id: 'abc123' }); const { buffer, err } = pduReturn(request, 'ESME_ROK', { message_id: 'abc123' });
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(buffer); assert.ok(buffer);
@@ -405,9 +406,16 @@ describe('pduReturn()', () => {
const pduObj = decode(buffer); const pduObj = decode(buffer);
assert.equal(pduObj.cmdName, 'submit_sm_resp'); assert.equal(pduObj.cmdName, 'submit_sm_resp');
assert.equal(pduObj.cmdStatus, 'ESME_RINVDSTADR'); assert.equal(pduObj.cmdStatus, 'ESME_ROK');
assert.equal(pduObj.params.message_id, 'abc123'); assert.equal(pduObj.params.message_id, 'abc123');
assert.equal(pduObj.seqNr, 9); assert.equal(pduObj.seqNr, 9);
// The spec drops the body of a failure response, so the id a caller passes is not sent.
const refused = pduReturn(request, 'ESME_RINVDSTADR', { message_id: 'abc123' });
assert.ok(refused.buffer);
assert.equal(refused.buffer.length, 16);
assert.equal(decode(refused.buffer).params.message_id, undefined);
}); });
test('refuses a command that has no response', () => { test('refuses a command that has no response', () => {
+34 -1
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import net from 'node:net'; import net from 'node:net';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { ErrorName } from '../src/defs/errors.ts'; import type { ErrorName } from '../src/defs/errors.ts';
import type { MessageState } from '../src/defs/constants.ts';
import type { MessageDlr } from '../src/session.ts'; import type { MessageDlr } from '../src/session.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Result } from '../src/result.ts'; import type { Result } from '../src/result.ts';
@@ -9,7 +11,9 @@ import type { SendSmsResult } from '../src/send-sms.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 { Reassembler, decodeSegments } from '../src/reassembly.ts'; import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { consts } from '../src/defs/constants.ts';
import { errors } from '../src/defs/errors.ts'; import { errors } from '../src/defs/errors.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';
@@ -108,6 +112,34 @@ describe('merged delivery reports', () => {
}); });
}); });
describe('merging segment statuses', () => {
function receipt(smsId: string, statusMsg: MessageState): Dlr {
return {
doneDate: undefined,
errorCode: undefined,
receipt: undefined,
smsId,
statusId: consts.MESSAGE_STATE[statusMsg],
statusMsg,
};
}
// MESSAGE_STATE is a flat enum: ACCEPTED is 6 where UNDELIVERABLE is 5, so reducing on the
// wire value called a part-failed message delivered.
test('reports the worse of two states the wire numbers the other way round', () => {
const merger = new DlrMerger({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
merger.expect(['msg-1', 'msg-2']);
assert.equal(merger.collect(receipt('msg-1', 'UNDELIVERABLE')), undefined);
const merged = merger.collect(receipt('msg-2', 'ACCEPTED'));
assert.ok(merged);
assert.equal(merged.statusMsg, 'UNDELIVERABLE');
});
});
describe('sendSms()', () => { describe('sendSms()', () => {
function submitResp(seqNr: number, messageId: string, status: ErrorName = 'ESME_ROK'): PduObject { function submitResp(seqNr: number, messageId: string, status: ErrorName = 'ESME_ROK'): PduObject {
return { return {
@@ -332,7 +364,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
maxOctets: 30, // One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
maxOctets: 80,
now: () => 0, now: () => 0,
timeout: 60_000, timeout: 60_000,
}); });
+90 -4
View File
@@ -353,12 +353,14 @@ describe('bind', () => {
await named.close(); await named.close();
}); });
test('answers a refused bind with its own system_id too', async () => { // The echo leak cannot reach a refusal at all: the spec gives a failure response no body.
test('answers a refused bind with no body to leak', async () => {
const smpp = await startServer({ authenticate: () => false, systemId: 'the-smsc' }); const smpp = await startServer({ authenticate: () => false, systemId: 'the-smsc' });
const refused = await bindRaw(smpp, 0x34); const refused = await bindRaw(smpp, 0x34);
assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL'); assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL');
assert.equal(refused.params.system_id, 'the-smsc'); assert.equal(refused.cmdLength, 16);
assert.deepEqual(refused.params, {});
await smpp.close(); await smpp.close();
}); });
@@ -373,7 +375,7 @@ describe('bind', () => {
await smpp.close(); await smpp.close();
}); });
test('answers a second bind with ESME_RALYBND and its own system_id', async () => { test('answers a second bind with ESME_RALYBND and no body', async () => {
const smpp = await startServer({ systemId: 'the-smsc' }); const smpp = await startServer({ systemId: 'the-smsc' });
const peer = rawPeer(smpp.port); const peer = rawPeer(smpp.port);
@@ -384,7 +386,7 @@ describe('bind', () => {
const again = await peer.next(); const again = await peer.next();
assert.equal(again.cmdStatus, 'ESME_RALYBND'); assert.equal(again.cmdStatus, 'ESME_RALYBND');
assert.equal(again.params.system_id, 'the-smsc'); assert.deepEqual(again.params, {});
peer.close(); peer.close();
await smpp.close(); await smpp.close();
@@ -905,6 +907,90 @@ describe('application hooks that throw', () => {
await smpp.close(); await smpp.close();
}); });
// The guard for a throwing sms listener used to emit sessionError from inside its own catch.
test('survives a sessionError listener that throws as well', async () => {
const smpp = await startServer();
smpp.on('session', session => {
session.on('sessionError', () => { throw new Error('the reporter exploded too'); });
session.on('sms', () => { throw new Error('listener exploded'); });
});
const { session } = await connect(smpp, { responseTimeout: 200 });
assert.ok(session);
const sent = await session.sendSms({
from: '46701113311',
message: 'blows up both listeners',
to: '46709771337',
});
assert.ok(sent.err instanceof Error);
session.close();
await smpp.close();
});
test('closes even when an application close listener throws', async () => {
const smpp = await startServer();
smpp.on('session', session => {
session.on('close', () => { throw new Error('close listener exploded'); });
});
const { session } = await connect(smpp);
assert.ok(session);
await smpp.close();
assert.equal(smpp.sessions.size, 0);
session.close();
});
test('refuses a send window that can never free a slot', async () => {
const smpp = await startServer();
const { err, session } = await connect(smpp, { maxOutstanding: 0 });
assert.ok(err instanceof Error);
assert.match(err.message, /maxOutstanding/);
assert.equal(session, undefined);
await smpp.close();
});
test('keeps the message id off a submit_sm_resp that refuses the message', async () => {
const smpp = await startServer();
smpp.on('session', bound => {
bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); });
});
const peer = rawPeer(smpp.port);
peer.write(bindOf(0x34));
await peer.next();
peer.write({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
short_message: 'full queue',
source_addr: '46701113311',
},
seqNr: 2,
});
const refused = await peer.next();
assert.equal(refused.cmdName, 'submit_sm_resp');
assert.equal(refused.cmdStatus, 'ESME_RMSGQFUL');
assert.equal(refused.cmdLength, 16);
assert.deepEqual(refused.params, {});
peer.close();
await smpp.close();
});
test('keeps the reconnect loop alive when connect throws', async () => { test('keeps the reconnect loop alive when connect throws', async () => {
let attempts = 0; let attempts = 0;
const loop = new ReconnectLoop({ const loop = new ReconnectLoop({
+8
View File
@@ -49,6 +49,14 @@ describe('string (Octet String)', () => {
assert.deepEqual(types.string.read(encoded, 0), { bytesRead: 9, value: expected }); assert.deepEqual(types.string.read(encoded, 0), { bytesRead: 9, value: expected });
}); });
// The length is one octet, so a longer value has nowhere to say how long it is.
test('refuses a value longer than the length octet can count', () => {
const tooLong = 'x'.repeat(256);
assert.ok(types.string.size(tooLong).err instanceof Error);
assert.ok(types.string.write(tooLong, Buffer.alloc(300), 0).err instanceof Error);
});
test('sizes as the string plus its length octet', () => { test('sizes as the string plus its length octet', () => {
assert.deepEqual(types.string.size(expected), { size: 9 }); assert.deepEqual(types.string.size(expected), { size: 9 });
}); });
+1 -1
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status ## Status
The rewrite is **feature complete and green**: 184 tests, lint and typecheck clean, verified on Node The rewrite is **feature complete and green**: 190 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