Files

103 lines
7.3 KiB
Markdown

# 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).