diff --git a/interop-tests/AGENTS.md b/interop-tests/AGENTS.md index ea0fe47..5712530 100644 --- a/interop-tests/AGENTS.md +++ b/interop-tests/AGENTS.md @@ -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. diff --git a/interop-tests/PLAN.md b/interop-tests/PLAN.md index d6b6004..bc2b212 100644 --- a/interop-tests/PLAN.md +++ b/interop-tests/PLAN.md @@ -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) | -| 2–10 | not started | — | +| 2–3 | not started | — | +| 4 | done | [04-kannel.md](findings/04-kannel.md) | +| 5–10 | 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 diff --git a/interop-tests/compose.kannel.yaml b/interop-tests/compose.kannel.yaml new file mode 100644 index 0000000..a385f51 --- /dev/null +++ b/interop-tests/compose.kannel.yaml @@ -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 diff --git a/interop-tests/findings/04-kannel.md b/interop-tests/findings/04-kannel.md new file mode 100644 index 0000000..71e26c7 --- /dev/null +++ b/interop-tests/findings/04-kannel.md @@ -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). diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts new file mode 100644 index 0000000..0d911d0 --- /dev/null +++ b/interop-tests/kannel.test.ts @@ -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 { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +/** Polls until `get()` stops returning undefined, or the budget runs out. */ +async function waitFor(get: () => T | undefined, budget = 5000): Promise { + 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(); +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(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; 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(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 { + 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): 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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/interop-tests/peers/kannel/Dockerfile b/interop-tests/peers/kannel/Dockerfile new file mode 100644 index 0000000..74a94d6 --- /dev/null +++ b/interop-tests/peers/kannel/Dockerfile @@ -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/ diff --git a/interop-tests/peers/kannel/iv33.conf b/interop-tests/peers/kannel/iv33.conf new file mode 100644 index 0000000..8418c82 --- /dev/null +++ b/interop-tests/peers/kannel/iv33.conf @@ -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 diff --git a/interop-tests/peers/kannel/main.conf b/interop-tests/peers/kannel/main.conf new file mode 100644 index 0000000..7b553da --- /dev/null +++ b/interop-tests/peers/kannel/main.conf @@ -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 diff --git a/interop-tests/peers/kannel/maxpending1.conf b/interop-tests/peers/kannel/maxpending1.conf new file mode 100644 index 0000000..e4b0f64 --- /dev/null +++ b/interop-tests/peers/kannel/maxpending1.conf @@ -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 diff --git a/interop-tests/peers/kannel/notransceiver.conf b/interop-tests/peers/kannel/notransceiver.conf new file mode 100644 index 0000000..f909387 --- /dev/null +++ b/interop-tests/peers/kannel/notransceiver.conf @@ -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