Files
smpp-js/interop-tests/findings/01-smscsim.md
T

134 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 01 smscsim
Date: 2026-09-05. Repo commit: `7d855cf` (working tree, phase 0+1 changes uncommitted on top).
Host Docker: 29.6.2. Images: `ukarim/smscsim:0.2.0` (peer, both `smscsim` and `smscsim-failing`),
`nicolaka/netshoot:v0.16` (capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner,
from the root `compose.yaml`).
## Setup
Worked as designed: `interop-tests/run.py smscsim` brings up `smscsim`, `smscsim-failing` and
`capture` via `interop-tests/compose.smscsim.yaml`, waits on their healthchecks (`netstat -lnt |
grep -q :2775`, both images have a busybox shell), runs `interop-tests/smscsim.test.ts` in the
`node` service, stops the capture, decodes it with tshark, and tears down.
Two snags fixed while building the harness, both in `run.py`/the compose overlay, not the peer:
- `dumpcap`'s binary is mode `0750` root:root inside `nicolaka/netshoot:v0.16`, so the `capture`
service has to run as root (the default) rather than `1000:1000` - matching the "otherwise fix
ownership from run.py" fallback the brief anticipated. `run.py` chowns and chmods
`interop-tests/captures/` to `1000:1000`/`0777` through a throwaway container after every run.
- This sandbox's Docker does not give a root container DAC-override: it can create a new file in a
`1000:1000`-owned `0777` directory, but not overwrite an existing `1000:1000`-owned file there
(dumpcap's own file mode, `0600`, blocks it). `run.py` now unlinks the previous
`<peer>.pcapng` itself before every run, so `dumpcap` always creates a fresh file.
- The research notes and PLAN.md's knobs column say `FAILED_SUBMITS=1`; `main.go` actually checks
`"true" == os.Getenv("FAILED_SUBMITS")`, so `1` is silently ignored (never fails anything). The
compose overlay sets `FAILED_SUBMITS: "true"`.
Two runs of `./interop-tests/run.py smscsim`, back to back, both exit 0:
```
frames: 114
commands:
bind_receiver: 1 bind_receiver_resp: 1
bind_transceiver: 12 bind_transceiver_resp: 12
bind_transmitter: 1 bind_transmitter_resp: 1
deliver_sm: 20 deliver_sm_resp: 10
enquire_link: 6 enquire_link_resp: 6
submit_sm: 19 submit_sm_resp: 19
unbind: 3 unbind_resp: 3
malformed: 0
expert errors: 0
```
(identical both times). `bind_transceiver` is 12, not the 5 a reconnect-free run would show (C1's
one transceiver bind + single-SMS + GSM-multipart + UCS2-multipart + MO, one each) - the extra 7
are the client's own reconnects after the defect below tears the link down; `deliver_sm_resp` is
half of `deliver_sm` for the same reason (below).
## Scenarios
| Id (from PLAN.md) | Result | Evidence |
| --- | --- | --- |
| C1 (bind transceiver/transmitter/receiver, keepalive, clean unbind) | pass | `smscsim - C1 bind, keepalive, unbind`, all 3 bind types; no `sessionError`, one `close` each |
| smoke: single SMS + DLR | pass | `smscsim - a single SMS`; DLR `statusMsg` `DELIVERED`, `smsId` matches the `submit_sm_resp` id |
| smoke: 2-segment GSM long MT | pass | `smscsim - multipart segments a 2-segment GSM message…`; 2 ids, 2 DLRs (via retry - see defect) |
| smoke: 2-segment UCS2 long MT (一 + emoji) | pass | `smscsim - multipart segments a 2-segment UCS2 message…`; 2 ids, 2 DLRs (via retry) |
| smoke: MO injection via web UI | pass | `smscsim - MO injection…`; `sms.from`/`to`/`message` match the posted form, `sendResp()` clean |
| C12 (smscsim part: refusal + undeliverable DLR) | pass | `smscsim-failing - C12 refusals`; refused sends name `ESME_RSYSERR`, accepted ones' DLRs name `UNDELIVERABLE`; session stayed bound throughout (`enquire_link` answered after) |
Every scenario passed both runs, but the multipart, single-SMS and MO scenarios only pass because
they retry past the defect below (`DLR_MAX_ATTEMPTS = 20` in `smscsim.test.ts`) - see Defects.
## Defects in @larvit/smpp
### An out-of-range `deliver_sm` sequence_number drops the whole link, not just that PDU
**What happened.** `smscsim` signs every `deliver_sm` it sends unprompted - a delivery receipt or
an injected MO - with a raw `rand.Int()` truncated to `uint32` for `sequence_number`
(`smsc.go`'s `deliverSmPDU`, called from both `deliveryReceiptPDU` and `SendMoMessage`), so about
half the time the value is `>= 0x80000000`. `pdu.ts`'s `parseOnce` rejects that with `Invalid
seqNr, exceeds 2147483646: <n>`, and `pdu-transport.ts`'s `read()` routes *every* `pduToObj` error -
this one included - to `onUnreadable`, which `session.ts` wires to `sessionError` +
`teardown()`. `teardown()` destroys the socket outright; with the client's default `reconnect: true`
the session then reconnects (invisibly to the caller: `sendSms()` on a mid-reconnect session just
queues until the new link is bound), but the `deliver_sm` that triggered it - and its answer, since
none is ever sent - are gone. Confirmed live: binding, then sending a 2-segment message with `dlr:
true` against a real `smscsim`, printed `SESSION ERROR Invalid seqNr, exceeds 2147483646:
4085734660` for the second segment's receipt, no `dlr` event fired for it, and the capture showed
the peer's two `deliver_sm` PDUs answered by only one `deliver_sm_resp`.
**What the spec says.** SMPP 3.4 §4.7.1: `sequence_number` is `0x00000001` to `0x7FFFFFFF`; a
value outside it is certainly not a request this library ever intends to send and arguably not
one it must answer either. But target 1 in PLAN.md is exactly this shape: "`pdu-transport.ts`
routes every codec error... to the teardown a framing error takes, although `command_length` was
honoured and the stream is still in sync." Here `command_length` is honoured, the command is
`deliver_sm`, and only one 4-byte field is out of range - the spec gives no status for "sequence
number out of range" specifically, but continuing to read the stream and refusing just this PDU
(there is no `*_resp` to send back without a valid sequence number to answer with; a `generic_nack`
naming e.g. `ESME_RINVCMDID` would need a sequence number too, which is presumably part of why the
current code gives up on the whole link) would lose one receipt instead of the link.
**Reproducer.** A minimal `deliver_sm` with every field empty/zero except the header:
```
000000210000000500000000800000010000000000000000000000000000000000
```
(33 bytes: `command_length=0x21`, `command_id=0x00000005` deliver_sm, `command_status=0`,
`sequence_number=0x80000001`, then 17 zero bytes for `service_type`..`short_message` each
empty/0.) Feeding this to `pduToObj` (`src/pdu.ts`) returns `{ err: Error("Invalid seqNr, exceeds
2147483646: 2147483649") }`; feeding it to a live session's socket reproduces the teardown.
**Severity.** Medium-high against this peer specifically: roughly half of `smscsim`'s DLRs and MOs
are silently lost and bounce the link. Against a spec-conforming peer (small incrementing sequence
numbers) it never fires, so it is plausibly why the suite's own dummy peers never caught it - which
is the whole reason this experiment exists.
## Peer quirks
- No PDU validation (documented): a bad `interface_version` or malformed PDU is never rejected.
- `FAILED_SUBMITS` needs the literal string `true`; PLAN.md's research notes say `1`, which the
peer silently ignores (see Setup).
- DLR is always exactly `DELIVERED` (or, with `FAILED_SUBMITS=true`, `UNDELIVERABLE` on odd
sequence numbers) after a fixed ~2s; no other status is reachable.
- `FAILED_SUBMITS=true` refuses only `submit_sm`s whose *own* sequence number is even
(`ESME_RSYSERR`); it does not otherwise vary behaviour, and the DLR-triggering rule above applies
to every accepted submit regardless of parity.
- MO injection (the `12775` web page) always encodes the message as UCS2 (`data_coding=8`)
regardless of its content, and requires an already-bound session whose `system_id` matches the
form's `system_id` field exactly (`sender`, `recipient`, `message`, `system_id`, `POST /`,
`web.go`'s `webHandler`); the response is a `303` redirect to `/?message=...` (or `?error=...`).
- Message ids and `deliver_sm` sequence numbers are `rand.Int()`-derived per-process, not reset or
seeded per connection - not proven security-relevant here, but they are not unique across a
restarted container in the way a UUID would be.
## Open questions
- Whether the same out-of-range-sequence-number shape reaches other peers (Jasmin, SMPPSim) or is
particular to `smscsim`'s unconstrained `rand.Int()` - phase 2+ should watch for the same
`sessionError` text.
- Per PLAN.md's Order of work, this defect should get a regression test in `test/` and a fix before
phase 2 starts; both are out of scope for this experiment (`src/`/`test/` are read-only here).