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.
5. `down -v` at the end of every run. Locally built images are kept, and their tag goes in the
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.
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.
+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) |
| 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
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