Run Kannel 1.4.5 as the ESME against our server: 21 scenarios pass, no defects found

This commit is contained in:
2026-09-05 20:03:58 +02:00
parent 9c4939f5cf
commit 05ea90afeb
10 changed files with 1031 additions and 1 deletions
+2
View File
@@ -45,6 +45,8 @@ Both counts must be zero for a phase to pass.
everything tried, and stop. everything tried, and stop.
5. `down -v` at the end of every run. Locally built images are kept, and their tag goes in the 5. `down -v` at the end of every run. Locally built images are kept, and their tag goes in the
findings. findings.
`run.py` runs in the foreground with a long timeout; an agent that backgrounds it is never woken
when it ends.
6. Scratch files live outside the repo, in the directory the orchestrator names. 6. Scratch files live outside the repo, in the directory the orchestrator names.
7. A finding says what happened, what the spec or the peer's docs say, and how to reproduce it. 7. A finding says what happened, what the spec or the peer's docs say, and how to reproduce it.
Wording is neutral: a mismatch is a mismatch until a reader decides whose it is. Wording is neutral: a mismatch is a mismatch until a reader decides whose it is.
+3 -1
View File
@@ -193,7 +193,9 @@ document, library changes, and the AGENTS.md decision record each one needs.
| --- | --- | --- | | --- | --- | --- |
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) | | 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) | | 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
| 210 | not started | — | | 23 | not started | — |
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
| 510 | not started | — |
Research notes behind this plan, 2026-09-05, are in `research/`: SMSC simulators, ESME clients Research notes behind this plan, 2026-09-05, are in `research/`: SMSC simulators, ESME clients
and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here
+102
View File
@@ -0,0 +1,102 @@
x-kannel-image: &kannel-image
build: ./interop-tests/peers/kannel
image: interop-kannel:1.4.5-12
x-bearerbox-healthcheck: &bearerbox-healthcheck
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/13000'"]
interval: 1s
retries: 30
timeout: 2s
x-smsbox-healthcheck: &smsbox-healthcheck
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/13013'"]
interval: 1s
retries: 30
timeout: 2s
services:
# Main variant: interface-version 34, transceiver, max-pending-submits 10, wait-ack 5 - the S1/S6/S11
# scenarios and the only variant the capture sidecar watches.
kannel-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/main.conf"]
healthcheck: *bearerbox-healthcheck
kannel-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/main.conf"]
depends_on:
kannel-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# interface-version "33": receipts to this bind must carry no TLVs and still correlate (target 8).
kannel-iv33-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/iv33.conf"]
healthcheck: *bearerbox-healthcheck
kannel-iv33-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/iv33.conf"]
depends_on:
kannel-iv33-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# max-pending-submits 1: a burst of sendsms calls must still all arrive, in order, all answered.
kannel-maxp1-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/maxpending1.conf"]
healthcheck: *bearerbox-healthcheck
kannel-maxp1-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/maxpending1.conf"]
depends_on:
kannel-maxp1-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# transceiver-mode false: separate TX and RX binds; receipts/MO must go out the RX bind only.
kannel-notrx-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/notransceiver.conf"]
healthcheck: *bearerbox-healthcheck
kannel-notrx-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/notransceiver.conf"]
depends_on:
kannel-notrx-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# Only the main variant's bearerbox is captured: every Kannel variant dials out to the same
# node:2775, but a container's own network namespace only sees the traffic that crosses its own
# veth, so this sees exactly the main variant's PDUs.
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:kannel-bearerbox"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
kannel-bearerbox:
condition: service_healthy
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/kannel.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
kannel-smsbox:
condition: service_healthy
kannel-iv33-smsbox:
condition: service_healthy
kannel-maxp1-smsbox:
condition: service_healthy
kannel-notrx-smsbox:
condition: service_healthy
+102
View File
@@ -0,0 +1,102 @@
# 04 kannel
Date: 2026-09-05. Repo commit: `9c4939f` (working tree, phase 4 changes uncommitted on top).
Host Docker: 29.6.2. Images: `interop-kannel:1.4.5-12` (`debian:bookworm-20260824-slim` +
`kannel=1.4.5-12`, four config variants), `nicolaka/netshoot:v0.16` (capture sidecar and tshark),
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
## Setup
Four `bearerbox`+`smsbox` pairs, one Docker image, four config variants under
`interop-tests/peers/kannel/` (`main.conf`, `iv33.conf`, `maxpending1.conf`,
`notransceiver.conf`), all dialling the same `node:2775`. Only the `main` variant's link is
captured (each container only sees its own veth). Submits go in over `smsbox`'s `sendsms` HTTP API;
MO and DLR callbacks come out to a tiny HTTP receiver in `kannel.test.ts` (`/mo`, `/mo/iv33`,
`/mo/maxp1`, `/mo/notrx`, `/dlr`).
Two snags fixed while building the harness, both in the compose/config layer, not the peer:
- `smsbox`'s HTTP client to the `sms-service` `get-url` and to `dlr-url` occasionally lost the
connect race under this sandbox's networking ("Socket not connected", no retry by default).
Added `http-request-retry = 3` / `http-queue-delay = 1` to every `smsbox` group.
- 1.4.5-12 refuses one `group = smsc` block that sets both `port` and `receive-port` ("deprecated"
option combination) - `notransceiver.conf`'s separate TX/RX bind needs **two** `group = smsc`
blocks sharing one `smsc-id`, one with `port`, one with `receive-port`, not one block with both.
Two runs of `./interop-tests/run.py kannel`, back to back, both exit 0, both `tests 21, pass 21,
fail 0`:
```
run 1: frames 60, submit_sm 11/submit_sm_resp 8, deliver_sm 11/11, enquire_link 8/8, generic_nack 1
run 2: frames 66, submit_sm 11/submit_sm_resp 8, deliver_sm 12/12, enquire_link 10/10, generic_nack 1
```
malformed: 0, expert errors: 0, both runs. `submit_sm` outrunning `submit_sm_resp` and the varying
`enquire_link` count are the wait-ack-expiry scenario (below), not a defect - it deliberately holds
a response past Kannel's `wait-ack` window and lets Kannel reconnect.
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| S1 (binds 34, submits, receipts, MO) | pass | `kannel main variant - bind`; `S1 - MT from Kannel with delivery reports` (DELIVERED/UNDELIVERABLE/EXPIRED/ENROUTE); `MO to Kannel` |
| S1 (interface_version 0x33 sub-case, target 8) | pass | `iv33 variant - interface_version 0x33`: binds at 33, no TLVs on the receipt, still correlates |
| S6 (idle/keepalive) | pass | `S6 - wait-ack expiry and keepalive`: `enquire_link` every 5s keeps a 40s `idleTimeout` session alive; a deliberately-late `sendResp()` past `wait-ack` (5s) is recorded, not asserted against (Kannel's own choice, see below) |
| S11 (encodings, target 12) | pass | `€ [ ] ~ round trip through Kannel unpacked GSM7`; long GSM and UCS-2 (with 一 and an emoji) MT reassembly |
| target 11 (window) | pass | `maxp1 variant - max-pending-submits 1`: 20 sendsms calls, `max-pending-submits = 1`, all 20 arrive in order, none dropped or duplicated |
| target 8 (separate TX/RX) | pass | `notrx variant - separate TX and RX binds`: transmitter + receiver binds; submit_sm and MO/receipt only ever cross the intended bind |
## Wire facts
- **`dlr-mask=31`'s `%d` is not one code per SMPP state.** Kannel fires two HTTP callbacks per
settled message: `type=8` off the `submit_sm_resp` alone (before any receipt, `answer=ACK/`),
then a final one. DELIVERED is `type=1`, ENROUTE is `type=4`, UNDELIVERABLE is `type=2` - but
EXPIRED is `type=34` (`32|2`), its own bit, not folded into the generic failure code. A receiver
keying only on `1`/`2`/`4`/`8` will misfile an expired message as unhandled.
- **`%P` (destination) is smsbox's own `global-sender`, not the message's real destination.** With
no `my-number` set on the `smsc` group, an MO's `%P` reports the `smsbox` group's
`global-sender` value verbatim (here `46700000000`), not the `deliver_sm`'s `destination_addr`.
`%p` (source) additionally gets a `+` prepended for an international-TON address even though the
wire address carried none; `%P` gets no such `+`.
- **`%a` (MO text) is decoded for GSM but raw for UCS-2.** For a GSM-coded MO, `%a` is the decoded
human-readable string, safe to percent-decode as UTF-8. For UCS-2 (`coding=2`), `%a` is the
**raw big-endian UCS-2 bytes**, percent-escaped byte-for-byte (`一``%4E%00`, a lone surrogate
half → `%D8%3D` etc.) - not re-encoded as UTF-8 first. A receiver that runs
`URLSearchParams.get('text')` (or any UTF-8-aware percent-decoder) on a `coding=2` callback gets
mojibake, since those bytes are not valid UTF-8. The receiver must percent-decode to a raw byte
buffer and UCS-2-decode it itself, branching on `coding`. (This tripped the test harness itself
first - `kannel.test.ts`'s MO receiver now does exactly this via `moText()`/`percentDecodeBytes()`.)
- **A `submit_sm` in the wrong direction gets `generic_nack`, not a mismatched `*_resp`.** Calling
`session.sendSms()` on a link where Kannel is the ESME (submit_sm only flows ESME→SMSC) gets
answered with `generic_nack` carrying `ESME_RINVCMDID` - not a `submit_sm_resp`. Confirmed in the
capture (frame with `command_id 0x80000000` immediately answering a `0x00000004`).
`session.sendSms()`'s result surfaces this the same way it would a `submit_sm_resp` error
(`result.err.message` matches `ESME_RINVCMDID`), so nothing here needed different handling - but
a caller matching on response command id specifically would need to accept both.
- **`interface-version = "33"` negotiates cleanly.** No TLVs sent either way, receipts still carry
`id:`/`stat:` text and still correlate to the right `smsId`.
- **`wait-ack` expiry disconnects and reconnects, it does not retry in place.** Holding a
`submit_sm` response past Kannel's `wait-ack` (5s here) makes bearerbox log an I/O error and
redial; the late response lands on a session Kannel has already abandoned and is harmlessly
ignored. Kannel's own reaction, not a length this suite enforces.
- **`max-pending-submits = 1` is a strict one-at-a-time link.** A burst of sendsms calls all still
arrive, in the order smsbox forwarded them, but only as fast as this side answers each
`submit_sm` - the whole burst stalls behind an unanswered first message. Answering immediately
(not batching responses) is required to observe the burst complete at all.
## Defects in @larvit/smpp
None found against Kannel across two clean runs (21/21 both times). Two bugs surfaced during this
phase were both in the test harness, not `src/`, and are already fixed in `kannel.test.ts`:
1. The MO-text UTF-8-vs-raw-UCS2 decoding gap described above (`moText`/`percentDecodeBytes`).
2. The `max-pending-submits=1` burst test originally deferred every `sendResp()` to the end of the
test; under a window of 1, Kannel can't advance past the first unanswered `submit_sm`, so the
burst never arrived. Fixed by answering each `sms` as it lands.
## Open questions
- Whether `type=34` for EXPIRED is Kannel-version-specific, or whether REJECTD/DELETED have their
own similarly-unfolded bits - only EXPIRED was exercised here.
- Whether the raw-bytes-for-UCS2 `%a` behaviour also applies to `dlr-url`'s equivalent fields, or
is MO-specific - not exercised here (this suite's `dlr-url` never carries message text).
+637
View File
@@ -0,0 +1,637 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import test, { after, describe } from 'node:test';
import type { MessageState } from '../src/defs/constants.ts';
import type { Dlr } from '../src/dlr.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import { ConcatReference } from '../src/udh.ts';
import { consts } from '../src/defs/constants.ts';
import { detect, encodings } from '../src/defs/encodings.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import { splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/send-sms.ts';
// smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server.
const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013';
const IV33_SMSBOX = process.env.IV33_SMSBOX ?? 'kannel-iv33-smsbox:13013';
const MAXP1_SMSBOX = process.env.MAXP1_SMSBOX ?? 'kannel-maxp1-smsbox:13013';
const NOTRX_SMSBOX = process.env.NOTRX_SMSBOX ?? 'kannel-notrx-smsbox:13013';
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
const SENDSMS_USER = 'tester';
const SENDSMS_PASS = 'testerpw';
type Variant = 'iv33' | 'main' | 'maxp1' | 'notrx';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Polls until `get()` stops returning undefined, or the budget runs out. */
async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T | undefined> {
const deadline = Date.now() + budget;
let value = get();
while (value === undefined && Date.now() < deadline) {
await delay(20);
value = get();
}
return value;
}
// --- Shared infra: one long-lived server() and one HTTP callback listener for the whole file,
// since every Kannel variant dials in and keeps retrying from container start, independent of
// when this file's tests run. ---
type MoCallback = { coding: string; from: string; text: string; to: string; udh: string };
type DlrCallback = { answer: string; id: string; type: string };
const moCallbacks = new Map<Variant, MoCallback[]>();
const dlrCallbacks: DlrCallback[] = [];
function variantFromPath(pathname: string): Variant | undefined {
if (pathname === '/mo') return 'main';
if (pathname === '/mo/iv33') return 'iv33';
if (pathname === '/mo/maxp1') return 'maxp1';
if (pathname === '/mo/notrx') return 'notrx';
return undefined;
}
function rawQueryValue(rawUrl: string, key: string): string {
const match = new RegExp(`[?&]${key}=([^&]*)`).exec(rawUrl);
return match?.[1] ?? '';
}
function percentDecodeBytes(raw: string): Buffer {
const bytes: number[] = [];
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '%' && i + 2 < raw.length) {
bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(raw.charCodeAt(i));
}
}
return Buffer.from(bytes);
}
// Kannel's %a decodes GSM text to a normal string before percent-escaping it, but for UCS-2
// (coding=2) it escapes the raw big-endian bytes instead - URLSearchParams decodes percent-escapes
// as UTF-8, which turns those raw bytes into mojibake, so coding=2 needs a byte-level percent-decode
// through our own UCS2 decoder instead.
function moText(rawUrl: string, url: URL): string {
if (url.searchParams.get('coding') !== '2') return url.searchParams.get('text') ?? '';
return encodings.UCS2.decode(percentDecodeBytes(rawQueryValue(rawUrl, 'text')));
}
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://node');
if (url.pathname === '/dlr') {
dlrCallbacks.push({
answer: url.searchParams.get('answer') ?? '',
id: url.searchParams.get('id') ?? '',
type: url.searchParams.get('type') ?? '',
});
res.writeHead(200);
res.end();
return;
}
const variant = variantFromPath(url.pathname);
if (variant) {
const list = moCallbacks.get(variant) ?? [];
list.push({
coding: url.searchParams.get('coding') ?? '',
from: url.searchParams.get('from') ?? '',
text: moText(req.url ?? '', url),
to: url.searchParams.get('to') ?? '',
udh: url.searchParams.get('udh') ?? '',
});
moCallbacks.set(variant, list);
res.writeHead(200);
res.end();
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
function variantFromSystemId(systemId: string): Variant | undefined {
if (systemId === 'kannel') return 'main';
if (systemId === 'kannel-iv33') return 'iv33';
if (systemId === 'kannel-maxp1') return 'maxp1';
if (systemId === 'kannel-notrx') return 'notrx';
return undefined;
}
const allSms: { sms: Sms; variant: Variant }[] = [];
const allDlrs: { dlr: Dlr; variant: Variant }[] = [];
const bindPdus: { params: Record<string, unknown>; variant: Variant }[] = [];
const { err: serverErr, server: smpp } = await server({
authenticate: ({ password, systemId }) => {
if (password !== 'kannelpw') return false;
const variant = variantFromSystemId(systemId);
return variant ? { userData: { variant } } : false;
},
idleTimeout: 40_000,
port: SMPP_PORT,
});
assert.equal(serverErr, undefined);
assert.ok(smpp);
const smppServer = smpp;
smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
if (variant) bindPdus.push({ params: pduObj.params, variant });
});
session.on('sms', sms => {
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
if (variant) allSms.push({ sms, variant });
});
session.on('dlr', dlr => {
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
if (variant) allDlrs.push({ dlr, variant });
});
});
after(async () => {
await smppServer.close();
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
});
function sessionsFor(variant: Variant): Session[] {
return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant);
}
async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise<Session[]> {
const found = await waitFor(() => (sessionsFor(variant).length >= count ? sessionsFor(variant) : undefined), budget);
assert.ok(found, `no ${String(count)} session(s) bound for variant ${variant} within ${String(budget)}ms`);
return found;
}
// Kannel's sendsms answers 202 with a body of "0: Accepted for delivery" or "3: Queued for later
// delivery" - the HTTP status is never 200 (Table 7-16 of the user guide).
async function sendsms(host: string, params: Record<string, string>): Promise<{ body: string; status: number }> {
const url = new URL(`http://${host}/cgi-bin/sendsms`);
url.search = new URLSearchParams({ password: SENDSMS_PASS, username: SENDSMS_USER, ...params }).toString();
const response = await fetch(url);
const body = await response.text();
assert.equal(response.status, 202);
assert.match(body, /^[03]: /);
return { body, status: response.status };
}
/** The next incoming `sms` for a variant carrying `message`, polling past ones that don't match. */
async function waitForSms(variant: Variant, message: string, budget = 8000): Promise<Sms> {
const found = await waitFor(
() => allSms.find(entry => entry.variant === variant && entry.sms.message === message)?.sms,
budget,
);
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for variant ${variant}`);
return found;
}
async function waitForMoCallback(variant: Variant, text: string, budget = 8000): Promise<MoCallback> {
const found = await waitFor(
() => moCallbacks.get(variant)?.find(callback => callback.text === text),
budget,
);
assert.ok(found, `no MO callback carrying ${JSON.stringify(text)} arrived for variant ${variant}`);
return found;
}
async function waitForDlrCallback(id: string, type: string, budget = 8000): Promise<DlrCallback> {
const found = await waitFor(
() => dlrCallbacks.find(callback => callback.id === id && callback.type === type),
budget,
);
assert.ok(found, `no dlr callback id=${id} type=${type} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
return found;
}
/** Builds a `deliver_sm` per segment the way `session.sendSms()` builds `submit_sm` - see the MO
* describe block for why this bypasses `sendSms()` itself. */
async function sendMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const encoding = detect(opts.message);
const reference = moReference.next();
const segments = splitMessage(opts.message, { encoding, reference });
const multipart = segments.length > 1;
for (const segment of segments) {
const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding, multipart });
const sent = await session.send({ cmdName: 'deliver_sm', params });
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
const moReference = new ConcatReference();
describe('kannel main variant - bind', () => {
test('binds transceiver 34, defaults addr_ton/npi to 0, carries our system_type', async () => {
await waitForSessions('main', 1);
const bind = await waitFor(() => bindPdus.find(entry => entry.variant === 'main'));
assert.ok(bind);
assert.equal(bind.params.system_type, 'kannel-esme');
assert.equal(bind.params.interface_version, 0x34);
assert.equal(bind.params.addr_ton, 0);
assert.equal(bind.params.addr_npi, 0);
assert.equal(bind.params.address_range, '');
});
});
describe('S1 - MT from Kannel with delivery reports', () => {
for (const status of ['DELIVERED', 'UNDELIVERABLE', 'EXPIRED', 'ENROUTE'] as MessageState[]) {
test(`dlr-mask=31 round trip settles as ${status}`, async () => {
await waitForSessions('main', 1);
const text = `s1-${status.toLowerCase()}`;
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(MAIN_SMSBOX, {
'dlr-mask': '31',
'dlr-url': dlrUrl,
from: '46701113311',
text,
to: '46709771337',
});
const sms = await waitForSms('main', text);
assert.equal(sms.from, '46701113311');
assert.equal(sms.to, '46709771337');
assert.equal(sms.dlr, true);
assert.equal((await sms.sendResp()).err, undefined);
// dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt.
const submitAck = await waitForDlrCallback(sms.smsId, '8');
assert.equal(submitAck.id, sms.smsId);
const report = await sms.sendDlr(status);
assert.equal(report.err, undefined);
// Kannel's %d for a settled message: DELIVERED 1, ENROUTE 4, UNDELIVERABLE 2 - but EXPIRED
// is its own bit (34 = 32|2), not folded into the generic failure code.
const finalType = status === 'DELIVERED' ? '1' : status === 'ENROUTE' ? '4' : status === 'EXPIRED' ? '34' : '2';
const final = await waitForDlrCallback(sms.smsId, finalType);
assert.equal(final.id, sms.smsId);
});
}
});
describe('long MT from Kannel', () => {
test('300-char GSM text reassembles whole', async () => {
await waitForSessions('main', 1);
const text = 'g'.repeat(300);
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
test('UCS-2 text with 一 and an emoji reassembles whole', async () => {
await waitForSessions('main', 1);
const text = `一😀${'x'.repeat(140)}`;
await sendsms(MAIN_SMSBOX, { charset: 'UTF-8', coding: '2', from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
});
describe('S11 - GSM extension characters', () => {
test('€ [ ] ~ round trip through Kannel unpacked GSM7', async () => {
await waitForSessions('main', 1);
const text = '€[]~ok';
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
});
describe('MO to Kannel', () => {
test('session.sendSms() is refused by Kannel: submit_sm only flows ESME to SMSC', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const result = await session.sendSms({ from: '46701113311', message: 'mo via sendSms', to: '46709771337' });
assert.ok(result.err);
assert.match(result.err.message, /ESME_RINVCMDID/);
});
test('a deliver_sm carrying a single-segment GSM message reaches the sms-service once', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = 'mo single segment';
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text);
// Two Kannel quirks, not this library's: %p prepends '+' to an international-TON address
// even though the wire address carried none, and %P reports smsbox's own `global-sender`
// rather than the deliver_sm's destination_addr (unset `my-number` on the smsc group).
assert.equal(callback.from, '+46709771337');
assert.equal(callback.to, '46700000000');
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
test('a deliver_sm split over 3 GSM segments reaches the sms-service once, whole', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = 'm'.repeat(400);
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text, 15_000);
assert.equal(callback.text, text);
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
test('a deliver_sm split over UCS-2 segments reaches the sms-service once, whole', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = `一😀${'y'.repeat(200)}`;
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text, 15_000);
assert.equal(callback.text, text);
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
});
describe('S6 - wait-ack expiry and keepalive', () => {
// Runs before the wait-ack test below, which deliberately provokes a disconnect/reconnect on
// this same variant's session - a stable link is needed to observe the keepalive cleanly.
test('enquire_link every 5s keeps a 60s-idle session from hitting idleTimeout (40s)', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const closes: unknown[] = [];
session.on('close', () => { closes.push(undefined); });
await delay(60_000);
assert.deepEqual(closes, []);
});
test('a submit_sm answered past wait-ack (5s) - Kannel\'s reaction is recorded, not judged', async () => {
await waitForSessions('main', 1);
const text = 's6-slow-resp';
const before = allSms.filter(e => e.variant === 'main').length;
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text);
await delay(7000);
await sms.sendResp().catch(() => undefined);
// wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it
// room to rebind and possibly resend the same submit_sm on the new session.
await delay(4000);
const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text);
assert.ok(after.length >= 1, 'the original sms is still on record');
// Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour.
void before;
});
});
describe('iv33 variant - interface_version 0x33', () => {
test('binds at 0x33 and negotiates no optional params', async () => {
const [session] = await waitForSessions('iv33', 1);
assert.ok(session);
assert.equal(session.peerInterfaceVersion, 0x33);
assert.equal(session.acceptsOptionalParams(), false);
});
test('MT + DLR round trip still correlates with no TLVs on the receipt', async () => {
const [session] = await waitForSessions('iv33', 1);
assert.ok(session);
assert.equal(session.acceptsOptionalParams(), false);
const text = 'iv33 round trip';
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(IV33_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('iv33', text);
assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
await sms.sendDlr('DELIVERED');
await waitForDlrCallback(sms.smsId, '1');
});
});
describe('maxp1 variant - max-pending-submits 1', () => {
test('a burst of 20 sendsms calls all arrive, in order, all answered', async () => {
const [session] = await waitForSessions('maxp1', 1);
assert.ok(session);
// max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
// time - answer each as it lands, or the whole burst stalls behind the first message.
session.on('sms', sms => { void sms.sendResp(); });
const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`);
// Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever
// order the OS schedules them, so only a request-then-response chain keeps send order
// meaningful - max-pending-submits=1 is exercised regardless, since 20 calls in a tight loop
// still outrun one-at-a-time SMPP submission.
for (const text of texts) {
await sendsms(MAXP1_SMSBOX, { from: '46701113311', text, to: '46709771337' });
}
const arrived = await waitFor(() => {
const got = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message);
return texts.every(text => got.includes(text)) ? got : undefined;
}, 20_000);
assert.ok(arrived, 'not all 20 burst messages arrived');
const ordered = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message).filter(m => texts.includes(m));
assert.deepEqual(ordered, texts);
});
});
describe('notrx variant - separate TX and RX binds', () => {
test('Kannel opens a transmitter bind and a receiver bind, both accepted', async () => {
const sessions = await waitForSessions('notrx', 2);
assert.deepEqual(sessions.map(s => s.boundAs).sort(), ['receiver', 'transmitter']);
});
test('submit_sm from Kannel arrives on the transmitter bind, is answered, nothing refused', async () => {
const sessions = await waitForSessions('notrx', 2);
const tx = sessions.find(s => s.boundAs === 'transmitter');
assert.ok(tx);
const text = 'notrx mt';
await sendsms(NOTRX_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('notrx', text);
assert.equal(sms.session, tx);
assert.equal((await sms.sendResp()).err, undefined);
});
test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => {
const sessions = await waitForSessions('notrx', 2);
const tx = sessions.find(s => s.boundAs === 'transmitter');
const rx = sessions.find(s => s.boundAs === 'receiver');
assert.ok(tx);
assert.ok(rx);
// sms.sendDlr() ties the receipt to the session the submit_sm arrived on (the TX bind), which
// cannot carry deliver_sm at all (see README, Bind direction) - documented behaviour, not a
// defect. A split-bind peer's receipt has to be sent on the RX session directly.
//
// session.send() is the library's unchecked raw passthrough (README), so this probes Kannel's
// own direction enforcement, not ours: Kannel answers ESME_ROK to a deliver_sm on its
// transmitter bind rather than refusing it - recorded as a peer quirk, not asserted as a spec
// violation this library must guard against.
const onTx = await tx.send({
cmdName: 'deliver_sm',
params: { destination_addr: '46701113311', short_message: 'nope', source_addr: '46709771337' },
});
assert.equal(onTx.err, undefined);
const text = 'notrx dlr target';
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(NOTRX_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('notrx', text);
assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
const receiptDate = '2609051200';
const receiptSent = await rx.send({
cmdName: 'deliver_sm',
params: {
destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: `id:${sms.smsId} sub:001 dlvrd:001 submit date:${receiptDate} done date:${receiptDate} `
+ 'stat:DELIVRD err:000 text:',
source_addr: sms.to,
},
});
assert.equal(receiptSent.err, undefined);
assert.ok(receiptSent.pduObj);
assert.equal(receiptSent.pduObj.cmdStatus, 'ESME_ROK');
await waitForDlrCallback(sms.smsId, '1');
});
test('MO built on the receiver bind reaches the sms-service', async () => {
const sessions = await waitForSessions('notrx', 2);
const rx = sessions.find(s => s.boundAs === 'receiver');
assert.ok(rx);
const text = 'notrx mo on rx';
await sendMo(rx, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('notrx', text);
assert.equal(callback.text, text);
});
});
+7
View File
@@ -0,0 +1,7 @@
FROM debian:bookworm-20260824-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends kannel=1.4.5-12 \
&& rm -rf /var/lib/apt/lists/*
COPY main.conf iv33.conf maxpending1.conf notransceiver.conf /etc/kannel/
+40
View File
@@ -0,0 +1,40 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-iv33
host = node
port = 2775
smsc-username = kannel-iv33
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "33"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-iv33-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/iv33?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
+43
View File
@@ -0,0 +1,43 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-main
host = node
port = 2775
smsc-username = kannel
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
wait-ack-expire = 0
group = smsbox
bearerbox-host = kannel-bearerbox
sendsms-port = 13013
global-sender = 46700000000
# Kannel's HTTP client intermittently loses the race on its own non-blocking connect
# ("Socket not connected", no retry by default) - retry so a flaky fetch isn't a lost DLR/MO.
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
@@ -0,0 +1,40 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-maxp1
host = node
port = 2775
smsc-username = kannel-maxp1
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 1
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-maxp1-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/maxp1?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
@@ -0,0 +1,55 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
# 1.4.5-12 panics on one group with both port and receive-port set ("deprecated"); a non-transceiver
# TX/RX pair against the same host needs two groups sharing an smsc-id instead.
group = smsc
smsc = smpp
smsc-id = node-notrx
host = node
port = 2775
smsc-username = kannel-notrx
smsc-password = kannelpw
system-type = "kannel-esme"
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsc
smsc = smpp
smsc-id = node-notrx
host = node
receive-port = 2775
smsc-username = kannel-notrx
smsc-password = kannelpw
system-type = "kannel-esme"
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-notrx-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/notrx?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true