diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index da98f90..19edac2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4a54bf5..4dcee95 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f6ce0a9..924d929 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 1286ddd..7b6fb59 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/client.ts b/src/client.ts index 7f49d94..64b7e78 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { 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> 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'; } diff --git a/src/defs/tlvs.ts b/src/defs/tlvs.ts index 10c52e6..a893da2 100644 --- a/src/defs/tlvs.ts +++ b/src/defs/tlvs.ts @@ -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 }, diff --git a/src/defs/types.ts b/src/defs/types.ts index 3b6f4eb..e843245 100644 --- a/src/defs/types.ts +++ b/src/defs/types.ts @@ -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> = { 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): WireType { + 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 = { default: '', @@ -513,9 +545,9 @@ export const tlv = { return err ? { err } : writeCstring(text, buf, offset); }, } satisfies WireType, - int8, - int16, - int32, + int8: tlvInt(int8), + int16: tlvInt(int16), + int32: tlvInt(int32), string: { default: '', read(buf: Buffer, offset: number, length = 0) { diff --git a/src/dlr.ts b/src/dlr.ts index 61b807f..0a6c7bd 100644 --- a/src/dlr.ts +++ b/src/dlr.ts @@ -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; } /** diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts new file mode 100644 index 0000000..1786ee3 --- /dev/null +++ b/src/incoming-requests.ts @@ -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 { + 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 { + 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 { + 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), + })); + } +} diff --git a/src/reassembly.ts b/src/reassembly.ts index 47f59e7..c3b1b92 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -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); diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts index 3fcc554..ce7f454 100644 --- a/src/reconnect-loop.ts +++ b/src/reconnect-loop.ts @@ -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 { + 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)) }; + } + } } diff --git a/src/server.ts b/src/server.ts index 134adb8..dcfeca7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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)); } diff --git a/src/session-options.ts b/src/session-options.ts index f2e810b..46514d0 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -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; + /** * 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) | 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, diff --git a/src/session.ts b/src/session.ts index 4024eb6..6699d90 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { 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 { 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 { input: PduObjectInput, options: SendOptions, ): Promise> { + // 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 { 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 { 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 { }); } - private async handle(pduObj: PduObject): Promise { - 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 { - 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 { - 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; diff --git a/test/dlr.test.ts b/test/dlr.test.ts index 886bb67..c7bb2cb 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -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); }); diff --git a/test/encodings.test.ts b/test/encodings.test.ts index 190a61f..97a08fb 100644 --- a/test/encodings.test.ts +++ b/test/encodings.test.ts @@ -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'); diff --git a/test/interop.test.ts b/test/interop.test.ts index b6c2b1a..10d9457 100644 --- a/test/interop.test.ts +++ b/test/interop.test.ts @@ -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(resolve => { refServer.close(() => { resolve(); }); })); await new Promise(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(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(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(); }); }); diff --git a/test/message.test.ts b/test/message.test.ts index db95e5c..ec30aa4 100644 --- a/test/message.test.ts +++ b/test/message.test.ts @@ -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]), diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 5ee11d5..52bb76d 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -28,8 +28,18 @@ async function startServer(options: Parameters[0] = {}): Promise< return smpp; } +/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */ function once(register: (resolve: (value: T) => void) => void): Promise { - return new Promise(resolve => { register(resolve); }); + return new Promise((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 }); diff --git a/test/session.test.ts b/test/session.test.ts index 6f87c38..493c6e6 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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[0] = {}): Promise { 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(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(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(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(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 { + 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', () => { diff --git a/test/types.test.ts b/test/types.test.ts index d7b503b..516d71a 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -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', () => { diff --git a/todo.md b/todo.md index c399830..ddaa1bb 100644 --- a/todo.md +++ b/todo.md @@ -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();