Add the interoperability harness and the smscsim experiment, which confirms the one-bad-PDU link drop

This commit is contained in:
2026-09-05 18:40:56 +02:00
parent 7d855cfa5d
commit 1723a0381c
7 changed files with 641 additions and 2 deletions
+133
View File
@@ -0,0 +1,133 @@
# 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).