7.3 KiB
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 thesms-serviceget-urland todlr-urloccasionally lost the connect race under this sandbox's networking ("Socket not connected", no retry by default). Addedhttp-request-retry = 3/http-queue-delay = 1to everysmsboxgroup.- 1.4.5-12 refuses one
group = smscblock that sets bothportandreceive-port("deprecated" option combination) -notransceiver.conf's separate TX/RX bind needs twogroup = smscblocks sharing onesmsc-id, one withport, one withreceive-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%dis not one code per SMPP state. Kannel fires two HTTP callbacks per settled message:type=8off thesubmit_sm_respalone (before any receipt,answer=ACK/), then a final one. DELIVERED istype=1, ENROUTE istype=4, UNDELIVERABLE istype=2- but EXPIRED istype=34(32|2), its own bit, not folded into the generic failure code. A receiver keying only on1/2/4/8will misfile an expired message as unhandled.%P(destination) is smsbox's ownglobal-sender, not the message's real destination. With nomy-numberset on thesmscgroup, an MO's%Preports thesmsboxgroup'sglobal-sendervalue verbatim (here46700000000), not thedeliver_sm'sdestination_addr.%p(source) additionally gets a+prepended for an international-TON address even though the wire address carried none;%Pgets no such+.%a(MO text) is decoded for GSM but raw for UCS-2. For a GSM-coded MO,%ais the decoded human-readable string, safe to percent-decode as UTF-8. For UCS-2 (coding=2),%ais the raw big-endian UCS-2 bytes, percent-escaped byte-for-byte (一→%4E%00, a lone surrogate half →%D8%3Detc.) - not re-encoded as UTF-8 first. A receiver that runsURLSearchParams.get('text')(or any UTF-8-aware percent-decoder) on acoding=2callback 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 oncoding. (This tripped the test harness itself first -kannel.test.ts's MO receiver now does exactly this viamoText()/percentDecodeBytes().)- A
submit_smin the wrong direction getsgeneric_nack, not a mismatched*_resp. Callingsession.sendSms()on a link where Kannel is the ESME (submit_sm only flows ESME→SMSC) gets answered withgeneric_nackcarryingESME_RINVCMDID- not asubmit_sm_resp. Confirmed in the capture (frame withcommand_id 0x80000000immediately answering a0x00000004).session.sendSms()'s result surfaces this the same way it would asubmit_sm_resperror (result.err.messagematchesESME_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 carryid:/stat:text and still correlate to the rightsmsId.wait-ackexpiry disconnects and reconnects, it does not retry in place. Holding asubmit_smresponse past Kannel'swait-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 = 1is 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 eachsubmit_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:
- The MO-text UTF-8-vs-raw-UCS2 decoding gap described above (
moText/percentDecodeBytes). - The
max-pending-submits=1burst test originally deferred everysendResp()to the end of the test; under a window of 1, Kannel can't advance past the first unansweredsubmit_sm, so the burst never arrived. Fixed by answering eachsmsas it lands.
Open questions
- Whether
type=34for 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
%abehaviour also applies todlr-url's equivalent fields, or is MO-specific - not exercised here (this suite'sdlr-urlnever carries message text).