From b54f5b02f310a4705531d7bb1fe383afd6af98df Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 22 Sep 2026 21:43:57 +0200 Subject: [PATCH 1/3] Leave alert_notification and outbind unanswered --- CHANGELOG.md | 3 +++ src/incoming-requests.ts | 7 +++++++ src/server.ts | 3 ++- test/session.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ todo.md | 6 ------ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94672e..a3a9f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ a caller that reads only `smsIds` meets a failure it has not met before. A whole number in an address or an id still spells its digits, so `message_id: 123` is unchanged. The integer fields name a refused `NaN` too, where the refusal used to read `null`. +- An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4 + gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no + response command`, and a server did the same for an `outbind` before bind. ## 0.5.0 diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 420dbf7..95d83c2 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -16,6 +16,7 @@ import { createSms } from './sms.ts'; import { dlrFromPdu } from './dlr.ts'; import { paramText } from './defs/types.ts'; import { respIdParams, segmentId } from './sms-id.ts'; +import { respNameFor } from './defs/commands.ts'; /** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */ export function refusedSegmentStatus( @@ -171,6 +172,12 @@ export class IncomingRequests { return; } + if (!respNameFor(pduObj.cmdName)) { + this.log.info('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName }); + + return; + } + this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); } diff --git a/src/server.ts b/src/server.ts index 51303bf..7cad123 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import { defaultInterfaceVersion } from './defs/constants.ts'; import { errorFrom } from './error-from.ts'; import { paramText } from './defs/types.ts'; import { guardedLog } from './log.ts'; +import { respNameFor } from './defs/commands.ts'; export type AuthenticateResult = { userData?: unknown } | boolean; @@ -224,7 +225,7 @@ async function handleRequest( return true; } - if (pduObj.cmdName === 'unbind') return false; + if (pduObj.cmdName === 'unbind' || !respNameFor(pduObj.cmdName)) return false; session.log.debug('server - command before bind', { cmdName: pduObj.cmdName }); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); diff --git a/test/session.test.ts b/test/session.test.ts index fa7d0a1..c0f6c39 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -316,6 +316,28 @@ describe('bind', () => { assert.equal(await responded, '00000010800000150000000400000001'); }); + test('leaves an outbind from an unbound peer unanswered', async t => { + const smpp = await startServer(t); + const errors: Error[] = []; + + smpp.on('session', session => { session.on('sessionError', err => { errors.push(err); }); }); + + const responded = once(resolve => { + const sock = net.connect({ port: smpp.port }, () => { + sock.write(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 })); + sock.write(Buffer.from('00000010000000150000000000000002', 'hex')); + }); + + sock.on('data', data => { + sock.destroy(); + resolve(data.toString('hex')); + }); + }); + + assert.equal(await responded, '00000010800000150000000400000002'); + assert.deepEqual(errors, []); + }); + test('answers the enquire_link a bound peer sends', async t => { const smpp = await startServer(t); const peer = rawPeer(t, smpp.port); @@ -1792,6 +1814,26 @@ describe('a PDU the codec cannot read', () => { assert.ok((await raceWithin(2000, failed)) instanceof Error, 'one sessionError per refused PDU'); }); + test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => { + const { peer, session } = await bound(t); + const errors: Error[] = []; + + session.on('sessionError', err => { errors.push(err); }); + peer.writeRaw(pduBytes({ + cmdName: 'alert_notification', + params: { esme_addr: '46709771337', source_addr: '46701113311' }, + seqNr: 10, + })); + peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 })); + peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 })); + + const answered = await answerTo(peer); + + assert.equal(answered.cmdName, 'enquire_link_resp'); + assert.equal(answered.seqNr, 12); + assert.deepEqual(errors, []); + }); + test('answers a deliver_sm with a truncated TLV stream with ESME_RINVTLVSTREAM', async t => { const { peer, session } = await bound(t); let reports = 0; diff --git a/todo.md b/todo.md index fe521dd..0ecb709 100644 --- a/todo.md +++ b/todo.md @@ -200,12 +200,6 @@ and is also what the panel ranked hardest — two methods, one answer. caller never wrote." A goal is the maintainer's, so nothing edits README until that is answered. From the prose pass of #18. -- [ ] **Answer `alert_notification` and `outbind` by not answering them.** Both are response-less in - SMPP 3.4, both fall through `route()`'s default into `unhandled()`, which calls - `sendReturn(pduObj, 'ESME_RINVCMDID')`; `pduReturn()` then finds no response command, and the - failure reaches the application as `sessionError` on every occurrence. `alert_notification` - appears nowhere in `src/` but `defs/commands.ts`. One case arm each: log and return. - - [ ] **Range-check `maxOctets` with its five siblings.** `limitsOf()` in `session-options.ts` covers `idleTimeout`, `maxOutstanding`, `maxReassembly`, `reassemblyTimeout`, `responseTimeout` and `shutdownTimeout`; `maxOctets` is documented, consumed by `Reassembler`, and absent from -- 2.52.0 From 65ac3a3fd71630f8d16707659f12adb82fdb8929 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 22 Sep 2026 21:49:25 +0200 Subject: [PATCH 2/3] Register the raw peer's teardown, and keep the changelog line to what changed --- CHANGELOG.md | 2 +- test/session.test.ts | 63 ++++++++++++++++++++++---------------------- todo.md | 5 ++++ 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a9f5d..0a9f2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ name a refused `NaN` too, where the refusal used to read `null`. - An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4 gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no - response command`, and a server did the same for an `outbind` before bind. + response command`. ## 0.5.0 diff --git a/test/session.test.ts b/test/session.test.ts index c0f6c39..3b4de31 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -322,19 +322,16 @@ describe('bind', () => { smpp.on('session', session => { session.on('sessionError', err => { errors.push(err); }); }); - const responded = once(resolve => { - const sock = net.connect({ port: smpp.port }, () => { - sock.write(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 })); - sock.write(Buffer.from('00000010000000150000000000000002', 'hex')); - }); + const peer = rawPeer(t, smpp.port); - sock.on('data', data => { - sock.destroy(); - resolve(data.toString('hex')); - }); - }); + peer.write({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 }); + peer.write({ cmdName: 'enquire_link', seqNr: 2 }); - assert.equal(await responded, '00000010800000150000000400000002'); + const answered = await raceWithin(2000, peer.next()); + + assert.ok(answered, 'the peer was never answered'); + assert.equal(answered.cmdStatus, 'ESME_RINVBNDSTS'); + assert.equal(answered.seqNr, 2); assert.deepEqual(errors, []); }); @@ -1529,6 +1526,30 @@ describe('robustness', () => { assert.ok(Date.now() - started < 5000, 'should have given up quickly'); }); + test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => { + const peer = await smscPeer(t); + const { session } = await client({ port: peer.port }); + const errors: Error[] = []; + + assert.ok(session); + closeAfter(t, session); + session.on('sessionError', err => { errors.push(err); }); + peer.writeRaw(pduBytes({ + cmdName: 'alert_notification', + params: { esme_addr: '46709771337', source_addr: '46701113311' }, + seqNr: 10, + })); + peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 })); + peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 })); + + const answered = await raceWithin(2000, peer.next()); + + assert.ok(answered, 'the peer was never answered'); + assert.equal(answered.cmdName, 'enquire_link_resp'); + assert.equal(answered.seqNr, 12); + assert.deepEqual(errors, []); + }); + test('stops a connection attempt on an aborted signal', async () => { const controller = new AbortController(); @@ -1814,26 +1835,6 @@ describe('a PDU the codec cannot read', () => { assert.ok((await raceWithin(2000, failed)) instanceof Error, 'one sessionError per refused PDU'); }); - test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => { - const { peer, session } = await bound(t); - const errors: Error[] = []; - - session.on('sessionError', err => { errors.push(err); }); - peer.writeRaw(pduBytes({ - cmdName: 'alert_notification', - params: { esme_addr: '46709771337', source_addr: '46701113311' }, - seqNr: 10, - })); - peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 })); - peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 })); - - const answered = await answerTo(peer); - - assert.equal(answered.cmdName, 'enquire_link_resp'); - assert.equal(answered.seqNr, 12); - assert.deepEqual(errors, []); - }); - test('answers a deliver_sm with a truncated TLV stream with ESME_RINVTLVSTREAM', async t => { const { peer, session } = await bound(t); let reports = 0; diff --git a/todo.md b/todo.md index 0ecb709..f45351d 100644 --- a/todo.md +++ b/todo.md @@ -409,6 +409,11 @@ and is also what the panel ranked hardest — two methods, one answer. ## Worth doing, not blocking +- [ ] **Decide whether `alert_notification` reaches the application as more than `incomingPduObj`.** + It is the SMSC saying a handset it could not reach is reachable again (`esme_addr`, + `ms_availability_status`); a client has no `onRequest`, so the raw PDU event is the only way in. + Raised by the stability review of #21. + - [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment, -- 2.52.0 From b105752d5386102f19537a10d759215f4abda0c4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 23 Sep 2026 22:46:13 +0200 Subject: [PATCH 3/3] Log an ignored response-less command at verbose, since a peer drives it --- src/incoming-requests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 95d83c2..e29ac52 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -173,7 +173,7 @@ export class IncomingRequests { } if (!respNameFor(pduObj.cmdName)) { - this.log.info('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName }); + this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName }); return; } -- 2.52.0