Leave alert_notification and outbind unanswered #21

Merged
lilleman merged 3 commits from alert-outbind into main 2026-09-23 22:49:55 +02:00
5 changed files with 60 additions and 7 deletions
+3
View File
@@ -32,6 +32,9 @@
a caller that reads only `smsIds` meets a failure it has not met before. A whole number in an 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 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`. 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`.
## 0.5.0 ## 0.5.0
+7
View File
@@ -16,6 +16,7 @@ import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts'; import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.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. */ /** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
export function refusedSegmentStatus( export function refusedSegmentStatus(
@@ -171,6 +172,12 @@ export class IncomingRequests {
return; return;
} }
if (!respNameFor(pduObj.cmdName)) {
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
} }
+2 -1
View File
@@ -13,6 +13,7 @@ import { defaultInterfaceVersion } from './defs/constants.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
import { respNameFor } from './defs/commands.ts';
export type AuthenticateResult = { userData?: unknown } | boolean; export type AuthenticateResult = { userData?: unknown } | boolean;
@@ -224,7 +225,7 @@ async function handleRequest(
return true; 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 }); session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
+43
View File
@@ -316,6 +316,25 @@ describe('bind', () => {
assert.equal(await responded, '00000010800000150000000400000001'); 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 peer = rawPeer(t, smpp.port);
peer.write({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 });
peer.write({ cmdName: 'enquire_link', seqNr: 2 });
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, []);
});
test('answers the enquire_link a bound peer sends', async t => { test('answers the enquire_link a bound peer sends', async t => {
const smpp = await startServer(t); const smpp = await startServer(t);
const peer = rawPeer(t, smpp.port); const peer = rawPeer(t, smpp.port);
@@ -1507,6 +1526,30 @@ describe('robustness', () => {
assert.ok(Date.now() - started < 5000, 'should have given up quickly'); 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 () => { test('stops a connection attempt on an aborted signal', async () => {
const controller = new AbortController(); const controller = new AbortController();
+5 -6
View File
@@ -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 caller never wrote." A goal is the maintainer's, so nothing edits README until that is
answered. From the prose pass of #18. 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` - [ ] **Range-check `maxOctets` with its five siblings.** `limitsOf()` in `session-options.ts`
covers `idleTimeout`, `maxOutstanding`, `maxReassembly`, `reassemblyTimeout`, `responseTimeout` covers `idleTimeout`, `maxOutstanding`, `maxReassembly`, `reassemblyTimeout`, `responseTimeout`
and `shutdownTimeout`; `maxOctets` is documented, consumed by `Reassembler`, and absent from and `shutdownTimeout`; `maxOctets` is documented, consumed by `Reassembler`, and absent from
@@ -415,6 +409,11 @@ and is also what the panel ranked hardest — two methods, one answer.
## Worth doing, not blocking ## 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 - [ ] **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 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, exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment,