Compare commits
162 Commits
v0.4.0
..
14b114afda
| Author | SHA1 | Date | |
|---|---|---|---|
| 14b114afda | |||
| 27e8f288ec | |||
| e1999a7305 | |||
| baa0ad553a | |||
| e32f9ccf46 | |||
| b3cbbba615 | |||
| 95e1f910a3 | |||
| 0f14ca85c2 | |||
| e8558aa471 | |||
| 773db43305 | |||
| 793839d0e0 | |||
| 68cd8e3f31 | |||
| 8315f1882a | |||
| 311ee7c677 | |||
| e3062066c1 | |||
| 7310634d7d | |||
| 549b3260c9 | |||
| 13e3ff8f1f | |||
| ca1a7473ed | |||
| 989eb01763 | |||
| ef13290230 | |||
| c06cc0648b | |||
| cced03767e | |||
| 7d6039796d | |||
| ff6ba9f825 | |||
| e80b07167c | |||
| bd466d393a | |||
| 9673af67b6 | |||
| 079d833ea8 | |||
| 7937534677 | |||
| 5a0cf56754 | |||
| 83176f53b0 | |||
| 5e53fad802 | |||
| a7f3ea70d7 | |||
| ab93833cee | |||
| ffda0e9c98 | |||
| 18de565aad | |||
| 061c1871bd | |||
| 30eedc81ee | |||
| 0a7c491e55 | |||
| 039951e69b | |||
| c89005168d | |||
| 9a312e5126 | |||
| ad094b3897 | |||
| d21df7c04b | |||
| c22dce53cf | |||
| bcbb042c1b | |||
| 0d1da1dc87 | |||
| 4194816d6b | |||
| 184d1dc7af | |||
| 5b7b563dc2 | |||
| 66b49ebfb3 | |||
| db02f0058d | |||
| afe188eecd | |||
| 45d2f5548e | |||
| ea42bc6d20 | |||
| fec4fda082 | |||
| 05ea90afeb | |||
| 9c4939f5cf | |||
| 1723a0381c | |||
| 7d855cfa5d | |||
| 8a565cf557 | |||
| 9ca83ac05c | |||
| 2e422c7584 | |||
| 80530c6265 | |||
| 8c46e925a1 | |||
| 4d91240e62 | |||
| 8717cedfb6 | |||
| 144567c294 | |||
| 54493c12b2 | |||
| 2936c7060d | |||
| e28b219a4f | |||
| fa40bb486c | |||
| 42e6e97447 | |||
| dd458bfa53 | |||
| 50679ec2fe | |||
| 94184a9c63 | |||
| 0d30864886 | |||
| 3eeb0b82c0 | |||
| 3aadb64df8 | |||
| 6cb186d38a | |||
| 66fac3768d | |||
| 8de65fd0e4 | |||
| f10c8e58cb | |||
| 0fd6da1542 | |||
| 471923a7b6 | |||
| e6250ef44e | |||
| c8d265d5fa | |||
| 82a95593bd | |||
| a91c55cc64 | |||
| 8a8d6faccf | |||
| f0327044b1 | |||
| 042a62be97 | |||
| 61985fb914 | |||
| 67c0402def | |||
| 1e7e113bd7 | |||
| 8becbc0ab6 | |||
| 3bcea108e3 | |||
| a99e1227ac | |||
| 2fe36fa4e8 | |||
| 2c0dfd172f | |||
| 58817eebbf | |||
| 35dd1e7678 | |||
| 8d9656b4e0 | |||
| bfc85ee4dd | |||
| 8e4d472f72 | |||
| b1c790b9a0 | |||
| b2d121b4f3 | |||
| 880454b4f4 | |||
| 18177053bc | |||
| 140464802b | |||
| 706b61d6ca | |||
| 696e018b77 | |||
| 8e5b9bb556 | |||
| 098891bb84 | |||
| 685495f534 | |||
| 427565ed3e | |||
| fa720e244a | |||
| 7e8af3c308 | |||
| 66b7d90bbf | |||
| 5c618e0061 | |||
| bebd74b42b | |||
| c06eda4582 | |||
| 5c797f5383 | |||
| aa3cf41c7f | |||
| 8a71dbbc87 | |||
| f4e55909a1 | |||
| 30e0cfd38a | |||
| 344249b080 | |||
| 0c55e5ee11 | |||
| a5fe43d7a9 | |||
| 0e8f298957 | |||
| 3c6195a938 | |||
| cf315737b7 | |||
| c9c3121d40 | |||
| b9f77ec969 | |||
| 4c08fe8fa3 | |||
| 9225f6ca23 | |||
| 3c06ba6e2f | |||
| f5fdbb3a53 | |||
| 366fa26b1c | |||
| 8d245c82c4 | |||
| ff3667e120 | |||
| 8a182604b7 | |||
| 884afdb87b | |||
| 5af75a3c57 | |||
| 435fa42708 | |||
| 6d842a8539 | |||
| 1e5807f647 | |||
| f0858aacf8 | |||
| 694c3a506e | |||
| 54b73205ac | |||
| b671ebafb2 | |||
| 2e4699944b | |||
| fd2f98464f | |||
| 1827ab5964 | |||
| 649e93cdde | |||
| db5dc8cd15 | |||
| 79c9df79c6 | |||
| 4299a8cc35 | |||
| 854c89f609 | |||
| 16c935bb18 |
@@ -0,0 +1,13 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = tab
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{yaml,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"es6": true,
|
|
||||||
"mocha": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"camelcase": [0],
|
|
||||||
"comma-spacing": [2, {"before": false, "after": true}],
|
|
||||||
"eol-last": [0],
|
|
||||||
"indent": ["error", "tab"],
|
|
||||||
"key-spacing": [0],
|
|
||||||
"no-mixed-requires": [0],
|
|
||||||
"no-multi-spaces": [0],
|
|
||||||
"no-process-exit": [0],
|
|
||||||
"no-shadow": [0],
|
|
||||||
"no-underscore-dangle": [0],
|
|
||||||
"no-unused-expressions": [2],
|
|
||||||
"no-unused-vars": [2],
|
|
||||||
"no-use-before-define": [0],
|
|
||||||
"no-var": ["error"],
|
|
||||||
"one-var": [2],
|
|
||||||
"quotes": [2, "single"],
|
|
||||||
"semi": [2, "always"],
|
|
||||||
"space-infix-ops": [2],
|
|
||||||
"space-unary-ops": [1, { "words": true, "nonwords": true }],
|
|
||||||
"strict": [2, "global"],
|
|
||||||
"vars-on-top": [2],
|
|
||||||
"space-before-function-paren": ["error", {
|
|
||||||
"anonymous": "always",
|
|
||||||
"named": "never",
|
|
||||||
"asyncArrow": "ignore"
|
|
||||||
}],
|
|
||||||
"keyword-spacing": ["error"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Mirror deletions
|
||||||
|
|
||||||
|
on:
|
||||||
|
delete:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# No concurrency group: Gitea cancels a queued run when the next one in its group arrives, dropping the delete.
|
||||||
|
delete:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Delete the ref from GitHub once Gitea no longer has it
|
||||||
|
env:
|
||||||
|
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
|
||||||
|
MIRROR_URL: https://github.com/larvit/smpp-js.git
|
||||||
|
REF: ${{ github.event.ref }}
|
||||||
|
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||||
|
run: |
|
||||||
|
# Gitea Actions sends the full ref name here, where its webhooks send the short one.
|
||||||
|
case "$REF" in
|
||||||
|
refs/heads/*|refs/tags/*) ;;
|
||||||
|
*) echo "delete event names '$REF', not a full branch or tag ref"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ls-remote --exit-code: 0 the ref exists, 2 it does not, anything else the remote could not be read.
|
||||||
|
on_gitea=0
|
||||||
|
git ls-remote --exit-code "$SOURCE_URL" "$REF" > /dev/null || on_gitea=$?
|
||||||
|
if [ "$on_gitea" -ne 2 ]; then exit "$on_gitea"; fi
|
||||||
|
|
||||||
|
on_github=0
|
||||||
|
git ls-remote --exit-code "$MIRROR_URL" "$REF" > /dev/null || on_github=$?
|
||||||
|
if [ "$on_github" -eq 2 ]; then exit 0; fi
|
||||||
|
if [ "$on_github" -ne 0 ]; then exit "$on_github"; fi
|
||||||
|
|
||||||
|
git init --bare --quiet mirror.git
|
||||||
|
git -C mirror.git -c credential.helper= \
|
||||||
|
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
|
||||||
|
push "$MIRROR_URL" ":$REF"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
# Gitea 1.26 rolls back a run whose jobs share a group, so the delete job lives in mirror-delete.yaml.
|
||||||
|
concurrency:
|
||||||
|
group: mirror
|
||||||
|
steps:
|
||||||
|
- name: Push every branch and tag to GitHub, overwriting a same-named ref
|
||||||
|
env:
|
||||||
|
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
|
||||||
|
MIRROR_URL: https://github.com/larvit/smpp-js.git
|
||||||
|
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||||
|
run: |
|
||||||
|
git clone --bare --quiet "$SOURCE_URL" mirror.git
|
||||||
|
git -C mirror.git -c credential.helper= \
|
||||||
|
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
|
||||||
|
push "$MIRROR_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@v7.0.0
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
node-version: 24.18.0
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
- run: npm ci
|
||||||
|
- name: The tag must match the version being published
|
||||||
|
run: |
|
||||||
|
tagged="${GITHUB_REF_NAME#v}"
|
||||||
|
packaged="$(node -p 'require("./package.json").version')"
|
||||||
|
test "$tagged" = "$packaged" || {
|
||||||
|
echo "tag $GITHUB_REF_NAME does not match package.json $packaged"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
|
- run: npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '43 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate against this repo
|
||||||
|
env:
|
||||||
|
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e GITHUB_COM_TOKEN \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||||
|
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||||
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
|
-e RENOVATE_TOKEN \
|
||||||
|
renovate/renovate:44.39.2
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@v7.0.0
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
node-version: 24.18.0
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# The floor in package.json engines, every LTS above it, and current.
|
||||||
|
node: ['18', '20', '22', '24', '26']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@v7.0.0
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
- run: npm ci
|
||||||
|
# Compiled rather than type-stripped: Node 18 and 20 cannot run TypeScript directly.
|
||||||
|
- run: npm run test:compiled
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
|
.claude
|
||||||
|
dist
|
||||||
|
dist-test
|
||||||
|
interop-tests/captures
|
||||||
node_modules
|
node_modules
|
||||||
coverage
|
|
||||||
.idea
|
|
||||||
.tags
|
|
||||||
tmp
|
|
||||||
|
|||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
language: node_js
|
|
||||||
|
|
||||||
node_js:
|
|
||||||
- 6
|
|
||||||
- 8
|
|
||||||
- 10
|
|
||||||
|
|
||||||
script: "npm run-script cover"
|
|
||||||
|
|
||||||
after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js"
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
email:
|
|
||||||
- lilleman@larvit.se
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Migrating from larvitsmpp 0.4.0
|
||||||
|
|
||||||
|
`@larvit/smpp` 0.5.0 succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
|
||||||
|
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
- **The package is `@larvit/smpp`** and ESM only. `require()` no longer works.
|
||||||
|
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and
|
||||||
|
`session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await
|
||||||
|
`close()`, or the socket outlives the call.
|
||||||
|
- **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and
|
||||||
|
a `session` event. It no longer calls back once per connection.
|
||||||
|
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
|
||||||
|
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
|
||||||
|
Assigning to it throws a `TypeError`, since modules are strict mode.
|
||||||
|
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
|
||||||
|
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
|
||||||
|
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
|
||||||
|
returns `false` or `{ userData }`.
|
||||||
|
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
|
||||||
|
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
|
||||||
|
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
|
||||||
|
codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
|
||||||
|
- **`pduObj.isResp()` is the standalone `isResp(pduObj)`.** `pduObj.cmdStatus` is `undefined` for
|
||||||
|
a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
|
||||||
|
- **`defs.filters` is gone.** It was declared on every command and TLV and never invoked. SMPP time
|
||||||
|
formatting, the one part worth keeping, is `smppTime`.
|
||||||
|
- **`DATAGRAM`, `FORWARD` and `STORE_FORWARD` moved from `consts.ESM_CLASS` to
|
||||||
|
`consts.MESSAGING_MODE`**, which also names `SMSC_DEFAULT`. They are bits 1-0 of `esm_class`, not
|
||||||
|
whole values of it. Read them from the new group, or pass `messagingMode` to `sendSms()`. A stale
|
||||||
|
`consts.ESM_CLASS.STORE_FORWARD` reads `undefined`, which OR-s into an `esm_class` carrying no mode.
|
||||||
|
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
|
||||||
|
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
|
||||||
|
a `larvitutils` one, and is silent by default: [README](README.md#logging).
|
||||||
|
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
|
||||||
|
3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00.
|
||||||
|
|
||||||
|
## Behaviour that changed on the wire
|
||||||
|
|
||||||
|
0.4.0 had protocol defects. Fixing them changes the bytes on the wire, so remove any workaround you
|
||||||
|
have for these:
|
||||||
|
|
||||||
|
- Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2
|
||||||
|
instead of 67), so long messages were split into more segments than necessary, each one billed.
|
||||||
|
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
|
||||||
|
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
|
||||||
|
defines the 7-character `UNDELIV`.
|
||||||
|
- Every receipt went out as `esm_class` 0x04, the report of a message's final state. A receipt for a
|
||||||
|
transient state, `sendDlr('ENROUTE')`, is now marked 0x20, the intermediate delivery notification.
|
||||||
|
- `flash: true` discarded UCS2, mangling flash messages with non-GSM characters, and put the GSM
|
||||||
|
alphabet on a Latin-1 message that has no `data_coding` at all; that pair is refused now. Inbound,
|
||||||
|
only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the whole 0xF0
|
||||||
|
coding group arrived as ordinary messages.
|
||||||
|
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5, so `$` and
|
||||||
|
`@` reached a peer honouring the field as STX and NUL. It goes out as 0x00, the SMSC default
|
||||||
|
alphabet, and so does a receipt `sendDlr()` writes. Latin-1 and UCS2 stay at 0x03 and 0x08, and an
|
||||||
|
inbound 0x01 is still read as GSM 03.38.
|
||||||
|
- The multipart reference counter was shared by every session in the process.
|
||||||
|
- `tls: true` never performed a handshake, so the connection was not encrypted.
|
||||||
|
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||||||
|
- Delivery receipts carrying only the standard receipt text, with no TLVs, what Kannel and several
|
||||||
|
other SMSCs send, were rejected outright. They are parsed now.
|
||||||
|
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` reported the
|
||||||
|
full length, so it went out corrupt. In UCS2 that is any message ending in a character like 一
|
||||||
|
(U+4E00), routine for CJK text.
|
||||||
|
- Every response carried a `message_id`, `deliver_sm_resp` included, where SMPP 3.4 4.6.2 makes that
|
||||||
|
field unused and NULL. Jasmin closes the connection on one. Answering an inbound message now puts
|
||||||
|
nothing in it, and `sms.smsId` is the local handle it always was.
|
||||||
|
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
|
||||||
|
a hex string and written back as the ASCII of that string, so every round trip corrupted them.
|
||||||
|
They are `Buffer`s in both directions now; drop any hex encoding of your own.
|
||||||
|
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
|
||||||
|
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
|
||||||
|
the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
|
||||||
|
- A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum`
|
||||||
|
TLVs rather than a user data header was never reassembled, so each segment arrived as its own
|
||||||
|
message. Both spellings reassemble now.
|
||||||
|
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
|
||||||
|
- A PDU whose optional parameters do not end exactly on `command_length` is refused with
|
||||||
|
`ESME_RINVTLVSTREAM` and dropped, where 0.4.0 kept the TLVs it had read and ignored the octets
|
||||||
|
left over, losing the `receipted_message_id` that makes a receipt a receipt. The refusal reaches
|
||||||
|
`sessionError` as a `PduRefusedError` with `reason` `tlvs`.
|
||||||
|
- Binds declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks
|
||||||
|
SMPP 3.3 or earlier, and a spec-following SMSC then withholds every optional parameter, the TLVs
|
||||||
|
delivery receipts are carried in included.
|
||||||
|
- A response reporting a failure carries no body, as the spec defines. 0.4.0 filled the body with
|
||||||
|
empty defaults, so a refused `submit_sm_resp` went out with an empty `message_id` a caller could
|
||||||
|
mistake for a real one.
|
||||||
|
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
|
||||||
|
|
||||||
|
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
|
||||||
|
independent implementation, in both directions and over a live session.
|
||||||
@@ -1,199 +1,651 @@
|
|||||||
[](https://travis-ci.org/larvit/larvitsmpp)
|
# @larvit/smpp
|
||||||
[](https://david-dm.org/larvit/larvitsmpp.svg)
|
|
||||||
[](https://coveralls.io/github/larvit/larvitsmpp)
|
|
||||||
|
|
||||||
# Larv IT SMPP
|
[](https://www.npmjs.com/package/@larvit/smpp)
|
||||||
|
|
||||||
This is a simplified implementation of the SMPP protocol.
|
SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send
|
||||||
|
window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
|
||||||
|
|
||||||
## Installation
|
- **Keepalive.** `enquire_link` every 20 s on a quiet link; a peer that stops answering is dropped.
|
||||||
|
- **Reconnect.** A dropped client link re-binds on its own, backing off from 1 s to 30 s.
|
||||||
|
- **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC.
|
||||||
|
- **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings.
|
||||||
|
- **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given.
|
||||||
|
- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess.
|
||||||
|
- **Never throws.** Every fallible call resolves to `{ err?, … }`.
|
||||||
|
- **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
|
||||||
|
jsmpp, Cloudhopper, python-smpplib and php-smpp:
|
||||||
|
[interop-tests/](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/interop-tests/README.md).
|
||||||
|
|
||||||
|
[Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) ·
|
||||||
|
[Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) ·
|
||||||
|
[Client options](#client-options) · [Server options](#server-options) ·
|
||||||
|
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
|
||||||
|
[Server in depth](#server-in-depth) · [Logging](#logging) ·
|
||||||
|
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
|
||||||
|
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install larvitsmpp
|
npm install @larvit/smpp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client
|
Node 18 or later. ESM only, types included.
|
||||||
|
|
||||||
### Simplest possible
|
## Send an SMS
|
||||||
|
|
||||||
This will setup a client that connects to localhost, port 2775 without username or password and send a message.
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const larvitsmpp = require('larvitsmpp');
|
import { client } from '@larvit/smpp';
|
||||||
|
|
||||||
larvitsmpp.client(function(err, clientSession) {
|
const { err, session } = await client();
|
||||||
clientSession.sendSms({
|
if (err) throw err;
|
||||||
'from': '46701113311',
|
|
||||||
'to': '46709771337',
|
|
||||||
'message': 'Hello world'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gracefully close connection
|
await session.sendSms({
|
||||||
clientSession.unbind();
|
from: '46701113311',
|
||||||
|
message: 'Hello world',
|
||||||
|
to: '46709771337',
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.unbind();
|
||||||
|
```
|
||||||
|
|
||||||
|
Without options this binds to `localhost:2775` as a transceiver with the default credentials. A real
|
||||||
|
SMSC needs `host`, `port`, `username` and `password`: [Client options](#client-options). A message
|
||||||
|
longer than one SMS is split and sent as one concatenated message: [Send options](#send-options).
|
||||||
|
|
||||||
|
## Delivery reports
|
||||||
|
|
||||||
|
Connection parameters, a receipt per segment, and logging:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Log } from '@larvit/log';
|
||||||
|
import { client } from '@larvit/smpp';
|
||||||
|
|
||||||
|
const log = new Log('debug');
|
||||||
|
|
||||||
|
const { err, session } = await client({
|
||||||
|
host: 'smpp.somewhere.com',
|
||||||
|
log,
|
||||||
|
password: 'bar',
|
||||||
|
port: 2775,
|
||||||
|
username: 'foo',
|
||||||
|
});
|
||||||
|
if (err) throw err;
|
||||||
|
|
||||||
|
session.on('dlr', dlr => {
|
||||||
|
// dlr.smsId, dlr.statusMsg, dlr.statusId
|
||||||
|
});
|
||||||
|
|
||||||
|
const { err: sendErr, smsIds } = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: '46701113311',
|
||||||
|
message: '«baff»',
|
||||||
|
to: '46709771337',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Some connection parameters and DLR
|
`dlr: true` asks the SMSC to report on each segment. Match `dlr.smsId` against the `smsIds` the send
|
||||||
|
returned. `statusMsg` is `DELIVERED`, `UNDELIVERABLE`, `EXPIRED` and so on; `intermediate` is true
|
||||||
|
for a report that is not final. The full shape, the `messageDlr` event that merges a long message's
|
||||||
|
receipts into one, and SMSCs that write ids in two notations: [Delivery receipts](#delivery-receipts).
|
||||||
|
|
||||||
This will setup a client that connects to given host, port with username and password, send a password and retrieve a DLR and with a custom log driver, compatible with winston.
|
## Receive SMS
|
||||||
|
|
||||||
|
A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const larvitsmpp = require('larvitsmpp');
|
session.on('sms', async sms => {
|
||||||
const LUtils = require('larvitutils');
|
// sms.from, sms.to, sms.message
|
||||||
const lUtils = new LUtils();
|
await sms.sendResp();
|
||||||
const log = new lUtils.Log('debug');
|
});
|
||||||
|
```
|
||||||
|
|
||||||
larvitsmpp.client({
|
Call `sendResp()` for every message; it is part of the protocol. Delivery receipts reach you as
|
||||||
'host': 'smpp.somewhere.com',
|
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by
|
||||||
'port': 2775,
|
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
|
||||||
'username': 'foo',
|
|
||||||
'password': 'bar',
|
|
||||||
'log': log
|
|
||||||
}, function(err, clientSession) {
|
|
||||||
if (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSession.sendSms({
|
## Run an SMPP server
|
||||||
'from': '46701113311',
|
|
||||||
'to': '46709771337',
|
```javascript
|
||||||
'message': '«baff»',
|
import { server } from '@larvit/smpp';
|
||||||
'dlr': true
|
|
||||||
}, function(err, smsId, retPduObj) {
|
const { err, server: smpp } = await server();
|
||||||
if (err) {
|
if (err) throw err;
|
||||||
throw err;
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
session.on('sms', async sms => {
|
||||||
|
// sms.from, sms.to, sms.message, sms.dlr
|
||||||
|
await sms.sendResp();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
With authentication and delivery reports:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { server } from '@larvit/smpp';
|
||||||
|
|
||||||
|
const { err, server: smpp } = await server({
|
||||||
|
// Replace with your own auth. Returning an object attaches it to session.userData.
|
||||||
|
authenticate: async ({ password, systemId }) => {
|
||||||
|
if (systemId !== 'foo' || password !== 'bar') return false;
|
||||||
|
|
||||||
|
return { userData: { userId: 123 } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (err) throw err;
|
||||||
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
session.on('sms', async sms => {
|
||||||
|
if (sms.answeredOnArrival) {
|
||||||
|
await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain
|
||||||
|
} else {
|
||||||
|
// no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' })
|
||||||
|
await sms.sendResp();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Return PDU object:');
|
if (sms.dlr) {
|
||||||
console.log(retPduObj);
|
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
clientSession.on('dlr', function(dlr, dlrPduObj) {
|
console.log(smpp.port); // the port actually bound, useful when 0 was requested
|
||||||
console.log('DLR received:');
|
await smpp.close(); // stop listening, then drain and close every live session
|
||||||
console.log(dlr);
|
```
|
||||||
|
|
||||||
console.log('DLR PDU object:');
|
- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id.
|
||||||
console.log(dlrPduObj);
|
`sendResp({ smsId, status })` names the id or refuses the message.
|
||||||
|
- `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`,
|
||||||
|
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
|
||||||
|
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
|
||||||
|
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
|
||||||
|
- `smpp.close()` stops listening, then drains and closes every live session.
|
||||||
|
|
||||||
// Gracefully close connection
|
## Errors
|
||||||
clientSession.unbind();
|
|
||||||
});
|
Nothing throws. Every fallible call returns a result with an optional `err`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { err, session } = await client({ host: 'smpp.somewhere.com' });
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
|
||||||
|
```
|
||||||
|
|
||||||
|
Failures on a live session arrive as `sessionError` events, on a server handle as `serverError`.
|
||||||
|
Neither is named `error`, because Node throws on an unhandled `error` event.
|
||||||
|
|
||||||
|
`sessionError` carries three kinds of failure:
|
||||||
|
|
||||||
|
| Kind | Type | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. |
|
||||||
|
| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. |
|
||||||
|
| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. |
|
||||||
|
|
||||||
|
The last two are told apart by message text only, so this alerts on both:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { PduRefusedError } from '@larvit/smpp';
|
||||||
|
|
||||||
|
session.on('sessionError', err => {
|
||||||
|
if (err instanceof PduRefusedError) {
|
||||||
|
log.warn('the peer sent a PDU that could not be read', {
|
||||||
|
cmdName: err.header.cmdName ?? err.header.cmdId,
|
||||||
|
reason: err.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error('a session failure or lost traffic', { message: err.message });
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server
|
- `reason` is `command`, `body` or `tlvs`: the part the codec stopped at.
|
||||||
|
- `header` is the 16 octets that did parse: `cmdId`, `cmdLength`, `cmdName`, `cmdStatusId` and
|
||||||
|
`seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type.
|
||||||
|
- A refused request is answered with the status SMPP names for it. A refused response is answered
|
||||||
|
with nothing, and settles the request it named as `unanswered`.
|
||||||
|
- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms`
|
||||||
|
or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()`
|
||||||
|
waiting on it and here.
|
||||||
|
- A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an
|
||||||
|
alphabet, a time, a body its `data_coding` cannot carry) is a plain `Error` in the call's result.
|
||||||
|
|
||||||
### Simplest possible
|
## Client options
|
||||||
|
|
||||||
This will setup a password less server on localhost, port 2775 and console.log() incomming commands.
|
All optional. Timeouts and delays are milliseconds.
|
||||||
|
|
||||||
|
| Option | Default | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host`, `port` | `localhost`, `2775` | Where to connect. |
|
||||||
|
| `username`, `password` | `user`, `pass` | Bind credentials: `system_id` and `password`. |
|
||||||
|
| `bindType` | `transceiver` | `transceiver`, `transmitter` or `receiver`. |
|
||||||
|
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
|
||||||
|
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
|
||||||
|
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
|
||||||
|
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
||||||
|
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
||||||
|
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
||||||
|
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. |
|
||||||
|
| `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. |
|
||||||
|
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). |
|
||||||
|
| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. |
|
||||||
|
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). |
|
||||||
|
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
|
||||||
|
|
||||||
|
**Reconnect.** After a drop, an idle timeout, or a stream the library cannot frame, the client
|
||||||
|
reopens the socket and re-binds, doubling the delay from `minDelay` (1 s) to `maxDelay` (30 s), and
|
||||||
|
starts over at `minDelay` once a link has lasted `maxDelay`.
|
||||||
|
|
||||||
|
`reconnect: { fromStart: true }` puts the first connect and bind through the same loop, a bind the
|
||||||
|
SMSC refuses included, so a client started while its SMSC is down keeps retrying. `client()` then
|
||||||
|
resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the session
|
||||||
|
once bound, so write a deadline as an `AbortController` you stop arming when `client()` returns,
|
||||||
|
not as `AbortSignal.timeout(ms)`.
|
||||||
|
|
||||||
|
## Server options
|
||||||
|
|
||||||
|
All optional. Timeouts are milliseconds.
|
||||||
|
|
||||||
|
| Option | Default | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. |
|
||||||
|
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
|
||||||
|
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). |
|
||||||
|
| `systemId` | `''` | The SMSC identity returned in the bind response. |
|
||||||
|
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. |
|
||||||
|
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
|
||||||
|
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
|
||||||
|
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
|
||||||
|
| `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
|
||||||
|
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||||||
|
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
||||||
|
|
||||||
|
## Send options
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const larvitsmpp = require('larvitsmpp');
|
await session.sendSms({
|
||||||
|
dlr: true, // ask for a delivery report
|
||||||
|
destinationAddrNpi: 0, // override the numbering plan of the recipient
|
||||||
|
destinationAddrTon: 1,
|
||||||
|
encoding: 'UCS2', // override the automatic choice
|
||||||
|
flash: false,
|
||||||
|
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
|
||||||
|
maxSegments: 10, // refuse a longer message instead of sending it
|
||||||
|
message: 'Hello world',
|
||||||
|
messagingMode: 'SMSC_DEFAULT', // or DATAGRAM or STORE_FORWARD
|
||||||
|
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
|
||||||
|
sourceAddrNpi: 0, // override the numbering plan of the sender
|
||||||
|
sourceAddrTon: 5,
|
||||||
|
to: '46709771337',
|
||||||
|
validityPeriod: 3600, // seconds, or a Date
|
||||||
|
}, { signal }); // optional per-call AbortSignal
|
||||||
|
```
|
||||||
|
|
||||||
larvitsmpp.server(function(err, serverSession) {
|
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
|
||||||
if (err) {
|
and 1 for a numeric one; the NPI fields default to 0.
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
serverSession.on('data', function(data) {
|
**Encoding.**
|
||||||
console.log('command: ' + data.command);
|
|
||||||
});
|
| `encoding` | Alphabet | Characters per SMS | Per segment of a long message |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ASCII` | GSM 03.38 7-bit | 160 | 153 |
|
||||||
|
| `LATIN1` | ISO 8859-1 | 140 | 134 |
|
||||||
|
| `UCS2` | UCS-2 | 70 | 67 |
|
||||||
|
|
||||||
|
- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named.
|
||||||
|
Any other name is refused.
|
||||||
|
- GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside
|
||||||
|
the basic multilingual plane in `UCS2`.
|
||||||
|
- An alphabet you name has to carry every character, or the send is refused before anything goes
|
||||||
|
out, naming the character, its code point and its index. Detection never refuses.
|
||||||
|
- `LATIN1` carries every octet, so `buffer.toString('latin1')` reaches the SMSC byte for byte, under
|
||||||
|
`data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `session.send()` a
|
||||||
|
`Buffer` body and the `data_coding` you want: [PDUs and the low-level API](#pdus-and-the-low-level-api).
|
||||||
|
- `consts.ENCODING` is the low-level `data_coding` table, not this option's list.
|
||||||
|
|
||||||
|
**Long messages.**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
|
||||||
|
```
|
||||||
|
|
||||||
|
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
|
||||||
|
SMSC took the segment without naming an id; some name one for the first segment only. No receipt
|
||||||
|
ever carries an empty id, so an unnamed entry matches nothing.
|
||||||
|
- `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together,
|
||||||
|
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
|
||||||
|
enough to resend the rest. Treat a partial failure as a failed message.
|
||||||
|
- `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
|
||||||
|
and lost only the response, so a message with `unanswered` above zero cannot be resent without
|
||||||
|
risking a duplicate.
|
||||||
|
- More than 255 segments is refused before anything is sent, since the concatenation header numbers
|
||||||
|
segments in one octet. `maxSegments` lowers that ceiling; most handsets and SMSCs stop well short.
|
||||||
|
|
||||||
|
**Flash.** `flash: true` asks for GSM 03.38 message class 0, shown on arrival instead of stored. It
|
||||||
|
travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. `flash` with
|
||||||
|
`encoding: 'LATIN1'` is refused: no `data_coding` carries both.
|
||||||
|
|
||||||
|
**Messaging mode.** `messagingMode` names the `esm_class` mode: `SMSC_DEFAULT`, which is what an
|
||||||
|
omitted option sends, `DATAGRAM` or `STORE_FORWARD`. Every segment of a long message also carries the
|
||||||
|
user data header indicator, so `STORE_FORWARD` on one sends `esm_class` 0x43. `DATAGRAM` with
|
||||||
|
`dlr: true` is refused, since datagram mode has no delivery reports. Transaction mode
|
||||||
|
(`consts.MESSAGING_MODE.FORWARD`) exists only on `data_sm`, which is never sent, and is refused too.
|
||||||
|
|
||||||
|
**Times.** `scheduleDeliveryTime` and `validityPeriod` take a `Date`, a number of seconds, or a stamp
|
||||||
|
you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infinity`, a negative
|
||||||
|
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
|
||||||
|
Name a later instant as a `Date`, which goes out absolute.
|
||||||
|
|
||||||
|
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string
|
||||||
|
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
|
||||||
|
formatted, passes through as written. The same rule holds for `session.send()`.
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
| Event | Fires when |
|
||||||
|
| --- | --- |
|
||||||
|
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. |
|
||||||
|
| `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). |
|
||||||
|
| `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). |
|
||||||
|
| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
|
||||||
|
| `disconnected` | The link dropped and the reconnect loop will retry. Do not open a replacement client: this session comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. |
|
||||||
|
| `reconnected` | The client re-bound after a drop. |
|
||||||
|
| `sessionError` | Something failed on a live session, a PDU the codec refused included: [Errors](#errors). |
|
||||||
|
| `data` | Raw bytes arrived on the socket. |
|
||||||
|
| `incomingPdu` | A complete PDU arrived, as a buffer. |
|
||||||
|
| `incomingPduObj` | The same PDU, parsed into an object. |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`.
|
||||||
|
|
||||||
|
**Shutdown.** `close()` and `unbind()` both:
|
||||||
|
|
||||||
|
1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after
|
||||||
|
`sendResp()`; await anything in between and it races the shutdown like any other send.
|
||||||
|
2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application
|
||||||
|
has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a
|
||||||
|
message answered on arrival, when it is called at all), or when every listener that took the
|
||||||
|
message has failed. Answering through `sendReturn()` instead leaves the wait running.
|
||||||
|
3. Tear down what is left, resolving to an `err` that says what was lost.
|
||||||
|
|
||||||
|
At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is
|
||||||
|
dropped with a warning on the log and waited for no longer. Neither bound is an option.
|
||||||
|
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||||||
|
`responseTimeout` for its own response.
|
||||||
|
|
||||||
|
**Sends and the link.**
|
||||||
|
|
||||||
|
- A send issued while the link is down waits for the reconnect and goes out once the new link is
|
||||||
|
bound, up to `responseTimeout`, after which it gives up having sent nothing.
|
||||||
|
- A request already on the wire when the link drops, when the peer fails to answer in time, or when
|
||||||
|
you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the
|
||||||
|
response.
|
||||||
|
- With `reconnect: false` a drop ends the session, and every send after it is refused.
|
||||||
|
- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response
|
||||||
|
carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new
|
||||||
|
link.
|
||||||
|
- `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait
|
||||||
|
for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass
|
||||||
|
`{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before
|
||||||
|
anything reached the socket adds nothing to `unanswered`. A message with more segments than slots
|
||||||
|
goes out a slot at a time, so a deadline that expires mid-message is how you get a partial failure.
|
||||||
|
- There is no throughput limit. An operator's rate limit is per account, across every process bound
|
||||||
|
to it, so enforce it outside this library. `ESME_RTHROTTLED` reaches you as a send's `err`.
|
||||||
|
|
||||||
|
**Raw commands.** `send()` reaches all 33 SMPP commands, not just the four the session handles itself:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { err, pduObj } = await session.send({
|
||||||
|
cmdName: 'query_sm',
|
||||||
|
params: { message_id: smsId },
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### With auth and custom logging, returning smsId and DLR
|
**The peer.**
|
||||||
|
|
||||||
Example code below:
|
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
|
||||||
|
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
|
||||||
|
a `send()` you build is passed through as written, so check it yourself.
|
||||||
|
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none.
|
||||||
|
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
|
||||||
|
|
||||||
|
## Receiving in depth
|
||||||
|
|
||||||
|
- **Multipart.** Segments tied together by a user data header, or by the `sar_msg_ref_num`,
|
||||||
|
`sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one `sms` alike. A PDU carrying
|
||||||
|
both is read from the header. The two reference numbers are separate counters: the same number in
|
||||||
|
each is two messages.
|
||||||
|
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||||||
|
[Server in depth](#server-in-depth).
|
||||||
|
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
|
||||||
|
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
|
||||||
|
and receipts included. A PDU filling both is read from `short_message`.
|
||||||
|
- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a
|
||||||
|
receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way
|
||||||
|
it is answered `data_sm_resp`.
|
||||||
|
- **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every
|
||||||
|
coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where
|
||||||
|
the handset stores the message and are not flash.
|
||||||
|
- **Binary.** A message whose `data_coding` says 8-bit binary arrives as Latin-1:
|
||||||
|
`Buffer.from(sms.message, 'latin1')` gives the original octets.
|
||||||
|
|
||||||
|
### Delivery receipts
|
||||||
|
|
||||||
|
Receipts travel on the same command as messages but reach you as `dlr`, one per segment. What marks
|
||||||
|
one: `esm_class`; where that names no type, a `receipted_message_id` TLV; failing both, `id:` and
|
||||||
|
`stat:` in the body. The body is read as text whatever `data_coding` the receipt declares, since
|
||||||
|
SMSCs commonly copy the reported message's onto it. An intermediate delivery notification is a report
|
||||||
|
too, never an inbound message.
|
||||||
|
|
||||||
|
| `Dlr` field | |
|
||||||
|
| --- | --- |
|
||||||
|
| `smsId` | The id reported on. `undefined` where the receipt carries no readable id. |
|
||||||
|
| `statusMsg` | `DELIVERED`, `UNDELIVERABLE`, `EXPIRED`, `REJECTED`, `DELETED`, `ACCEPTED`, `UNKNOWN`, `ENROUTE`, `SCHEDULED` or `SKIPPED`. `stat:FAILED`, which several operators write and SMPP does not define, reads as `UNDELIVERABLE`. |
|
||||||
|
| `statusId` | The numeric `message_state`. Where the peer sent one this library cannot name, its raw value, with `statusMsg` from the body or `UNKNOWN`. |
|
||||||
|
| `intermediate` | The report is not final: marked an intermediate notification, or reporting `ENROUTE` or `SCHEDULED`. |
|
||||||
|
| `receipt` | The receipt text parsed: `id`, `sub`, `dlvrd`, `submitDate`, `doneDate`, `stat` as the SMSC wrote it, `err` and `text`. |
|
||||||
|
| `doneDate`, `errorCode` | The `done date:` field as a `Date`, and the `err:` field. |
|
||||||
|
|
||||||
|
**Matching a receipt to a send** means comparing `dlr.smsId` with the `smsIds` from `sendSms()`.
|
||||||
|
Some SMSCs write the two in different notations, a hex `message_id` on the `submit_sm_resp` and a
|
||||||
|
decimal `id:` in the receipt, or one of them zero-padded, and the comparison then matches nothing.
|
||||||
|
Name each notation and both are read into plain decimal:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const larvitsmpp = require('larvitsmpp');
|
const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
|
||||||
const LUtils = require('larvitutils');
|
```
|
||||||
const lUtils = new LUtils();
|
|
||||||
const log = new lUtils.Log('debug');
|
|
||||||
|
|
||||||
// This should of course be replaced with your preferred auth system
|
`receipt` is the notation of the body's `id:`; `submitResp` that of the `message_id` in
|
||||||
function checkuserpass(username, password, cb) {
|
`submit_sm_resp` and of the `receipted_message_id` TLV, which carries that same id. An id that is not
|
||||||
if (username === 'foo' && password === 'bar') {
|
a number in the notation named is left as it arrived. The PDUs carry what the peer wrote either way:
|
||||||
// The last parameter is just user meta data that will be attached to the session as "userData" and is optional
|
`pduObjs` from the send, and the `dlr` event's second argument.
|
||||||
cb(null, true, {'username': 'foo', 'userId': 123});
|
|
||||||
} else {
|
**`messageDlr`** fires once every segment of a long message sent with `dlr: true` has a final report,
|
||||||
cb(null, false);
|
carrying the worst status of the segments and each of them under `segments`. An `intermediate`
|
||||||
}
|
report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
|
||||||
|
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
|
||||||
|
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
|
||||||
|
earlier one still collecting loses its merged report.
|
||||||
|
|
||||||
|
## Server in depth
|
||||||
|
|
||||||
|
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
|
||||||
|
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
|
||||||
|
numbers itself into no message this session can join, which refuses it, or the reassembly buffer is
|
||||||
|
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
|
||||||
|
you hold was answered that way; a segment count cannot, since a peer may number a message one part
|
||||||
|
of one.
|
||||||
|
|
||||||
|
- The id was fixed with the first segment, so `sendResp()` there only says you are done, and
|
||||||
|
returns `err` for an `smsId` or a refusing `status`.
|
||||||
|
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
|
||||||
|
`submit_sm` responses carried.
|
||||||
|
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
||||||
|
message's base is a handle of your own only.
|
||||||
|
|
||||||
|
**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown
|
||||||
|
recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on
|
||||||
|
every request a bound peer sends, before reassembly and before the `sms` event:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { isCommand, server } from '@larvit/smpp';
|
||||||
|
|
||||||
|
const knownRecipients = new Set(['46709771337']);
|
||||||
|
|
||||||
|
const { err } = await server({
|
||||||
|
onRequest: async (session, pduObj) => {
|
||||||
|
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (err) throw err;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Return `true`: the hook answered the PDU and the library leaves it alone. `false`: the built-in
|
||||||
|
handling runs.
|
||||||
|
- Every segment of a long message is a request of its own, so the hook sees each one.
|
||||||
|
- No bind reaches it, nor anything a peer sends before one: `server()` answers those and runs
|
||||||
|
`authenticate` itself.
|
||||||
|
- A hook that throws or rejects reaches `sessionError`, and nothing is written for that request;
|
||||||
|
the peer's own response timeout settles it. `authenticate` fails the same way, leaving the bind
|
||||||
|
unanswered.
|
||||||
|
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
|
||||||
|
the link. Guard on the command name, as above, and a failing hook costs only its own request.
|
||||||
|
- A `Session` you construct yourself takes the same hook as a session option, and that is where a
|
||||||
|
peer's bind gets accepted, since a hand-wired session has no bind handling of its own.
|
||||||
|
|
||||||
|
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||||||
|
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
|
||||||
|
notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
|
||||||
|
|
||||||
|
### Bind direction
|
||||||
|
|
||||||
|
The three bind types are honoured in both directions, whichever end of the link the session is:
|
||||||
|
|
||||||
|
- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
|
||||||
|
return `err` before anything reaches the wire.
|
||||||
|
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
|
||||||
|
is answered `ESME_RINVBNDSTS`.
|
||||||
|
- `data_sm` carries a message either way, so which end the session is decides: a client refuses one
|
||||||
|
on a transmitter bind, a `server()` session on a receiver bind. `bindAllows('data_sm')` answers for
|
||||||
|
the inbound direction. A `Session` you construct yourself is the ESME end, as `client()` builds;
|
||||||
|
a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
|
||||||
|
- `transceiver`, the default, carries both. `session.send()` is a passthrough and is not checked.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
`log` takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods, each
|
||||||
|
`(msg: string, metadata?: Record<string, boolean | number | string>) => void`. Message strings are
|
||||||
|
static; every dynamic value is in the metadata, so entries group by message.
|
||||||
|
|
||||||
|
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log) implements it as it stands:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Log } from '@larvit/log';
|
||||||
|
import { client } from '@larvit/smpp';
|
||||||
|
|
||||||
|
const { err, session } = await client({ log: new Log('debug') });
|
||||||
|
```
|
||||||
|
|
||||||
|
So does an object of your own:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const log = {
|
||||||
|
debug: () => undefined,
|
||||||
|
error: (msg, metadata) => { console.error(msg, metadata); },
|
||||||
|
info: (msg, metadata) => { console.info(msg, metadata); },
|
||||||
|
verbose: () => undefined,
|
||||||
|
warn: (msg, metadata) => { console.warn(msg, metadata); },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`SmppLog` is the type.
|
||||||
|
|
||||||
|
## PDUs and the low-level API
|
||||||
|
|
||||||
|
The codec is exported, synchronous, and never throws:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { isCommand, objToPdu, pduToObj } from '@larvit/smpp';
|
||||||
|
|
||||||
|
const { err, pduObj } = pduToObj(buffer);
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
if (isCommand(pduObj, 'submit_sm')) {
|
||||||
|
pduObj.params.destination_addr; // typed as a string
|
||||||
}
|
}
|
||||||
|
|
||||||
larvitsmpp.server({
|
|
||||||
'checkuserpass': checkuserpass,
|
|
||||||
'log': log
|
|
||||||
}, function(err, serverSession) {
|
|
||||||
if (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming SMS!
|
|
||||||
serverSession.on('sms', function(sms) {
|
|
||||||
// It is important to run the sms.resp() since this is a part of the protocol
|
|
||||||
sms.sendResp(
|
|
||||||
// Status code
|
|
||||||
// Default is ESME_ROK == no error
|
|
||||||
// See SMPP spec for all available status codes
|
|
||||||
// For example: ESME_RINVDSTADR == "Invalid destination address".
|
|
||||||
'ESME_ROK'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Oh, the sms sender wants a dlr (delivery report), send it!
|
|
||||||
if (sms.dlr === true) {
|
|
||||||
sms.sendDlr(); // Equalent to sms.sendDlr('DELIVERED');
|
|
||||||
|
|
||||||
// To send a negative delivery report for example do:
|
|
||||||
sms.sendDlr('UNDELIVERABLE');
|
|
||||||
// Possible values are:
|
|
||||||
// SCHEDULED
|
|
||||||
// ENROUTE
|
|
||||||
// DELIVERED <-- Default
|
|
||||||
// EXPIRED
|
|
||||||
// DELETED
|
|
||||||
// UNDELIVERABLE
|
|
||||||
// ACCEPTED
|
|
||||||
// UNKNOWN
|
|
||||||
// REJECTED
|
|
||||||
// SKIPPED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Events
|
**Reading.**
|
||||||
|
|
||||||
#### connect
|
- `params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
|
||||||
|
field as it arrived. Neither holds a body carried in the `message_payload` TLV, which a `data_sm`
|
||||||
|
always uses.
|
||||||
|
- `messageOctets(pduObj)` is the one answer to which of the two the peer used, undecoded.
|
||||||
|
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text
|
||||||
|
and hands back the UDH where the PDU carries one.
|
||||||
|
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
|
||||||
|
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
|
||||||
|
- `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
|
||||||
|
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
|
||||||
|
|
||||||
Triggered when the socket is connected to a client. This is server specific.
|
**Building.**
|
||||||
|
|
||||||
#### data
|
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
|
||||||
|
names, detected from the text where you name none. One that alphabet cannot carry is refused,
|
||||||
|
naming the character, its code point and where it is.
|
||||||
|
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
|
||||||
|
data headers, deliberately malformed bodies.
|
||||||
|
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
||||||
|
- `unencodable(message, encoding)`: `{ char, index }` for the first character an alphabet cannot
|
||||||
|
carry, `undefined` where it carries them all. The check `sendSms()` makes before encoding.
|
||||||
|
- `dataCodingByEncoding[encoding]`: the `data_coding` this library writes each alphabet under, which
|
||||||
|
is what to put beside octets from `encodeMessage()`.
|
||||||
|
- `smppTime.encode(value)` returns `{ err, text }` for a `validity_period` or
|
||||||
|
`schedule_delivery_time`; `smppTime.decode(text)` returns `{ err, date }`.
|
||||||
|
|
||||||
Triggered when data is comming in on the socket.
|
**Everything exported.**
|
||||||
|
|
||||||
#### close
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Sessions | `client`, `server`, `Session`, `SmppServer` |
|
||||||
|
| Codec | `pduToObj`, `objToPdu`, `pduReturn`, `isCommand`, `isResp`, `PduFramer`, `PduRefusedError`, `maxPduLength`, `maxSeqNr` |
|
||||||
|
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
|
||||||
|
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
||||||
|
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
|
||||||
|
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. |
|
||||||
|
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
|
||||||
|
|
||||||
Triggered when the socket is closed.
|
## Migrating from larvitsmpp 0.4.0
|
||||||
|
|
||||||
#### error
|
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
|
||||||
|
|
||||||
Generic error event.
|
## Development
|
||||||
|
|
||||||
#### sms
|
Everything runs in the container; nothing is installed on the host.
|
||||||
|
|
||||||
Incoming SMS.
|
```bash
|
||||||
|
docker compose run --rm node npm install
|
||||||
|
docker compose run --rm node npm test # lint, typecheck and tests
|
||||||
|
docker compose run --rm node npm run build
|
||||||
|
docker compose run --rm node npm run test:compiled # what CI runs on older Node versions
|
||||||
|
```
|
||||||
|
|
||||||
#### incomingPdu
|
Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the
|
||||||
|
development loop. CI compiles them and runs them on Node 18, every LTS above it, and current.
|
||||||
|
|
||||||
Incoming PDU.
|
## License
|
||||||
|
|
||||||
#### incomingPduObj
|
MIT
|
||||||
|
|
||||||
Incoming PDU Object. Same as incomingPdu, but it have been converted into an object instead of a buffer.
|
|
||||||
|
|
||||||
## Session commands
|
|
||||||
|
|
||||||
### send
|
|
||||||
|
|
||||||
Send a PDU to the remote.
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
node:
|
||||||
|
image: node:24.18.0-bookworm-slim
|
||||||
|
init: true
|
||||||
|
user: "1000:1000"
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
environment:
|
||||||
|
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist/', 'dist-test/'] },
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||||
|
'@typescript-eslint/no-floating-promises': ['error', {
|
||||||
|
allowForKnownSafeCalls: [
|
||||||
|
{ from: 'package', name: ['describe', 'it', 'test'], package: 'node:test' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'no-console': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
complexity: ['error', 10],
|
||||||
|
'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-lines-per-function': ['error', { max: 40, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-params': ['error', 5],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The spec tables are data: their length tracks the specification, not any complexity.
|
||||||
|
files: ['src/defs/*.ts'],
|
||||||
|
rules: { 'max-lines': 'off' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
|
||||||
|
files: ['src/dlr.ts'],
|
||||||
|
rules: { complexity: ['error', 19] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
|
||||||
|
files: ['src/defs/encodings.ts'],
|
||||||
|
rules: { 'no-control-regex': 'off' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['eslint.config.js'],
|
||||||
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
exports.server = require(__dirname + '/lib/server');
|
|
||||||
exports.client = require(__dirname + '/lib/client');
|
|
||||||
exports.utils = require(__dirname + '/lib/utils');
|
|
||||||
exports.defs = require(__dirname + '/lib/defs');
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# interop-tests
|
||||||
|
|
||||||
|
How the experiments in [README.md](README.md) are run and recorded. Read README.md first, and the
|
||||||
|
root [AGENTS.md](../AGENTS.md) for the conventions all code here follows.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `compose.<peer>.yaml` | Overlay on the root `compose.yaml`: the peer's services, a `capture` sidecar in the peer's network namespace, and `node` given `depends_on` the peer |
|
||||||
|
| `<peer>.test.ts` | `node:test` file driving our library against the peer, in the style of `test/`. Reads `PEER_HOST`/`PEER_PORT` from env, defaulting to the compose service name and 2775 |
|
||||||
|
| `peers/<peer>/` | Dockerfile and config for a peer without a published image, or one that needs config files |
|
||||||
|
| `captures/` | pcapng and tshark JSON from a run. Gitignored |
|
||||||
|
| `findings/<NN>-<peer>.md` | What a phase found. Committed after every phase |
|
||||||
|
| `run.py` | Brings a peer up, runs its tests, stops the capture, tears down, analyses the capture |
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./interop-tests/run.py <peer> # one peer, all its tests
|
||||||
|
./interop-tests/run.py <peer> --keep # leave the peer up for a manual look
|
||||||
|
```
|
||||||
|
|
||||||
|
`run.py` is the one spelling; it wraps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml run --rm --use-aliases node node --test interop-tests/<peer>.test.ts
|
||||||
|
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml stop capture
|
||||||
|
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
and then decodes `captures/<peer>.pcapng` with tshark (`-d tcp.port==<port>,smpp -Y smpp -T json`),
|
||||||
|
printing the command histogram and the counts of `_ws.malformed` and error-severity `_ws.expert`.
|
||||||
|
Both counts must be zero for a phase to pass, and an empty capture, or one carrying no bind and its
|
||||||
|
response, fails too.
|
||||||
|
|
||||||
|
A peer whose scenarios send malformed PDUs on purpose — jsmpp does, to prove they are refused — has
|
||||||
|
no way to say so, so its run exits non-zero every time and its findings file carries the count that
|
||||||
|
is expected. Give the runner an expected count per peer, and a deviation from it becomes the signal
|
||||||
|
that a bare threshold cannot be: today a third malformed frame appearing beside jsmpp's two
|
||||||
|
deliberate ones looks exactly like the two.
|
||||||
|
|
||||||
|
## Rules for an experiment
|
||||||
|
|
||||||
|
1. Peers run in Docker with full patch-version pins. Nothing is installed on the host. Node runs
|
||||||
|
only through the `node` service. Every peer service caps its logs (`logging: json-file`,
|
||||||
|
`max-size`/`max-file`) and healthchecks something the peer does not log a stack trace for —
|
||||||
|
SMPPSim once filled the whole host disk in twenty minutes from a TCP-probe healthcheck.
|
||||||
|
2. `src/` and `test/` are read-only during an experiment. A defect is recorded with a reproducer,
|
||||||
|
never fixed here — a fix is a separate change with a regression test in `test/`.
|
||||||
|
3. No git command that changes state: no add, commit, push, checkout, stash, reset. The
|
||||||
|
orchestrator commits after each phase.
|
||||||
|
4. A peer that will not come up is time-boxed: after about an hour of trying, record `blocked` with
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Findings file
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <NN> <peer>
|
||||||
|
|
||||||
|
Date, images and tags, the commit of this repo, host Docker version.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
Commands that worked, and what did not, so the next run starts where this one ended.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
| Id (from README.md) | Result (pass / fail / blocked / not run) | Evidence (test name, log line, tshark frame) |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
One subsection each: what happened, what the spec or the peer's docs say, reproducer (PDU hex or
|
||||||
|
test), severity.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
Behaviour of the peer worth knowing that is not our defect.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the file to README.md's findings table.
|
||||||
|
|
||||||
|
## Fixing what a phase found
|
||||||
|
|
||||||
|
Every defect a phase records is fixed before the next phase runs — a fix can change behaviour in
|
||||||
|
ways the next experiment must see. One fix per defect class, as its own change:
|
||||||
|
|
||||||
|
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
|
||||||
|
for the defect.
|
||||||
|
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
|
||||||
|
then the implementation; then the decision record in the root `AGENTS.md` where the fix settles
|
||||||
|
a question of the wire or the session's life.
|
||||||
|
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
|
||||||
|
ready, fast-forward it.
|
||||||
|
4. Back in the experiments worktree: fast-forward `main`, rerun the experiment that found the
|
||||||
|
defect, delete the workaround its test carried, and note the fix in the findings file.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# interop-tests
|
||||||
|
|
||||||
|
Eight real SMPP implementations, run against this library in both directions, with every session
|
||||||
|
decoded independently by tshark so no result rests on our own view of the wire. It exists because
|
||||||
|
the unit suite and this library's own dummy peers agree with themselves; these peers do not.
|
||||||
|
|
||||||
|
It ran between 2026-09-05 and 2026-09-08 and found twelve defects, all fixed. This file replaces
|
||||||
|
`PLAN.md`, which the findings below still cite by name.
|
||||||
|
|
||||||
|
[AGENTS.md](AGENTS.md) is how a run works: the layout, `run.py`, what the capture must show, and
|
||||||
|
the rules an experiment follows.
|
||||||
|
|
||||||
|
## What it found
|
||||||
|
|
||||||
|
| Peer | Findings |
|
||||||
|
| --- | --- |
|
||||||
|
| ukarim/smscsim | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
|
| SMPPSim | [02-smppsim.md](findings/02-smppsim.md) |
|
||||||
|
| Jasmin | [03-jasmin.md](findings/03-jasmin.md) |
|
||||||
|
| Kannel | [04-kannel.md](findings/04-kannel.md) |
|
||||||
|
| jsmpp, Cloudhopper | [05-java-clients.md](findings/05-java-clients.md) |
|
||||||
|
| python-smpplib, php-smpp | [06-python-php.md](findings/06-python-php.md) |
|
||||||
|
| smppload, smpp-dumb-client | [07-load.md](findings/07-load.md) |
|
||||||
|
| Operator documentation, as fixtures | [09-operator-fixtures.md](findings/09-operator-fixtures.md) |
|
||||||
|
|
||||||
|
Each records what the peer does on the wire, every defect with a reproducer, and the peer's own
|
||||||
|
quirks — several findings are the peer's bug, not ours, and say so.
|
||||||
|
|
||||||
|
## Peers
|
||||||
|
|
||||||
|
Our client binds to these:
|
||||||
|
|
||||||
|
| Peer | What it is for | Run |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Jasmin 0.11.0** | A production gateway with an independent codec, SAR segmentation, UUID ids, and a DLR pipeline that can use `data_sm` | `jookies/jasmin:0.11.0` + `redis:8.8.2-alpine` + `rabbitmq:3.13.7-management-alpine`, bootstrapped over `jcli` by `peers/jasmin/bootstrap.py` |
|
||||||
|
| **SMPPSim 2.6.11** | The richest fault injection available: per-state receipt percentages, delayed and intermediate receipts, queue-full, loopback, SMSC-initiated `outbind`, receipts with or without TLVs | Built from `kwahome/smpp-sim-docker`; nine `.props` variants under `peers/smppsim/` |
|
||||||
|
| **ukarim/smscsim 0.2.0** | Zero setup and MO injection from a web page — the smoke test that proves the harness | `ukarim/smscsim:0.2.0`. No PDU validation, so it proves nothing about strictness |
|
||||||
|
|
||||||
|
These bind to our server:
|
||||||
|
|
||||||
|
| Peer | What it is for | Run |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
|
||||||
|
| **jsmpp** | Strict and low-level: the driver builds UDH, `sar_*` and `message_payload` bytes by hand, and rejects an answer it dislikes | Maven build at a pinned commit, `peers/jsmpp/` |
|
||||||
|
| **Cloudhopper** | The one peer with real windowing knobs, plus a TLS client | Maven build at a pinned commit, `peers/cloudhopper/`. Its 2015-era TLS client cannot do 1.3, so that scenario caps the server at 1.2 |
|
||||||
|
| **python-smpplib 2.2.4** | An independent GSM 03.38 table to cross-check ours character by character | `python:3.12.14-slim-bookworm`, `peers/python/` |
|
||||||
|
| **php-smpp** | Three long-message spellings from one client, and separate transmitter and receiver binds | `php:8.4.25-cli` at a pinned commit, `peers/php/`. Its socket guard uses a check PHP 8 broke, so the image patches it |
|
||||||
|
| **smpp-dumb-client** | A genuinely enforced bounded window, which is what tests backpressure rather than raw rate | Go build at a pinned commit, `peers/dumbclient/` |
|
||||||
|
| **smppload** | Intended for throughput; its own `bind_transceiver` is two octets shorter than it declares, so it never binds. Kept as a live reproducer that our server refuses the stream rather than hanging | Erlang build, `peers/smppload/` |
|
||||||
|
|
||||||
|
## Scenario ids
|
||||||
|
|
||||||
|
The findings cite these. `fixture` means a raw-socket peer in our own suite, because no open
|
||||||
|
implementation emits that shape on demand.
|
||||||
|
|
||||||
|
| # | Scenario | Peers |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 | Bind each type, `enquire_link` both ways, `unbind` | all SMSC peers |
|
||||||
|
| C2 | Text-only receipts, no TLVs | SMPPSim |
|
||||||
|
| C3 | Receipts with `receipted_message_id` and `message_state` | Jasmin, SMPPSim |
|
||||||
|
| C4 | Intermediate then final receipt | SMPPSim |
|
||||||
|
| C5 | Failure states, and the worst segment winning a merge | SMPPSim |
|
||||||
|
| C6 | A receipt delayed past a link drop | SMPPSim |
|
||||||
|
| C7 | Long MT in GSM and UCS-2, 2, 3 and 10 segments | SMPPSim, Jasmin |
|
||||||
|
| C8 | Long MO as UDH 8-bit, UDH 16-bit, `sar_*` and `message_payload` | Jasmin, jsmpp, SMPPSim |
|
||||||
|
| C9 | MO or receipt on `data_sm` | Jasmin |
|
||||||
|
| C10 | Unknown command id, malformed and vendor TLVs | fixture, jsmpp |
|
||||||
|
| C11 | Bind refused, and the backoff that must not flood | Jasmin, SMPPSim, a closed port |
|
||||||
|
| C12 | Throttling and queue-full on submit | Jasmin, SMPPSim, smscsim |
|
||||||
|
| C13 | A slow SMSC and a full window | Jasmin, SMPPSim |
|
||||||
|
| C14 | TLS against a public certificate authority | not run — see [Untested](#untested) |
|
||||||
|
| C15 | `interfaceVersion` 0x50, and a peer answering 3.3 or nothing | SMPPSim, fixture |
|
||||||
|
| C16 | Receipt text variants from operator documentation | fixture |
|
||||||
|
| C17 | Encodings round trip | SMPPSim |
|
||||||
|
| C18 | `outbind` from the SMSC | SMPPSim |
|
||||||
|
| S1 | Kannel at "34" and at "33", submitting, receipts, MO | Kannel |
|
||||||
|
| S2 | Long messages in every spelling to our server | jsmpp, php-smpp, python-smpplib |
|
||||||
|
| S3 | Unhandled and unknown commands, and whether a strict client accepts our answers | jsmpp |
|
||||||
|
| S4 | Separate transmitter and receiver binds | php-smpp |
|
||||||
|
| S5 | Window pressure against a slow listener | Cloudhopper |
|
||||||
|
| S6 | A peer that never sends a keepalive | smpp-dumb-client |
|
||||||
|
| S7 | A production gateway as the ESME, parsing our receipts | Jasmin |
|
||||||
|
| S8 | Throughput, with receipts and long messages | smppload — blocked, see above |
|
||||||
|
| S9 | A bounded window under load | smpp-dumb-client |
|
||||||
|
| S10 | TLS from a Java client | Cloudhopper |
|
||||||
|
| S11 | Encodings from another implementation's encoder | python-smpplib |
|
||||||
|
|
||||||
|
## Untested
|
||||||
|
|
||||||
|
Three things this suite never exercised. None is a known defect; each is a claim resting on the
|
||||||
|
specification and on Node rather than on a peer having agreed.
|
||||||
|
|
||||||
|
- **A TLS handshake against a certificate a public authority signed.** Every TLS test here uses a
|
||||||
|
certificate generated for the test, so what is proven is that the handshake works and that a bad
|
||||||
|
certificate is refused. Verifying a real chain is Node's job and we pass `tls.ConnectionOptions`
|
||||||
|
through untouched, which is why this is a thin risk rather than none.
|
||||||
|
- **A peer that genuinely speaks SMPP 5.0.** `interfaceVersion: 0x50` is tested against peers that
|
||||||
|
answer 3.4 or answer nothing, so what 5.0 declares back is unobserved.
|
||||||
|
- **An SMSC written by someone who never sees this code.** Every peer here is open source and
|
||||||
|
configured by us. A closed commercial SMSC is the one thing a free suite cannot buy, and the first
|
||||||
|
operator integration is where that gets answered.
|
||||||
|
|
||||||
|
The research behind the peer choices and the operator quirks, one source URL per claim, is in
|
||||||
|
`research/`. Ask before trusting a claim there that a peer's own docs would settle.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import type { SmppServer } from '../src/server.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
const TLS_PORT = Number(process.env.TLS_PORT ?? '2776');
|
||||||
|
/** How long the "slow" server holds a submit_sm before answering it - long enough that a burst of
|
||||||
|
* concurrent submits genuinely queues behind a small window instead of finishing before it matters. */
|
||||||
|
const SLOW_DELAY_MS = 300;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriverResult = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
|
||||||
|
const url = `http://${CLOUDHOPPER_HOST}${path}?${new URLSearchParams(params).toString()}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
return response.json() as Promise<DriverResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualTexts = new Set<string>();
|
||||||
|
const allSms: { session: Session; sms: Sms }[] = [];
|
||||||
|
|
||||||
|
function attach(session: Session): void {
|
||||||
|
session.on('sms', sms => {
|
||||||
|
allSms.push({ session, sms });
|
||||||
|
|
||||||
|
if (manualTexts.has(sms.message)) return;
|
||||||
|
|
||||||
|
// The slow server this phase's window scenarios need: every ordinary submit is held for
|
||||||
|
// SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
|
||||||
|
void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||||||
|
|
||||||
|
assert.equal(serverErr, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
smpp.on('session', attach);
|
||||||
|
|
||||||
|
const key = readFileSync('/shared-certs/server.key');
|
||||||
|
const cert = readFileSync('/shared-certs/server.crt');
|
||||||
|
// Cloudhopper's SSL client (Netty 3.9.6.Final, from 2015) cannot complete a TLS 1.3 handshake - see
|
||||||
|
// findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is
|
||||||
|
// unrestricted.
|
||||||
|
const { err: tlsServerErr, server: tlsSmpp } = await server({
|
||||||
|
authenticate: () => true,
|
||||||
|
idleTimeout: 40_000,
|
||||||
|
port: TLS_PORT,
|
||||||
|
tls: { cert, key, maxVersion: 'TLSv1.2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tlsServerErr, undefined);
|
||||||
|
assert.ok(tlsSmpp);
|
||||||
|
tlsSmpp.on('session', attach);
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smpp.close();
|
||||||
|
await tlsSmpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForSessionCount(server_: SmppServer, count: number, budget = 15_000): Promise<Session[]> {
|
||||||
|
const found = await waitFor(() => ([...server_.sessions].length >= count ? [...server_.sessions] : undefined), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('S5 - window pressure against a slow sms handler (target 11)', () => {
|
||||||
|
for (const windowSize of [1, 10, 50]) {
|
||||||
|
test(`window ${String(windowSize)}: every request answered, none twice, order preserved`, async () => {
|
||||||
|
const session = `w${String(windowSize)}`;
|
||||||
|
const bind = await driver('/bind', { password: 'chpw', session, systemId: `ch-${session}`, windowSize: String(windowSize) });
|
||||||
|
|
||||||
|
assert.equal(bind.ok, true);
|
||||||
|
await waitForSessionCount(smpp, 1);
|
||||||
|
|
||||||
|
const count = windowSize === 50 ? 60 : windowSize * 3;
|
||||||
|
const burst = await driver('/windowBurst', {
|
||||||
|
count: String(count), prefix: session, session, timeoutMs: '30000',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(burst.ok, true);
|
||||||
|
const results = burst.results as { index: number; messageId?: string; ok: boolean }[];
|
||||||
|
|
||||||
|
assert.equal(results.length, count);
|
||||||
|
assert.ok(results.every(r => r.ok), `every submit answered: ${JSON.stringify(results.filter(r => !r.ok))}`);
|
||||||
|
|
||||||
|
const ids = results.map(r => r.messageId);
|
||||||
|
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'no message id answered twice');
|
||||||
|
assert.ok((burst.peakWindowSize as number) <= windowSize, `peak window ${String(burst.peakWindowSize)} stayed within ${String(windowSize)}`);
|
||||||
|
|
||||||
|
// "Order preserved" here means each response correlates to its own request rather than a
|
||||||
|
// different one - guaranteed by Cloudhopper's own sequence-number-keyed window, which is
|
||||||
|
// exactly why the per-index messageId uniqueness above is the meaningful assertion: each
|
||||||
|
// of the `count` concurrent callers blocks on its own submit() and gets its own answer,
|
||||||
|
// racing only on which of them the OS schedules onto the window's free slot(s) first.
|
||||||
|
const arrived = allSms.filter(entry => entry.sms.message.startsWith(`${session}-`)).length;
|
||||||
|
|
||||||
|
assert.equal(arrived, count);
|
||||||
|
|
||||||
|
await driver('/unbind', { session });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S5 - request expiry shorter than the handler delay (target 11)', () => {
|
||||||
|
test('the peer reports the expiry itself; our side is not left in a bad state', async () => {
|
||||||
|
const bind = await driver('/bind', {
|
||||||
|
password: 'chpw', requestExpiryTimeout: '100', session: 'expiry', systemId: 'ch-expiry',
|
||||||
|
windowMonitorInterval: '50', windowSize: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(bind.ok, true);
|
||||||
|
await waitForSessionCount(smpp, 1);
|
||||||
|
|
||||||
|
const text = 'expiry-probe';
|
||||||
|
|
||||||
|
manualTexts.add(text);
|
||||||
|
|
||||||
|
const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' });
|
||||||
|
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||||
|
|
||||||
|
assert.ok(sms);
|
||||||
|
|
||||||
|
// Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window
|
||||||
|
// monitor gives up on it first - recorded, not asserted against, since that is the peer's call.
|
||||||
|
await delay(1000);
|
||||||
|
await sms.sendResp();
|
||||||
|
|
||||||
|
const result = await submitted;
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
// Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException,
|
||||||
|
// not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are
|
||||||
|
// distinct mechanisms and this is the window monitor's own name for it.
|
||||||
|
assert.match(String(result.errorClass), /RecoverablePduException/);
|
||||||
|
|
||||||
|
const health = await driver('/health');
|
||||||
|
|
||||||
|
assert.equal(health.ok, true);
|
||||||
|
await driver('/unbind', { session: 'expiry' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S10 - Cloudhopper SSL client against our server({ tls })', () => {
|
||||||
|
test('handshake, bind, submit over TLS', async () => {
|
||||||
|
const bind = await driver('/bind', {
|
||||||
|
password: 'chsslpw', port: String(TLS_PORT), session: 'tls', systemId: 'ch-tls', useSsl: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(bind.ok, true);
|
||||||
|
await waitForSessionCount(tlsSmpp, 1);
|
||||||
|
|
||||||
|
const text = 'over-tls-phase5';
|
||||||
|
const result = await driver('/submit', { session: 'tls', text });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.commandStatus, 0);
|
||||||
|
|
||||||
|
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||||
|
|
||||||
|
assert.ok(sms);
|
||||||
|
await driver('/unbind', { session: 'tls' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a refusing status is surfaced back to Cloudhopper', () => {
|
||||||
|
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => {
|
||||||
|
const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' });
|
||||||
|
|
||||||
|
assert.equal(bind.ok, true);
|
||||||
|
await waitForSessionCount(smpp, 1);
|
||||||
|
|
||||||
|
const text = 'ch-refuse-me';
|
||||||
|
|
||||||
|
manualTexts.add(text);
|
||||||
|
|
||||||
|
const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' });
|
||||||
|
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||||
|
|
||||||
|
assert.ok(sms);
|
||||||
|
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
|
||||||
|
|
||||||
|
const result = await submitted;
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.commandStatus, 0x00000014);
|
||||||
|
await driver('/unbind', { session: 'refuse' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# The build-time self-signed cert cloudhopper's image trusts (S10) - copied out at container
|
||||||
|
# start so node's test file can load the same key/cert into server({ tls }). Never committed.
|
||||||
|
cloudhopper-tls:
|
||||||
|
|
||||||
|
services:
|
||||||
|
cloudhopper:
|
||||||
|
build: ./interop-tests/peers/cloudhopper
|
||||||
|
image: interop-cloudhopper:5.0.10-ae6485a
|
||||||
|
command: ["node", "2775"]
|
||||||
|
<<: *log-limits
|
||||||
|
volumes:
|
||||||
|
- cloudhopper-tls:/shared-certs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# cloudhopper only ever dials node on this container's own network namespace, so this sees exactly
|
||||||
|
# its side of every scenario below (same pattern as compose.kannel.yaml). Both the plain (2775) and
|
||||||
|
# TLS (2776) listeners node opens are on this one veth.
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:cloudhopper"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
cloudhopper:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775 or tcp port 2776", "-w", "/captures/cloudhopper.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
volumes:
|
||||||
|
- cloudhopper-tls:/shared-certs
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
cloudhopper:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
x-dumbclient-healthcheck: &dumbclient-healthcheck
|
||||||
|
test: ["CMD-SHELL", "test -f /tmp/healthy"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
|
||||||
|
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
|
||||||
|
# sharing one netns is only ever a source-IP detail, never a port collision.
|
||||||
|
dumbclient-w2000:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/window2000.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
|
||||||
|
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
|
||||||
|
# should ever be evicted - see findings/07-load.md.
|
||||||
|
dumbclient-w500:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
command: ["conf/window500.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
|
||||||
|
# no enquire_link either, which nothing else built for this phase can say (smppload is blocked).
|
||||||
|
dumbclient-idle:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
environment:
|
||||||
|
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
||||||
|
command: ["conf/idle.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||||
|
# grows without bound.
|
||||||
|
dumbclient-soak:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
command: ["conf/soak.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# Every dumbclient-* service shares dumbclient-w2000's netns (see above), so this one sidecar
|
||||||
|
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
|
||||||
|
# four bearerbox variants, one namespace deeper.
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
dumbclient-idle:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-soak:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-w500:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
# Bare TCP connect, no bytes written - Kannel's own healthcheck pattern (interop-tests/compose.kannel.yaml).
|
||||||
|
# Jasmin's SMPP codec, like SMPPSim's, stack-traces on a probe that writes anything to 2775, so this
|
||||||
|
# probes the HTTP API port instead, which answers (or ignores) a bare connect harmlessly.
|
||||||
|
x-jasmin-healthcheck: &jasmin-healthcheck
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/1401'"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 60
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
x-rabbit-healthcheck: &rabbit-healthcheck
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
retries: 60
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
x-jasmin-bootstrap-image: &jasmin-bootstrap-image
|
||||||
|
image: python:3.13.7-slim-bookworm
|
||||||
|
<<: *log-limits
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/peers/jasmin:/bootstrap:ro
|
||||||
|
command: ["python3", "-u", "/bootstrap/bootstrap.py"]
|
||||||
|
|
||||||
|
services:
|
||||||
|
jasmin-redis:
|
||||||
|
image: redis:8.8.2-alpine
|
||||||
|
<<: *log-limits
|
||||||
|
|
||||||
|
jasmin-rabbit:
|
||||||
|
# 0.11.0's txamqp client declares transient/non-exclusive queues, a feature RabbitMQ 4.x
|
||||||
|
# refuses by default ("transient_nonexcl_queues... not permitted anymore"), so jasmind's
|
||||||
|
# RouterPB/DLRThrower services fail to start against rabbitmq:4.3.5. Falling back to the 3.13
|
||||||
|
# series (confirmed against 3.13.7) starts clean - see findings/03-jasmin.md.
|
||||||
|
image: rabbitmq:3.13.7-management-alpine
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
|
healthcheck: *rabbit-healthcheck
|
||||||
|
|
||||||
|
jasmin:
|
||||||
|
image: jookies/jasmin:0.11.0
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
AMQP_BROKER_HOST: jasmin-rabbit
|
||||||
|
REDIS_CLIENT_HOST: jasmin-redis
|
||||||
|
depends_on:
|
||||||
|
jasmin-redis:
|
||||||
|
condition: service_started
|
||||||
|
jasmin-rabbit:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck: *jasmin-healthcheck
|
||||||
|
|
||||||
|
# group, users, the smppc connector to our own server() and the MT/MO routes - see
|
||||||
|
# interop-tests/peers/jasmin/bootstrap.py. Runs once and exits; node waits for it.
|
||||||
|
jasmin-bootstrap:
|
||||||
|
<<: *jasmin-bootstrap-image
|
||||||
|
environment:
|
||||||
|
JCLI_HOST: jasmin
|
||||||
|
depends_on:
|
||||||
|
jasmin:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# dlr-thrower's dlr_pdu is a [dlr-thrower] config-file setting, not a jcli/connector key (target
|
||||||
|
# 4 / C9) - a second instance with its own config is the only way to flip it without touching the
|
||||||
|
# main instance's receipts. Its own redis/rabbit rather than sharing the main pair: two unrelated
|
||||||
|
# Jasmin instances sharing a broker is exactly the entanglement CLAUDE.md's compose rule bans.
|
||||||
|
jasmin-datasm-redis:
|
||||||
|
image: redis:8.8.2-alpine
|
||||||
|
<<: *log-limits
|
||||||
|
|
||||||
|
jasmin-datasm-rabbit:
|
||||||
|
image: rabbitmq:3.13.7-management-alpine
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
|
healthcheck: *rabbit-healthcheck
|
||||||
|
|
||||||
|
jasmin-datasm:
|
||||||
|
image: jookies/jasmin:0.11.0
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
AMQP_BROKER_HOST: jasmin-datasm-rabbit
|
||||||
|
REDIS_CLIENT_HOST: jasmin-datasm-redis
|
||||||
|
volumes:
|
||||||
|
# Not bind-mounted straight over /etc/jasmin/jasmin.cfg: the entrypoint's `sed -i` renames a
|
||||||
|
# temp file over its target, which the kernel refuses for a bind-mounted path ("Device or
|
||||||
|
# resource busy"). Copying it into place first, over a normal writable file, sidesteps that.
|
||||||
|
- ./interop-tests/peers/jasmin/jasmin-datasm.cfg:/custom-cfg/jasmin.cfg:ro
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
cp /custom-cfg/jasmin.cfg /etc/jasmin/jasmin.cfg &&
|
||||||
|
exec /docker-entrypoint.sh jasmind.py --enable-interceptor-client --enable-dlr-thrower --enable-dlr-lookup -u jcliadmin -p jclipwd
|
||||||
|
depends_on:
|
||||||
|
jasmin-datasm-redis:
|
||||||
|
condition: service_started
|
||||||
|
jasmin-datasm-rabbit:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck: *jasmin-healthcheck
|
||||||
|
|
||||||
|
jasmin-datasm-bootstrap:
|
||||||
|
<<: *jasmin-bootstrap-image
|
||||||
|
environment:
|
||||||
|
CONNECTOR_CID: upstreamds
|
||||||
|
CONNECTOR_USERNAME: upstreamdsesme
|
||||||
|
JCLI_HOST: jasmin-datasm
|
||||||
|
depends_on:
|
||||||
|
jasmin-datasm:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:jasmin"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
jasmin:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jasmin.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
jasmin-bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
jasmin-datasm-bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
jsmpp:
|
||||||
|
build: ./interop-tests/peers/jsmpp
|
||||||
|
image: interop-jsmpp:3.0.3-a24db96
|
||||||
|
command: ["node", "2775"]
|
||||||
|
<<: *log-limits
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# jsmpp only ever dials node:2775 on this container's own network namespace, so this sees exactly
|
||||||
|
# its side of every scenario below (same pattern as compose.kannel.yaml).
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:jsmpp"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
jsmpp:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jsmpp.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
jsmpp:
|
||||||
|
condition: service_healthy
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
php:
|
||||||
|
build: ./interop-tests/peers/php
|
||||||
|
image: interop-php:8.4.25-1d3b53c
|
||||||
|
command: ["node", "2775"]
|
||||||
|
<<: *log-limits
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# php only ever dials node:2775 on this container's own network namespace, so this sees exactly
|
||||||
|
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:php"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
php:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/php.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
php:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
python:
|
||||||
|
build: ./interop-tests/peers/python
|
||||||
|
image: interop-python:2.2.4-3.12.14
|
||||||
|
command: ["node", "2775"]
|
||||||
|
<<: *log-limits
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# python only ever dials node:2775 on this container's own network namespace, so this sees exactly
|
||||||
|
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:python"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
python:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/python.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
python:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Blocked (findings/07-load.md): builds and connects, but its bind_transceiver goes out on the
|
||||||
|
# wire two bytes short - a corrupted command_length/command_id no SMSC can parse. Kept running
|
||||||
|
# here (rather than removed) so smppload.test.ts's own reproducer stays exercised.
|
||||||
|
smppload:
|
||||||
|
build: ./interop-tests/peers/smppload
|
||||||
|
image: interop-smppload:2.5.3-49fb653
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
SMPP_HOST: node
|
||||||
|
SMPP_PORT: "2775"
|
||||||
|
command: ["-H", "node", "-P", "2775", "-i", "sload-probe", "-p", "password", "-B", "trx", "-d", "15550001234", "-s", "15550005678", "-c", "1", "-r", "1", "-T", "1", "-l", "20", "--bind_timeout", "5000"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -f /tmp/healthy"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# smppload only ever dials node:2775 on this container's own network namespace, so this sees
|
||||||
|
# exactly its side of the exchange (same pattern as compose.kannel.yaml).
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppload"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppload:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppload.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
smppload:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
x-smppsim-healthcheck: &smppsim-healthcheck
|
||||||
|
# Not a raw TCP probe on 2775: SMPPSim reads an empty connection as a malformed PDU and logs a
|
||||||
|
# full stack trace per attempt: with DECODE_PDUS_IN_LOG and a 1s interval that fills the disk
|
||||||
|
# within tens of minutes (24GB+ across the 9 services in-house). The HTTP admin port answers a
|
||||||
|
# bare request harmlessly.
|
||||||
|
test: ["CMD-SHELL", "curl -sf -o /dev/null http://127.0.0.1:8884/"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# A second line of defence for the same runaway-logging risk: DECODE_PDUS_IN_LOG dumps every PDU,
|
||||||
|
# so a stuck reconnect loop or a bad probe fills the disk long before anyone notices.
|
||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
smppsim:
|
||||||
|
build:
|
||||||
|
context: interop-tests/peers/smppsim
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-textdlr:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-textdlr.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-transition:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-transition.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-undeliv:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-undeliv.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-rejected:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-rejected.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-accepted:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-accepted.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-delayed:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-delayed.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-queuefull:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-queuefull.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
# OUTBIND_ESME_IP_ADDRESS in smppsim-outbind.props names the node service by its --use-aliases
|
||||||
|
# hostname; our own server() in smppsim.test.ts listens on port 2776 for it to connect to.
|
||||||
|
smppsim-outbind:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-outbind.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppsim"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppsim:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim.pcapng"]
|
||||||
|
# dumpcap writes the pcapng section header as soon as it opens the interface, before any
|
||||||
|
# packet arrives, so a non-empty file means the capture is actually running.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -s /captures/smppsim.pcapng"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
# A second sidecar, cheap to add, so a textdlr-specific wire question can be checked without a
|
||||||
|
# second run. Not read by run.py's own pass/fail (that only decodes captures/smppsim.pcapng).
|
||||||
|
capture-textdlr:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppsim-textdlr"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppsim-textdlr:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim-textdlr.pcapng"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -s /captures/smppsim-textdlr.pcapng"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_healthy
|
||||||
|
capture-textdlr:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-textdlr:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-transition:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-undeliv:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-rejected:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-accepted:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-delayed:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-queuefull:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-outbind:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
smscsim:
|
||||||
|
image: ukarim/smscsim:0.2.0
|
||||||
|
environment:
|
||||||
|
SMSC_PORT: "2775"
|
||||||
|
WEB_PORT: "12775"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
smscsim-failing:
|
||||||
|
image: ukarim/smscsim:0.2.0
|
||||||
|
environment:
|
||||||
|
FAILED_SUBMITS: "true"
|
||||||
|
SMSC_PORT: "2775"
|
||||||
|
WEB_PORT: "12775"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smscsim"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smscsim:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smscsim.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
smscsim:
|
||||||
|
condition: service_healthy
|
||||||
|
smscsim-failing:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import type { LogMethod, SmppLog } from '../src/log.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog
|
||||||
|
* presses on the configured window instead of draining as fast as it fills - see findings/07-load.md. */
|
||||||
|
const SLOW_HANDLER_DELAY_MS = 2;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget: number): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(50);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioUserData = { systemId: string };
|
||||||
|
|
||||||
|
function isScenarioUserData(value: unknown): value is ScenarioUserData {
|
||||||
|
return typeof value === 'object' && value !== null && typeof (value as { systemId?: unknown }).systemId === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scenarioOf(session: Session): string {
|
||||||
|
return isScenarioUserData(session.userData) ? session.userData.systemId : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioStats = {
|
||||||
|
answerOrder: number[];
|
||||||
|
answered: number;
|
||||||
|
arrived: number;
|
||||||
|
// Set from a 'close' listener attached the moment the session is first seen (smpp.on('session')),
|
||||||
|
// never lazily inside a test body - a session can close well before a test gets around to
|
||||||
|
// watching for it (S9 above may run for a minute; idleTimeout is 40s), and an EventEmitter never
|
||||||
|
// replays an event to a listener added after it fired.
|
||||||
|
closed: boolean;
|
||||||
|
duplicateIds: number;
|
||||||
|
ids: Set<string>;
|
||||||
|
peakOutstanding: number;
|
||||||
|
unansweredErrors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = new Map<string, ScenarioStats>();
|
||||||
|
|
||||||
|
function statsFor(name: string): ScenarioStats {
|
||||||
|
const existing = stats.get(name);
|
||||||
|
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const created: ScenarioStats = {
|
||||||
|
answerOrder: [],
|
||||||
|
answered: 0,
|
||||||
|
arrived: 0,
|
||||||
|
closed: false,
|
||||||
|
duplicateIds: 0,
|
||||||
|
ids: new Set(),
|
||||||
|
peakOutstanding: 0,
|
||||||
|
unansweredErrors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.set(name, created);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogEntry = { level: string; message: string; metadata: Record<string, boolean | number | string> | undefined };
|
||||||
|
|
||||||
|
const logEntries: LogEntry[] = [];
|
||||||
|
|
||||||
|
function capture(level: string): LogMethod {
|
||||||
|
return (message, metadata) => { logEntries.push({ level, message, metadata }); };
|
||||||
|
}
|
||||||
|
|
||||||
|
const log: SmppLog = {
|
||||||
|
debug: capture('debug'),
|
||||||
|
error: capture('error'),
|
||||||
|
info: capture('info'),
|
||||||
|
verbose: capture('verbose'),
|
||||||
|
warn: capture('warn'),
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemSample = { heapUsed: number; rss: number; t: number };
|
||||||
|
|
||||||
|
const memSamples: MemSample[] = [];
|
||||||
|
const memTimer = setInterval(() => {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
|
||||||
|
memSamples.push({ heapUsed: usage.heapUsed, rss: usage.rss, t: Date.now() });
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
memTimer.unref();
|
||||||
|
|
||||||
|
const { err, server: smpp } = await server({
|
||||||
|
authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }),
|
||||||
|
idleTimeout: 40_000,
|
||||||
|
log,
|
||||||
|
port: SMPP_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
|
||||||
|
const serverErrors: Error[] = [];
|
||||||
|
|
||||||
|
smpp.on('serverError', serverError => { serverErrors.push(serverError); });
|
||||||
|
|
||||||
|
const sessionByScenario = new Map<string, Session>();
|
||||||
|
const slowQueues = new Map<Session, Promise<void>>();
|
||||||
|
|
||||||
|
function answered(session: Session, arrivalIndex: number, result: { err?: Error }): void {
|
||||||
|
const s = statsFor(scenarioOf(session));
|
||||||
|
|
||||||
|
s.answered++;
|
||||||
|
s.answerOrder.push(arrivalIndex);
|
||||||
|
if (result.err) s.unansweredErrors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void {
|
||||||
|
const chain = (slowQueues.get(session) ?? Promise.resolve())
|
||||||
|
.then(async () => { await delay(SLOW_HANDLER_DELAY_MS); })
|
||||||
|
.then(async () => { answered(session, arrivalIndex, await sms.sendResp()); });
|
||||||
|
|
||||||
|
slowQueues.set(session, chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void {
|
||||||
|
void sms.sendResp().then(result => { answered(session, arrivalIndex, result); });
|
||||||
|
}
|
||||||
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
// Attached now, not lazily in a test body - see the comment on ScenarioStats.closed.
|
||||||
|
session.on('close', () => { statsFor(scenarioOf(session)).closed = true; });
|
||||||
|
|
||||||
|
session.on('sms', sms => {
|
||||||
|
const name = scenarioOf(session);
|
||||||
|
|
||||||
|
sessionByScenario.set(name, session);
|
||||||
|
|
||||||
|
const s = statsFor(name);
|
||||||
|
const arrivalIndex = s.arrived;
|
||||||
|
|
||||||
|
s.arrived++;
|
||||||
|
if (s.ids.has(sms.smsId)) s.duplicateIds++;
|
||||||
|
else s.ids.add(sms.smsId);
|
||||||
|
s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered);
|
||||||
|
|
||||||
|
if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex);
|
||||||
|
else fastRespond(session, sms, arrivalIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function memShape(): string {
|
||||||
|
if (memSamples.length === 0) return 'no samples taken (run shorter than the 5s sample interval)';
|
||||||
|
|
||||||
|
const first = memSamples[0];
|
||||||
|
const last = memSamples[memSamples.length - 1];
|
||||||
|
|
||||||
|
assert.ok(first);
|
||||||
|
assert.ok(last);
|
||||||
|
|
||||||
|
const rssValues = memSamples.map(sample => sample.rss);
|
||||||
|
const peakRss = Math.max(...rssValues);
|
||||||
|
const minRss = Math.min(...rssValues);
|
||||||
|
const spanS = ((last.t - first.t) / 1000).toFixed(0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`samples=${String(memSamples.length)} over ${spanS}s`,
|
||||||
|
`rss first=${String(Math.round(first.rss / 1024 / 1024))}MiB`,
|
||||||
|
`min=${String(Math.round(minRss / 1024 / 1024))}MiB`,
|
||||||
|
`max=${String(Math.round(peakRss / 1024 / 1024))}MiB`,
|
||||||
|
`last=${String(Math.round(last.rss / 1024 / 1024))}MiB`,
|
||||||
|
`heapUsed last=${String(Math.round(last.heapUsed / 1024 / 1024))}MiB`,
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(values: number[]): boolean {
|
||||||
|
return values.every((value, index) => index === 0 || (values[index - 1] ?? 0) <= value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(line: string): void {
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
clearInterval(memTimer);
|
||||||
|
report(`memory shape: ${memShape()}`);
|
||||||
|
for (const [name, s] of stats) {
|
||||||
|
report(`${name}: arrived=${String(s.arrived)} answered=${String(s.answered)} duplicateIds=${String(s.duplicateIds)} peakOutstanding=${String(s.peakOutstanding)} unansweredErrors=${String(s.unansweredErrors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await smpp.close();
|
||||||
|
|
||||||
|
for (const serverError of serverErrors) report(`serverError: ${serverError.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
|
||||||
|
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
|
||||||
|
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for
|
||||||
|
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
|
||||||
|
// peer's window.
|
||||||
|
describe('S9 - bounded window against a slowed handler', () => {
|
||||||
|
for (const [name, expectedCount] of [['dumb-w500', 20_000], ['dumb-w2000', 20_000]] as const) {
|
||||||
|
test(`${name}: every message answered exactly once, ordering holds`, async () => {
|
||||||
|
const done = await waitFor(() => (statsFor(name).answered >= expectedCount ? true : undefined), 180_000);
|
||||||
|
|
||||||
|
assert.ok(done, `${name} did not answer ${String(expectedCount)} messages within budget`);
|
||||||
|
|
||||||
|
const s = statsFor(name);
|
||||||
|
|
||||||
|
assert.equal(s.arrived, expectedCount);
|
||||||
|
assert.equal(s.answered, expectedCount);
|
||||||
|
assert.equal(s.duplicateIds, 0);
|
||||||
|
assert.equal(s.unansweredErrors, 0);
|
||||||
|
assert.equal(s.ids.size, expectedCount);
|
||||||
|
assert.ok(isSorted(s.answerOrder), `${name} answered out of arrival order`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('window 2000 pressed past maxHeldMessages (1000): the internal held-message cap evicts, window500 never does', async () => {
|
||||||
|
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
|
||||||
|
|
||||||
|
const evictions = logEntries.filter(entry => entry.message === 'heldMessages - buffer full, dropping the oldest message');
|
||||||
|
|
||||||
|
// window500's peak (<=500) never reaches the 1000 default, so any eviction observed is
|
||||||
|
// necessarily from the w2000 session - the two runs share one server and one log.
|
||||||
|
assert.ok(evictions.length > 0, 'expected at least one held-message eviction under window 2000');
|
||||||
|
// The peer's own window, respected exactly both runs (peakOutstanding read 500 and 2000 on
|
||||||
|
// the nose) - the lower bound is what distinguishes this from window500's own eviction-free run.
|
||||||
|
assert.ok(statsFor('dumb-w2000').peakOutstanding > 1000 && statsFor('dumb-w2000').peakOutstanding <= 2000);
|
||||||
|
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memory after the backlog drains back down is close to before either window run started', async () => {
|
||||||
|
const before = memSamples[0];
|
||||||
|
|
||||||
|
assert.ok(before, 'no memory sample taken before the window runs started');
|
||||||
|
|
||||||
|
// Node's GC is opportunistic, so this is printed evidence of the shape (per findings/07-load.md),
|
||||||
|
// not a hard bound - a real leak reads as a trend across the whole run's samples, not one pair.
|
||||||
|
await delay(5000);
|
||||||
|
|
||||||
|
const after = process.memoryUsage();
|
||||||
|
|
||||||
|
report(
|
||||||
|
`memory around the window runs: before=${String(Math.round(before.rss / 1024 / 1024))}MiB `
|
||||||
|
+ `after=${String(Math.round(after.rss / 1024 / 1024))}MiB`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// S6 (target: idleTimeout) - a peer that sends one message and then, using the no-ping binary
|
||||||
|
// (Dockerfile), never speaks again: no enquire_link, ever. smppload was meant to be this peer and
|
||||||
|
// is blocked (findings/07-load.md), so this is the substitute.
|
||||||
|
describe('S6 - idle peer, no enquire_link at all', () => {
|
||||||
|
test('our server drops it at idleTimeout, with no response sent past the one it owed', async () => {
|
||||||
|
const bound = await waitFor(() => (statsFor('dumb-idle').arrived >= 1 ? true : undefined), 20_000);
|
||||||
|
|
||||||
|
assert.ok(bound, 'dumb-idle never submitted its one message');
|
||||||
|
|
||||||
|
// idleTimeout is 40s from the last byte the peer sent (its submit_sm), never from our own
|
||||||
|
// writes (link-timers.ts resets only on inbound data). This test may start running well
|
||||||
|
// past that mark on its own (S9 above can take a minute) - statsFor(...).closed is set from
|
||||||
|
// a 'close' listener attached at session-creation time, so a close from before this test
|
||||||
|
// even started is still seen; budget is slack for a session that is still open, not a clock.
|
||||||
|
const droppedIdle = await waitFor(() => (statsFor('dumb-idle').closed ? true : undefined), 60_000);
|
||||||
|
|
||||||
|
assert.ok(droppedIdle, 'server never dropped the idle peer within idleTimeout + slack');
|
||||||
|
assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer'));
|
||||||
|
// teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the
|
||||||
|
// wire for this session, which the capture's histogram (findings/07-load.md) confirms.
|
||||||
|
assert.equal(statsFor('dumb-idle').answered, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||||
|
// grows without bound (held messages, listeners, memory). Bounded by wall-clock rather than a
|
||||||
|
// target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load
|
||||||
|
// (findings/07-load.md, Peer quirks) well short of the configured count, on the client's side only
|
||||||
|
// - our own arrived/answered stay in lockstep throughout, which is what this asserts.
|
||||||
|
describe('Long soak', () => {
|
||||||
|
const SOAK_DURATION_MS = 300_000;
|
||||||
|
|
||||||
|
test('the longest run the time-box allows: every arrival answered, nothing duplicated, memory does not grow without bound', async () => {
|
||||||
|
await delay(SOAK_DURATION_MS);
|
||||||
|
|
||||||
|
// One more turn for a response mid-flight when the clock ran out to land, not a target count.
|
||||||
|
await waitFor(() => {
|
||||||
|
const s = statsFor('dumb-soak');
|
||||||
|
|
||||||
|
return s.arrived === s.answered ? true : undefined;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const s = statsFor('dumb-soak');
|
||||||
|
|
||||||
|
report(`soak reached: arrived=${String(s.arrived)} over ${String(SOAK_DURATION_MS / 1000)}s`);
|
||||||
|
|
||||||
|
assert.ok(s.arrived > 0, 'dumb-soak never submitted anything');
|
||||||
|
assert.equal(s.answered, s.arrived);
|
||||||
|
assert.equal(s.duplicateIds, 0);
|
||||||
|
assert.equal(s.unansweredErrors, 0);
|
||||||
|
|
||||||
|
const session = sessionByScenario.get('dumb-soak');
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const closed = await session.close();
|
||||||
|
|
||||||
|
assert.equal(closed.err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
**Fixed** in PR #79: only a framing error tears the link down now, any 32-bit `sequence_number` is
|
||||||
|
read and echoed, and `smscsim.test.ts`'s retry crutch is gone. A rerun of `./interop-tests/run.py
|
||||||
|
smscsim` shows 54 frames, `deliver_sm: 6` answered by `deliver_sm_resp: 6`, `bind_transceiver: 5`
|
||||||
|
(no reconnects), `malformed: 0`, `expert errors: 0`, 8/8 tests passing on their first attempt.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 02 smppsim
|
||||||
|
|
||||||
|
Date: 2026-09-05. Repo commit: `9c4939f`. Host Docker: 29.6.2. Images: `larvitsmpp-interop/smppsim:bc29982`
|
||||||
|
(built locally here from `kwahome/smpp-sim-docker` at commit `bc299828af9046ab290da3b4957dfbc472f02bfb`,
|
||||||
|
running SMPPSim 2.6.11 on `eclipse-temurin:8u452-b09-jre`; cloned during the build with
|
||||||
|
`debian:13.2-slim`, never vendored), `nicolaka/netshoot:v0.16` (capture sidecars and tshark),
|
||||||
|
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
`interop-tests/peers/smppsim/Dockerfile` clones the pinned commit in a build stage and copies just
|
||||||
|
`smppsim.jar`, `lib`, `www`, `mo` and `conf/logging.properties` into the runtime stage; each
|
||||||
|
`smppsim*.props` file under the same directory is copied in and selected via the container
|
||||||
|
`command`. `interop-tests/compose.smppsim.yaml` runs nine variants of the one image (`smppsim`,
|
||||||
|
`-textdlr`, `-transition`, `-undeliv`, `-rejected`, `-accepted`, `-delayed`, `-queuefull`,
|
||||||
|
`-outbind`) plus `capture` (on `smppsim`) and `capture-textdlr`, all on one network so
|
||||||
|
`./interop-tests/run.py smppsim` covers everything in one run.
|
||||||
|
|
||||||
|
Two snags fixed while building the harness:
|
||||||
|
|
||||||
|
- **The first healthcheck (`bash -c 'echo > /dev/tcp/127.0.0.1/2775'`, 1s interval) filled the
|
||||||
|
host's disk.** SMPPSim reads an empty connection as a malformed PDU and logs a full Java stack
|
||||||
|
trace per attempt; with `DECODE_PDUS_IN_LOG=true` and a 1s probe interval, nine containers left
|
||||||
|
running for ~20 minutes wrote 24GB+ of container logs between them and took the whole shared host
|
||||||
|
to 0 bytes free (`docker run` itself started failing with "no space left on device"). Fixed by
|
||||||
|
probing the HTTP admin port instead (`curl -sf -o /dev/null http://127.0.0.1:8884/`), which
|
||||||
|
SMPPSim answers harmlessly, plus a `json-file` log cap (`max-size: 20m`, `max-file: "3"`) on every
|
||||||
|
`smppsim*` service as a second line of defence. Worth knowing for anyone else pointing a 1s
|
||||||
|
TCP-connect healthcheck at this peer.
|
||||||
|
- The `capture` sidecar's `dumpcap` needs the same root/DAC-override handling phase 1 documented
|
||||||
|
(`interop-tests/captures/` chowned to `1000:1000`/`0777` by `run.py` after every run, stale
|
||||||
|
`<peer>.pcapng` unlinked before the next). Once, a capture attempt failed outright
|
||||||
|
("Permission denied" opening the pcapng) for a reason not pinned down - not reproduced since;
|
||||||
|
treat as a rare, unexplained flake in this harness rather than a peer issue.
|
||||||
|
- Two early runs showed `malformed: 2`, `expert errors: 2`. Both traced to this test file, not
|
||||||
|
SMPPSim: C17's raw-UDH test used IEI `0x01` ("Special SMS Message Indication"), a real GSM 03.40
|
||||||
|
information element with its own defined length (2 bytes), at length 1 - Wireshark's
|
||||||
|
`gsm_sms_ud` dissector correctly flags that as malformed. Fixed by using IEI `0x70`
|
||||||
|
(reserved-for-future-use, so no dissector validates its length) for what the test only ever
|
||||||
|
needed to be an arbitrary, unparsed UDH element.
|
||||||
|
|
||||||
|
Two runs of `./interop-tests/run.py smppsim`, back to back, after that fix: both exit 0, 25/25
|
||||||
|
tests passing, `malformed: 0`, `expert errors: 0` in both.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
| Id (from PLAN.md) | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C2 (`smppsim-textdlr`) | pass | `smppsim-textdlr - C2 text-only receipts`; no TLVs, `dlr.smsId` matches `submit_sm_resp` |
|
||||||
|
| C3 (`smppsim`, TLVs on) | pass | `smppsim - C3+C7 …`; `receipted_message_id`/`message_state` TLVs present and agree with the body on every segment |
|
||||||
|
| C4 (`smppsim-transition`) | fail (peer defect, see below) | `smppsim-transition - C4 …`; intermediate report arrives, no final ever does |
|
||||||
|
| C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) |
|
||||||
|
| C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link |
|
||||||
|
| C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] |
|
||||||
|
| C7 (loopback, UCS2 2-segment) | pass with a defect, since fixed | `… 2-segment UCS2 …`; TLVs and loopback reassembly both correct, receipt body unreadable at this commit - `@larvit/smpp` defect below |
|
||||||
|
| C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows |
|
||||||
|
| C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains |
|
||||||
|
| C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost |
|
||||||
|
| C15 (bind version) | pass (peer never declares, see below) | `smppsim - C15 …`, both 0x34 and 0x50 |
|
||||||
|
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (flash), raw UDH+8-bit-binary |
|
||||||
|
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
|
||||||
|
|
||||||
|
C17's 0xF0 sub-test originally read `not flash`, recording a `@larvit/smpp` defect: 0xF0 is GSM
|
||||||
|
03.38 message class 0, immediate display. Fixed in
|
||||||
|
[#95](https://github.com/larvit/larvitsmpp/pull/95), and the assertion inverted in `ff6ba9f`.
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
|
||||||
|
|
||||||
|
**What happened.** A message sent with `encoding: 'UCS2'` gets `data_coding: 8` on its `submit_sm`.
|
||||||
|
SMPPSim's delivery receipt for it (confirmed against source, `DeliveryReceipt` extends `DeliverSM`
|
||||||
|
by copy-constructing the original `SubmitSM`) inherits that same `data_coding: 8`, but its
|
||||||
|
`short_message` is always plain ASCII text (`id:… sub:… dlvrd:… stat:…`). `pdu.ts`'s parser decodes
|
||||||
|
any non-UDH PDU's `short_message` using its own `data_coding` unconditionally
|
||||||
|
(`params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message`), so
|
||||||
|
the ASCII receipt bytes are read back as UCS2 (byte pairs swapped) - `id:001 sub:…` becomes
|
||||||
|
unrecoverable CJK-range glyphs, and `dlr.receipt` (hence `.stat`, `.id`, every body field) is
|
||||||
|
garbage or `undefined` for every receipt whose triggering message used a non-ASCII encoding.
|
||||||
|
|
||||||
|
**What the spec says.** SMPP 3.4 5.2.25 defines `short_message`/`message_payload` generically, but
|
||||||
|
Appendix B's receipt format is specified as fixed ASCII fields; `data_coding` on the *receipt* PDU
|
||||||
|
describes nothing about how to read that text, since the MC is reporting on a message rather than
|
||||||
|
carrying one. Trusting `data_coding` to decode a receipt body is reading a field the spec never
|
||||||
|
attaches that meaning to. The `receipted_message_id`/`message_state` TLVs are unaffected (typed
|
||||||
|
fields, not text), which is why `dlr.statusMsg` and `dlr.smsId` stay correct here - only the
|
||||||
|
body-derived `dlr.receipt` (and anything relying on it, e.g. a peer without TLVs) is lost.
|
||||||
|
|
||||||
|
**Reproducer.** `session.sendSms({ encoding: 'UCS2', dlr: true, message: 'x', from, to })` against
|
||||||
|
`smppsim`, then inspect the `dlr` event's second argument (the raw `PduObject`):
|
||||||
|
`pduObj.params.data_coding === 8` and `dlr.receipt === undefined`, while
|
||||||
|
`pduObj.tlvs.receipted_message_id`/`message_state` are present and correct. Confirmed both against
|
||||||
|
SMPPSim and by constructing the PDU directly: a `deliver_sm` with `data_coding=8`, `esm_class=4`
|
||||||
|
and an ASCII `id:0 sub:001 dlvrd:001 …` body decodes to garbage text via `pduToObj`.
|
||||||
|
|
||||||
|
**Severity.** Medium: harmless when TLVs are present (as here), since `statusMsg`/`smsId` still
|
||||||
|
resolve correctly - but total for a peer that answers `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false`
|
||||||
|
(text-only, `smppsim-textdlr`'s own style) *and* accepts non-ASCII submits, where nothing would be
|
||||||
|
left to fall back on. Not reproduced against `smppsim-textdlr` here (that variant's own C2 test
|
||||||
|
only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see
|
||||||
|
Open questions.
|
||||||
|
|
||||||
|
**Fixed** in PR #81: a receipt body is read as the octets that arrived, never by its `data_coding`.
|
||||||
|
|
||||||
|
### `reconnect` never retries the very first connect or bind attempt
|
||||||
|
|
||||||
|
**What happened.** `client()` performs the socket connect and the initial bind directly, not
|
||||||
|
through the `ReconnectLoop`; on either failing (`ESME_RINVPASWD`, `ESME_RBINDFAIL`, connection
|
||||||
|
refused, …) it calls `session.close()` and returns `{ err }` with no session at all - the
|
||||||
|
`reconnect` option is never consulted. Confirmed with a wrong password and with a closed port
|
||||||
|
against `smppsim` (`smppsim - C11 …`, both "one attempt, no retry" sub-tests): each is exactly one
|
||||||
|
bind attempt, logged once, no matter how long the test then waits. `ReconnectLoop.schedule()` only
|
||||||
|
runs from `onClose()`/a failed `comeBackUp()` after a session has been up at least once - confirmed
|
||||||
|
separately by binding once, then mutating the same `options` object's `password` before dropping
|
||||||
|
the live link: the loop *does* retry with proper backoff then (`smppsim - C11 …, "a rebind refused
|
||||||
|
after a live link drops"`: 2-6 attempts over 12s, each gap ≥150ms).
|
||||||
|
|
||||||
|
**What the spec/README say.** Neither documents this boundary explicitly; `README.md`'s `reconnect`
|
||||||
|
entry reads as covering any drop uniformly. Given hard rule/goal 4 ("no bind flooding"), never
|
||||||
|
retrying a cold failure is arguably the safer default - a bad password retried forever would flood
|
||||||
|
exactly as much as this avoids it entirely - but it means a client started before its peer's
|
||||||
|
listener is up gets one shot and gives up for good, which a caller relying on `reconnect` for that
|
||||||
|
race would not expect.
|
||||||
|
|
||||||
|
**Reproducer.** `client({ host, password: 'wrong', reconnect: { minDelay: 1000, maxDelay: 4000 } })`
|
||||||
|
against `smppsim` (valid `system_id`, wrong `password`): resolves `{ err }` once, no `session`; a
|
||||||
|
`log.info` capture shows exactly one `'client - bind refused'` line even after a further 2s wait.
|
||||||
|
|
||||||
|
**Severity.** Low / informational - plausibly intentional, not previously written down anywhere
|
||||||
|
`git grep`-able in this repo.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **`registered_delivery` 0x11 (final + intermediate together) never produces a final receipt.**
|
||||||
|
SMPPSim's own user guide (v2.5 release notes) says "Set to 0x11 for both intermediate
|
||||||
|
notification and final delivery receipts." `LifeCycleManager.setState()` tests
|
||||||
|
`registered_delivery_flag == 1` or `== 2` by exact integer equality rather than a bitmask
|
||||||
|
(`InboundQueue.addMessageState()`'s own intermediate-notification check correctly uses
|
||||||
|
`(rd & 0x10) == 0x10`), so 17 (0x11) matches neither branch: the intermediate report (esm_class
|
||||||
|
0x20, `ENROUTE`) always arrives, a final one never does, however long you wait. Confirmed by
|
||||||
|
reading `LifeCycleManager.java`, `MessageState.java` and `OutboundQueue.java`, and empirically
|
||||||
|
with a 16s wait against `smppsim-transition`.
|
||||||
|
- **Two `submit_sm`'s on the same connection without a response in between - exactly how this
|
||||||
|
library always sends a multi-segment message's segments (`Promise.all`, never one after the
|
||||||
|
previous one's response, a documented, deliberate design choice, not something to change here) -
|
||||||
|
sometimes make SMPPSim lose one of the resulting receipts or loopback echoes entirely.** Never
|
||||||
|
seen as a malformed/corrupted PDU on the wire (both kept runs, and every other run once the
|
||||||
|
harness's own UDH bug above was fixed, show zero); the PDU that should have arrived just never
|
||||||
|
does. Confirmed by direct A/B: a script sending two segments concurrently (this library's own
|
||||||
|
shape) got only one of two receipts about half the time across several tries; the same script
|
||||||
|
sending two independent `submit_sm`'s sequentially, awaiting each response before the next, never
|
||||||
|
lost one in the same number of tries. `interop-tests/smppsim.test.ts`'s own multi-segment tests
|
||||||
|
work around it by resending a fresh message (up to 10 times) until every segment's receipt and,
|
||||||
|
where relevant, the loopback reassembly are all present in one attempt - see
|
||||||
|
`sendUntilAllDlrsArrive`/`sendUntilComplete`/`dlrLooksIntact` there. The mechanism is unconfirmed
|
||||||
|
(see Open questions).
|
||||||
|
- **`bind_resp` never carries `sc_interface_version`, whatever the ESME declared.** No
|
||||||
|
`BindTransceiverResp`/`BindReceiverResp`/`BindTransmitterResp` class sets that TLV (confirmed
|
||||||
|
against source). `@larvit/smpp`'s client therefore always records
|
||||||
|
`peerInterfaceVersion === 0x00` (undeclared) and `acceptsOptionalParams() === false` against this
|
||||||
|
peer, whether the client declared 0x34 or 0x50 - yet `smppsim` (with
|
||||||
|
`DELIVERY_RECEIPT_OPTIONAL_PARAMS=true`) still sends `receipted_message_id`/`message_state` TLVs
|
||||||
|
on every receipt regardless, because that gate reads the *client's own declared* version from the
|
||||||
|
bind PDU it received, not anything it echoes back. Confirmed for both 0x34 and 0x50 (`smppsim -
|
||||||
|
C15 …`).
|
||||||
|
- **`DelayedDrQueue`'s own poll loop is hardcoded to 5000ms** (`private static final int period =
|
||||||
|
5000;`, not a props knob - `DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD` is a different queue, for
|
||||||
|
redelivery after `ESME_RMSGQFUL`). `DELAY_DELIVERY_RECEIPTS_BY` is a floor, not the delay: a
|
||||||
|
receipt configured for an 8000ms delay can take up to ~13000ms in practice. Confirmed by direct
|
||||||
|
measurement (an 11s wait saw nothing; 16s did).
|
||||||
|
- **Message ids are decimal, a per-process global counter** (`message_id++`, not per-connection),
|
||||||
|
starting at 0 unless `START_MESSAGE_ID_AT`/`MESSAGE_ID_PREFIX` are set (neither is here) - matches
|
||||||
|
the research notes; `submit_sm_resp` and every receipt spelling (TLV and body `id:`) agree, so no
|
||||||
|
`smsIdFormat` was needed anywhere in this suite.
|
||||||
|
- **The receipt body's echoed-message field is `Text:` (capitalised, not `text:`)** and carries up
|
||||||
|
to the first 20 bytes of the original message when it is non-empty - never the empty `text:` some
|
||||||
|
other peers (documented: LINK) send. `@larvit/smpp`'s parsing is already case-insensitive, so this
|
||||||
|
needed no special handling.
|
||||||
|
- **`outbind()` closes the socket immediately after writing, without reading anything back.**
|
||||||
|
Confirmed against source (`Smsc.outbind()`: `out.write(...); out.flush(); out.close(); s.close();`
|
||||||
|
with no read in between) and on the wire (`smppsim-outbind - C18 …`): our server's
|
||||||
|
`incomingPduObj` sees the `outbind` (`system_id: smppclient1`), `onRequest` tries to answer
|
||||||
|
`ESME_RINVBNDSTS` and fails before writing anything (`outbind_resp` is not a defined command, so
|
||||||
|
`pduReturn` errors first) - `sessionError` fires with `"outbind" has no response command`, no PDU
|
||||||
|
reaches the wire, matching target 6. Whether our socket sees SMPPSim's own close land as a clean
|
||||||
|
`close` was observed but not asserted against, per the task ("record, do not judge").
|
||||||
|
- **`outbind` fires the moment SMPPSim has an MO to deliver and no receiver is bound** - not on a
|
||||||
|
timer. `InboundQueue.processQueue()`'s wait/notify wakes on `iq.addMessage(...)`, and moves
|
||||||
|
straight to `PENDING_QUEUE` + `outbind()` when `getReceiverBoundCount() == 0`; the MO Injection
|
||||||
|
endpoint (`GET /inject_mo?source_addr=…&destination_addr=…&short_message=…`, not `/inject_mo.htm`
|
||||||
|
as the docs might suggest - that path is the *form*, `/inject_mo` is the handler) works with zero
|
||||||
|
ESMEs ever bound, which is what let this test trigger `outbind` deterministically instead of
|
||||||
|
racing `DELIVERY_MESSAGES_PER_MINUTE` against container startup.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether the `data_coding`-decodes-the-receipt-body defect also loses receipts against a peer
|
||||||
|
answering `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false` (no TLV fallback) - `smppsim-textdlr`'s own
|
||||||
|
scenario here only exercises an ASCII message, so the compounding case (non-ASCII + no TLVs) is
|
||||||
|
untested.
|
||||||
|
- The exact mechanism behind the occasional lost receipt/loopback echo under concurrent
|
||||||
|
`submit_sm`'s. A plausible read of SMPPSim's threading (the connection-handler thread writing
|
||||||
|
`submit_sm_resp`s and loopback echoes, a separate `OutboundQueue`/`InboundQueue` thread writing
|
||||||
|
receipts, both against the same socket with no synchronisation found in
|
||||||
|
`StandardConnectionHandler`) would predict corruption, not a clean loss - but no malformed PDU
|
||||||
|
was ever observed for a genuine SMPPSim-authored frame in this suite, so that read is unconfirmed
|
||||||
|
and the actual mechanism (dropped at the SMPPSim side before it ever reaches the wire, versus
|
||||||
|
something on our side discarding a well-formed PDU) is still open.
|
||||||
|
- Phase 2's own instructions call for `smppsim-accepted`/`-rejected` as "similar single-state
|
||||||
|
variants... if cheap"; both were cheap and are included, alongside `-undeliv`.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# 03 jasmin
|
||||||
|
|
||||||
|
Date: 2026-09-06. Repo commit: `db02f00`. Host Docker: 29.6.2. Images: `jookies/jasmin:0.11.0`
|
||||||
|
(both directions), `redis:8.8.2-alpine`, `rabbitmq:3.13.7-management-alpine` (fallback - see
|
||||||
|
below), `python:3.13.7-slim-bookworm` (jcli bootstrap), `nicolaka/netshoot:v0.16` (capture),
|
||||||
|
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Two full Jasmin instances (`jasmin`, `jasmin-datasm`), each with its own `redis`/`rabbitmq` pair -
|
||||||
|
kept separate rather than shared, since two unrelated Jasmin instances sharing a broker is the
|
||||||
|
kind of proximity-only coupling the project avoids. `jasmin-datasm` mounts a custom `jasmin.cfg`
|
||||||
|
(`[dlr-thrower] dlr_pdu = data_sm`) for C9/target 4. Both run a `jcli` bootstrap
|
||||||
|
(`interop-tests/peers/jasmin/bootstrap.py`, a plain socket client - no telnet negotiation reply is
|
||||||
|
needed, the server proceeds regardless) that creates a group, two users (`esme1` for most
|
||||||
|
scenarios, `esme2` with a throttled `smpps_throughput` quota for C12), an `smppccm` connector
|
||||||
|
("upstream"/"upstreamds") pointing at our own `node` container, and `mtrouter`/`morouter`
|
||||||
|
`DefaultRoute`s wiring MT to that connector and MO back to `smpps(esme1)`. `interop-tests/jasmin.test.ts`
|
||||||
|
runs one persistent `server()` on `node:2777` as the fake real-world SMSC both connectors bind out
|
||||||
|
to, plus a tiny HTTP listener for Jasmin's DLR-thrower webhook (S7).
|
||||||
|
|
||||||
|
**RabbitMQ 4.3.5 does not work with Jasmin 0.11.0**: its `txamqp` client declares
|
||||||
|
transient/non-exclusive queues, a feature RabbitMQ 4.x refuses by default ("transient_nonexcl_queues
|
||||||
|
... not permitted anymore"), so `RouterPB`/`DLRThrower` fail to start. Fell back to the 3.13 series;
|
||||||
|
`rabbitmq:3.13.7-management-alpine` starts clean.
|
||||||
|
|
||||||
|
Snags fixed while building the harness, roughly in the order found:
|
||||||
|
- **jcli commands after `smppccm -a` need loud failure detection, not keyword-sniffing.** A failed
|
||||||
|
`ok` ("Failed adding connector, check log for details") doesn't contain any of "error"/"unknown"/
|
||||||
|
"invalid" - it left the bootstrap's own session stuck at the `>` sub-prompt, and every later
|
||||||
|
command was misread as a key inside it. Fixed by also checking the last reply lands back on the
|
||||||
|
top-level `jcli :` prompt.
|
||||||
|
- **The connector's password has an 8-character wire maximum.** SMPP's `password` is a C-octet-string
|
||||||
|
with an 8-char + NUL limit; Jasmin's own `smpp.pdu` encoder enforces it strictly when it builds the
|
||||||
|
connector's own bind PDU (our library's encoder is permissive and doesn't). A 10-char password
|
||||||
|
made every single connector bind throw mid-encode (`ValueError: COctetString is longer than
|
||||||
|
allowed maximum size (9)`), logged only in the connector's own per-CID log file
|
||||||
|
(`/var/log/jasmin/default-<cid>.log`), not in the main Jasmin process log.
|
||||||
|
- **`session.userData` is unset when the `session` event fires** (it fires on raw connect, before
|
||||||
|
`authenticate()` runs) - populating a map off it there silently never worked. Fixed like
|
||||||
|
`kannel.test.ts`'s `bindPdus`: read the bind PDU's own `system_id` from `incomingPduObj` instead,
|
||||||
|
and only read `userData` later, from `sms`, once authenticate() has long since run.
|
||||||
|
- **Jasmin's own MT dispatch to one connector is strictly serialized** (see the defect below) -
|
||||||
|
moving `C13`/`S7`'s HTTP-send test ahead of `C3+C7`'s multi-segment sends in file order (both need
|
||||||
|
the same connector's queue to be unstuck) turned three tests that always failed into two that
|
||||||
|
always pass.
|
||||||
|
- The `jasmin`/`jasmin-datasm` healthcheck probes the HTTP API port (1401) with a bare TCP connect,
|
||||||
|
no bytes written - Jasmin's SMPP codec, like SMPPSim's, is not something to probe with an empty
|
||||||
|
PDU. `rabbitmq` uses `rabbitmq-diagnostics -q ping`.
|
||||||
|
- Bootstrap and the whole `docker compose up` sequence are slow and variable on this host - jcli's
|
||||||
|
own connector/router commands (which round-trip through AMQP/redis, unlike the plain in-memory
|
||||||
|
group/user commands) sometimes took most of a minute each under contention; the harness itself
|
||||||
|
budgets for it (`run.py` runs past its own foreground tool timeout and is watched to completion),
|
||||||
|
not something to read as a Jasmin defect.
|
||||||
|
|
||||||
|
Three runs of `./interop-tests/run.py jasmin`, all after the fixes above: all exit non-zero (the
|
||||||
|
4 failing tests below), all `malformed: 0`, `expert errors: 0`. Wire commands, from the last run:
|
||||||
|
|
||||||
|
Re-run 2026-09-06, after every defect below was fixed and after `run.py` learned to refuse a capture
|
||||||
|
holding no frames: exit 0, 19 of 19 tests pass, 175 frames, `malformed: 0`, `expert errors: 0`. The
|
||||||
|
four multi-segment failures are gone with the deadlock, and the counts below are the state that
|
||||||
|
found the defects, kept because that is what the reproducers refer to.
|
||||||
|
|
||||||
|
```
|
||||||
|
bind_transceiver: 14, bind_transmitter: 1, bind_receiver: 6 (+ their _resp)
|
||||||
|
submit_sm: 45, submit_sm_resp: 43
|
||||||
|
deliver_sm: 15, deliver_sm_resp: 1
|
||||||
|
enquire_link: 7, enquire_link_resp: 7
|
||||||
|
unbind: 1, unbind_resp: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 | pass | `binds transceiver, sees Jasmin's own enquire_link, unbinds clean`; `binds transmitter`; `binds receiver` |
|
||||||
|
| C3+C7 (single-segment) | pass | `single-segment GSM with extension chars`: UUID id, `receipted_message_id`/`message_state` TLVs present, `DELIVERED` |
|
||||||
|
| C3+C7 (multi-segment) | **fail (peer/library interaction, see Defects)** | `2-segment`/`3-segment`/`10-segment GSM`, `2-segment UCS2`: every attempt across 3 runs times out waiting for a receipt |
|
||||||
|
| C8 target 3 (SAR) | pass | `SAR-segmented deliver_sm from the fake upstream`: reassembles into one whole `sms` |
|
||||||
|
| C8 target 3 (UDH) | pass | `UDH-segmented deliver_sm from the fake upstream`: reassembles into one whole `sms` |
|
||||||
|
| C8 target 2 (`message_payload`) | pass (defect confirmed) | `a deliver_sm carrying message_payload instead of short_message`: Jasmin accepts and relays it; our `sms` arrives with an empty message - see Defects |
|
||||||
|
| C9 target 4 (`data_sm` DLR) | pass (defect confirmed) | `a receipt thrown as data_sm is not read as a dlr`: the raw `data_sm` PDU is seen, no `dlr` event ever fires - see Defects |
|
||||||
|
| C11 | pass | `wrong password: one attempt, no retry` (`ESME_RINVPASWD`); `a rebind refused after a live link drops: backs off, never floods` (2-8 attempts, ≥150ms apart) |
|
||||||
|
| C12 | pass | `flooding submits past the quota`: `ESME_RTHROTTLED` from `esme2`'s `smpps_throughput 0.1` quota on some of 15 parallel sends; session stays bound; a later send succeeds once the next slot opens |
|
||||||
|
| C13 | pass | `every send is answered, none lost, order preserved`: 10 distinct ids, arrival order matches send order |
|
||||||
|
| S7 | pass | `a message pushed through /send arrives as submit_sm`: HTTP `/send` → Jasmin → connector → our `server()`; our `sendResp()`+`sendDlr('DELIVERED')` fires Jasmin's own DLR webhook, both the SMSC-ack (`ESME_ROK`) and terminal (`DELIVRD`) callbacks at `dlr-level=3`; long GSM and UCS-2 MO pushes from our server reassemble whole at Jasmin's connector |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### `message_payload` is never read (target 2) - confirmed against a real peer
|
||||||
|
|
||||||
|
**What happened.** Jasmin's connector accepts a `deliver_sm` with `sm_length` 0 and the text in a
|
||||||
|
`message_payload` TLV from our fake upstream SMSC without complaint, and relays it through
|
||||||
|
`morouter` to our real client bound to Jasmin's `smpps`. The `sms` event fires with the right
|
||||||
|
envelope (`from`/`to`) but `message` is the empty string - the actual text, which only ever existed
|
||||||
|
in `message_payload`, is lost. Matches the target exactly: `incoming-requests.ts` reads
|
||||||
|
`short_message` only.
|
||||||
|
|
||||||
|
**Reproducer.** `interop-tests/jasmin.test.ts`, `C8 (target 2)`: build a `deliver_sm` with
|
||||||
|
`short_message: Buffer.alloc(0)` and `tlvs: { message_payload: { tagValue: Buffer.from(text) } }`,
|
||||||
|
send it over the connector's session, and inspect the `sms` event at a client bound to Jasmin's
|
||||||
|
`smpps` - `sms.message === ''`.
|
||||||
|
|
||||||
|
**Severity.** Medium: silent data loss, not a wire error - the message is fully present on the wire
|
||||||
|
and Jasmin forwards it faithfully; only this library's read of it is incomplete.
|
||||||
|
|
||||||
|
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): the body is read from
|
||||||
|
`message_payload` where `short_message` carries none. The relay is confirmed on the wire - the
|
||||||
|
capture's one `sm_length` 0 `deliver_sm` is Jasmin's own, out of `smpps` to our client, carrying the
|
||||||
|
`0x0424` TLV intact. The reproducer's own fixture was wrong as well as the library: it built the TLV
|
||||||
|
as Latin-1 while the PDU declared `data_coding` 0, so `_` (GSM 03.38 0x11, Latin-1 0x5F) came back
|
||||||
|
as `§` even once the body was read. It now encodes the payload the way the PDU says it is written.
|
||||||
|
|
||||||
|
### `sar_*`/UDH segmentation from an upstream SMSC reassembles fine (target 3, MO direction) - not reproduced as a defect here
|
||||||
|
|
||||||
|
C8's SAR and UDH MO pushes both reassembled into one whole `sms` at our real client. This does not
|
||||||
|
confirm target 3 is fixed in general (our own reassembly still keys on UDH only - a SAR-tagged
|
||||||
|
`deliver_sm` from Jasmin's connector would still arrive as an unrelated fragment if Jasmin ever
|
||||||
|
sent SAR MO unprompted), it confirms only that pushing SAR/UDH-tagged `deliver_sm`s ourselves,
|
||||||
|
directly over the connector session, reassembles correctly on the way out through `smpps` - Jasmin
|
||||||
|
does not re-segment or otherwise disturb an already-short single PDU in transit.
|
||||||
|
|
||||||
|
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91), where a second peer did reproduce
|
||||||
|
it (`findings/05-java-clients.md`): reassembly now reads the `sar_*` TLVs as well as the UDH. C8's
|
||||||
|
SAR scenario no longer accepts fragments as an outcome - it asserts one whole `sms` and no segment
|
||||||
|
arriving on its own - and a rerun is 19/19 with no malformed frame and no expert error.
|
||||||
|
|
||||||
|
### `data_sm` is refused (target 4) - confirmed against a real peer
|
||||||
|
|
||||||
|
**What happened.** With `jasmin-datasm`'s `[dlr-thrower] dlr_pdu = data_sm`, a receipt requested via
|
||||||
|
`sendSms({ dlr: true, ... })` throws as a `data_sm` PDU on the client's bind, exactly as documented.
|
||||||
|
Our client's `incomingPduObj` sees it arrive; no `dlr` event ever fires. The receipt is lost -
|
||||||
|
`data_sm` falls to the generic unhandled-command path (`ESME_RINVCMDID`), matching the target.
|
||||||
|
|
||||||
|
**Reproducer.** `interop-tests/jasmin.test.ts`, `C9`: bind to `jasmin-datasm`, `sendSms({ dlr: true,
|
||||||
|
... })`, assert an incoming `data_sm` PDU arrives and no `dlr` event ever fires.
|
||||||
|
|
||||||
|
**Severity.** Medium: a real, documented Jasmin configuration (`dlr_pdu = data_sm`) that a receipt
|
||||||
|
depends on silently drops delivery reports, with no error surfaced to the application either.
|
||||||
|
|
||||||
|
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): `data_sm` is classified and
|
||||||
|
answered exactly as `deliver_sm` is, and the receipt reaches `dlr` naming the id the `submit_sm_resp`
|
||||||
|
carried, `DELIVERED`. The wire histogram still shows no `data_sm`: `capture` runs
|
||||||
|
`network_mode: service:jasmin`, so it only ever sees the main instance's namespace, never
|
||||||
|
`jasmin-datasm`'s.
|
||||||
|
|
||||||
|
### A multi-part MT send deadlocks against Jasmin's serialized per-connector relay - a library/peer interaction, not a wire defect
|
||||||
|
|
||||||
|
**What happened.** A 2-, 3-, 10-segment GSM or UCS-2 message sent through Jasmin's `smpps` (our real
|
||||||
|
client → Jasmin → `mtrouter` → the `upstream` connector → our fake upstream) never gets a receipt,
|
||||||
|
in every one of 3 runs, regardless of segment count or encoding - only the always-unsegmented
|
||||||
|
single-segment case succeeds. Jasmin's own `messages.log` shows why: `SubmitSmPDU[...] request
|
||||||
|
timed out through [cid:upstream], message requeued` after its 120s response timeout, and nothing
|
||||||
|
for the later segments at all (they stay queued behind the stuck one). The mechanism, confirmed by
|
||||||
|
dumping every `submit_sm` this library's `server()` received as Jasmin's fake upstream: segment 1
|
||||||
|
arrives intact (`esm_class: 64`, a correct `05 00 03 <ref> <total> <seq>` UDH, matching exactly what
|
||||||
|
`splitMessage()` itself would produce - Jasmin relays the UDH byte-for-byte) - but segment 2 never
|
||||||
|
arrives. `src/incoming-requests.ts`'s `onMessage()` answers nothing for an incomplete concatenation
|
||||||
|
group (`if (whole) this.emitSms(whole);` - a partial group falls through silently); `sms.sendResp()`
|
||||||
|
only exists once the whole group is in hand, so it answers all segments of a group atomically, in
|
||||||
|
one call, after the last one arrives. Jasmin, on its side, dispatches to a given connector one
|
||||||
|
`submit_sm` at a time, waiting for that one's response before sending the next queued message for
|
||||||
|
the same connector (confirmed indirectly: reordering the test file so `C13`'s 10 *independent*
|
||||||
|
single-segment sends and `S7`'s HTTP-send run *before* any multi-segment send turned both from
|
||||||
|
always-failing into always-passing at sub-second speed, while running them *after* a stuck
|
||||||
|
multi-segment send left them queued behind it for the same reason). Two designs that are each
|
||||||
|
individually reasonable - Jasmin never advances its connector queue past an unanswered request;
|
||||||
|
this library never answers part of a concatenated message - deadlock when composed: Jasmin will not
|
||||||
|
send segment 2 until segment 1 is acked, and this library will not ack segment 1 until segment 2
|
||||||
|
arrives.
|
||||||
|
|
||||||
|
Ruled out before landing on this: RabbitMQ/host CPU contention (real, but the failure is
|
||||||
|
deterministic across three runs regardless of load - not a timing flake); the connector's own
|
||||||
|
`submit_throughput` default of 1 msg/s (set to `0`, no change); the fake upstream's `idleTimeout`
|
||||||
|
dropping the connector mid-flight (widened to 300s, no change - the connector stayed bound
|
||||||
|
throughout, confirmed in its own log).
|
||||||
|
|
||||||
|
**Reproducer.** `interop-tests/jasmin.test.ts`, `C3+C7`, any multi-segment case, run after
|
||||||
|
`waitForUpstreamSession('main')`: `session.sendSms({ dlr: true, message: 'g'.repeat(200), from,
|
||||||
|
to })` against Jasmin's `smpps`, with a `server()` bound as the fake upstream that only calls
|
||||||
|
`sms.sendResp()`/`sendDlr()` once its own `'sms'` event fires. No receipt arrives within Jasmin's
|
||||||
|
own 120s response timeout; `messages.log` on the peer shows the requeue.
|
||||||
|
|
||||||
|
**Severity.** Medium, narrow: needs a real relaying gateway whose own dispatch is serialized
|
||||||
|
per-outbound-link, which is exactly Jasmin's `smppc` connector shape - a directly-connected SMSC
|
||||||
|
(every other peer in this suite) never exhibits it, since it always has a response ready for every
|
||||||
|
segment as they arrive rather than being a relay itself. Worth a decision record either way (answer
|
||||||
|
each segment as it's held, not only once the group completes; or document that a `server()` sitting
|
||||||
|
behind a serializing relay needs its own segment-level ack), but not fixed here per the phase rules.
|
||||||
|
|
||||||
|
**Fixed** in [#83](https://github.com/larvit/larvitsmpp/pull/83): every segment is answered as it
|
||||||
|
arrives, with `<base>-<n>` off an id the group is opened with. All four multi-segment cases pass, in
|
||||||
|
200-360 ms each, malformed 0 and expert errors 0.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **Jasmin's own `enquireLinkTimerSecs` (30, `[smpp-server]` default) is an idle timer, not a strict
|
||||||
|
period.** Our client's own default 20s keepalive counts as activity and resets it, so Jasmin's own
|
||||||
|
probe never gets a chance to fire on a link that's never quiet for 30s. `C1` disables our own
|
||||||
|
keepalive (`enquireLinkInterval: 0`, widening `idleTimeout` to compensate) to observe it.
|
||||||
|
- **Message ids are UUIDs, self-consistent end to end when the upstream hands out one id per PDU.**
|
||||||
|
Every id the real client's `submit_sm_resp` carried matched exactly what the fake upstream's
|
||||||
|
`sendResp()` assigned; the later receipt named the same id. Since the fake upstream here is this
|
||||||
|
library's own `server()`, a multi-segment message's ids came back in this library's own
|
||||||
|
`<base>-<n>` shape - an artifact of the test rig (our own server minting one base per group),
|
||||||
|
not evidence that Jasmin itself produces that convention; a real upstream SMSC would very likely
|
||||||
|
hand back unrelated ids per segment, as the research notes expected.
|
||||||
|
- **`smpps_throughput`, not the connector's `submit_throughput`, gates an ESME's own submission
|
||||||
|
rate.** The plan named `submit_throughput` "on the connector" for C12; that setting throttles the
|
||||||
|
connector's own *outbound* rate to its upstream. The knob that actually throttles submissions
|
||||||
|
*into* Jasmin's `smpps` from a bound ESME is a **user**-level quota
|
||||||
|
(`user -u <uid> mt_messaging_cred quota smpps_throughput <n>`), which does answer `ESME_RTHROTTLED`
|
||||||
|
reliably once exceeded.
|
||||||
|
- **`smppccm`'s `cid` must be 3-25 chars, `morouter`'s `smpps(<system_id>)` target is validated by
|
||||||
|
the same regex** (`[A-Za-z0-9_-]{3,25}`) - confirmed from `jasmin/protocols/cli/morouterm.py`.
|
||||||
|
- **`[dlr-thrower] dlr_pdu`** is a config-file setting (`deliver_sm` default, `data_sm` the other
|
||||||
|
option), not a per-connector or per-user key - the only way to test both is two Jasmin instances.
|
||||||
|
- **HTTP DLR callbacks carry fixed query args** (`id`, `level`, `message_status`, `connector`, plus
|
||||||
|
`id_smsc`/`sub`/`dlvrd`/`subdate`/`donedate`/`err`/`text` at `dlr-level` 2/3) appended by
|
||||||
|
`DLRThrower` itself to whatever bare URL `dlr-url` names - unlike Kannel's `%d`/`%F`
|
||||||
|
printf-style placeholders, nothing is written into the URL by the caller. `dlr-level=1` fires once,
|
||||||
|
immediately, with `message_status=ESME_ROK` (an SMSC-ack, not a terminal state); `dlr-level=3`
|
||||||
|
additionally fires once more, later, with the real terminal status (`DELIVRD` here).
|
||||||
|
- **Jasmin FINs the connection on a `deliver_sm_resp` carrying a `message_id`.** SMPP 3.4 4.6.2
|
||||||
|
makes that field unused and NULL, and Jasmin's own decoder sizes it at one octet; a 38-octet UUID
|
||||||
|
in it cost the link immediately after the response, taking the rest of the MO group with it. Found
|
||||||
|
by the fix for the multipart deadlock above, which is what first had this library answer an inbound
|
||||||
|
`deliver_sm` in this suite at all; the field now goes out empty.
|
||||||
|
- **jcli is a plain-text protocol dressed as Telnet** - it sends real `IAC`/option-negotiation bytes
|
||||||
|
and a couple of ANSI escapes in its banner, but never waits for or requires a reply to them; a raw
|
||||||
|
socket client that ignores negotiation entirely and just reads/writes lines works throughout.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether Jasmin's own MT dispatch is serialized *per connector* specifically, or *globally* across
|
||||||
|
every connector on the instance - only one connector was configured, so the two are
|
||||||
|
indistinguishable here.
|
||||||
|
- Whether the deadlock above is specific to a message requesting a receipt (`registered_delivery`
|
||||||
|
set) or would also occur for a plain multi-segment send with no `dlr` - not isolated separately,
|
||||||
|
since every `C3+C7` case here requests one.
|
||||||
|
- Whether a real peer accepts a `data_sm_resp` carrying a `message_id`. SMPP 3.4 4.7.2 defines the
|
||||||
|
field, unlike `deliver_sm_resp`'s, and this library fills it when a `data_sm` carried a message -
|
||||||
|
but Jasmin only ever sends one as a receipt, which is answered with the field empty, so the filled
|
||||||
|
case has met no peer. Jasmin FINing over a `deliver_sm_resp` that carried one is the nearest
|
||||||
|
precedent there is.
|
||||||
|
- Whether Jasmin, given an *upstream* connector that itself defaults to SAR (rather than our
|
||||||
|
library's own UDH), would relay an MT message using SAR instead of preserving our UDH bytes - the
|
||||||
|
120s-timeout deadlock always intervened before a second segment could be observed on the wire in
|
||||||
|
either direction.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 05 java clients
|
||||||
|
|
||||||
|
Date: 2026-09-06. Repo commit: `4194816` (working tree, phase 5 changes uncommitted on top). Host
|
||||||
|
Docker: 29.6.2. Images: `interop-jsmpp:3.0.3-a24db96` (jsmpp cloned at commit `a24db96`, built with
|
||||||
|
`maven:3.9.11-eclipse-temurin-21`, run on `eclipse-temurin:21.0.8_9-jre-jammy`),
|
||||||
|
`interop-cloudhopper:5.0.10-ae6485a` (fizzed/cloudhopper-smpp cloned at commit `ae6485a`, same
|
||||||
|
build/runtime image pair, plus a build-time self-signed cert for S10), `nicolaka/netshoot:v0.16`
|
||||||
|
(capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner, from the root
|
||||||
|
`compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Each peer is a small Java driver (its own Maven project, `interop-tests/peers/<peer>/`) exposing an
|
||||||
|
HTTP command channel on 8080 - the node test file drives scenarios by calling it, the same shape as
|
||||||
|
`kannel.test.ts`'s `sendsms()` HTTP calls, and the same port doubles as the compose healthcheck
|
||||||
|
target. jsmpp's driver binds one or more named `SMPPSession`s and answers with the real client's own
|
||||||
|
exceptions and return values; Cloudhopper's binds one or more named `SmppSession`s the same way.
|
||||||
|
Long-message wire shapes (UDH 8/16-bit, `sar_*`, `message_payload`) are all built through jsmpp's own
|
||||||
|
typed `submitShortMessage(..., OptionalParameter...)` API, never a raw socket - jsmpp exposes exactly
|
||||||
|
the fields needed. Only three deliberately-malformed PDUs (an unknown command id, a truncated TLV
|
||||||
|
stream, a body shorter than `sm_length` declares) cannot be expressed through any typed SMPP client,
|
||||||
|
jsmpp included, so those go out over a second, plain `Socket` the driver opens alongside its jsmpp
|
||||||
|
session - noted here so "jsmpp accepts our answer" claims below are read as applying to the typed-API
|
||||||
|
scenarios only, where jsmpp's own reaction is what is being tested.
|
||||||
|
|
||||||
|
Build snags, both fixed in the Dockerfile, not in `src/`:
|
||||||
|
|
||||||
|
- Cloudhopper's parent pom (`fizzed-maven-parent:1.15`) hardcodes `<source>1.7</source>` /
|
||||||
|
`<target>1.7</target>` directly in its compiler-plugin config, which modern `javac` refuses
|
||||||
|
("Source option 7 is no longer supported") and which `-Dmaven.compiler.source` cannot override
|
||||||
|
since it is not read from a property. Fixed by `sed`-injecting a `<build>` block into
|
||||||
|
`ch-smpp`'s own pom (which declares none) with `source`/`target` `8`, the narrowest override that
|
||||||
|
changes nothing else.
|
||||||
|
- Cloudhopper's test sources reference `javax.annotation.PreDestroy` (JSR-250), removed from the JDK
|
||||||
|
this builds with. `-DskipTests` only skips running them; Maven's `install` lifecycle still
|
||||||
|
test-*compiles* them first. `-Dmaven.test.skip=true` skips compiling them too.
|
||||||
|
- Cloudhopper's `SslContextFactory` only takes the "no validation" branch when *neither* a keystore
|
||||||
|
nor a truststore is configured; a client with only a trust store falls through to
|
||||||
|
`loadKeyStore()` with a null path and fails ("SSL doesn't have a valid keystore"). Fixed by also
|
||||||
|
building a PKCS12 keystore from the same build-time self-signed cert and pointing
|
||||||
|
`SslConfiguration.setKeyStorePath()` at it - functionally unused (our server never requests a
|
||||||
|
client certificate) but required for Cloudhopper's own SSL setup to get past its own null check.
|
||||||
|
- The shared `cloudhopper-tls` volume Cloudhopper's entrypoint copies `server.key`/`server.crt`
|
||||||
|
into (so node's test file can load the same pair into `server({ tls })`) came out root-owned,
|
||||||
|
0600 - unreadable by `node`'s container, which runs as uid 1000. Fixed with a `chmod 644` in the
|
||||||
|
same entrypoint step.
|
||||||
|
- Cloudhopper's SSL client (Netty 3.9.6.Final, 2015-era) cannot complete a handshake against our
|
||||||
|
server's default TLS 1.3 - see Peer quirks. `server({ tls: { ..., maxVersion: 'TLSv1.2' } })` on
|
||||||
|
the S10 listener only; the plain listener the window scenarios use is unrestricted, and a Python
|
||||||
|
`ssl` client confirmed TLS 1.3 itself works before this was traced to the peer.
|
||||||
|
- One GSM7 encoding footgun in the jsmpp driver's own text fixtures, not in `@larvit/smpp`: the
|
||||||
|
driver's "gsm7" mode sends plain ASCII bytes for `short_message`, and GSM 03.38's default
|
||||||
|
alphabet maps ASCII `_` (0x5F) to `§`, not underscore - a message_payload fixture containing `_`
|
||||||
|
round-tripped as `§` until the character was dropped from the fixture text.
|
||||||
|
|
||||||
|
Two runs each of `./interop-tests/run.py jsmpp` and `./interop-tests/run.py cloudhopper`, all four
|
||||||
|
stable: jsmpp 12/12 both times, Cloudhopper 6/6 both times.
|
||||||
|
|
||||||
|
```
|
||||||
|
jsmpp: frames 37, submit_sm 8/8, query_sm 1/1, cancel_sm 1/1, replace_sm 1/1,
|
||||||
|
deliver_sm 2/2, enquire_link 2/2, generic_nack 1, malformed 2, expert errors 2
|
||||||
|
cloudhopper: frames 90-92 (varies slightly run to run - see Peer quirks), bind_transceiver 5/5,
|
||||||
|
submit_sm/submit_sm_resp present, unbind 4/4, malformed 0, expert errors 0
|
||||||
|
```
|
||||||
|
|
||||||
|
jsmpp's `malformed: 2` / `expert errors: 2` are not a stability problem: they are tshark
|
||||||
|
independently flagging the same two deliberately-malformed PDUs the "S3" scenarios send on purpose
|
||||||
|
(the truncated-TLV and short-body reproducers below) - both are genuinely malformed by the spec, so
|
||||||
|
an independent dissector agreeing is the expected outcome, not a surprise.
|
||||||
|
|
||||||
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
|
||||||
|
| S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read |
|
||||||
|
| S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" |
|
||||||
|
| S2 `sar_*` (target 3) | defect confirmed, second peer; fixed in [#91](https://github.com/larvit/larvitsmpp/pull/91) | `jsmpp.test.ts` "sar_* (target 3)": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
|
||||||
|
| S3 known-but-unhandled (targets 1, 6) | pass | `jsmpp.test.ts` "query_sm, cancel_sm, replace_sm": `ESME_RINVCMDID`, link survives, jsmpp raises `NegativeResponseException` and keeps going |
|
||||||
|
| S3 unknown command id (target 1) | pass | `jsmpp.test.ts` "an unknown command id gets generic_nack..." |
|
||||||
|
| S3 truncated TLV stream (target 1) | pass | `jsmpp.test.ts` "a deliver_sm with a truncated TLV stream..." |
|
||||||
|
| S3 short body (target 1) | pass | `jsmpp.test.ts` "a deliver_sm whose body is shorter than sm_length declares..." |
|
||||||
|
| Bind strictness (target 8) | pass, quirk noted | `jsmpp.test.ts` "bind version negotiation": 0x34 gets `sc_interface_version` back, 0x33 gets none; see Peer quirks for jsmpp's own negotiated-version report |
|
||||||
|
| Refusing status (jsmpp) | pass | `jsmpp.test.ts` "a refusing status is surfaced back to jsmpp" |
|
||||||
|
| S5 window 1/10/50 (target 11) | pass | `cloudhopper.test.ts` "S5 - window pressure...": all answered, no id answered twice, peak window never exceeds the configured size |
|
||||||
|
| S5 request expiry (target 11) | pass | `cloudhopper.test.ts` "the peer reports the expiry itself...": Cloudhopper's own window monitor reports it, our side does nothing unusual |
|
||||||
|
| S10 TLS | pass, quirk noted | `cloudhopper.test.ts` "handshake, bind, submit over TLS" |
|
||||||
|
| Refusing status (Cloudhopper) | pass | `cloudhopper.test.ts` "a refusing status is surfaced back to Cloudhopper" |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### `sar_*` segmentation confirmed unread, from a second independent peer (target 3)
|
||||||
|
|
||||||
|
What happened: jsmpp splits a message into two `sar_*`-tagged `submit_sm`s (no UDH, `esm_class`
|
||||||
|
carries no UDHI bit); each arrives at our server as its own, independent `sms` event carrying only
|
||||||
|
its own ~half of the text, with its own unrelated generated id - never merged into one message.
|
||||||
|
Confirms Jasmin's finding (`findings/03-jasmin.md`) from a second, independently-written client.
|
||||||
|
|
||||||
|
Spec: SMPP 3.4 5.3.2.16-5.3.2.18 defines `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum`
|
||||||
|
as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may
|
||||||
|
ignore it.
|
||||||
|
|
||||||
|
Reproducer: `jsmpp.test.ts`, "sar_\* (target 3)" - two `submit_sm`s to the same
|
||||||
|
`source_addr`/`destination_addr`, `esm_class` 0x00, one `sar_msg_ref_num` (0x77) across both, `1/2`
|
||||||
|
then `2/2` in `sar_total_segments`/`sar_segment_seqnum`. Severity: as already scoped in PLAN.md
|
||||||
|
target 3 / phase 10 - a known, tracked limitation, not new.
|
||||||
|
|
||||||
|
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91): `concatOf()` reads the
|
||||||
|
concatenation from the UDH, or from the `sar_*` TLVs where the PDU declares none, and each spelling
|
||||||
|
groups in a reference space of its own. The reproducer now asserts what a rerun shows - one `sms`
|
||||||
|
carrying the whole 200-char text, its two `submit_sm`s answered `<base>-1` and `<base>-2`, and
|
||||||
|
neither half ever reaching the application on its own. The rest of the suite is unchanged: 12/12,
|
||||||
|
frames 37, `submit_sm` 8/8, malformed 2 and expert errors 2 - the two deliberately malformed PDUs
|
||||||
|
the S3 scenarios send.
|
||||||
|
|
||||||
|
### A 4-octet truncated TLV tail is silently accepted rather than refused (target 1)
|
||||||
|
|
||||||
|
What happened: a `deliver_sm` whose mandatory fields are complete, followed by exactly one bare TLV
|
||||||
|
header (tag, 2-octet declared length) and *no* value octets at all, is answered `ESME_ROK` and its
|
||||||
|
mandatory-field text delivered as an ordinary `sms` - the TLV is silently dropped rather than the PDU
|
||||||
|
being refused with `ESME_RINVTLVSTREAM`, which is what the *same* codec path does correctly when a
|
||||||
|
few value octets (but still short of the declared length) follow the header instead of none.
|
||||||
|
|
||||||
|
Why: `pdu.ts`'s `pduToObj()` tries two parses of every PDU - "plain", and "padded" (some peers add a
|
||||||
|
trailing NUL after `short_message` for non-UDH text). Here "plain" parsing hits the TLV loop, reads a
|
||||||
|
length that overruns `command_length`, and correctly errors. But "padded" parsing shifts the TLV
|
||||||
|
region by one octet (treating the first of the four trailing octets as that padding NUL), leaving
|
||||||
|
only 3 octets - one short of what `parseTlvs()`'s loop needs even to read a tag+length pair
|
||||||
|
(`offset + 4 <= cmdLength` is false) - so the loop exits with no error and 3 octets unconsumed
|
||||||
|
(`aligned` false). `pduToObj()`'s fallback chain then reaches `if (!padded.err) return
|
||||||
|
{ pduObj: padded.pduObj }`, which accepts a misaligned parse whenever it produced no error, even
|
||||||
|
though 3 octets of the peer's PDU were never read. The fix is scoped to that fallback, not touched
|
||||||
|
here per the read-only rule.
|
||||||
|
|
||||||
|
Spec: SMPP 3.4 4.3 - a TLV field this codec cannot parse should be refused with
|
||||||
|
`ESME_RINVTLVSTREAM` (5.0's name; 3.4 spells it `ESME_RINVOPTPARSTREAM`), the same as the sibling
|
||||||
|
case with a few value octets present.
|
||||||
|
|
||||||
|
Reproducer (raw hex, sent after an ordinary `bind_transceiver`; independently reproduced with a
|
||||||
|
plain Python socket, no Java involved):
|
||||||
|
|
||||||
|
```
|
||||||
|
000000460000000500000000000000630000007261772d66726f6d0000007261772d746f00000000000000000000137472756e636174656420746c762070726f6265001d00c8
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a `deliver_sm` (`source_addr` `raw-from`, `destination_addr` `raw-to`, body "truncated tlv
|
||||||
|
probe") followed by `00 1d 00 c8` - tag `0x001D`, declared length 200, zero value octets.
|
||||||
|
Our server answers `command_status 0x00000000` (`ESME_ROK`) and delivers the text as `sms`.
|
||||||
|
Appending 4 more arbitrary octets to the same tail (8 total, still declaring length 200) correctly
|
||||||
|
triggers `ESME_RINVTLVSTREAM` instead - `jsmpp.test.ts`'s "a deliver_sm with a truncated TLV stream"
|
||||||
|
test uses that 8-octet form deliberately, to test the *documented* refusal path rather than this
|
||||||
|
adjacent bug. Reproduced with jsmpp's own driver too:
|
||||||
|
`jsmpp.test.ts`, "a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no
|
||||||
|
listener" - the same shape, over a `net.Socket` opened directly against `server()` (not through
|
||||||
|
jsmpp's typed API, which cannot build it at all). Severity: low - a narrow boundary condition (exactly 4
|
||||||
|
trailing octets, no value) rather than a general TLV-validation gap, but it is a hole in the fix
|
||||||
|
target 1 otherwise closed, silently dropping a TLV the peer meant to send instead of losing (and
|
||||||
|
counting) the one malformed PDU.
|
||||||
|
|
||||||
|
Fixed in [#87](https://github.com/larvit/larvitsmpp/pull/87): the optional parameters now have to end
|
||||||
|
on `command_length`, so this PDU is refused `ESME_RINVTLVSTREAM` like the sibling case. The hex above
|
||||||
|
is asserted octet for octet by `test/pdu.test.ts`, "refuses a bare TLV header the same way it refuses
|
||||||
|
a truncated value"; the same shape over a socket is `jsmpp.test.ts`, "a deliver_sm ending in a bare
|
||||||
|
TLV header gets ESME_RINVTLVSTREAM, and reaches no listener".
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **jsmpp's `session.getInterfaceVersion()` echoes what the driver declared, not what the earlier
|
||||||
|
research pass expected.** `research/esme-clients-and-validators.md` A2 quotes jsmpp's own source
|
||||||
|
(`scVersion != null ? IF_50.min(valueOf(scVersion)) : IF_34`) as defaulting to 3.4 whenever the
|
||||||
|
server's `bind_resp` omits `sc_interface_version`. Binding at 0x33 against our server (which
|
||||||
|
omits the TLV for a pre-3.4 peer) and reading `session.getInterfaceVersion()` back gives `0x33`,
|
||||||
|
not `0x34` - this getter does not visibly take that fallback branch here. Recorded as observed;
|
||||||
|
whether jsmpp's *internal* negotiated-version state (used, per its source, to decide whether to
|
||||||
|
attach optional parameters to requests it sends) differs from what this getter reports is not
|
||||||
|
established either way.
|
||||||
|
- **jsmpp accepts every one of our target-1/6 answers without closing the link.** `query_sm`,
|
||||||
|
`cancel_sm` and `replace_sm` each raise a catchable `NegativeResponseException` carrying
|
||||||
|
`ESME_RINVCMDID` (0x00000003); the session stays `BOUND_TRX` afterward (confirmed with a
|
||||||
|
follow-up `enquire_link`). A strict, actively-maintained Java client tolerates the exact answers
|
||||||
|
the interop plan's fixes promise.
|
||||||
|
- **Cloudhopper's SSL client (Netty 3.9.6.Final, last touched 2018) cannot complete a TLS 1.3
|
||||||
|
handshake.** `setUseSsl(true)` against our server's default listener fails immediately with
|
||||||
|
`org.jboss.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record`, on the very first
|
||||||
|
record. A plain Python `ssl.SSLContext` client handshakes the same listener at TLS 1.3 without
|
||||||
|
issue, isolating the incompatibility to Cloudhopper's decade-old SSL stack rather than our
|
||||||
|
server. Capping the S10 listener at `maxVersion: 'TLSv1.2'` resolves it completely - bind,
|
||||||
|
submit and response all succeed. Not attempted: whether an older JRE for the *driver* (rather
|
||||||
|
than capping the server) would let Cloudhopper negotiate TLS 1.3 on its own terms.
|
||||||
|
- **Cloudhopper's window-monitor expiry and its own per-call timeout are different exceptions.**
|
||||||
|
Setting `requestExpiryTimeout` shorter than how long our (deliberately slow) handler holds a
|
||||||
|
message completes the blocking `session.submit(pdu, timeoutMs)` call early with
|
||||||
|
`RecoverablePduException`, distinct from the `SmppTimeoutException` a plain `timeoutMs` expiry
|
||||||
|
raises - worth telling apart in anything scripting around Cloudhopper's timeouts. Our side does
|
||||||
|
nothing unusual: the held message is answered on its own schedule, over the still-open socket,
|
||||||
|
once our slow handler gets to it; nothing server-side errors or is left in a half-finished state.
|
||||||
|
- **tshark's per-frame JSON export undercounts `submit_sm` frames under a tight concurrent
|
||||||
|
Cloudhopper burst on one TCP connection**, e.g. 90-92 total frames decoded across two otherwise
|
||||||
|
identical runs of the same test file (`dumpcap` itself reports zero drops: "Packets
|
||||||
|
received/dropped on interface 'any': 200/0"). Not chased further - `malformed`/`expert errors`
|
||||||
|
are unaffected (both 0 on every run), and the S5 assertions rely on the driver's own structured
|
||||||
|
per-request results, not the tshark histogram, for exactly this reason.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether jsmpp's own negotiated-version fallback (the `IF_34` branch in its source) is reachable
|
||||||
|
through any observable other than `getInterfaceVersion()` - not established this phase.
|
||||||
|
- Whether the tshark frame undercount under a Cloudhopper burst is specific to `-T json` batch
|
||||||
|
export, or would also show up reading the same capture interactively - not investigated, time-
|
||||||
|
boxed.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 06 python-php
|
||||||
|
|
||||||
|
Date: 2026-09-06. Repo commit: `ab93833` (working tree, phase 6 changes uncommitted on top). Host
|
||||||
|
Docker: 29.6.2. Images: `interop-python:2.2.4-3.12.14` (`python:3.12.14-slim-bookworm` +
|
||||||
|
`pip install smpplib==2.2.4`), `interop-php:8.4.25-1d3b53c` (php-smpp,
|
||||||
|
`alexandr-mironov/php-smpp`, cloned at commit `1d3b53c2d2b63d51ab70009d956914b7f8903118`, built on
|
||||||
|
`php:8.4.25-cli`), `nicolaka/netshoot:v0.16` (capture sidecar), `node:24.18.0-bookworm-slim` (test
|
||||||
|
runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Each peer is a small driver (`interop-tests/peers/<peer>/driver.{py,php}`) exposing an HTTP command
|
||||||
|
channel the node test file drives, the same shape as the Java drivers in phase 5 - one long-lived
|
||||||
|
process per peer holding named client sessions open across requests.
|
||||||
|
|
||||||
|
- **python**: `driver.py` runs a `ThreadingHTTPServer`; each named session is a `smpplib.client.Client`
|
||||||
|
plus a background thread calling `read_once()` in a loop once `/startReader` is called. A plain
|
||||||
|
`submit_sm` is answered by `@larvit/smpp` only once the application calls `sms.sendResp()` (README,
|
||||||
|
Server), so `/submit` sends and returns the sequence number immediately rather than blocking for
|
||||||
|
the ack - a synchronous wait here deadlocks against the node test's own "wait for the sms, then
|
||||||
|
answer it" flow. `/ack?name=&sequence=` polls the result afterwards. A multipart submit is
|
||||||
|
answered automatically by the library on arrival, so `/submitLong` keeps its per-part
|
||||||
|
`wait_ack()`, unaffected by the same deadlock.
|
||||||
|
- **php**: `driver.php` is a hand-rolled single-connection-at-a-time HTTP server (no framework),
|
||||||
|
since php-smpp itself is fully synchronous - every send blocks reading its own response on the
|
||||||
|
same call. This means a plain submit deadlocks against `sendResp()` exactly as above, but there is
|
||||||
|
no background thread to poll afterwards, so the fix is the opposite one: the node test's global
|
||||||
|
`session.on('sms', ...)` handler calls `sendResp()` immediately for every arrived sms (the same
|
||||||
|
pattern `kannel.test.ts` uses for its "maxp1" burst, generalised here to the whole file). A
|
||||||
|
`deliver_sm` built by hand from the node side and read via `/receive` (php's blocking `readSMS()`)
|
||||||
|
has the same shape in reverse: the `/receive` call has to be in flight before the `deliver_sm`
|
||||||
|
goes out, or `session.send()`'s own wait for `deliver_sm_resp` has nothing to unblock it yet.
|
||||||
|
|
||||||
|
Build snags, fixed in the Dockerfile, not in `src/`:
|
||||||
|
|
||||||
|
- **PHP 8's `sockets` extension returns a `Socket` object from `socket_create()`, not a resource.**
|
||||||
|
This fork's `Socket::isOpen()` (written pre-PHP8) checks `is_resource($this->socket)` alone, so it
|
||||||
|
is always `false` on PHP 8.x, and every guarded call - `bindReceiver()`, `bindTransmitter()`,
|
||||||
|
`bindTransceiver()`, `close()`, `sendCommand()` - throws `SocketTransportException('Socket is not
|
||||||
|
open')` immediately, regardless of the actual connection. The whole library is unusable on PHP 8+
|
||||||
|
without this. Patched with a build-time `sed` on `src/transport/Socket.php` (also accept
|
||||||
|
`$this->socket instanceof \Socket`), not in the vendored source itself.
|
||||||
|
- `mbstring` needs `libonig-dev` on this base image; the official image warns "mbstring is already
|
||||||
|
loaded" (it is bundled but not built as a shared extension), harmless.
|
||||||
|
|
||||||
|
Two runs each of `./interop-tests/run.py python` and `./interop-tests/run.py php`, all four stable:
|
||||||
|
python 13/13 both times (frames 449/450, `enquire_link` ~185, `submit_sm`/`submit_sm_resp` 24/24,
|
||||||
|
`deliver_sm`/`_resp` 3/3, malformed 0, expert errors 0); php 9/9 both times (frames 42,
|
||||||
|
`bind_transmitter`/`bind_receiver`/`bind_transceiver` and their resps all matched, `submit_sm`/`_resp`
|
||||||
|
11/11, `deliver_sm`/`_resp` 1/1, malformed 0, expert errors 0).
|
||||||
|
|
||||||
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S11 GSM 03.38 basic table + extension table (python) | pass, one peer-side quirk | `python.test.ts` "GSM 03.38 basic table + extension table round trip": whole 127-char table (minus the escape character itself) plus `€[]{}\|~^` round-trips exactly |
|
||||||
|
| S11 form feed (python) | pass, one peer-side quirk | `python.test.ts` "form feed (0x1B 0x0A)...": raw `1b 0a` appended after `gsm_encode()`'s own bytes decodes to `\f` |
|
||||||
|
| S11 Latin-1 (python) | pass | `python.test.ts` "Latin-1 round trip" |
|
||||||
|
| S11 UCS-2 with 一 and an emoji (python) | pass | `python.test.ts` "UCS-2 with 一 and an emoji round trip" |
|
||||||
|
| S11 reverse direction, GSM and UCS-2 (python) | pass | `python.test.ts` "reverse direction: server sends ... text back, python decodes it the same way" (both) |
|
||||||
|
| S11 the 0x5F quirk, both ways | pass (documents the peer's own table, not asserted as our defect) | `python.test.ts` "peer quirk, documented both ways: byte 0x5F..." |
|
||||||
|
| S2 UDH 2/3/10 segments (python) | pass | `python.test.ts` "S2 - long messages", one `sms` per size, `answeredOnArrival` true, ids `<base>-1..N` |
|
||||||
|
| S2 `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH` (php) | pass, all three | `php.test.ts` "S2 - long messages (php-smpp, three CSMS spellings)" |
|
||||||
|
| S4 separate TX/RX binds (php) | pass | `php.test.ts` "S4 - bind direction": `boundAs`/`bindAllows()` both ways, `submit_sm` on RX → `ESME_RINVBNDSTS` (status 4) and the peer keeps working, `submit_sm` on TX unaffected, `deliver_sm` reaches RX only (TX's own `/receive` times out) |
|
||||||
|
| Keepalive, reactive `enquire_link` (python) | pass | `python.test.ts` "Keepalive...": silent past 40s idleTimeout → session closes; `auto_send_enquire_link` with a 10s client timeout → survives the same 45s |
|
||||||
|
| Refusal via `onRequest` (python, php) | pass, both peers | `python.test.ts`/`php.test.ts` "Refusals via onRequest": `ESME_RTHROTTLED` surfaced as the ack status (python) or a caught `SmppException` code (php); `enquire_link` and a follow-up submit both still work |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
None found. Every encoding, both directions, every long-message spelling from both peers, the
|
||||||
|
bind-direction enforcement, the idle/keepalive behaviour, and the `onRequest` refusal path all
|
||||||
|
matched the README and the spec.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **python-smpplib's `gsm.GSM_CHARACTER_TABLE` disagrees with GSM 03.38 (and this library) at one
|
||||||
|
code point: 0x5F.** The real table's value there is SECTION SIGN (§); smpplib's own table (its
|
||||||
|
`gsm.py` docstring already calls it "vendor-specific and not recommended for use") has a backtick
|
||||||
|
instead. Encoding a literal backtick through `gsm.gsm_encode()` sends byte `0x5F`, which this
|
||||||
|
library correctly decodes to §; sending §'s own byte back and decoding it through smpplib's table
|
||||||
|
(as the driver's `gsm_decode()` deliberately does, to compare like for like) reads a backtick, not
|
||||||
|
a §. Both directions reproduced in `python.test.ts`'s "peer quirk" test. Confirmed by diffing
|
||||||
|
`smpplib.gsm.GSM_CHARACTER_TABLE[:128]` against this library's own `gsmChars` table (identical at
|
||||||
|
every other of the 128 positions).
|
||||||
|
- **`gsm.gsm_encode()` cannot produce a form feed at all.** The character table represents that
|
||||||
|
extension-table slot with a placeholder backtick, not the literal `\x0c` character, so
|
||||||
|
`GSM_CHARACTER_TABLE.index('\x0c')` raises `ValueError` (wrapped as `UnicodeError`) for any attempt
|
||||||
|
to encode one. The driver builds the `1b 0a` byte pair by hand for that one character (see
|
||||||
|
`python.test.ts`'s "form feed" test) rather than through the library's own encoder.
|
||||||
|
- **`auto_send_enquire_link` defaults to `True`**, on `read_once()`/`poll()`/`listen()` - not opt-in,
|
||||||
|
correcting `research/esme-clients-and-validators.md`'s A4 note. The driver passes it explicitly
|
||||||
|
either way so both keepalive scenarios are deliberate.
|
||||||
|
- **php-smpp's `GsmEncoderHelper::utf8_to_gsm0338()` has no dictionary entry for ¤ (CURRENCY SIGN,
|
||||||
|
U+00A4, GSM 03.38 code `0x24`).** `strtr()` only rewrites characters present in its dict; ¤ passes
|
||||||
|
through as its raw two-byte UTF-8 sequence (`c2 a4`), which this library's GSM decoder (correctly,
|
||||||
|
since neither byte is in the 128-entry table) renders as two spaces. Reproduced manually (not
|
||||||
|
asserted in `php.test.ts`, to keep that suite's own assertions unambiguous): submitting
|
||||||
|
`"price:¤100"` at `data_coding` 0 arrives as `"price: 100"`.
|
||||||
|
- **This php-smpp fork's `bindTransceiver()` actually works**, against both the plan's premise and
|
||||||
|
the fork's own inherited README ("You can't connect as a transceiver, otherwise supported by SMPP
|
||||||
|
v.3.4" - upstream OnlineCity text, unchanged by this fork despite `Client.php` plainly implementing
|
||||||
|
`bindTransceiver()`). Confirmed directly: `php.test.ts`'s "Peer quirk: bindTransceiver() actually
|
||||||
|
works" binds one against `server()` and gets `boundAs === 'transceiver'`. `php.test.ts`'s S4
|
||||||
|
scenarios still use separate TX/RX binds deliberately, since that is the shape target 8 needs
|
||||||
|
regardless of whether TRX also happens to work.
|
||||||
|
- **This fork's `composer.json` declares `"license": "LGPL-2.0-or-later"`**, though no top-level
|
||||||
|
`LICENSE` file exists in the repo - correcting the plan's "no licence declared" for this specific
|
||||||
|
fork (true of the field, not true of the declaration). Still test-only per the phase brief; not
|
||||||
|
vendored into this repo either way.
|
||||||
|
- **`submit_sm()`'s returned message id carries a trailing NUL byte** (`unpack("a*msgid", ...)`
|
||||||
|
keeps it, unlike PHP's `A`-format unpack which would trim it) - cosmetic, not asserted against in
|
||||||
|
`php.test.ts`.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether php-smpp's other `is_resource()`-adjacent assumptions (none found beyond `isOpen()` in this
|
||||||
|
version) would surface on a longer-running session than these scenarios exercise.
|
||||||
|
- Whether smpplib's `0x5F` table quirk affects any other vendor beyond this one - not checked against
|
||||||
|
a second Python client, since none of comparable maturity was in scope for this phase.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 07 load
|
||||||
|
|
||||||
|
Date: 2026-09-06. Repo commit: `ab93833`. Host: AMD Ryzen 9 5950X, 8 vCPUs allotted, 31GB RAM,
|
||||||
|
Alpine 6.18.38-0-virt kernel, Docker 29.6.2. Images: `interop-smppload:2.5.3-49fb653` (smppload
|
||||||
|
cloned at commit `49fb653`, tag 2.5.3, built with `erlang:27.3.4.17-alpine` + a fresh `rebar3`
|
||||||
|
3.27.0 release replacing the commit's own pre-OTP-27 vendored one), `interop-dumbclient:de0334b`
|
||||||
|
(vponomarev/libsmpp cloned at commit `de0334b`, built with `golang:1.26.8-alpine3.23` on
|
||||||
|
`alpine:3.23.5`), `nicolaka/netshoot:v0.16` (capture sidecar and tshark), `node:24.18.0-bookworm-slim`
|
||||||
|
(test runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### smppload: builds, binds, then puts a corrupted PDU on the wire - blocked
|
||||||
|
|
||||||
|
Issue #8 (rebar3/BEAM load errors) is real but resolved in minutes: the commit's own vendored
|
||||||
|
`./rebar3` escript predates OTP 27 and fails to load under it
|
||||||
|
(`please re-compile this module with an Erlang/OTP 27 compiler`). Replacing it with a fresh rebar3
|
||||||
|
3.27.0 release before `make escriptize` fixes the build outright - no further patching needed, and
|
||||||
|
`git://` dependency URLs in `rebar.config` resolved fine once rewritten to `https://` (a one-line
|
||||||
|
`git config --global url.insteadOf`).
|
||||||
|
|
||||||
|
The built escript is not usable against any SMSC, though: every `bind_transceiver` it sends is two
|
||||||
|
octets short of what its own `command_length` declares. Captured with a raw tshark sidecar against
|
||||||
|
`ukarim/smscsim:0.2.0` (independent of both this library and smppload's own logging):
|
||||||
|
|
||||||
|
```
|
||||||
|
002a000000090000000000000001736d7070636c69656e74310070617373776f7264000050010100
|
||||||
|
```
|
||||||
|
|
||||||
|
40 octets on the wire, but `command_length` (the first 4 octets, if the PDU were whole) would need
|
||||||
|
to read `0000002a` (42) for a `system_id` "smppclient1" / password "password" bind - the actual
|
||||||
|
first two octets of that field are simply missing, so the wire instead starts `002a0000`
|
||||||
|
(2,752,512) with `command_id` and everything after shifted two octets early. tshark's own SMPP
|
||||||
|
dissector does not recognise the stream as SMPP at all (`-Y smpp` matches zero frames, though the
|
||||||
|
raw capture has the SYN/ACK/PSH/FIN sequence and the 40-octet data frame) - a second, independent
|
||||||
|
confirmation this is not merely a framing quirk our own codec is stricter about.
|
||||||
|
|
||||||
|
Traced as far as `oserl`'s `smpp_pdu_syntax:pack/2` (the `trx_deadlock_fix_1` branch smppload's
|
||||||
|
`rebar.config` pins), which builds the header as plain 32-bit bit-syntax
|
||||||
|
(`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens
|
||||||
|
somewhere between that call and the socket write, not chased further given the time-box. Reproduced
|
||||||
|
identically on three separate runs (byte-for-byte). Recorded as **blocked**; `smppload.test.ts`
|
||||||
|
keeps a live reproducer asserting what our server does when it receives it (refuses the stream as
|
||||||
|
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
|
||||||
|
substitutes for S6 and (partially) S8 - see below.
|
||||||
|
|
||||||
|
### smpp-dumb-client: builds and interoperates cleanly; its own window bookkeeping stalls under sustained load
|
||||||
|
|
||||||
|
No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and
|
||||||
|
`smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build
|
||||||
|
time), the second built because every load tool built for this phase sends `enquire_link` on its
|
||||||
|
own otherwise (`smpp-dumb-client` every 10s, unconditionally, not configurable) and smppload - the
|
||||||
|
one peer that genuinely never does - is blocked, leaving S6 with no peer at all otherwise.
|
||||||
|
|
||||||
|
One integration snag, not a build one: `smpp.remote` in `config.yml` is fed straight into
|
||||||
|
`net.ParseIP` (`hdr.go`) with no DNS resolution at all, so the compose service name cannot appear
|
||||||
|
there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder,
|
||||||
|
resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
|
||||||
|
|
||||||
|
Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode:
|
||||||
|
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
|
||||||
|
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
|
||||||
|
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||||
|
|
||||||
|
The long soak (below) surfaced a peer-side limit worth designing around rather than fighting: with
|
||||||
|
a fast, immediate-response handler and a window of 100 - nothing our server should ever have
|
||||||
|
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
|
||||||
|
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
|
||||||
|
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
|
||||||
|
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
|
||||||
|
individually cross that 7s mark each second rather than by real responses being matched. Our own
|
||||||
|
server-side counters (`arrived`/`answered`/`peakOutstanding`, tracked independently in
|
||||||
|
`dumbclient.test.ts`) stay in lockstep throughout with a low peak - see Scenarios - which places the
|
||||||
|
stall entirely on the peer's own window bookkeeping, not on anything our server did or failed to
|
||||||
|
do. The soak test was redesigned around this: bounded by wall-clock (5 minutes) rather than a
|
||||||
|
target count, asserting the invariants that matter regardless of how much the peer's own bug lets
|
||||||
|
through (every arrival answered, nothing duplicated, memory shape), and reporting whatever
|
||||||
|
throughput was actually reached rather than requiring a specific one.
|
||||||
|
|
||||||
|
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
|
||||||
|
first version attached its `session.on('close', ...)` listener lazily inside the test body, after
|
||||||
|
already waiting on the S9 assertions (which can run for the better part of a minute) - by the time
|
||||||
|
the S6 test ran, the idle session had already closed, and an `EventEmitter` never replays a past
|
||||||
|
event to a listener added after it fired. Fixed by attaching every session's `close` listener at
|
||||||
|
`session`-creation time, recording it in the same per-scenario stats every other assertion reads.
|
||||||
|
|
||||||
|
Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
|
||||||
|
both the peer's TX-tracking stall and the S6 harness bug above; run 2, after both fixes, is the one
|
||||||
|
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
|
||||||
|
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
|
||||||
|
adding nothing.
|
||||||
|
|
||||||
|
```
|
||||||
|
dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the
|
||||||
|
capture stops moments after the test does, catching some requests before their
|
||||||
|
response), submit_sm 62441, submit_sm_resp 48830, malformed 0, expert errors 0
|
||||||
|
smppload: frames 0 (tshark's own SMPP dissector does not recognise the corrupted stream at all)
|
||||||
|
```
|
||||||
|
|
||||||
|
`submit_sm_resp` reads lower than `submit_sm` in the capture for the same reason
|
||||||
|
`enquire_link_resp` does - the sidecar is stopped right after the test file's own `after()` hook
|
||||||
|
finishes, which is itself moments after the last response goes out, so a handful of writes land
|
||||||
|
after the capture stops seeing them. Not a lost response: `submit_sm` (62441) matches the sum of
|
||||||
|
every session's own `arrived` exactly, and every session's own `answered` matches its `arrived` too
|
||||||
|
(see Scenarios) - both counted independently, in the server process, of anything on the wire.
|
||||||
|
|
||||||
|
## Throughput and memory
|
||||||
|
|
||||||
|
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
|
||||||
|
concurrently, against a handler serialised to answer roughly one message every 2ms
|
||||||
|
(`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured
|
||||||
|
windows, confirming the peer never let more than its own window ride at once.
|
||||||
|
|
||||||
|
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
|
||||||
|
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
|
||||||
|
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
|
||||||
|
ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across
|
||||||
|
the whole run (69 samples over 340s, all four scenarios combined): rss first=170MiB, min=124MiB,
|
||||||
|
max=306MiB (during the two window runs' backlog), last=125MiB, heapUsed at the last sample 15MiB -
|
||||||
|
back below its own starting point once the backlog drained, not merely flat. No monotonic trend in
|
||||||
|
either direction.
|
||||||
|
|
||||||
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed |
|
||||||
|
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) - and is itself capped well below what our server can sustain by the peer's own TX-tracking stall, also see Setup |
|
||||||
|
| S9 (bounded window) | pass | `dumbclient.test.ts` "S9 - bounded window..." - 20,000/20,000 answered on both window 500 and window 2000, in arrival order, no duplicate ids, `peakOutstanding` exactly 500 and exactly 2000 |
|
||||||
|
| Backpressure at the server | pass | Same run: `peakOutstanding` 2000 exceeds `maxHeldMessages` (1000, session-options.ts) and the eviction warning fires; window 500 (`peakOutstanding` 500) never does; memory sampled before/after the window runs (170MiB before, 306MiB after, 125MiB once the soak's own run had also settled) |
|
||||||
|
| Long soak | pass (run once at the redesigned, wall-clock-bounded shape - see Setup) | `dumbclient.test.ts` "Long soak" - 22,440 arrived, 22,440 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error |
|
||||||
|
| smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction
|
||||||
|
(refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this
|
||||||
|
library cannot frame...") is the documented behaviour working exactly as designed against a peer
|
||||||
|
that never gets as far as a readable PDU. The soak's throughput ceiling is the peer's own defect
|
||||||
|
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
|
||||||
|
run.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
|
||||||
|
`pack/2` (which is correct on inspection) given the time-box.
|
||||||
|
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
|
||||||
|
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
|
||||||
|
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
|
||||||
|
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
|
||||||
|
rather than configuring it.
|
||||||
|
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
|
||||||
|
Setup.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
|
||||||
|
(not reached, given the time-box) or something specific to this build's dependency versions.
|
||||||
|
- Whether `smpp-dumb-client`'s stall is a sequence-number correlation bug (a response failing to
|
||||||
|
match its `TrackTX` entry, falling back to the 7s expiry) or something else in its own window
|
||||||
|
accounting - not chased past the observation in Setup, given the time-box and that the fault is
|
||||||
|
clearly on the peer's side (our own counters stayed clean throughout).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 09 operator receipt fixtures
|
||||||
|
|
||||||
|
Date: 2026-09-08. Repo commit at the start of the phase: `83176f5`. Host: Alpine 6.18.38-0-virt
|
||||||
|
kernel. Images: `node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`) — and
|
||||||
|
nothing else. No peer runs in this phase and no capture is taken.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
This phase has no peer to bring up, so `run.py` is not involved. The eight peers the suite can run
|
||||||
|
are all open source, and none of them writes the receipt bodies commercial operators document —
|
||||||
|
that whole class of behaviour is what
|
||||||
|
[research/operator-quirks.md](../research/operator-quirks.md) topics 4 and 5 collected, one source
|
||||||
|
URL per claim, and what this phase turns into fixtures in `test/`.
|
||||||
|
|
||||||
|
Everything here runs under the ordinary suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm node npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
New file: `test/operator-receipts.test.ts` — a table of receipt bodies as each operator's own
|
||||||
|
documentation spells them, checked through `dlrFromPdu()`, plus four scenarios driven over a live
|
||||||
|
link against a dummy SMSC that answers each `submit_sm` with the message id the fixture names. Each
|
||||||
|
fixture carries the URL it was read from as its assertion message, so a failure names the page that
|
||||||
|
settles it. That dummy SMSC is `test/dummy-smsc.ts`, extracted from the copy `messaging-mode.test.ts`
|
||||||
|
already carried rather than written a second time. `test/session-extras.test.ts` gained C8's 16-bit
|
||||||
|
UDH and `test/session.test.ts` the `DlrMerger` fact the Telesign scenario turned up.
|
||||||
|
|
||||||
|
## What the research settles, and what it does not
|
||||||
|
|
||||||
|
Several things in topic 5 could not be taken at face value, and are recorded rather than guessed at:
|
||||||
|
|
||||||
|
- **Telesign's `err` width contradicts itself.** The page calls it "a 3-octet hex code" and then
|
||||||
|
gives 8-hex-digit examples (`0x000004A6`), which is four octets
|
||||||
|
(https://developer.telesign.com/enterprise/docs/smpp-protocol). smpp.org fixes the receipt field
|
||||||
|
at 3 octets. The fixture takes the 3-octet width and the hex notation (`err:4A6`) and asserts the
|
||||||
|
value reaches `dlr.errorCode` verbatim — this library never parses `err:`, so either reading
|
||||||
|
arrives intact at the application, which is the only claim the sourced material supports.
|
||||||
|
- **tyntec does not document the `stat:` its buffered receipt carries**, only that a buffered one
|
||||||
|
precedes the final one. The fixture uses `stat:ENROUTE` under `esm_class` 0x04 — Appendix B's own
|
||||||
|
spelling for a message still on its way, and the shape Infobip documents explicitly — rather than
|
||||||
|
inventing a vendor token.
|
||||||
|
- **Telesign's `message_parts_count` TLV has no published tag id** in either sourced page, so no
|
||||||
|
fixture names one. The behaviour it accompanies (only the first segment answered with a
|
||||||
|
`message_id`) is covered without it.
|
||||||
|
- **Telesign's `message_state` 9 is not a receipt body shape**, so it is not in the operator table.
|
||||||
|
Appendix B has no seven-character code for `SKIPPED`, so a fixture pairing the two would have had
|
||||||
|
Telesign's body and TLV contradict each other where its own page says the status is stated
|
||||||
|
redundantly in both. The TLV rule is asserted where it belongs instead — `dlr.test.ts` "names a
|
||||||
|
state only the TLV can spell" — and the Telesign fixture states one status in both fields, as
|
||||||
|
documented. The research file of 2026-09-05 is the source for 9 = `SKIPPED`; the TLV page no
|
||||||
|
longer shows that table.
|
||||||
|
- **Vonage's `stat:` set could not be re-fetched** during review (the support article answers 403).
|
||||||
|
Kaleyra and Route Mobile both verify independently and both define `FAILED` as a terminal delivery
|
||||||
|
failure, which is what the library change rests on; Vonage's own developer page documents a
|
||||||
|
lower-case status set for its HTTP callbacks, which is a different surface from the seven-character
|
||||||
|
`stat:` field. The fixture keeps the research's attribution, and `src/dlr.ts` cites the two that
|
||||||
|
verify.
|
||||||
|
- **Clickatell's cited page no longer resolves** — `archive.clickatell.com/developers/api-docs/pdu-details/`
|
||||||
|
now redirects to `docs.clickatell.com`. The body shape is quoted verbatim in the research file of
|
||||||
|
2026-09-05, which is what the fixture was built from.
|
||||||
|
|
||||||
|
Two further items in topic 5 are not receipt-body shapes at all and are out of this phase:
|
||||||
|
Syniverse's and Route Mobile's numeric status tables are vendor fields of their own rather than the
|
||||||
|
seven characters `stat:` holds, and LINK Mobility's `registered_delivery=0x21` is a submit field.
|
||||||
|
|
||||||
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C16 LINK Mobility: `sub:000`, `dlvrd:000`, empty `text:`, finals only | pass | `operator-receipts.test.ts` "LINK Mobility, whose sub and dlvrd are always 000…" — `receipt.sub`/`receipt.dlvrd` read 0 and nothing is derived from them, `receipt.text` is `''`; "finds none of LINK Mobility's among the transient ones" |
|
||||||
|
| C16 Vonage: `stat:FAILED` outside Appendix B, eight-value set, `err:` off `DELIVRD`/`ACCEPTD` only | fail, then fixed | "Vonage, whose stat:FAILED is six characters and outside Appendix B" and "names a state of its own for every one of them" — both failed against `83176f5`; see Defects |
|
||||||
|
| C16 Vonage: one receipt per segment | pass | "reports every segment and merges nothing" — three `dlr` events under the SMSC's own three unrelated ids, no `messageDlr` |
|
||||||
|
| C16 tyntec: a buffered receipt then a final one for one id | pass | "hands both receipts to the application rather than taking the second for a duplicate" — two `dlr` events, `intermediate` `[true, false]`, one `smsId` |
|
||||||
|
| C16 Infobip: `stat:ENROUTE` marked `esm_class` 0x04 | pass | "Infobip, reporting ENROUTE in an ordinary receipt that carries no text field at all" — `intermediate` true off the state where the marker says final, and `receipt.text` stays `undefined` |
|
||||||
|
| C16 Clickatell: the exact documented body | pass | "Clickatell, whose dates carry seconds" — every field of the documented order, 12-octet dates |
|
||||||
|
| C16 Telesign: hex `err:`, one id per concatenated send | pass | "Telesign, whose err is hexadecimal and whose status is stated in the body and the TLVs alike"; "hands the err field over as it arrived, whichever width the operator writes"; "hands back what landed where only the first segment is answered with one" |
|
||||||
|
| C16 CM.com: a four-digit year, an eight-character `stat:`, no `sub:` or `dlvrd:` | fail, then fixed | "CM.com, which writes a four-digit year, an eight-character stat, and the status twice" — absent fields stay `undefined` and the `message_state` TLV agrees with `stat:`, but both the documented `yyyyMMddHHmmss` date and the documented `stat:DELIVERD` failed against `83176f5`; see Defects |
|
||||||
|
| C16 a receipt with no `id:` at all | pass | "settles a status against no message where a marked receipt names no id" — marked, `smsId` is undefined and the status still settles; unmarked, the same body arrives as an `sms`. "takes the id from the TLV where the body names none" covers the third case |
|
||||||
|
| C16 fields in another order | pass | "reads the same fields whatever order they arrive in"; "reads the rest of the line as the text where a peer does not write text last" |
|
||||||
|
| C16 hex `message_id` against a decimal `id:` | pass | "correlates the receipt against the send once both notations are named" and "leaves the two incomparable where neither notation is named" |
|
||||||
|
| C16 a zero-padded id | pass | "strips the padding an operator writes the same number with" |
|
||||||
|
| C16 dates with and without seconds | pass | The LINK Mobility and Infobip fixtures carry 10-octet dates, Clickatell and Telesign 12-octet ones and CM.com a 14-octet one; every one asserts the `Date` it resolves to, and `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it in" pins all three against each other |
|
||||||
|
| C8 UDH 16-bit | pass | `session-extras.test.ts` "names the spelling a segment was numbered by, alongside the reference" reads GSM 03.40 element 0x08, and "assembles a message numbered by a 16-bit UDH reference" carries two of them through the `Reassembler` into one whole text — the width was previously exercised only by the jsmpp peer run ([05-java-clients.md](05-java-clients.md)) |
|
||||||
|
| C9 MO or receipt on `data_sm` | pass, already covered | `dlr.test.ts` "reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm"; `session-extras.test.ts` "reads a data_sm as the command its direction makes it" |
|
||||||
|
| C10 unknown command id, malformed and vendor TLVs | pass, already covered | `test/raw-pdus.ts` and the `session.test.ts` refusal suites |
|
||||||
|
| C15 `interfaceVersion` 0x50, a peer answering 3.3 or nothing | pass, already covered | `session.test.ts` bind-version suites around `sc_interface_version` |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### `stat:FAILED` read as `UNKNOWN`
|
||||||
|
|
||||||
|
**What happened.** A receipt body carrying `stat:FAILED` reached the application as
|
||||||
|
`statusMsg: 'UNKNOWN'`, `statusId: 7` — indistinguishable from a receipt that really says
|
||||||
|
`stat:UNKNOWN`.
|
||||||
|
|
||||||
|
**What the operators' docs say.** Vonage lists `FAILED` among the eight `stat` values it writes
|
||||||
|
(https://api.support.vonage.com/hc/en-us/articles/204015663), Kaleyra among its four
|
||||||
|
(https://messaging.kaleyra.com/support/solutions/articles/3000091798-delivery-reports), and Route
|
||||||
|
Mobile among its five (https://routemobile.com/pdf_files/developer/api/routemobilesmpp.pdf). In all
|
||||||
|
three it is a terminal delivery failure. SMPP 3.4 Appendix B does not define it, and it is six
|
||||||
|
characters where the field is seven.
|
||||||
|
|
||||||
|
**Reproducer.** `operator-receipts.test.ts`, the Vonage fixture and "names a state of its own for
|
||||||
|
every one of them" — the second walks every code all seven researched operators publish and fails
|
||||||
|
on any that reads as `UNKNOWN` without saying `UNKNOWN`.
|
||||||
|
|
||||||
|
**Severity.** Two ways it gives a wrong answer, both goal 2: an application cannot tell an
|
||||||
|
operator's "it failed" from its "I do not know", and `DlrMerger` ranks `UNKNOWN` (5) below `EXPIRED`
|
||||||
|
(6), so a multipart send with one failed segment and one expired one reported as expired.
|
||||||
|
|
||||||
|
**Fixed** in this phase: one entry added to `receiptStates` in `src/dlr.ts`. `receiptCodes` is
|
||||||
|
untouched, so this library still only ever writes `UNDELIV`. Decision recorded in the root
|
||||||
|
`AGENTS.md` under "The wire".
|
||||||
|
|
||||||
|
### CM.com's own `stat:` spelling read as `UNKNOWN`
|
||||||
|
|
||||||
|
**What happened.** A receipt spelled the way CM.com's code table prints it reached the application as
|
||||||
|
`statusMsg: 'UNKNOWN'`. Unmarked, it arrived as an inbound `sms` rather than as a report at all.
|
||||||
|
|
||||||
|
**What the operator's docs say.** The "Message state values" table at
|
||||||
|
https://developers.cm.com/messaging/docs/smpp gives the code column as `DELIVERD` — eight
|
||||||
|
characters — beside `EXPIRED`, `DELETED`, `UNDELIV`, `ACCEPTD`, `UNKNOWN` and `REJECTD`, which are
|
||||||
|
all correct Appendix B codes. The page prints `DELIVERD` four times and `DELIVRD` not once, verified
|
||||||
|
by fetching it.
|
||||||
|
|
||||||
|
**Reproducer.** `operator-receipts.test.ts` "names a state of its own for every one of them", whose
|
||||||
|
CM.com row now carries the published spelling, and the CM.com fixture.
|
||||||
|
|
||||||
|
**Severity.** The same class as the `stat:FAILED` defect above, on the most common status there is: an
|
||||||
|
application could not tell a delivered message from one whose state the library could not read.
|
||||||
|
|
||||||
|
**Fixed** in this phase: one entry in `receiptStates`. Whether CM.com's table is a typo or its wire
|
||||||
|
spelling, reading it costs nothing — no other code could be meant, and `receiptCodes` still writes
|
||||||
|
only `DELIVRD`. This supersedes the research file's CM.com line, which records the code as `DELIVRD`
|
||||||
|
and asks for an assertion that every `stat:` is exactly seven characters: written today, that
|
||||||
|
assertion fails against the page it cites.
|
||||||
|
|
||||||
|
### A receipt date carrying its century dropped
|
||||||
|
|
||||||
|
**What happened.** `dlr.doneDate` and every other parsed date came back `undefined` for a receipt
|
||||||
|
whose dates are 14 digits, while `dlr.receipt.doneDate` still carried the raw string — so the loss
|
||||||
|
was silent.
|
||||||
|
|
||||||
|
**What the operator's docs say.** CM.com gives its receipt body template as
|
||||||
|
`id:… submit date:yyyyMMddHHmmss done date:yyyyMMddHHmmss stat:SSSSSSS err:EEE`, with "Formatted:
|
||||||
|
yyyyMMddHHmmss" spelled out (https://developers.cm.com/messaging/docs/smpp). `receiptDate()` read 10
|
||||||
|
and 12 digits only — smpp.org's `YYMMDDhhmm` and the same with seconds.
|
||||||
|
|
||||||
|
**Reproducer.** `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it
|
||||||
|
in", and the CM.com row of the operator table.
|
||||||
|
|
||||||
|
**Severity.** Goal 3: a date the receipt states plainly is one the library can determine, and
|
||||||
|
dropping it leaves the application to re-parse `dlr.receipt.doneDate` itself. The three widths are
|
||||||
|
10, 12 and 14, so none can be read as another and nothing is guessed.
|
||||||
|
|
||||||
|
**Fixed** in this phase: `receiptDate()` in `src/dlr.ts` takes a four-digit year as the year, where a
|
||||||
|
two-digit one still means this century, and the rolled-over check now covers the year as well —
|
||||||
|
`Date.UTC` reads 26 as 1926.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
Not peer behaviour this time — operator behaviour, from documentation rather than from a run. What
|
||||||
|
the fixtures pin that a reader would not otherwise expect:
|
||||||
|
|
||||||
|
- **`sub:` and `dlvrd:` say nothing.** LINK Mobility hardcodes both to `000` on every receipt,
|
||||||
|
delivered ones included, and CM.com omits them entirely. Nothing in this library derives anything
|
||||||
|
from either, which is what makes both readable.
|
||||||
|
- **`text:` can only end where the line does**, because it is the one field allowed to hold spaces.
|
||||||
|
Every researched operator writes it last, and one that did not would have the rest of its line
|
||||||
|
read as the text. The other seven fields are order-independent.
|
||||||
|
- **A receipt's `esm_class` and its `stat:` can disagree about finality.** Infobip writes
|
||||||
|
`stat:ENROUTE` under 0x04, the marker for a final receipt; the state wins, which is why the
|
||||||
|
library tests both.
|
||||||
|
- **An operator that hands out an unrelated id per segment gets no `messageDlr`.** Vonage sends one
|
||||||
|
receipt per segment under ids that carry no `<base>-<n>` numbering, so nothing merges them. An
|
||||||
|
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
|
||||||
|
`sendSms()` returned and merges them itself.
|
||||||
|
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
|
||||||
|
`sendSms()` returns `['<id>', undefined, undefined]` — one entry per segment, positional with
|
||||||
|
`pduObjs`, `undefined` where the SMSC named nothing; it returned `''` there until #101.
|
||||||
|
`dlr.smsId` is never empty, so an unnamed entry matches no receipt, and no merge is armed.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Whether LINK Mobility really writes a space after the colon.** Its guide prints the extended
|
||||||
|
format as `id: xxx sub:000 dlvrd:000 submit date: yyMMddHHmm ... stat: <status> err: <error code>
|
||||||
|
text:` — spaced before every placeholder and unspaced before both literal `000`s, which reads as a
|
||||||
|
typographic convention for placeholders rather than as the wire shape. The fixture takes the
|
||||||
|
unspaced form every other operator documents. It matters because the parser reads a field as
|
||||||
|
ending at the first space, so a genuinely spaced receipt yields every field empty, and an unmarked
|
||||||
|
one would arrive as an inbound message. Tolerating a space cannot simply be added: `sub: stat:UNDELIV`
|
||||||
|
would then read `stat:UNDELIV` as the value of `sub`, which is the same ambiguity the other way
|
||||||
|
round. A single receipt off a real LINK link settles it; until then the shape is not guessed at.
|
||||||
|
- Whether Telesign's `err:` is really three hex characters or eight. Both readings reach the
|
||||||
|
application unchanged — "hands the err field over as it arrived, whichever width the operator
|
||||||
|
writes" pins that — so nothing in this library turns on it, but a real Telesign link would settle
|
||||||
|
it in one receipt.
|
||||||
|
- What `stat:` tyntec's buffered receipt actually carries. The library reads any of `ENROUTE`,
|
||||||
|
`SCHEDULED` or `esm_class` 0x20 as non-final, so all three plausible answers behave correctly; a
|
||||||
|
fourth, vendor-invented token would read as `UNKNOWN` and final.
|
||||||
|
- Whether any operator writes a `stat:` outside Appendix B beyond the two this phase found,
|
||||||
|
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
|
||||||
|
the place a new one goes, and it fails loudly for anything nothing names.
|
||||||
|
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
|
||||||
|
right shape for a caller. Settled in #101: the entry is `undefined`, and the decision with its
|
||||||
|
rejected alternatives is recorded in `AGENTS.md`.
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Dlr } from '../src/dlr.ts';
|
||||||
|
import type { EncodingName } from '../src/defs/encodings.ts';
|
||||||
|
import type { PduObject } from '../src/pdu.ts';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { ConcatReference } from '../src/udh.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
import { encodeMessage, splitMessage } from '../src/message.ts';
|
||||||
|
import { submitSmParams } from '../src/send-sms.ts';
|
||||||
|
|
||||||
|
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
|
||||||
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
const DATASM_HOST = process.env.DATASM_HOST ?? 'jasmin-datasm';
|
||||||
|
const HTTP_API_HOST = process.env.HTTP_API_HOST ?? 'jasmin';
|
||||||
|
const HTTP_API_PORT = Number(process.env.HTTP_API_PORT ?? '1401');
|
||||||
|
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT ?? '2777');
|
||||||
|
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
|
||||||
|
|
||||||
|
const USERNAME = 'esme1';
|
||||||
|
const PASSWORD = 'esme1pw';
|
||||||
|
const THROTTLED_USERNAME = 'esme2';
|
||||||
|
const THROTTLED_PASSWORD = 'esme2pw';
|
||||||
|
const UPSTREAM_USERNAME = 'upstreamesme';
|
||||||
|
const UPSTREAM_DATASM_USERNAME = 'upstreamdsesme';
|
||||||
|
const FROM = '46701113311';
|
||||||
|
const TO = '46709771337';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(username: string, password: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
|
||||||
|
return client({ host: PEER_HOST, password, port: PEER_PORT, username, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared infra: the fake upstream real-world SMSC that Jasmin's own smppc connector(s) bind
|
||||||
|
// out to (host `node`, port UPSTREAM_PORT - Jasmin resolves `node` via --use-aliases), and the HTTP
|
||||||
|
// listener that receives Jasmin's DLR-thrower webhook (S7). Both are wired once for the whole file,
|
||||||
|
// mirroring kannel.test.ts's shared-infra shape - every Jasmin variant dials in from container
|
||||||
|
// start, independent of when a given test runs. ---
|
||||||
|
|
||||||
|
type UpstreamVariant = 'datasm' | 'main';
|
||||||
|
|
||||||
|
const upstreamSessions = new Map<UpstreamVariant, Session>();
|
||||||
|
const upstreamSms: { sms: Sms; variant: UpstreamVariant }[] = [];
|
||||||
|
|
||||||
|
function variantFromSystemId(systemId: string): UpstreamVariant | undefined {
|
||||||
|
if (systemId === UPSTREAM_USERNAME) return 'main';
|
||||||
|
if (systemId === UPSTREAM_DATASM_USERNAME) return 'datasm';
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { err: upstreamErr, server: upstream } = await server({
|
||||||
|
authenticate: ({ password, systemId }) => {
|
||||||
|
// <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly
|
||||||
|
// (see bootstrap.py) - a longer one made every single connector bind attempt throw.
|
||||||
|
if (password !== 'upstrmpw') return false;
|
||||||
|
|
||||||
|
const variant = variantFromSystemId(systemId);
|
||||||
|
|
||||||
|
return variant ? { userData: { variant } } : false;
|
||||||
|
},
|
||||||
|
// Jasmin's connector sends enquire_link every elink_interval (30s); this host's own contention
|
||||||
|
// (rabbitmq/redis under load) sometimes delays it past our server's 40s default idleTimeout,
|
||||||
|
// dropping the one long-lived connector session. A dropped connection strands whatever submit_sm
|
||||||
|
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
|
||||||
|
// any per-test wait budget here - so this is generous specifically to never be the trigger.
|
||||||
|
idleTimeout: 300_000,
|
||||||
|
port: UPSTREAM_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(upstreamErr, undefined);
|
||||||
|
assert.ok(upstream);
|
||||||
|
|
||||||
|
const upstreamServer = upstream;
|
||||||
|
|
||||||
|
upstreamServer.on('session', session => {
|
||||||
|
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
|
||||||
|
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
|
||||||
|
// userData; userData is only read later, from 'sms', where authenticate() has long since run.
|
||||||
|
session.on('incomingPduObj', pduObj => {
|
||||||
|
if (!pduObj.cmdName.startsWith('bind_')) return;
|
||||||
|
|
||||||
|
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
|
||||||
|
|
||||||
|
if (variant) upstreamSessions.set(variant, session);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('sms', sms => {
|
||||||
|
const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
|
||||||
|
|
||||||
|
if (variant) upstreamSms.push({ sms, variant });
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await sms.sendResp();
|
||||||
|
|
||||||
|
if (sms.dlr) {
|
||||||
|
await delay(150);
|
||||||
|
await sms.sendDlr('DELIVERED');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
|
||||||
|
const found = await waitFor(() => upstreamSessions.get(variant), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `Jasmin's ${variant} connector never bound to our fake upstream within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DlrCallback = { id: string; messageStatus: string };
|
||||||
|
|
||||||
|
const dlrCallbacks: DlrCallback[] = [];
|
||||||
|
|
||||||
|
const httpServer = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://node');
|
||||||
|
|
||||||
|
if (url.pathname === '/dlr') {
|
||||||
|
dlrCallbacks.push({
|
||||||
|
id: url.searchParams.get('id') ?? '',
|
||||||
|
messageStatus: url.searchParams.get('message_status') ?? '',
|
||||||
|
});
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await upstreamServer.close();
|
||||||
|
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** dlr-level 1 (SMSC-ack) fires once, immediately, with message_status ESME_ROK - not a terminal
|
||||||
|
* state - so a caller after the final DELIVRD needs dlr-level 3 (both) and its own status filter. */
|
||||||
|
async function waitForDlrCallback(id: string, status: string, budget = 15_000): Promise<DlrCallback> {
|
||||||
|
const found = await waitFor(() => dlrCallbacks.find(callback => callback.id === id && callback.messageStatus === status), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no dlr callback for id=${id} status=${status} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jasmin's HTTP send API (`/send`): `to` must be digits only, `content` is the message body. */
|
||||||
|
async function httpSend(params: Record<string, string>): Promise<{ body: string; status: number }> {
|
||||||
|
const url = new URL(`http://${HTTP_API_HOST}:${String(HTTP_API_PORT)}/send`);
|
||||||
|
|
||||||
|
url.search = new URLSearchParams({ password: PASSWORD, username: USERNAME, ...params }).toString();
|
||||||
|
|
||||||
|
const response = await fetch(url, { method: 'POST' });
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
return { body, status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sarReference = new ConcatReference();
|
||||||
|
|
||||||
|
/** Splits into SAR segments: `splitMessage()`'s own encoding/chunking, its UDH stripped back off. */
|
||||||
|
function sarPayloads(message: string, encoding?: EncodingName): Buffer[] {
|
||||||
|
const reference = sarReference.next();
|
||||||
|
const segments = splitMessage(message, encoding === undefined ? { reference } : { encoding, reference });
|
||||||
|
|
||||||
|
return segments.length > 1 ? segments.map(segment => segment.subarray(6)) : segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushes a long MO into Jasmin over `session` (Jasmin's smppc connector bound to us) as
|
||||||
|
* `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` segments - Jasmin's own documented
|
||||||
|
* default MT segmentation (target 3). */
|
||||||
|
async function sendSarMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
|
||||||
|
const payloads = sarPayloads(opts.message);
|
||||||
|
const refNum = sarReference.next();
|
||||||
|
|
||||||
|
for (const [index, payload] of payloads.entries()) {
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
destination_addr: opts.to,
|
||||||
|
short_message: payload,
|
||||||
|
source_addr: opts.from,
|
||||||
|
},
|
||||||
|
tlvs: {
|
||||||
|
sar_msg_ref_num: { tagValue: refNum },
|
||||||
|
sar_segment_seqnum: { tagValue: index + 1 },
|
||||||
|
sar_total_segments: { tagValue: payloads.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sent.pduObj);
|
||||||
|
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As `sendSarMo`, but UDH concatenation (esm_class 0x40) - the same shape `sendSms()` emits, and
|
||||||
|
* Jasmin's documented "older system compatibility" alternative. */
|
||||||
|
async function sendUdhMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
|
||||||
|
const reference = sarReference.next();
|
||||||
|
const segments = splitMessage(opts.message, { 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: 'ASCII', 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `deliver_sm` with `sm_length` 0 and the body in `message_payload` (target 2). */
|
||||||
|
async function sendMessagePayloadMo(session: Session, opts: { from: string; message: string; to: string }): ReturnType<Session['send']> {
|
||||||
|
return session.send({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
destination_addr: opts.to,
|
||||||
|
short_message: Buffer.alloc(0),
|
||||||
|
source_addr: opts.from,
|
||||||
|
},
|
||||||
|
tlvs: {
|
||||||
|
// The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
|
||||||
|
message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('C1 - bind, enquire_link, unbind', () => {
|
||||||
|
test('binds transceiver, sees Jasmin\'s own enquire_link, unbinds clean', async () => {
|
||||||
|
// Jasmin's own enquireLinkTimerSecs (30) is an idle timer, not a strict period: our client's
|
||||||
|
// own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a
|
||||||
|
// chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off
|
||||||
|
// enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it.
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const incoming: PduObject[] = [];
|
||||||
|
const closes: true[] = [];
|
||||||
|
const sessionErrors: Error[] = [];
|
||||||
|
|
||||||
|
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
|
||||||
|
session.on('close', () => { closes.push(true); });
|
||||||
|
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||||
|
|
||||||
|
// enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config.
|
||||||
|
const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000);
|
||||||
|
|
||||||
|
assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s');
|
||||||
|
|
||||||
|
const ours = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(ours.err, undefined);
|
||||||
|
assert.ok(ours.pduObj);
|
||||||
|
assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
const unbound = await session.unbind();
|
||||||
|
|
||||||
|
assert.equal(unbound.err, undefined);
|
||||||
|
assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind');
|
||||||
|
assert.deepEqual(sessionErrors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binds transmitter', async t => {
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
assert.equal(session.boundAs, 'transmitter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binds receiver', async t => {
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
assert.equal(session.boundAs, 'receiver');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C13 - maxOutstanding 1 with 10 parallel sends', () => {
|
||||||
|
test('every send is answered, none lost, order preserved at the fake upstream', async t => {
|
||||||
|
await waitForUpstreamSession('main');
|
||||||
|
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const before = upstreamSms.length;
|
||||||
|
const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`);
|
||||||
|
|
||||||
|
const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO })));
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
assert.equal(result.err, undefined);
|
||||||
|
assert.equal(result.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = result.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
ids.push(smsId);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
|
||||||
|
|
||||||
|
const arrivedOrder = await waitFor(() => {
|
||||||
|
const seen = upstreamSms.slice(before).filter(e => e.variant === 'main').map(e => e.sms.message);
|
||||||
|
|
||||||
|
return texts.every(text => seen.includes(text)) ? seen : undefined;
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
assert.ok(arrivedOrder, 'not all 10 messages reached the fake upstream');
|
||||||
|
|
||||||
|
const ordered = arrivedOrder.filter(m => texts.includes(m));
|
||||||
|
|
||||||
|
assert.deepEqual(ordered, texts, 'maxOutstanding:1 should serialise sends end to end, in order');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callback)', () => {
|
||||||
|
test('a message pushed through /send arrives as submit_sm at our server; our receipt fires Jasmin\'s DLR webhook', async () => {
|
||||||
|
const before = upstreamSms.length;
|
||||||
|
// No printf-style placeholders (unlike Kannel's %d/%F): DLRThrower appends its own fixed
|
||||||
|
// query args - id, level, message_status, connector - to this bare URL (confirmed from
|
||||||
|
// jasmin/routing/throwers.py). dlr-method=get puts them in the query string; POST (Jasmin's
|
||||||
|
// own default) would need a form-body reader instead.
|
||||||
|
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr`;
|
||||||
|
|
||||||
|
const sent = await httpSend({
|
||||||
|
content: 's7 http send test',
|
||||||
|
dlr: 'yes',
|
||||||
|
// Level 3 (both): level 1 alone fires once, immediately, with message_status ESME_ROK -
|
||||||
|
// the SMSC-ack, not a terminal state - so proving Jasmin parses our own receipt needs the
|
||||||
|
// terminal-level callback too, which only follows the sendDlr('DELIVERED') below.
|
||||||
|
'dlr-level': '3',
|
||||||
|
'dlr-method': 'get',
|
||||||
|
'dlr-url': dlrUrl,
|
||||||
|
from: FROM,
|
||||||
|
to: TO,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(sent.body, /Success/i);
|
||||||
|
|
||||||
|
const arrived = await waitFor(() => upstreamSms.slice(before).find(e => e.variant === 'main' && e.sms.message === 's7 http send test'), 60_000);
|
||||||
|
|
||||||
|
assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()');
|
||||||
|
|
||||||
|
// Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the
|
||||||
|
// shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback.
|
||||||
|
const msgidMatch = /Success "([^"]+)"/i.exec(sent.body);
|
||||||
|
const msgid = msgidMatch?.[1];
|
||||||
|
|
||||||
|
assert.ok(msgid, `expected /send's response to carry a message id: ${sent.body}`);
|
||||||
|
|
||||||
|
const ack = await waitForDlrCallback(msgid, 'ESME_ROK', 15_000);
|
||||||
|
|
||||||
|
assert.equal(ack.id, msgid);
|
||||||
|
|
||||||
|
const final = await waitForDlrCallback(msgid, 'DELIVRD', 15_000);
|
||||||
|
|
||||||
|
assert.equal(final.id, msgid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
|
||||||
|
const upstreamSession = await waitForUpstreamSession('main');
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sms: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', s => { sms.push(s); });
|
||||||
|
|
||||||
|
const text = `s7-long-${'p'.repeat(300)}`;
|
||||||
|
|
||||||
|
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||||
|
|
||||||
|
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
|
||||||
|
const upstreamSession = await waitForUpstreamSession('main');
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sms: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', s => { sms.push(s); });
|
||||||
|
|
||||||
|
const text = `一😀${'q'.repeat(60)}`;
|
||||||
|
|
||||||
|
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||||
|
|
||||||
|
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('C3+C7 - long MT through the fake upstream, receipts and id consistency', () => {
|
||||||
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(-\d+)?$/i;
|
||||||
|
|
||||||
|
const cases: { encoding?: 'UCS2'; expectedSegments: number; label: string; message: string }[] = [
|
||||||
|
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: '€[]~single segment' },
|
||||||
|
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: `€[]~${'g'.repeat(200)}` },
|
||||||
|
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: `€[]~${'g'.repeat(400)}` },
|
||||||
|
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: `€[]~${'g'.repeat(1450)}` },
|
||||||
|
{ encoding: 'UCS2', expectedSegments: 2, label: '2-segment UCS2 with 一 and an emoji', message: `一😀${'x'.repeat(70)}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
test(testCase.label, async t => {
|
||||||
|
await waitForUpstreamSession('main');
|
||||||
|
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs: { dlr: Dlr; pduObj: PduObject }[] = [];
|
||||||
|
const messageDlrs: unknown[] = [];
|
||||||
|
|
||||||
|
session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
|
||||||
|
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||||
|
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: FROM,
|
||||||
|
message: testCase.message,
|
||||||
|
to: TO,
|
||||||
|
...(testCase.encoding ? { encoding: testCase.encoding } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.equal(sent.smsIds.length, testCase.expectedSegments);
|
||||||
|
|
||||||
|
for (const id of sent.smsIds) {
|
||||||
|
assert.ok(id, 'expected Jasmin to name a message id for every segment');
|
||||||
|
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
|
||||||
|
|
||||||
|
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
|
||||||
|
// our fake upstream's ack+DLR, AMQP again, DLRLookup, deliver_sm back - is slower than a
|
||||||
|
// single-segment send's, and visibly so under load; a generous budget beats a flaky one.
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === id), 25_000);
|
||||||
|
|
||||||
|
assert.ok(received, `no receipt for id ${id}`);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jasmin relays each segment as its own independent submit_sm to the connector (no MT-side
|
||||||
|
// UDH reassembly observed here - see findings), so the ids Jasmin hands back are whatever
|
||||||
|
// the fake upstream's own server() assigned per segment of ITS OWN reassembled view. That
|
||||||
|
// is this library's own <base>-<n> convention on both ends of this harness, which is why
|
||||||
|
// messageDlr can fire here - not evidence of Jasmin producing that convention itself; see
|
||||||
|
// findings/03-jasmin.md for what a real, independent upstream SMSC would hand back instead.
|
||||||
|
void messageDlrs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
|
||||||
|
test('SAR-segmented deliver_sm from the fake upstream', async () => {
|
||||||
|
const upstreamSession = await waitForUpstreamSession('main');
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sms: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', s => { sms.push(s); });
|
||||||
|
|
||||||
|
const text = `sar-mo-${'m'.repeat(300)}`;
|
||||||
|
|
||||||
|
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||||
|
|
||||||
|
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||||
|
const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== '');
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
|
||||||
|
assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms');
|
||||||
|
assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UDH-segmented deliver_sm from the fake upstream', async () => {
|
||||||
|
const upstreamSession = await waitForUpstreamSession('main');
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sms: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', s => { sms.push(s); });
|
||||||
|
|
||||||
|
const text = `udh-mo-${'n'.repeat(300)}`;
|
||||||
|
|
||||||
|
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||||
|
|
||||||
|
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||||
|
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
|
||||||
|
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C8 (target 2) - message_payload with sm_length 0', () => {
|
||||||
|
test('a deliver_sm carrying message_payload instead of short_message', async () => {
|
||||||
|
const upstreamSession = await waitForUpstreamSession('main');
|
||||||
|
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sms: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', s => { sms.push(s); });
|
||||||
|
|
||||||
|
const text = 'message-payload only, sm_length 0';
|
||||||
|
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||||
|
|
||||||
|
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
|
||||||
|
|
||||||
|
const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
|
||||||
|
// Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
|
||||||
|
// whole body has to reach the application from there.
|
||||||
|
assert.ok(arrived, 'expected the message_payload body to arrive as the sms text');
|
||||||
|
assert.equal(arrived.from, TO);
|
||||||
|
assert.equal(arrived.to, FROM);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
|
||||||
|
test('a receipt thrown as data_sm reaches the dlr event', async t => {
|
||||||
|
await waitForUpstreamSession('datasm');
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
const incomingDataSm: PduObject[] = [];
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
|
||||||
|
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
|
||||||
|
|
||||||
|
const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000);
|
||||||
|
|
||||||
|
assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`);
|
||||||
|
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C11 - bind refusal and reconnect backoff', () => {
|
||||||
|
test('wrong password: one attempt, no retry', async () => {
|
||||||
|
const refusals: { cmdStatus: unknown }[] = [];
|
||||||
|
const log = {
|
||||||
|
debug: () => undefined,
|
||||||
|
error: () => undefined,
|
||||||
|
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
|
||||||
|
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
|
||||||
|
},
|
||||||
|
verbose: () => undefined,
|
||||||
|
warn: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||||
|
|
||||||
|
assert.ok(err);
|
||||||
|
assert.equal(session, undefined);
|
||||||
|
await delay(2000);
|
||||||
|
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
|
||||||
|
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rebind refused after a live link drops: backs off, never floods', async t => {
|
||||||
|
const options: Parameters<typeof client>[0] = {
|
||||||
|
host: PEER_HOST,
|
||||||
|
password: PASSWORD,
|
||||||
|
port: PEER_PORT,
|
||||||
|
reconnect: { maxDelay: 4000, minDelay: 1000 },
|
||||||
|
username: USERNAME,
|
||||||
|
};
|
||||||
|
const { err, session } = await client(options);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const disconnectedAt: number[] = [];
|
||||||
|
|
||||||
|
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
|
||||||
|
options.password = 'wrong-after-drop';
|
||||||
|
session.sock.destroy();
|
||||||
|
|
||||||
|
await delay(12_000);
|
||||||
|
|
||||||
|
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 8, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
|
||||||
|
|
||||||
|
for (let i = 1; i < disconnectedAt.length; i++) {
|
||||||
|
const previous = disconnectedAt[i - 1];
|
||||||
|
const current = disconnectedAt[i];
|
||||||
|
|
||||||
|
assert.ok(previous !== undefined && current !== undefined);
|
||||||
|
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
|
||||||
|
test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => {
|
||||||
|
await waitForUpstreamSession('main');
|
||||||
|
|
||||||
|
const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refused = results.filter(r => r.err !== undefined);
|
||||||
|
|
||||||
|
assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends');
|
||||||
|
assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/);
|
||||||
|
|
||||||
|
const keepalive = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(keepalive.err, undefined);
|
||||||
|
assert.ok(keepalive.pduObj);
|
||||||
|
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
// 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling
|
||||||
|
// finds the next open slot instead of guessing it.
|
||||||
|
const deadline = Date.now() + 25_000;
|
||||||
|
let later: Awaited<ReturnType<typeof session.sendSms>> | undefined;
|
||||||
|
|
||||||
|
while (!later && Date.now() < deadline) {
|
||||||
|
const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO });
|
||||||
|
|
||||||
|
if (!attempt.err) later = attempt;
|
||||||
|
else await delay(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(later, 'expected a later send to succeed once the quota\'s next slot opened');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import net from 'node:net';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { PduRefusedError } from '../src/index.ts';
|
||||||
|
import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080';
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriverResult = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** The jsmpp driver's own HTTP command channel - one bound session or raw socket per `session`/`raw` name. */
|
||||||
|
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
|
||||||
|
const url = `http://${JSMPP_HOST}${path}?${new URLSearchParams(params).toString()}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
return response.json() as Promise<DriverResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSms: { session: Session; sms: Sms }[] = [];
|
||||||
|
const allSessionErrors: { err: Error; session: Session }[] = [];
|
||||||
|
const bindPdus: Record<string, unknown>[] = [];
|
||||||
|
/** Messages a test answers itself (a refusing status, or asserting on the response) - populate
|
||||||
|
* before triggering the submit that will carry this exact text, so the global auto-ack never runs. */
|
||||||
|
const manualTexts = new Set<string>();
|
||||||
|
|
||||||
|
const { err: serverErr, server: smpp } = await server({
|
||||||
|
authenticate: () => true,
|
||||||
|
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_')) bindPdus.push(pduObj.params);
|
||||||
|
});
|
||||||
|
session.on('sms', sms => {
|
||||||
|
allSms.push({ session, sms });
|
||||||
|
if (!manualTexts.has(sms.message)) void sms.sendResp();
|
||||||
|
});
|
||||||
|
session.on('sessionError', err => { allSessionErrors.push({ err, session }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smppServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForSms(message: string, budget = 8000): Promise<Sms> {
|
||||||
|
const found = await waitFor(() => allSms.find(entry => entry.sms.message === message)?.sms, budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived (seen: ${JSON.stringify(allSms.map(e => e.sms.message))})`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessions(count: number, budget = 15_000): Promise<Session[]> {
|
||||||
|
const found = await waitFor(() => ([...smppServer.sessions].length >= count ? [...smppServer.sessions] : undefined), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bind version negotiation (target 8)', () => {
|
||||||
|
test('0x34: our bind_resp carries sc_interface_version, jsmpp negotiates 3.4', async () => {
|
||||||
|
const result = await driver('/bind', { interfaceVersion: '52', password: 'jsmpppw', session: 'v34', systemId: 'jsmpp-v34' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.negotiatedInterfaceVersion, 52);
|
||||||
|
|
||||||
|
await waitForSessions(1);
|
||||||
|
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v34'));
|
||||||
|
|
||||||
|
assert.ok(bind);
|
||||||
|
assert.equal(bind.interface_version, 0x34);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('0x33: our bind_resp carries no TLVs, and jsmpp reports back what it asked for', async () => {
|
||||||
|
const result = await driver('/bind', { interfaceVersion: '51', password: 'jsmpppw', session: 'v33', systemId: 'jsmpp-v33' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
// jsmpp's session.getInterfaceVersion() simply echoes what the driver declared - it does not
|
||||||
|
// visibly fall back to 3.4 here despite the absent sc_interface_version TLV, which is the
|
||||||
|
// opposite of what its own source (a scVersion-null branch defaulting to IF_34) suggests.
|
||||||
|
// Recorded as observed rather than as a confirmation of that code path - see Peer quirks.
|
||||||
|
assert.equal(result.negotiatedInterfaceVersion, 51);
|
||||||
|
|
||||||
|
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v33'));
|
||||||
|
|
||||||
|
assert.ok(bind);
|
||||||
|
assert.equal(bind.interface_version, 0x33);
|
||||||
|
|
||||||
|
const session = [...smppServer.sessions].find(s => s.peerInterfaceVersion === 0x33);
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
assert.equal(session.acceptsOptionalParams(), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => {
|
||||||
|
test('UDH, 8-bit reference: one reassembled sms, each segment answered <base>-<n>', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const text = 'u8-'.padEnd(200, 'a');
|
||||||
|
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh8', session: 'v34', text, to: '2001' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const segments = result.segments as { messageId: string; part: number; total: number }[];
|
||||||
|
|
||||||
|
assert.equal(segments.length, 2);
|
||||||
|
|
||||||
|
const sms = await waitForSms(text, 15_000);
|
||||||
|
|
||||||
|
assert.equal(sms.answeredOnArrival, true);
|
||||||
|
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||||
|
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UDH, 16-bit reference: also reassembled - our library reads both widths', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const text = 'u16-'.padEnd(200, 'b');
|
||||||
|
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh16', session: 'v34', text, to: '2001' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const segments = result.segments as { messageId: string }[];
|
||||||
|
|
||||||
|
const sms = await waitForSms(text, 15_000);
|
||||||
|
|
||||||
|
assert.equal(sms.answeredOnArrival, true);
|
||||||
|
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||||
|
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message_payload: one sms, the full text', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
// No underscore: GSM 03.38's default alphabet maps ASCII 0x5F to section-sign, not "_" -
|
||||||
|
// the driver's "gsm7" mode sends plain ASCII bytes, so a real underscore round-trips wrong
|
||||||
|
// on purpose (a GSM7 encoder bug in this fixture, not in @larvit/smpp).
|
||||||
|
const text = 'payload carries the whole body in one submit sm';
|
||||||
|
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'payload', session: 'v34', text, to: '2001' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
|
||||||
|
const sms = await waitForSms(text);
|
||||||
|
|
||||||
|
assert.equal(sms.message, text);
|
||||||
|
assert.equal(sms.answeredOnArrival, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sar_* (target 3): one reassembled sms, each segment answered <base>-<n>', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const text = 'sar-'.padEnd(200, 'c');
|
||||||
|
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'sar', session: 'v34', text, to: '2001' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const segments = result.segments as { messageId: string; part: number }[];
|
||||||
|
|
||||||
|
assert.equal(segments.length, 2);
|
||||||
|
|
||||||
|
const sms = await waitForSms(text, 15_000);
|
||||||
|
|
||||||
|
assert.equal(sms.answeredOnArrival, true);
|
||||||
|
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||||
|
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||||
|
// Neither ~130-char slice ever reached the application on its own.
|
||||||
|
assert.equal(allSms.filter(entry => text.includes(entry.sms.message)).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => {
|
||||||
|
test('query_sm, cancel_sm, replace_sm: ESME_RINVCMDID, link survives, jsmpp accepts the answer', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const errorsBefore = allSessionErrors.length;
|
||||||
|
|
||||||
|
for (const path of ['/querySm', '/cancelSm', '/replaceSm']) {
|
||||||
|
const result = await driver(path, { messageId: '1', session: 'v34' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.refused, true);
|
||||||
|
assert.equal(result.commandStatus, 0x0003);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsmpp itself did not throw or close the link over any of the three refusals.
|
||||||
|
const link = await driver('/enquireLink', { session: 'v34' });
|
||||||
|
|
||||||
|
assert.equal(link.ok, true);
|
||||||
|
assert.equal(link.sessionState, 'BOUND_TRX');
|
||||||
|
assert.equal(allSessionErrors.length, errorsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown command id gets generic_nack ESME_RINVCMDID, and the link survives', async () => {
|
||||||
|
await driver('/rawBind', { interfaceVersion: '52', password: 'rawpw', raw: 'malformed', systemId: 'jsmpp-raw' });
|
||||||
|
|
||||||
|
const result = await driver('/rawUnknownCommand', { raw: 'malformed' });
|
||||||
|
const response = result.response as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(response.cmdIdHex, '0x80000000');
|
||||||
|
assert.equal(response.cmdStatusHex, '0x3');
|
||||||
|
|
||||||
|
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError
|
||||||
|
&& e.err.reason === 'command'));
|
||||||
|
|
||||||
|
assert.ok(refused);
|
||||||
|
|
||||||
|
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
|
||||||
|
const linkResponse = link.response as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(linkResponse.cmdStatusHex, '0x0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deliver_sm with a truncated TLV stream gets ESME_RINVTLVSTREAM, link survives', async () => {
|
||||||
|
const result = await driver('/rawTruncatedTlv', { raw: 'malformed' });
|
||||||
|
const response = result.response as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(response.cmdIdHex, '0x80000005');
|
||||||
|
assert.equal(response.cmdStatusHex, '0xc0');
|
||||||
|
|
||||||
|
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError && e.err.reason === 'tlvs'));
|
||||||
|
|
||||||
|
assert.ok(refused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deliver_sm whose body is shorter than sm_length declares gets ESME_RINVCMDLEN', async () => {
|
||||||
|
const result = await driver('/rawShortBody', { raw: 'malformed' });
|
||||||
|
const response = result.response as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(response.cmdIdHex, '0x80000005');
|
||||||
|
assert.equal(response.cmdStatusHex, '0x2');
|
||||||
|
|
||||||
|
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
|
||||||
|
const linkResponse = link.response as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(linkResponse.cmdStatusHex, '0x0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage), so this
|
||||||
|
// is a raw fixture opened directly against our server().
|
||||||
|
test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no listener', async t => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const sock = net.connect(SMPP_PORT, '127.0.0.1');
|
||||||
|
|
||||||
|
t.after(() => { sock.destroy(); });
|
||||||
|
await new Promise<void>(resolve => { sock.once('connect', () => { resolve(); }); });
|
||||||
|
|
||||||
|
sock.write(pduBytes({
|
||||||
|
cmdName: 'bind_transceiver',
|
||||||
|
params: { interface_version: 0x34, password: 'pw', system_id: 'rawverify' },
|
||||||
|
seqNr: 1,
|
||||||
|
}));
|
||||||
|
await new Promise<void>(resolve => { sock.once('data', () => { resolve(); }); });
|
||||||
|
|
||||||
|
const responsePromise = new Promise<Buffer>(resolve => { sock.once('data', data => { resolve(data); }); });
|
||||||
|
|
||||||
|
sock.write(bareTlvHeader({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
destination_addr: 'raw2-to',
|
||||||
|
short_message: 'truncated tlv probe silent',
|
||||||
|
source_addr: 'raw2-from',
|
||||||
|
},
|
||||||
|
seqNr: 777,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await responsePromise;
|
||||||
|
|
||||||
|
assert.equal(response.readUInt32BE(4), 0x80000005);
|
||||||
|
assert.equal(response.readUInt32BE(8), 0x000000C0);
|
||||||
|
assert.equal(response.readUInt32BE(12), 777);
|
||||||
|
|
||||||
|
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError
|
||||||
|
&& e.err.header.seqNr === 777));
|
||||||
|
|
||||||
|
assert.ok(refused);
|
||||||
|
assert.ok(refused.err instanceof PduRefusedError);
|
||||||
|
assert.equal(refused.err.reason, 'tlvs');
|
||||||
|
assert.equal(allSms.some(entry => entry.sms.message === 'truncated tlv probe silent'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a refusing status is surfaced back to jsmpp', () => {
|
||||||
|
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches jsmpp as a NegativeResponseException', async () => {
|
||||||
|
await waitForSessions(1);
|
||||||
|
|
||||||
|
const text = 'refuse-me';
|
||||||
|
|
||||||
|
manualTexts.add(text);
|
||||||
|
|
||||||
|
const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' });
|
||||||
|
const sms = await waitForSms(text);
|
||||||
|
|
||||||
|
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
|
||||||
|
|
||||||
|
const result = await submitted;
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.refused, true);
|
||||||
|
assert.equal(result.commandStatusHex, '0x' + (0x00000014).toString(16));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T | undefined> {
|
||||||
|
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<Variant, MoCallback[]>();
|
||||||
|
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<void>(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<string, unknown>; 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<void>(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<Session[]> {
|
||||||
|
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<string, string>): 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<Sms> {
|
||||||
|
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<MoCallback> {
|
||||||
|
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<DlrCallback> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# cloudhopper-smpp (the fizzed fork) has no published artifact; cloned at a fixed commit and
|
||||||
|
# installed into the local Maven repo, which the driver build below then depends on.
|
||||||
|
FROM maven:3.9.11-eclipse-temurin-21 AS cloudhopper-source
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG CLOUDHOPPER_COMMIT=ae6485a86c968d344bebf0fa180928905b4a8ad1
|
||||||
|
|
||||||
|
# The parent pom (fizzed-maven-parent:1.15) hardcodes source/target 1.7 in its own compiler-plugin
|
||||||
|
# config, which -Dmaven.compiler.source cannot override; ch-smpp's own pom declares no <build> of
|
||||||
|
# its own, so injecting one here (source/target 8, otherwise unmodified) is the narrowest override.
|
||||||
|
RUN git clone https://github.com/fizzed/cloudhopper-smpp.git /src \
|
||||||
|
&& cd /src \
|
||||||
|
&& git checkout "${CLOUDHOPPER_COMMIT}" \
|
||||||
|
&& sed -i 's#</project>#<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><source>8</source><target>8</target></configuration></plugin></plugins></build></project>#' pom.xml \
|
||||||
|
&& mvn -q install -Dmaven.test.skip=true -Dgpg.skip=true -Dmaven.javadoc.skip=true
|
||||||
|
|
||||||
|
# A self-signed cert this image trusts, generated at build time - never committed. server.key/crt
|
||||||
|
# are PEM (for our server()'s own tls option); truststore.jks is what the driver's SSL client trusts.
|
||||||
|
FROM eclipse-temurin:21.0.8_9-jre-jammy AS certs
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN mkdir -p /certs \
|
||||||
|
&& openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
|
||||||
|
-keyout /certs/server.key -out /certs/server.crt -subj "/CN=interop-cloudhopper" \
|
||||||
|
&& keytool -importcert -noprompt -alias interop -file /certs/server.crt \
|
||||||
|
-keystore /certs/truststore.jks -storepass changeit \
|
||||||
|
&& openssl pkcs12 -export -in /certs/server.crt -inkey /certs/server.key \
|
||||||
|
-out /certs/keystore.p12 -name interop -password pass:changeit
|
||||||
|
|
||||||
|
FROM maven:3.9.11-eclipse-temurin-21 AS build
|
||||||
|
|
||||||
|
COPY --from=cloudhopper-source /root/.m2 /root/.m2
|
||||||
|
|
||||||
|
WORKDIR /driver
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -q package -DskipTests
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /driver/target/driver.jar ./driver.jar
|
||||||
|
COPY --from=certs /certs /certs
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
# node's test file needs the same server.key/server.crt to configure its own tls option; the
|
||||||
|
# S10 compose overlay mounts a shared volume at /shared-certs on both this service and node.
|
||||||
|
ENTRYPOINT ["sh", "-c", "cp /certs/server.key /certs/server.crt /shared-certs/ 2>/dev/null && chmod 644 /shared-certs/server.key /shared-certs/server.crt || true; exec java -jar driver.jar \"$@\"", "--"]
|
||||||
|
CMD ["node", "2775"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>se.larvit.interop</groupId>
|
||||||
|
<artifactId>cloudhopper-driver</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fizzed</groupId>
|
||||||
|
<artifactId>ch-smpp</artifactId>
|
||||||
|
<version>5.0.10-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-simple</artifactId>
|
||||||
|
<version>1.7.36</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>driver</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.2</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>smpp.interop.cloudhopper.Driver</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package smpp.interop.cloudhopper;
|
||||||
|
|
||||||
|
import com.cloudhopper.smpp.SmppBindType;
|
||||||
|
import com.cloudhopper.smpp.SmppSession;
|
||||||
|
import com.cloudhopper.smpp.SmppSessionConfiguration;
|
||||||
|
import com.cloudhopper.smpp.impl.DefaultSmppClient;
|
||||||
|
import com.cloudhopper.smpp.impl.DefaultSmppSessionHandler;
|
||||||
|
import com.cloudhopper.smpp.pdu.PduRequest;
|
||||||
|
import com.cloudhopper.smpp.pdu.SubmitSm;
|
||||||
|
import com.cloudhopper.smpp.pdu.SubmitSmResp;
|
||||||
|
import com.cloudhopper.smpp.ssl.SslConfiguration;
|
||||||
|
import com.cloudhopper.smpp.type.Address;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTTP-driven Cloudhopper ESME: bind with a chosen window configuration, then fire concurrent
|
||||||
|
* submits against our (possibly deliberately slow) server and report per-request outcomes and the
|
||||||
|
* observed send-window occupancy, so the node test file can assert none lost, none duplicated.
|
||||||
|
*/
|
||||||
|
public final class Driver {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Driver.class);
|
||||||
|
private static final Map<String, SmppSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private static final AtomicInteger expiredCount = new AtomicInteger(0);
|
||||||
|
private static DefaultSmppClient client;
|
||||||
|
private static ScheduledThreadPoolExecutor monitorExecutor;
|
||||||
|
private static ExecutorService ioExecutor;
|
||||||
|
private static String host;
|
||||||
|
private static int port;
|
||||||
|
|
||||||
|
private Driver() { }
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
host = args.length > 0 ? args[0] : "node";
|
||||||
|
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
|
||||||
|
|
||||||
|
ioExecutor = Executors.newCachedThreadPool();
|
||||||
|
monitorExecutor = new ScheduledThreadPoolExecutor(2);
|
||||||
|
client = new DefaultSmppClient(Executors.newCachedThreadPool(), 50, monitorExecutor);
|
||||||
|
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||||
|
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
|
||||||
|
server.createContext("/bind", Driver::handleBind);
|
||||||
|
server.createContext("/unbind", Driver::handleUnbind);
|
||||||
|
server.createContext("/submit", Driver::handleSubmit);
|
||||||
|
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
||||||
|
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
||||||
|
server.setExecutor(null);
|
||||||
|
server.start();
|
||||||
|
System.out.println("cloudhopper driver listening on 8080, target " + host + ":" + port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP plumbing (same shape as the jsmpp driver's, kept independent on purpose) ---
|
||||||
|
|
||||||
|
private static Map<String, String> queryParams(HttpExchange exchange) {
|
||||||
|
Map<String, String> params = new LinkedHashMap<>();
|
||||||
|
String query = exchange.getRequestURI().getRawQuery();
|
||||||
|
|
||||||
|
if (query == null) return params;
|
||||||
|
|
||||||
|
for (String pair : query.split("&")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
String key = eq < 0 ? pair : pair.substring(0, eq);
|
||||||
|
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
|
||||||
|
params.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respond(HttpExchange exchange, int status, String body) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(status, bytes.length);
|
||||||
|
|
||||||
|
try (OutputStream out = exchange.getResponseBody()) {
|
||||||
|
out.write(bytes);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// The client gave up reading; nothing left to answer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
|
||||||
|
respond(exchange, 200, Json.write(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respondErr(HttpExchange exchange, Exception e) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", false);
|
||||||
|
result.put("errorClass", e.getClass().getName());
|
||||||
|
result.put("error", String.valueOf(e.getMessage()));
|
||||||
|
respond(exchange, 200, Json.write(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
|
||||||
|
private static void handleBind(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
String name = p.getOrDefault("session", "default");
|
||||||
|
|
||||||
|
try {
|
||||||
|
SmppSessionConfiguration config = new SmppSessionConfiguration();
|
||||||
|
config.setName(name);
|
||||||
|
config.setType(SmppBindType.TRANSCEIVER);
|
||||||
|
config.setHost(p.getOrDefault("host", host));
|
||||||
|
config.setPort(Integer.parseInt(p.getOrDefault("port", String.valueOf(port))));
|
||||||
|
config.setConnectTimeout(10_000);
|
||||||
|
config.setSystemId(p.getOrDefault("systemId", "cloudhopper"));
|
||||||
|
config.setPassword(p.getOrDefault("password", "chpw"));
|
||||||
|
config.setWindowSize(Integer.parseInt(p.getOrDefault("windowSize", "1")));
|
||||||
|
config.setRequestExpiryTimeout(Long.parseLong(p.getOrDefault("requestExpiryTimeout", "30000")));
|
||||||
|
config.setWindowMonitorInterval(Long.parseLong(p.getOrDefault("windowMonitorInterval", "15000")));
|
||||||
|
config.setCountersEnabled(true);
|
||||||
|
|
||||||
|
if (Boolean.parseBoolean(p.getOrDefault("useSsl", "false"))) {
|
||||||
|
// Cloudhopper's SslContextFactory only skips keystore loading when *neither* store is
|
||||||
|
// configured - a trust-store-only client falls through to loadKeyStore() with a null
|
||||||
|
// path and fails, so the build-time self-signed cert also gets used as the (otherwise
|
||||||
|
// unneeded) client keystore.
|
||||||
|
SslConfiguration ssl = new SslConfiguration();
|
||||||
|
ssl.setTrustStorePath("/certs/truststore.jks");
|
||||||
|
ssl.setTrustStorePassword("changeit");
|
||||||
|
ssl.setKeyStorePath("/certs/keystore.p12");
|
||||||
|
ssl.setKeyStorePassword("changeit");
|
||||||
|
ssl.setKeyStoreType("PKCS12");
|
||||||
|
config.setUseSsl(true);
|
||||||
|
config.setSslConfiguration(ssl);
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultSmppSessionHandler handler = new DefaultSmppSessionHandler(log) {
|
||||||
|
@Override
|
||||||
|
public void firePduRequestExpired(PduRequest pduRequest) {
|
||||||
|
expiredCount.incrementAndGet();
|
||||||
|
log.warn("PDU request expired in window monitor: {}", pduRequest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SmppSession session = client.bind(config, handler);
|
||||||
|
sessions.put(name, session);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleUnbind(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SmppSession session = sessions.remove(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session != null) {
|
||||||
|
session.unbind(5000);
|
||||||
|
session.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
respondOk(exchange, Map.of("ok", true));
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubmitSm buildSubmit(String from, String to, String text)
|
||||||
|
throws com.cloudhopper.smpp.type.SmppInvalidArgumentException {
|
||||||
|
SubmitSm submit = new SubmitSm();
|
||||||
|
submit.setSourceAddress(new Address((byte) 0x01, (byte) 0x01, from));
|
||||||
|
submit.setDestAddress(new Address((byte) 0x01, (byte) 0x01, to));
|
||||||
|
submit.setShortMessage(text.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
|
||||||
|
return submit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleSubmit(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "10000"));
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
SubmitSmResp resp = session.submit(
|
||||||
|
buildSubmit(p.getOrDefault("from", "1000"), p.getOrDefault("to", "2000"), p.getOrDefault("text", "hi")),
|
||||||
|
timeoutMs);
|
||||||
|
long elapsed = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("messageId", resp.getMessageId());
|
||||||
|
result.put("commandStatus", resp.getCommandStatus());
|
||||||
|
result.put("elapsedMs", (int) elapsed);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
|
||||||
|
private static void handleWindowBurst(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = Integer.parseInt(p.getOrDefault("count", "10"));
|
||||||
|
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
|
||||||
|
String from = p.getOrDefault("from", "1000");
|
||||||
|
String to = p.getOrDefault("to", "2000");
|
||||||
|
String prefix = p.getOrDefault("prefix", "burst");
|
||||||
|
|
||||||
|
AtomicInteger peakWindow = new AtomicInteger(0);
|
||||||
|
Thread sampler = new Thread(() -> {
|
||||||
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
|
try {
|
||||||
|
int size = session.getSendWindow().getSize();
|
||||||
|
peakWindow.updateAndGet(prev -> Math.max(prev, size));
|
||||||
|
Thread.sleep(10);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sampler.setDaemon(true);
|
||||||
|
sampler.start();
|
||||||
|
|
||||||
|
List<Future<Map<String, Object>>> futures = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int index = i;
|
||||||
|
futures.add(ioExecutor.submit((Callable<Map<String, Object>>) () -> {
|
||||||
|
Map<String, Object> entry = new LinkedHashMap<>();
|
||||||
|
entry.put("index", index);
|
||||||
|
|
||||||
|
try {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
SubmitSmResp resp = session.submit(buildSubmit(from, to, prefix + "-" + index), timeoutMs);
|
||||||
|
entry.put("ok", true);
|
||||||
|
entry.put("messageId", resp.getMessageId());
|
||||||
|
entry.put("elapsedMs", (int) (System.currentTimeMillis() - start));
|
||||||
|
} catch (Exception e) {
|
||||||
|
entry.put("ok", false);
|
||||||
|
entry.put("errorClass", e.getClass().getSimpleName());
|
||||||
|
entry.put("error", String.valueOf(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Object> results = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Future<Map<String, Object>> f : futures) {
|
||||||
|
try {
|
||||||
|
results.add(f.get());
|
||||||
|
} catch (Exception e) {
|
||||||
|
results.add(Map.of("ok", false, "error", String.valueOf(e.getMessage())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sampler.interrupt();
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("results", results);
|
||||||
|
result.put("peakWindowSize", peakWindow.get());
|
||||||
|
result.put("expiredCount", expiredCount.get());
|
||||||
|
respondOk(exchange, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleSendWindowSize(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
respondOk(exchange, Map.of("ok", true, "size", session.getSendWindow().getSize()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package smpp.interop.cloudhopper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
|
||||||
|
final class Json {
|
||||||
|
private Json() { }
|
||||||
|
|
||||||
|
static String write(Object value) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
writeValue(sb, value);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void writeValue(StringBuilder sb, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
sb.append("null");
|
||||||
|
} else if (value instanceof String s) {
|
||||||
|
writeString(sb, s);
|
||||||
|
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
|
||||||
|
sb.append(value);
|
||||||
|
} else if (value instanceof Map<?, ?> map) {
|
||||||
|
sb.append('{');
|
||||||
|
boolean first = true;
|
||||||
|
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
first = false;
|
||||||
|
writeString(sb, String.valueOf(entry.getKey()));
|
||||||
|
sb.append(':');
|
||||||
|
writeValue(sb, entry.getValue());
|
||||||
|
}
|
||||||
|
sb.append('}');
|
||||||
|
} else if (value instanceof List<?> list) {
|
||||||
|
sb.append('[');
|
||||||
|
boolean first = true;
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
first = false;
|
||||||
|
writeValue(sb, item);
|
||||||
|
}
|
||||||
|
sb.append(']');
|
||||||
|
} else if (value instanceof byte[] bytes) {
|
||||||
|
writeString(sb, hex(bytes));
|
||||||
|
} else {
|
||||||
|
writeString(sb, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeString(StringBuilder sb, String s) {
|
||||||
|
sb.append('"');
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '"' -> sb.append("\\\"");
|
||||||
|
case '\\' -> sb.append("\\\\");
|
||||||
|
case '\n' -> sb.append("\\n");
|
||||||
|
case '\r' -> sb.append("\\r");
|
||||||
|
case '\t' -> sb.append("\\t");
|
||||||
|
default -> {
|
||||||
|
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
else sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String hex(byte[] bytes) {
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# smpp-dumb-client has no published image; built from source at a pinned commit. A second binary
|
||||||
|
# is built from the same source with its own automatic enquire_link neutralised, for S6 - see
|
||||||
|
# findings/07-load.md for why (every peer built for this phase sends enquire_link on its own
|
||||||
|
# otherwise, and smppload, the one that never does, is blocked).
|
||||||
|
FROM --platform=linux/amd64 golang:1.26.8-alpine3.23 AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
ARG LIBSMPP_COMMIT=de0334bf2c1155fb3dd4f929674968254c932be6
|
||||||
|
|
||||||
|
RUN git clone https://github.com/vponomarev/libsmpp.git /build \
|
||||||
|
&& cd /build \
|
||||||
|
&& git checkout "${LIBSMPP_COMMIT}"
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
RUN go build -o /out/smpp-dumb-client ./app/smpp-dumb-client
|
||||||
|
|
||||||
|
# libsmpp's enquireSender(60)/enquireSender(10) are the two call sites that start its unconditional,
|
||||||
|
# unconfigurable 10s/60s enquire_link ticker (smpp.go) - both neutralised here for the no-ping build.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's/go s\.enquireSender(60)/\/\/ patched for S6 (no enquire_link): &/' \
|
||||||
|
-e 's/go s\.enquireSender(10)/\/\/ patched for S6 (no enquire_link): &/' \
|
||||||
|
smpp.go \
|
||||||
|
&& go build -o /out/smpp-dumb-client-noping ./app/smpp-dumb-client
|
||||||
|
|
||||||
|
FROM --platform=linux/amd64 alpine:3.23.5 AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache netcat-openbsd
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/smpp-dumb-client /app/smpp-dumb-client
|
||||||
|
COPY --from=builder /out/smpp-dumb-client-noping /app/smpp-dumb-client-noping
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
COPY conf ./conf
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/smpp-dumb-client /app/smpp-dumb-client-noping
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-idle
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - S6 idle probe"
|
||||||
|
count: 1
|
||||||
|
rate: 1
|
||||||
|
window: 10
|
||||||
|
# Sends its one message, gets the response, then goes silent - run through the no-ping binary
|
||||||
|
# (see the Dockerfile), so nothing at all crosses the wire after that: no enquire_link, no traffic.
|
||||||
|
stayConnected: yes
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-soak
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - long soak, fast handler"
|
||||||
|
count: 300000
|
||||||
|
rate: 500
|
||||||
|
window: 100
|
||||||
|
stayConnected: yes
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-w2000
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - window 2000, above maxHeldMessages (S9)"
|
||||||
|
count: 20000
|
||||||
|
rate: 2000
|
||||||
|
window: 2000
|
||||||
|
stayConnected: no
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-w500
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - window 500, below maxHeldMessages"
|
||||||
|
count: 20000
|
||||||
|
rate: 2000
|
||||||
|
window: 500
|
||||||
|
stayConnected: no
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# smpp-dumb-client is one-shot and dials out, so it needs node:2775 already listening - the same
|
||||||
|
# problem compose.smppload.yaml's entrypoint solves, and the same fix: a healthcheck this marker
|
||||||
|
# satisfies at once, and node depends on it rather than the other way round.
|
||||||
|
#
|
||||||
|
# Its own smpp.remote config field is fed straight into net.ParseIP with no DNS resolution at all
|
||||||
|
# (hdr.go), so the compose service name in every conf/*.yml is a NODE_HOST placeholder, resolved
|
||||||
|
# here and substituted into a writable copy before the real binary ever sees the config file.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
touch /tmp/healthy
|
||||||
|
|
||||||
|
host="${SMPP_HOST:-node}"
|
||||||
|
port="${SMPP_PORT:-2775}"
|
||||||
|
|
||||||
|
until nc -z "$host" "$port"; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
ip="$(getent hosts "$host" | awk '{print $1}' | head -n1)"
|
||||||
|
sed "s/NODE_HOST/$ip/" "$1" > /tmp/effective-config.yml
|
||||||
|
|
||||||
|
exec "${DUMBCLIENT_BIN:-/app/smpp-dumb-client}" -config /tmp/effective-config.yml
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Boot-time jcli bootstrap for one Jasmin instance: group, users, an smppc connector pointing at
|
||||||
|
our own server(), and the MT/MO routes that wire it up. jcli (port 8990) is a Twisted telnet
|
||||||
|
console: no negotiation reply is needed, the server proceeds regardless (confirmed empirically)."""
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
JCLI_HOST = os.environ.get('JCLI_HOST', 'jasmin')
|
||||||
|
JCLI_PORT = int(os.environ.get('JCLI_PORT', '8990'))
|
||||||
|
JCLI_USER = os.environ.get('JCLI_USER', 'jcliadmin')
|
||||||
|
JCLI_PASS = os.environ.get('JCLI_PASS', 'jclipwd')
|
||||||
|
|
||||||
|
GROUP = os.environ.get('GROUP', 'clients')
|
||||||
|
ESME_UID = os.environ.get('ESME_UID', 'esme1')
|
||||||
|
ESME_PASSWORD = os.environ.get('ESME_PASSWORD', 'esme1pw')
|
||||||
|
ESME2_UID = os.environ.get('ESME2_UID', 'esme2')
|
||||||
|
ESME2_PASSWORD = os.environ.get('ESME2_PASSWORD', 'esme2pw')
|
||||||
|
ESME2_THROUGHPUT = os.environ.get('ESME2_THROUGHPUT', '0.1')
|
||||||
|
|
||||||
|
CONNECTOR_CID = os.environ.get('CONNECTOR_CID', 'upstream')
|
||||||
|
CONNECTOR_HOST = os.environ.get('CONNECTOR_HOST', 'node')
|
||||||
|
CONNECTOR_PORT = os.environ.get('CONNECTOR_PORT', '2777')
|
||||||
|
CONNECTOR_USERNAME = os.environ.get('CONNECTOR_USERNAME', 'upstreamesme')
|
||||||
|
# <=8 chars: SMPP's password is a C-octet-string with an 8-char + NUL wire maximum, which Jasmin's
|
||||||
|
# own smpp.pdu encoder enforces strictly when it builds the connector's own bind PDU (unlike our
|
||||||
|
# library's encoder, which is permissive) - a longer one throws mid-bind on every single attempt.
|
||||||
|
CONNECTOR_PASSWORD = os.environ.get('CONNECTOR_PASSWORD', 'upstrmpw')
|
||||||
|
|
||||||
|
|
||||||
|
class Jcli:
|
||||||
|
def __init__(self, host, port):
|
||||||
|
last_err = None
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
self.sock = socket.create_connection((host, port), timeout=5)
|
||||||
|
self.sock.settimeout(5)
|
||||||
|
self._drain()
|
||||||
|
return
|
||||||
|
except OSError as err:
|
||||||
|
last_err = err
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f'could not reach jcli at {host}:{port}: {last_err}')
|
||||||
|
|
||||||
|
def _drain(self, wait=0.4):
|
||||||
|
time.sleep(wait)
|
||||||
|
buf = b''
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def send(self, line, wait=0.4):
|
||||||
|
self.sock.sendall(line.encode() + b'\r\n')
|
||||||
|
return self._drain(wait).decode(errors='replace')
|
||||||
|
|
||||||
|
def login(self, user, password):
|
||||||
|
self._drain()
|
||||||
|
self.send(user)
|
||||||
|
reply = self.send(password)
|
||||||
|
if 'Welcome to Jasmin' not in reply:
|
||||||
|
raise RuntimeError(f'jcli login failed: {reply!r}')
|
||||||
|
|
||||||
|
def run(self, *lines, label=''):
|
||||||
|
"""Sends a sequence ending in 'ok' and fails loudly if Jasmin refused it. Checking only for
|
||||||
|
keywords missed 'Failed adding connector, check log for details' once, which left the
|
||||||
|
session stuck at the '>' sub-prompt and every later command misread as a key inside it - so
|
||||||
|
this also insists the last reply lands back on the top-level 'jcli :' prompt."""
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
out.append(self.send(line))
|
||||||
|
joined = '\n'.join(out)
|
||||||
|
last = out[-1] if out else ''
|
||||||
|
back_at_top = last.rstrip().endswith('jcli :')
|
||||||
|
keyword_hit = any(word in joined.lower() for word in ('error', 'must set', 'unknown', 'invalid', 'failed'))
|
||||||
|
if keyword_hit or not back_at_top:
|
||||||
|
raise RuntimeError(f'{label} failed (last reply {last!r}):\n{joined}')
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
jcli = Jcli(JCLI_HOST, JCLI_PORT)
|
||||||
|
jcli.login(JCLI_USER, JCLI_PASS)
|
||||||
|
|
||||||
|
print(jcli.run('group -a', f'gid {GROUP}', 'ok', label='group'))
|
||||||
|
|
||||||
|
print(jcli.run(
|
||||||
|
'user -a', f'uid {ESME_UID}', f'gid {GROUP}', f'username {ESME_UID}', f'password {ESME_PASSWORD}', 'ok',
|
||||||
|
label='user esme1',
|
||||||
|
))
|
||||||
|
|
||||||
|
print(jcli.run(
|
||||||
|
'user -a', f'uid {ESME2_UID}', f'gid {GROUP}', f'username {ESME2_UID}', f'password {ESME2_PASSWORD}', 'ok',
|
||||||
|
label='user esme2',
|
||||||
|
))
|
||||||
|
print(jcli.run(
|
||||||
|
f'user -u {ESME2_UID}', f'mt_messaging_cred quota smpps_throughput {ESME2_THROUGHPUT}', 'ok',
|
||||||
|
label='user esme2 throughput quota',
|
||||||
|
))
|
||||||
|
|
||||||
|
print(jcli.run(
|
||||||
|
'smppccm -a',
|
||||||
|
f'cid {CONNECTOR_CID}',
|
||||||
|
f'host {CONNECTOR_HOST}',
|
||||||
|
f'port {CONNECTOR_PORT}',
|
||||||
|
f'username {CONNECTOR_USERNAME}',
|
||||||
|
f'password {CONNECTOR_PASSWORD}',
|
||||||
|
'bind transceiver',
|
||||||
|
'con_fail_delay 2',
|
||||||
|
'con_loss_delay 2',
|
||||||
|
# The connector's own default (1) is 1 msg/s - a second submit while the first is still
|
||||||
|
# in flight (a message's 2nd segment, or a 2nd message sent right after) then never reaches
|
||||||
|
# the connector's peer at all inside any sane test budget; see findings/03-jasmin.md.
|
||||||
|
'submit_throughput 0',
|
||||||
|
'ok',
|
||||||
|
label='smppccm',
|
||||||
|
))
|
||||||
|
started = jcli.send(f'smppccm -1 {CONNECTOR_CID}')
|
||||||
|
print(started)
|
||||||
|
if 'Successfully started' not in started:
|
||||||
|
raise RuntimeError(f'smppccm -1 {CONNECTOR_CID} failed: {started!r}')
|
||||||
|
|
||||||
|
print(jcli.run(
|
||||||
|
'mtrouter -a', 'type DefaultRoute', 'order 0', f'connector smppc({CONNECTOR_CID})', 'rate 0.0', 'ok',
|
||||||
|
label='mtrouter',
|
||||||
|
))
|
||||||
|
print(jcli.run(
|
||||||
|
'morouter -a', 'type DefaultRoute', 'order 0', f'connector smpps({ESME_UID})', 'ok',
|
||||||
|
label='morouter',
|
||||||
|
))
|
||||||
|
|
||||||
|
jcli.send('persist')
|
||||||
|
jcli.send('quit', wait=0.2)
|
||||||
|
print('jasmin bootstrap complete')
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
#
|
||||||
|
# This is the main Jasmin SMS gateway configuration file.
|
||||||
|
# For any modifications to this file, refer to Jasmin Documentation.
|
||||||
|
# If that does not help, post your question on Jasmin's web forum
|
||||||
|
# hosted at Google Groups: https://groups.google.com/group/jasmin-sms-gateway
|
||||||
|
#
|
||||||
|
# Do NOT simply read the instructions in here without understanding
|
||||||
|
# what they do. They're here only as hints or reminders. If you are unsure
|
||||||
|
# consult the online docs.
|
||||||
|
|
||||||
|
[smpp-server]
|
||||||
|
|
||||||
|
# SMPP Server identifier
|
||||||
|
#id = "smpps_01"
|
||||||
|
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 0.0.0.0
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 2775
|
||||||
|
#port = 2775
|
||||||
|
|
||||||
|
# Activate billing feature
|
||||||
|
# May be disabled if not needed/used
|
||||||
|
#billing_feature = True
|
||||||
|
|
||||||
|
# Timeout for response to bind request
|
||||||
|
#sessionInitTimerSecs = 30
|
||||||
|
|
||||||
|
# Enquire link interval
|
||||||
|
#enquireLinkTimerSecs = 30
|
||||||
|
|
||||||
|
# Maximum time lapse allowed between transactions, after which,
|
||||||
|
# the connection is considered as inactive
|
||||||
|
#inactivityTimerSecs = 300
|
||||||
|
|
||||||
|
# Timeout for responses to any request PDU
|
||||||
|
#responseTimerSecs = 60
|
||||||
|
|
||||||
|
# Timeout for reading a single PDU, this is the maximum lapse of time between
|
||||||
|
# receiving PDU's header and its complete read, if the PDU reading timed out,
|
||||||
|
# the connection is considered as 'corrupt' and will reconnect
|
||||||
|
#pduReadTimerSecs = 10
|
||||||
|
|
||||||
|
# When message is routed to a SMPP Client connecter: How much time it is kept in
|
||||||
|
# redis waiting for receipt
|
||||||
|
#dlr_expiry = 86400
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/default-smpps_01.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = midnight
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
#log_privacy = False
|
||||||
|
|
||||||
|
[smpp-server-pb]
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 0.0.0.0
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 14000
|
||||||
|
#port = 14000
|
||||||
|
|
||||||
|
# If authentication is True, access will require entering a username and password
|
||||||
|
# as defined in admin_username and admin_password, you can disable this security
|
||||||
|
# layer by setting authentication to False, in this case admin_* values are ignored.
|
||||||
|
#authentication = True
|
||||||
|
#admin_username = smppsadmin
|
||||||
|
# This is a MD5 password digest hex encoded
|
||||||
|
#admin_password = e97ab122faa16beea8682d84f3d2eea4
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/smpp-server-pb.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[client-management]
|
||||||
|
# Jasmin persists its configuration profiles in /etc/jasmin/store by
|
||||||
|
# default. You can specify a custom location here
|
||||||
|
#store_path = /etc/jasmin/store
|
||||||
|
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 0.0.0.0
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 8989
|
||||||
|
#port = 8989
|
||||||
|
|
||||||
|
# If authentication is True, access will require entering a username and password
|
||||||
|
# as defined in admin_username and admin_password, you can disable this security
|
||||||
|
# layer by setting authentication to False, in this case admin_* values are ignored.
|
||||||
|
#authentication = True
|
||||||
|
#admin_username = cmadmin
|
||||||
|
# This is a MD5 password digest hex encoded
|
||||||
|
#admin_password = e1c5136acafb7016bc965597c992eb82
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/smppclient-manager.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
# The protocol version used to pickle objects before transfering
|
||||||
|
# them to client side, this is used in the client manager only,
|
||||||
|
# the pickle protocol defined in SMPPClientManagerPBProxy is set
|
||||||
|
# to 2 and is not configurable
|
||||||
|
#pickle_protocol = 2
|
||||||
|
|
||||||
|
[service-smppclient]
|
||||||
|
# For each smppclient connector a service is associated
|
||||||
|
# refer to "Message flows" documentation for more details
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/service-smppclients.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[sm-listener]
|
||||||
|
# SM listener consumes submit_sm and deliver_sm messages from amqp broker
|
||||||
|
# refer to "Message flows" documentation for more details
|
||||||
|
|
||||||
|
# If publish_submit_sm_resp is True, any received SubmitSm PDU will be published
|
||||||
|
# to the 'messaging' exchange on 'submit.sm.resp.CID' route, useful when you have
|
||||||
|
# a third party application waiting for these messages.
|
||||||
|
#publish_submit_sm_resp = False
|
||||||
|
|
||||||
|
# If the error is defined in submit_error_retrial, Jasmin will retry sending submit_sm if it
|
||||||
|
# gets one of these errors.
|
||||||
|
# submit_sm retrial will be executed 'count' times and delayed for 'delay' seconds each time.
|
||||||
|
#submit_error_retrial = {
|
||||||
|
# 'ESME_RSYSERR': {'count': 2, 'delay': 30},
|
||||||
|
# 'ESME_RTHROTTLED': {'count': 20, 'delay': 30},
|
||||||
|
# 'ESME_RMSGQFUL': {'count': 2, 'delay': 180},
|
||||||
|
# 'ESME_RINVSCHED': {'count': 2, 'delay': 300},
|
||||||
|
# }
|
||||||
|
|
||||||
|
# The maximum number of seconds a message can stay in queue waiting for SMPPC to get ready for
|
||||||
|
# delivey (connected and bound).
|
||||||
|
#submit_max_age_smppc_not_ready = 1200
|
||||||
|
|
||||||
|
# Delay (seconds) when retrying a submit with a not-yet ready SMPPc
|
||||||
|
# Hint: for large scale messaging deployment, it is advised to set this value to few seconds
|
||||||
|
# in order to keep Jasmin free.
|
||||||
|
#submit_retrial_delay_smppc_not_ready = 30
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/messages.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = midnight
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
#log_privacy = False
|
||||||
|
|
||||||
|
[dlr]
|
||||||
|
# DLRLookup process id
|
||||||
|
#pid = main
|
||||||
|
|
||||||
|
# DLRLookup mechanism configuration
|
||||||
|
#dlr_lookup_retry_delay = 10
|
||||||
|
#dlr_lookup_max_retries = 2
|
||||||
|
|
||||||
|
# If smpp_receipt_on_success_submit_sm_resp is True, every connected user to smpp server will
|
||||||
|
# receive a receipt (data_sm or deliver_sm) whenever a submit_sm_resp is received
|
||||||
|
# for a message he sent and requested receipt for it.
|
||||||
|
#smpp_receipt_on_success_submit_sm_resp = False
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/messages.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = midnight
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
#log_privacy = False
|
||||||
|
|
||||||
|
[amqp-broker]
|
||||||
|
host=rabbitmq
|
||||||
|
port=5672
|
||||||
|
# The following directives define the way how Jasmin is connecting to the AMQP Broker,
|
||||||
|
# default values must work with a freshly installed RabbitMQ server.
|
||||||
|
#host = 127.0.0.1
|
||||||
|
#vhost = /
|
||||||
|
#spec = /etc/jasmin/resource/amqp0-9-1.xml
|
||||||
|
#port = 5672
|
||||||
|
#username = guest
|
||||||
|
#password = guest
|
||||||
|
#heartbeat = 0
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/amqp-client.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
#connection_loss_retry = True
|
||||||
|
#connection_failure_retry = True
|
||||||
|
#connection_loss_retry_delay = 10
|
||||||
|
#connection_loss_failure_delay = 10
|
||||||
|
|
||||||
|
[http-api]
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 0.0.0.0
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 1401
|
||||||
|
#port = 1401
|
||||||
|
|
||||||
|
# Activate billing feature
|
||||||
|
# May be disabled if not needed/used
|
||||||
|
#billing_feature = True
|
||||||
|
|
||||||
|
# How many message parts you can get for a long message, default is 5 so you
|
||||||
|
# can't exceed 800 characters (160x5) when sending a long latin message.
|
||||||
|
#long_content_max_parts = 5
|
||||||
|
|
||||||
|
# Splitting long content can be made through SAR options or UDH
|
||||||
|
# Possible values are: sar and udh
|
||||||
|
#long_content_split = udh
|
||||||
|
|
||||||
|
# Specify the access log file path
|
||||||
|
#access_log = /var/log/jasmin/http-access.log
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/http-api.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
#log_privacy = False
|
||||||
|
|
||||||
|
[router]
|
||||||
|
# Jasmin router persists its routing configuration profiles in /etc/jasmin/store by
|
||||||
|
# default. You can specify a custom location here
|
||||||
|
#store_path = /etc/jasmin/store
|
||||||
|
|
||||||
|
# Router will automatically persist users and groups to disk whenever a critical information
|
||||||
|
# is updated (ex: user balance), persistence is executed every persistence_timer_secs
|
||||||
|
#persistence_timer_secs = 60
|
||||||
|
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 0.0.0.0
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 8988
|
||||||
|
#port = 8988
|
||||||
|
|
||||||
|
# If authentication is True, access will require entering a username and password
|
||||||
|
# as defined in admin_username and admin_password, you can disable this security
|
||||||
|
# layer by setting authentication to False, in this case admin_* values are ignored.
|
||||||
|
#authentication = True
|
||||||
|
#admin_username = radmin
|
||||||
|
# This is a MD5 password digest hex encoded
|
||||||
|
#admin_password = 82a606ca5a0deea2b5777756788af5c8
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/router.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
# The protocol version used to pickle objects before transfering
|
||||||
|
# them to client side, this is used in the client manager only,
|
||||||
|
# the pickle protocol defined in SMPPClientManagerPBProxy is set
|
||||||
|
# to 2 and is not configurable
|
||||||
|
#pickle_protocol = 2
|
||||||
|
|
||||||
|
[deliversm-thrower]
|
||||||
|
# The following directives define the process of delivery SMS-MO through http to third party
|
||||||
|
# application, it is explained in "HTTP API" documentation
|
||||||
|
# Sets socket timeout in seconds for outgoing client http connections.
|
||||||
|
#http_timeout = 30
|
||||||
|
# Define how many seconds should pass within the queuing system for retrying a failed throw.
|
||||||
|
#retry_delay = 30
|
||||||
|
# Define how many retries should be performed for failing throws of SMS-MO.
|
||||||
|
#max_retries = 3
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/deliversm-thrower.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[dlr-thrower]
|
||||||
|
# The following directives define the process of delivering delivery-receipts through http to third party
|
||||||
|
# application, it is explained in "HTTP API" documentation
|
||||||
|
# Sets socket timeout in seconds for outgoing client http connections.
|
||||||
|
#http_timeout = 30
|
||||||
|
# Define how many seconds should pass within the queuing system for retrying a failed throw.
|
||||||
|
#retry_delay = 30
|
||||||
|
# Define how many retries should be performed for failing throws of DLR.
|
||||||
|
#max_retries = 3
|
||||||
|
|
||||||
|
# Specify the pdu type to consider when throwing a receipt through SMPPs, possible values:
|
||||||
|
# - data_sm
|
||||||
|
# - deliver_sm (default pdu)
|
||||||
|
dlr_pdu = data_sm
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/dlr-thrower.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[redis-client]
|
||||||
|
host=redis
|
||||||
|
port=6379
|
||||||
|
# The following directives define the way how Jasmin is connecting to the redis server,
|
||||||
|
# default values must work with a freshly installed redis server.
|
||||||
|
#host = 127.0.0.1
|
||||||
|
#port = 6379
|
||||||
|
#dbid = 0
|
||||||
|
#password = None
|
||||||
|
#poolsize = 10
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/redis-client.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[jcli]
|
||||||
|
bind=0.0.0.0
|
||||||
|
# If you want you can bind a single interface, you can specify its IP here
|
||||||
|
#bind = 127.0.0.1
|
||||||
|
|
||||||
|
# Accept connections on the specified port, default is 8990
|
||||||
|
#port = 8990
|
||||||
|
|
||||||
|
# If authentication is True, access will require entering a username and password
|
||||||
|
# as defined in admin_username and admin_password, you can disable this security
|
||||||
|
# layer by setting authentication to False, in this case admin_* values are ignored.
|
||||||
|
#authentication = True
|
||||||
|
#admin_username = jcliadmin
|
||||||
|
# This is a MD5 password digest hex encoded
|
||||||
|
#admin_password = 79e9b0aa3f3e7c53e916f7ac47439bcb
|
||||||
|
|
||||||
|
# Specify the server verbosity level.
|
||||||
|
# This can be one of:
|
||||||
|
# NOTSET (disable logging)
|
||||||
|
# DEBUG (a lot of information, useful for development/testing)
|
||||||
|
# INFO (moderately verbose, what you want in production probably)
|
||||||
|
# WARNING (only very important / critical messages and errors are logged)
|
||||||
|
# ERROR (only errors / critical messages are logged)
|
||||||
|
# CRITICAL (only critical messages are logged)
|
||||||
|
#log_level = INFO
|
||||||
|
|
||||||
|
# Specify the log file path
|
||||||
|
#log_file = /var/log/jasmin/jcli.log
|
||||||
|
|
||||||
|
# When to rotate the log file, possible values:
|
||||||
|
# S: Seconds
|
||||||
|
# M: Minutes
|
||||||
|
# H: Hours
|
||||||
|
# D: Days
|
||||||
|
# W0-W6: Weekday (0=Monday)
|
||||||
|
# midnight: Roll over at midnight
|
||||||
|
#log_rotate = W6
|
||||||
|
|
||||||
|
# The following directives define logging patterns including:
|
||||||
|
# - log_format: using python logging's attributes
|
||||||
|
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
|
||||||
|
# -log_date_format: using python strftime formating directives
|
||||||
|
# refer to https://docs.python.org/2/library/time.html#time.strftime
|
||||||
|
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
|
||||||
|
#log_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
[interceptor-client]
|
||||||
|
# The following directives define client connector to InterceptorPB, it's used when jasmind
|
||||||
|
# is started with --enable-interceptor-client
|
||||||
|
#host = 127.0.0.1
|
||||||
|
#port = 8987
|
||||||
|
#username = iadmin
|
||||||
|
#password = ipwd
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# jsmpp has no published artifact for this pin; cloned at a fixed commit and installed into the
|
||||||
|
# local Maven repo, which the driver build below then depends on.
|
||||||
|
FROM maven:3.9.11-eclipse-temurin-21 AS jsmpp-source
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG JSMPP_COMMIT=a24db96ad7014cdc84a3eebfa64a75c362daef2a
|
||||||
|
|
||||||
|
RUN git clone https://github.com/opentelecoms-org/jsmpp.git /src \
|
||||||
|
&& cd /src \
|
||||||
|
&& git checkout "${JSMPP_COMMIT}" \
|
||||||
|
&& mvn -q -pl jsmpp -am install -DskipTests -Dgpg.skip=true
|
||||||
|
|
||||||
|
FROM maven:3.9.11-eclipse-temurin-21 AS build
|
||||||
|
|
||||||
|
COPY --from=jsmpp-source /root/.m2 /root/.m2
|
||||||
|
|
||||||
|
WORKDIR /driver
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -q package -DskipTests
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /driver/target/driver.jar ./driver.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "driver.jar"]
|
||||||
|
CMD ["node", "2775"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>se.larvit.interop</groupId>
|
||||||
|
<artifactId>jsmpp-driver</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsmpp</groupId>
|
||||||
|
<artifactId>jsmpp</artifactId>
|
||||||
|
<version>3.0.3-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>driver</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.2</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>smpp.interop.jsmpp.Driver</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package smpp.interop.jsmpp;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
import org.jsmpp.InvalidResponseException;
|
||||||
|
import org.jsmpp.PDUException;
|
||||||
|
import org.jsmpp.bean.Alphabet;
|
||||||
|
import org.jsmpp.bean.AlertNotification;
|
||||||
|
import org.jsmpp.bean.BindType;
|
||||||
|
import org.jsmpp.bean.DataSm;
|
||||||
|
import org.jsmpp.bean.DeliverSm;
|
||||||
|
import org.jsmpp.bean.ESMClass;
|
||||||
|
import org.jsmpp.bean.GeneralDataCoding;
|
||||||
|
import org.jsmpp.bean.NumberingPlanIndicator;
|
||||||
|
import org.jsmpp.bean.OptionalParameter;
|
||||||
|
import org.jsmpp.bean.RegisteredDelivery;
|
||||||
|
import org.jsmpp.bean.SMSCDeliveryReceipt;
|
||||||
|
import org.jsmpp.bean.TypeOfNumber;
|
||||||
|
import org.jsmpp.bean.InterfaceVersion;
|
||||||
|
import org.jsmpp.extra.NegativeResponseException;
|
||||||
|
import org.jsmpp.extra.ProcessRequestException;
|
||||||
|
import org.jsmpp.extra.ResponseTimeoutException;
|
||||||
|
import org.jsmpp.session.BindParameter;
|
||||||
|
import org.jsmpp.session.MessageReceiverListener;
|
||||||
|
import org.jsmpp.session.QuerySmResult;
|
||||||
|
import org.jsmpp.session.SMPPSession;
|
||||||
|
import org.jsmpp.session.Session;
|
||||||
|
import org.jsmpp.session.SubmitSmResult;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
|
||||||
|
* the SMPP server named by host/port args, and answers with the result as JSON. Kept alive as one
|
||||||
|
* process so a session can be reused across several requests, the way a real ESME would.
|
||||||
|
*/
|
||||||
|
public final class Driver {
|
||||||
|
private static final Map<String, SMPPSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Integer> requestedVersions = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Socket> rawSockets = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Integer> rawSeqNr = new ConcurrentHashMap<>();
|
||||||
|
private static String host;
|
||||||
|
private static int port;
|
||||||
|
|
||||||
|
private Driver() { }
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
host = args.length > 0 ? args[0] : "node";
|
||||||
|
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
|
||||||
|
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||||
|
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
|
||||||
|
server.createContext("/bind", Driver::handleBind);
|
||||||
|
server.createContext("/unbind", Driver::handleUnbind);
|
||||||
|
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
||||||
|
server.createContext("/submit", Driver::handleSubmit);
|
||||||
|
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
||||||
|
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
||||||
|
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
|
||||||
|
server.createContext("/rawBind", Driver::handleRawBind);
|
||||||
|
server.createContext("/rawUnknownCommand", exchange -> handleRawAction(exchange, RawSmpp::unknownCommandPdu));
|
||||||
|
server.createContext("/rawTruncatedTlv", exchange -> handleRawAction(exchange, RawSmpp::truncatedTlvDeliverSmPdu));
|
||||||
|
server.createContext("/rawShortBody", exchange -> handleRawAction(exchange, RawSmpp::shortBodyDeliverSmPdu));
|
||||||
|
server.createContext("/rawEnquireLink", exchange -> handleRawAction(exchange, RawSmpp::enquireLinkPdu));
|
||||||
|
server.setExecutor(null);
|
||||||
|
server.start();
|
||||||
|
System.out.println("jsmpp driver listening on 8080, target " + host + ":" + port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP plumbing ---
|
||||||
|
|
||||||
|
private static Map<String, String> queryParams(HttpExchange exchange) {
|
||||||
|
Map<String, String> params = new LinkedHashMap<>();
|
||||||
|
String query = exchange.getRequestURI().getRawQuery();
|
||||||
|
|
||||||
|
if (query == null) return params;
|
||||||
|
|
||||||
|
for (String pair : query.split("&")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
String key = eq < 0 ? pair : pair.substring(0, eq);
|
||||||
|
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
|
||||||
|
params.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respond(HttpExchange exchange, int status, String body) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(status, bytes.length);
|
||||||
|
|
||||||
|
try (OutputStream out = exchange.getResponseBody()) {
|
||||||
|
out.write(bytes);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// The client gave up reading; nothing left to answer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
|
||||||
|
respond(exchange, 200, Json.write(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respondErr(HttpExchange exchange, Exception e) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", false);
|
||||||
|
result.put("errorClass", e.getClass().getName());
|
||||||
|
result.put("error", String.valueOf(e.getMessage()));
|
||||||
|
|
||||||
|
if (e instanceof NegativeResponseException nre) {
|
||||||
|
result.put("commandStatus", nre.getCommandStatus());
|
||||||
|
result.put("commandStatusHex", "0x" + Integer.toHexString(nre.getCommandStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(exchange, 200, Json.write(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- jsmpp-backed handlers ---
|
||||||
|
|
||||||
|
private static void handleBind(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
String name = p.getOrDefault("session", "default");
|
||||||
|
|
||||||
|
try {
|
||||||
|
SMPPSession session = new SMPPSession();
|
||||||
|
session.setMessageReceiverListener(new NoopListener());
|
||||||
|
|
||||||
|
String type = p.getOrDefault("type", "transceiver");
|
||||||
|
BindType bindType = switch (type) {
|
||||||
|
case "receiver" -> BindType.BIND_RX;
|
||||||
|
case "transmitter" -> BindType.BIND_TX;
|
||||||
|
default -> BindType.BIND_TRX;
|
||||||
|
};
|
||||||
|
|
||||||
|
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
|
||||||
|
InterfaceVersion interfaceVersion = InterfaceVersion.valueOf((byte) ifVersion);
|
||||||
|
|
||||||
|
String systemId = p.getOrDefault("systemId", "jsmpp");
|
||||||
|
String password = p.getOrDefault("password", "jsmpppw");
|
||||||
|
|
||||||
|
BindParameter bindParameter = new BindParameter(bindType, systemId, password, "interop",
|
||||||
|
TypeOfNumber.UNKNOWN, NumberingPlanIndicator.UNKNOWN, null, interfaceVersion);
|
||||||
|
|
||||||
|
String scSystemId = session.connectAndBind(host, port, bindParameter);
|
||||||
|
|
||||||
|
sessions.put(name, session);
|
||||||
|
requestedVersions.put(name, ifVersion);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("scSystemId", scSystemId);
|
||||||
|
result.put("requestedInterfaceVersion", ifVersion);
|
||||||
|
result.put("negotiatedInterfaceVersion", session.getInterfaceVersion().value() & 0xFF);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleUnbind(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SMPPSession session = sessions.remove(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session != null) session.unbindAndClose();
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleEnquireLink(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
result.put("ok", false);
|
||||||
|
result.put("error", "no such session");
|
||||||
|
respondOk(exchange, result);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("sessionState", session.getSessionState().name());
|
||||||
|
respondOk(exchange, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] encode(String text, String encoding) {
|
||||||
|
return switch (encoding) {
|
||||||
|
case "ucs2" -> text.getBytes(StandardCharsets.UTF_16BE);
|
||||||
|
case "latin1" -> text.getBytes(StandardCharsets.ISO_8859_1);
|
||||||
|
default -> text.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GeneralDataCoding dataCoding(String encoding) {
|
||||||
|
Alphabet alphabet = switch (encoding) {
|
||||||
|
case "ucs2" -> Alphabet.ALPHA_UCS2;
|
||||||
|
case "latin1" -> Alphabet.ALPHA_LATIN1;
|
||||||
|
default -> Alphabet.ALPHA_DEFAULT;
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GeneralDataCoding(alphabet, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunk size chosen well under any segment limit for every encoding this driver sends. */
|
||||||
|
private static final int CHUNK_CHARS = 130;
|
||||||
|
|
||||||
|
private static void handleSubmit(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String from = p.getOrDefault("from", "12345");
|
||||||
|
String to = p.getOrDefault("to", "67890");
|
||||||
|
String text = p.getOrDefault("text", "hello");
|
||||||
|
String mode = p.getOrDefault("mode", "plain");
|
||||||
|
String encoding = p.getOrDefault("encoding", "gsm7");
|
||||||
|
|
||||||
|
try {
|
||||||
|
java.util.List<Map<String, Object>> segments = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "udh8" -> submitUdh(session, from, to, text, encoding, false, segments);
|
||||||
|
case "udh16" -> submitUdh(session, from, to, text, encoding, true, segments);
|
||||||
|
case "sar" -> submitSar(session, from, to, text, encoding, segments);
|
||||||
|
case "payload" -> submitPayload(session, from, to, text, encoding, segments);
|
||||||
|
default -> submitPlain(session, from, to, text, encoding, segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("segments", segments);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (NegativeResponseException e) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("refused", true);
|
||||||
|
result.put("commandStatus", e.getCommandStatus());
|
||||||
|
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
|
||||||
|
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||||
|
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||||
|
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||||
|
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||||
|
encode(text, encoding));
|
||||||
|
segments.add(Map.of("messageId", r.getMessageId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.List<String> chunk(String text, int size) {
|
||||||
|
java.util.List<String> out = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < text.length(); i += size) {
|
||||||
|
out.add(text.substring(i, Math.min(text.length(), i + size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void submitUdh(SMPPSession session, String from, String to, String text, String encoding,
|
||||||
|
boolean sixteenBit, java.util.List<Map<String, Object>> segments) throws Exception {
|
||||||
|
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
|
||||||
|
int reference = sixteenBit ? 0x1234 : 0x42;
|
||||||
|
int total = chunks.size();
|
||||||
|
|
||||||
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
|
byte[] chunkBytes = encode(chunks.get(i), encoding);
|
||||||
|
byte[] udh = sixteenBit
|
||||||
|
? new byte[] { 0x06, 0x08, 0x04, (byte) ((reference >> 8) & 0xFF), (byte) (reference & 0xFF), (byte) total, (byte) (i + 1) }
|
||||||
|
: new byte[] { 0x05, 0x00, 0x03, (byte) reference, (byte) total, (byte) (i + 1) };
|
||||||
|
byte[] shortMessage = new byte[udh.length + chunkBytes.length];
|
||||||
|
System.arraycopy(udh, 0, shortMessage, 0, udh.length);
|
||||||
|
System.arraycopy(chunkBytes, 0, shortMessage, udh.length, chunkBytes.length);
|
||||||
|
|
||||||
|
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||||
|
new ESMClass(0x40), (byte) 0, (byte) 1, null, null,
|
||||||
|
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||||
|
shortMessage);
|
||||||
|
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void submitSar(SMPPSession session, String from, String to, String text, String encoding,
|
||||||
|
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||||
|
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
|
||||||
|
int reference = 0x77;
|
||||||
|
int total = chunks.size();
|
||||||
|
|
||||||
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
|
byte[] chunkBytes = encode(chunks.get(i), encoding);
|
||||||
|
OptionalParameter refNum = new OptionalParameter.Sar_msg_ref_num((short) reference);
|
||||||
|
OptionalParameter totalSegments = new OptionalParameter.Sar_total_segments((byte) total);
|
||||||
|
OptionalParameter seqNum = new OptionalParameter.Sar_segment_seqnum((byte) (i + 1));
|
||||||
|
|
||||||
|
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||||
|
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||||
|
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||||
|
chunkBytes, refNum, totalSegments, seqNum);
|
||||||
|
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void submitPayload(SMPPSession session, String from, String to, String text, String encoding,
|
||||||
|
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||||
|
byte[] payload = encode(text, encoding);
|
||||||
|
OptionalParameter messagePayload = new OptionalParameter.Message_payload(payload);
|
||||||
|
|
||||||
|
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||||
|
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||||
|
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||||
|
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||||
|
new byte[0], messagePayload);
|
||||||
|
segments.add(Map.of("messageId", r.getMessageId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleUnhandledCommand(HttpExchange exchange, String which) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||||
|
String messageId = p.getOrDefault("messageId", "0");
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (which) {
|
||||||
|
case "query" -> {
|
||||||
|
QuerySmResult r = session.queryShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
|
||||||
|
NumberingPlanIndicator.UNKNOWN, "12345");
|
||||||
|
respondOk(exchange, Map.of("ok", true, "refused", false, "finalDate", String.valueOf(r.getFinalDate())));
|
||||||
|
}
|
||||||
|
case "cancel" -> {
|
||||||
|
session.cancelShortMessage("CMT", messageId, TypeOfNumber.INTERNATIONAL,
|
||||||
|
NumberingPlanIndicator.UNKNOWN, "12345", TypeOfNumber.INTERNATIONAL,
|
||||||
|
NumberingPlanIndicator.UNKNOWN, "67890");
|
||||||
|
respondOk(exchange, Map.of("ok", true, "refused", false));
|
||||||
|
}
|
||||||
|
case "replace" -> {
|
||||||
|
session.replaceShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
|
||||||
|
NumberingPlanIndicator.UNKNOWN, "12345", null, null,
|
||||||
|
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, "replacement".getBytes(StandardCharsets.US_ASCII));
|
||||||
|
respondOk(exchange, Map.of("ok", true, "refused", false));
|
||||||
|
}
|
||||||
|
default -> respond(exchange, 400, "{}");
|
||||||
|
}
|
||||||
|
} catch (NegativeResponseException e) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("refused", true);
|
||||||
|
result.put("commandStatus", e.getCommandStatus());
|
||||||
|
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- raw-socket handlers, for PDUs jsmpp's typed API cannot construct ---
|
||||||
|
|
||||||
|
private static void handleRawBind(HttpExchange exchange) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
String name = p.getOrDefault("raw", "default");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Socket sock = new Socket();
|
||||||
|
sock.connect(new InetSocketAddress(host, port), 5000);
|
||||||
|
|
||||||
|
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
|
||||||
|
int seqNr = 1;
|
||||||
|
RawSmpp.write(sock, RawSmpp.bindTransceiverPdu(
|
||||||
|
p.getOrDefault("systemId", "rawjsmpp"), p.getOrDefault("password", "rawpw"), ifVersion, seqNr));
|
||||||
|
|
||||||
|
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
|
||||||
|
rawSockets.put(name, sock);
|
||||||
|
rawSeqNr.put(name, seqNr + 1);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("bindResp", resp);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface PduBuilder {
|
||||||
|
byte[] build(int seqNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleRawAction(HttpExchange exchange, PduBuilder builder) {
|
||||||
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
String name = p.getOrDefault("raw", "default");
|
||||||
|
Socket sock = rawSockets.get(name);
|
||||||
|
|
||||||
|
if (sock == null) {
|
||||||
|
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such raw socket - call rawBind first")));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int seqNr = rawSeqNr.getOrDefault(name, 1);
|
||||||
|
RawSmpp.write(sock, builder.build(seqNr));
|
||||||
|
rawSeqNr.put(name, seqNr + 1);
|
||||||
|
|
||||||
|
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
result.put("response", resp);
|
||||||
|
respondOk(exchange, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
respondErr(exchange, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoopListener implements MessageReceiverListener {
|
||||||
|
@Override
|
||||||
|
public void onAcceptDeliverSm(DeliverSm deliverSm) throws ProcessRequestException {
|
||||||
|
// This phase's scenarios never have the SMSC push a deliver_sm to jsmpp.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAcceptAlertNotification(AlertNotification alertNotification) {
|
||||||
|
// Nothing to do: no scenario here sends one.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public org.jsmpp.session.DataSmResult onAcceptDataSm(DataSm dataSm, Session source)
|
||||||
|
throws ProcessRequestException {
|
||||||
|
throw new ProcessRequestException("data_sm not supported by this driver", 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package smpp.interop.jsmpp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
|
||||||
|
final class Json {
|
||||||
|
private Json() { }
|
||||||
|
|
||||||
|
static String write(Object value) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
writeValue(sb, value);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void writeValue(StringBuilder sb, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
sb.append("null");
|
||||||
|
} else if (value instanceof String s) {
|
||||||
|
writeString(sb, s);
|
||||||
|
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
|
||||||
|
sb.append(value);
|
||||||
|
} else if (value instanceof Map<?, ?> map) {
|
||||||
|
sb.append('{');
|
||||||
|
boolean first = true;
|
||||||
|
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
first = false;
|
||||||
|
writeString(sb, String.valueOf(entry.getKey()));
|
||||||
|
sb.append(':');
|
||||||
|
writeValue(sb, entry.getValue());
|
||||||
|
}
|
||||||
|
sb.append('}');
|
||||||
|
} else if (value instanceof List<?> list) {
|
||||||
|
sb.append('[');
|
||||||
|
boolean first = true;
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
first = false;
|
||||||
|
writeValue(sb, item);
|
||||||
|
}
|
||||||
|
sb.append(']');
|
||||||
|
} else if (value instanceof byte[] bytes) {
|
||||||
|
writeString(sb, hex(bytes));
|
||||||
|
} else {
|
||||||
|
writeString(sb, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeString(StringBuilder sb, String s) {
|
||||||
|
sb.append('"');
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '"' -> sb.append("\\\"");
|
||||||
|
case '\\' -> sb.append("\\\\");
|
||||||
|
case '\n' -> sb.append("\\n");
|
||||||
|
case '\r' -> sb.append("\\r");
|
||||||
|
case '\t' -> sb.append("\\t");
|
||||||
|
default -> {
|
||||||
|
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
else sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String hex(byte[] bytes) {
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package smpp.interop.jsmpp;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hand-rolled SMPP encoder over a plain socket, for the malformed PDUs jsmpp's own typed API
|
||||||
|
* cannot construct (target 1: unknown command id, a truncated TLV stream, a body shorter than it
|
||||||
|
* declares). Only what these scenarios need - a bind and the malformed shapes - not a real client.
|
||||||
|
*/
|
||||||
|
final class RawSmpp {
|
||||||
|
private RawSmpp() { }
|
||||||
|
|
||||||
|
private static void u8(ByteArrayOutputStream out, int v) {
|
||||||
|
out.write(v & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u32(ByteArrayOutputStream out, long v) {
|
||||||
|
out.write((int) ((v >> 24) & 0xFF));
|
||||||
|
out.write((int) ((v >> 16) & 0xFF));
|
||||||
|
out.write((int) ((v >> 8) & 0xFF));
|
||||||
|
out.write((int) (v & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cstring(ByteArrayOutputStream out, String s) {
|
||||||
|
if (s != null && !s.isEmpty()) out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1));
|
||||||
|
out.write(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] pdu(int cmdId, int status, int seqNr, byte[] body) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
u32(out, 16L + body.length);
|
||||||
|
u32(out, cmdId);
|
||||||
|
u32(out, status);
|
||||||
|
u32(out, seqNr);
|
||||||
|
out.writeBytes(body);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] bindTransceiverPdu(String systemId, String password, int interfaceVersion, int seqNr) {
|
||||||
|
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||||
|
cstring(body, systemId);
|
||||||
|
cstring(body, password);
|
||||||
|
cstring(body, "");
|
||||||
|
u8(body, interfaceVersion);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, "");
|
||||||
|
return pdu(0x00000009, 0, seqNr, body.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** command_id 0x00050001 names no SMPP 3.4 command - an empty body is a well-framed PDU. */
|
||||||
|
static byte[] unknownCommandPdu(int seqNr) {
|
||||||
|
return pdu(0x00050001, 0, seqNr, new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliver_sm whose mandatory fields are all present and correct, followed by one TLV header
|
||||||
|
* whose declared length (200) runs past cmd_length - the codec's parseTlvs() refuses this with
|
||||||
|
* reason 'tlvs'.
|
||||||
|
*/
|
||||||
|
static byte[] truncatedTlvDeliverSmPdu(int seqNr) {
|
||||||
|
ByteArrayOutputStream body = deliverSmMandatory("raw-from", "raw-to", "truncated tlv probe");
|
||||||
|
// Tag 0x001D (any tag id serves), declared length 200, only 4 value octets actually follow.
|
||||||
|
// A full 8-byte tail (not a bare 4-byte header) so the codec's trailing-NUL retry - which
|
||||||
|
// shifts the TLV region by one octet looking for a padded short_message - still finds an
|
||||||
|
// overrunning length rather than silently swallowing an unparsed remainder as alignment slack.
|
||||||
|
body.write(0x00);
|
||||||
|
body.write(0x1D);
|
||||||
|
body.write(0x00);
|
||||||
|
body.write(0xC8);
|
||||||
|
body.write(0x41);
|
||||||
|
body.write(0x41);
|
||||||
|
body.write(0x41);
|
||||||
|
body.write(0x41);
|
||||||
|
return pdu(0x00000005, 0, seqNr, body.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliver_sm whose sm_length declares 200 octets while only 5 are actually present. */
|
||||||
|
static byte[] shortBodyDeliverSmPdu(int seqNr) {
|
||||||
|
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||||
|
cstring(body, "");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, "raw-from");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, "raw-to");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, "");
|
||||||
|
cstring(body, "");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 200); // sm_length declares 200 octets
|
||||||
|
body.writeBytes("short".getBytes(StandardCharsets.ISO_8859_1)); // only 5 actually follow
|
||||||
|
return pdu(0x00000005, 0, seqNr, body.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] enquireLinkPdu(int seqNr) {
|
||||||
|
return pdu(0x00000015, 0, seqNr, new byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ByteArrayOutputStream deliverSmMandatory(String from, String to, String text) {
|
||||||
|
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||||
|
cstring(body, "");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, from);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, to);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
cstring(body, "");
|
||||||
|
cstring(body, "");
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
u8(body, 0);
|
||||||
|
byte[] textBytes = text.getBytes(StandardCharsets.ISO_8859_1);
|
||||||
|
u8(body, textBytes.length);
|
||||||
|
body.writeBytes(textBytes);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write(Socket sock, byte[] pdu) throws IOException {
|
||||||
|
OutputStream out = sock.getOutputStream();
|
||||||
|
out.write(pdu);
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads exactly one PDU (command_length-framed) and parses its 16-octet header. */
|
||||||
|
static Map<String, Object> readOne(Socket sock, int timeoutMs) throws IOException {
|
||||||
|
sock.setSoTimeout(timeoutMs);
|
||||||
|
DataInputStream in = new DataInputStream(sock.getInputStream());
|
||||||
|
byte[] lenBytes = new byte[4];
|
||||||
|
in.readFully(lenBytes);
|
||||||
|
long cmdLength = ((long) (lenBytes[0] & 0xFF) << 24) | ((lenBytes[1] & 0xFF) << 16)
|
||||||
|
| ((lenBytes[2] & 0xFF) << 8) | (lenBytes[3] & 0xFF);
|
||||||
|
byte[] rest = new byte[(int) cmdLength - 4];
|
||||||
|
in.readFully(rest);
|
||||||
|
|
||||||
|
int cmdId = b32(rest, 0);
|
||||||
|
int status = b32(rest, 4);
|
||||||
|
int seqNr = b32(rest, 8);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("cmdId", cmdId);
|
||||||
|
result.put("cmdIdHex", "0x" + Integer.toHexString(cmdId));
|
||||||
|
result.put("cmdStatus", status);
|
||||||
|
result.put("cmdStatusHex", "0x" + Integer.toHexString(status));
|
||||||
|
result.put("seqNr", seqNr);
|
||||||
|
byte[] full = new byte[4 + rest.length];
|
||||||
|
System.arraycopy(lenBytes, 0, full, 0, 4);
|
||||||
|
System.arraycopy(rest, 0, full, 4, rest.length);
|
||||||
|
result.put("hex", Json.hex(full));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int b32(byte[] b, int offset) {
|
||||||
|
return ((b[offset] & 0xFF) << 24) | ((b[offset + 1] & 0xFF) << 16)
|
||||||
|
| ((b[offset + 2] & 0xFF) << 8) | (b[offset + 3] & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# No licence declared for this fork (composer.json says LGPL-2.0-or-later, but no top-level
|
||||||
|
# LICENSE file - see findings/06-python-php.md); test-only, cloned at a pinned commit, never
|
||||||
|
# vendored into this repo.
|
||||||
|
FROM php:8.4.25-cli AS source
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG PHPSMPP_COMMIT=1d3b53c2d2b63d51ab70009d956914b7f8903118
|
||||||
|
|
||||||
|
RUN git clone https://github.com/alexandr-mironov/php-smpp.git /src \
|
||||||
|
&& cd /src \
|
||||||
|
&& git checkout "${PHPSMPP_COMMIT}"
|
||||||
|
|
||||||
|
# PHP 8's sockets extension returns a Socket object from socket_create(), not a resource; this
|
||||||
|
# fork's own isOpen() checks is_resource() alone (written pre-PHP8), so it is always false and every
|
||||||
|
# guarded call ("Socket is not open") fails immediately, on any PHP 8.x. Patched here, not in
|
||||||
|
# src/Socket.php - see findings/06-python-php.md.
|
||||||
|
RUN sed -i 's/if (!is_resource(\$this->socket)) {/if (!is_resource(\$this->socket) \&\& !(\$this->socket instanceof \\Socket)) {/' /src/src/transport/Socket.php
|
||||||
|
|
||||||
|
FROM php:8.4.25-cli
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libonig-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& docker-php-ext-install sockets mbstring
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=source /src/src /app/smpp-src
|
||||||
|
COPY driver.php .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["php", "/app/driver.php"]
|
||||||
|
CMD ["node", "2775"]
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// HTTP-driven php-smpp ESME: one connection at a time (php-smpp itself blocks synchronously on
|
||||||
|
// every send and read), holding named client sockets open across requests the way the Java and
|
||||||
|
// Python drivers do (see AGENTS.md). No composer: a tiny PSR-4-shaped autoloader over the fork's
|
||||||
|
// own src/ tree, cloned at a pinned commit during the image build.
|
||||||
|
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$prefix = 'smpp\\';
|
||||||
|
|
||||||
|
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($class, strlen($prefix));
|
||||||
|
$path = '/app/smpp-src/' . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
require $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
use smpp\Address;
|
||||||
|
use smpp\Client;
|
||||||
|
use smpp\DeliveryReceipt;
|
||||||
|
use smpp\exceptions\SmppException;
|
||||||
|
use smpp\helpers\GsmEncoderHelper;
|
||||||
|
use smpp\SMPP;
|
||||||
|
use smpp\transport\Socket;
|
||||||
|
|
||||||
|
$targetHost = $argv[1] ?? 'node';
|
||||||
|
$targetPort = (int) ($argv[2] ?? 2775);
|
||||||
|
|
||||||
|
/** @var array<string, Client> */
|
||||||
|
$clients = [];
|
||||||
|
|
||||||
|
function jsonBody(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
|
||||||
|
return $raw === '' || $raw === false ? [] : (json_decode($raw, true) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(int $status, array $payload): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBody(string $text, int $dataCoding): string
|
||||||
|
{
|
||||||
|
if ($dataCoding === SMPP::DATA_CODING_DEFAULT) {
|
||||||
|
return GsmEncoderHelper::utf8_to_gsm0338($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
|
||||||
|
return mb_convert_encoding($text, 'ISO-8859-1', 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// UCS2: sendSMS() converts internally, so the raw UTF-8 text passes straight through here.
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse of GsmEncoderHelper's dict, for decoding what this driver receives - reusing the peer's
|
||||||
|
// own table so a mismatch against what was sent is the peer's own encode/decode disagreeing with
|
||||||
|
// itself, not this driver guessing at the real GSM 03.38 one.
|
||||||
|
function gsmDecode(string $data): string
|
||||||
|
{
|
||||||
|
static $reverse = null;
|
||||||
|
|
||||||
|
if ($reverse === null) {
|
||||||
|
$reverse = [];
|
||||||
|
|
||||||
|
foreach (gsmEncodeDict() as $char => $bytes) {
|
||||||
|
$reverse[$bytes] = $char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = '';
|
||||||
|
$length = strlen($data);
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
while ($i < $length) {
|
||||||
|
if ($data[$i] === "\x1B" && $i + 1 < $length) {
|
||||||
|
$pair = substr($data, $i, 2);
|
||||||
|
$result .= $reverse[$pair] ?? '?';
|
||||||
|
$i += 2;
|
||||||
|
} else {
|
||||||
|
$result .= $reverse[$data[$i]] ?? $data[$i];
|
||||||
|
$i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dict inside utf8_to_gsm0338() is a local literal, not a class constant - re-derived once by
|
||||||
|
// encoding every basic-table/extension character singly and reading back what came out, rather
|
||||||
|
// than duplicating the private table here.
|
||||||
|
function gsmEncodeDict(): array
|
||||||
|
{
|
||||||
|
static $dict = null;
|
||||||
|
|
||||||
|
if ($dict !== null) {
|
||||||
|
return $dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dict = [];
|
||||||
|
$candidates = ['@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', 'Ø', 'ø', 'Å', 'å', 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', 'Σ', 'Θ', 'Ξ', 'Æ', 'æ', 'ß', 'É', '¡', 'Ä', 'Ö', 'Ñ', 'Ü', '§', '¿', 'ä', 'ö', 'ñ', 'ü', 'à', '^', '{', '}', '\\', '[', '~', ']', '|', '€'];
|
||||||
|
|
||||||
|
foreach ($candidates as $char) {
|
||||||
|
$encoded = GsmEncoderHelper::utf8_to_gsm0338($char);
|
||||||
|
|
||||||
|
if ($encoded !== $char) {
|
||||||
|
$dict[$char] = $encoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBody(string $data, int $dataCoding): string
|
||||||
|
{
|
||||||
|
if ($dataCoding === SMPP::DATA_CODING_UCS2) {
|
||||||
|
return mb_convert_encoding($data, 'UTF-8', 'UCS-2BE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
|
||||||
|
return mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsmDecode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doBind(array $body): array
|
||||||
|
{
|
||||||
|
global $clients, $targetHost, $targetPort;
|
||||||
|
|
||||||
|
$name = $body['name'];
|
||||||
|
$recvTimeoutMs = (int) ($body['recvTimeoutMs'] ?? 5000);
|
||||||
|
|
||||||
|
$transport = new Socket([$targetHost], $targetPort);
|
||||||
|
$transport->setRecvTimeout($recvTimeoutMs);
|
||||||
|
$transport->open();
|
||||||
|
|
||||||
|
$client = new Client($transport);
|
||||||
|
Client::$smsNullTerminateOctetStrings = false;
|
||||||
|
|
||||||
|
if (isset($body['interfaceVersion'])) {
|
||||||
|
Client::$interfaceVersion = (int) $body['interfaceVersion'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemId = $body['systemId'];
|
||||||
|
$password = $body['password'];
|
||||||
|
|
||||||
|
switch ($body['mode']) {
|
||||||
|
case 'transmitter':
|
||||||
|
$client->bindTransmitter($systemId, $password);
|
||||||
|
break;
|
||||||
|
case 'receiver':
|
||||||
|
$client->bindReceiver($systemId, $password);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$client->bindTransceiver($systemId, $password);
|
||||||
|
}
|
||||||
|
|
||||||
|
$clients[$name] = $client;
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
function doUnbind(array $body): array
|
||||||
|
{
|
||||||
|
global $clients;
|
||||||
|
|
||||||
|
$clients[$body['name']]->close();
|
||||||
|
unset($clients[$body['name']]);
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
function doEnquireLink(array $body): array
|
||||||
|
{
|
||||||
|
global $clients;
|
||||||
|
|
||||||
|
$clients[$body['name']]->enquireLink();
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single, non-CSMS submit - used by the refusal and bind-direction scenarios. */
|
||||||
|
function doSubmit(array $body): array
|
||||||
|
{
|
||||||
|
global $clients;
|
||||||
|
|
||||||
|
$client = $clients[$body['name']];
|
||||||
|
$dataCoding = (int) ($body['dataCoding'] ?? SMPP::DATA_CODING_DEFAULT);
|
||||||
|
$message = encodeBody($body['text'], $dataCoding);
|
||||||
|
$from = new Address($body['from']);
|
||||||
|
$to = new Address($body['to']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$messageId = $client->sendSMS($from, $to, $message, null, $dataCoding);
|
||||||
|
|
||||||
|
return ['messageId' => $messageId, 'ok' => true];
|
||||||
|
} catch (SmppException $e) {
|
||||||
|
return ['ok' => false, 'status' => $e->getCode()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message in each of the three CSMS spellings; Client::$csmsMethod is process-global, so this
|
||||||
|
* driver serves one request at a time by construction (see the top-of-file note). */
|
||||||
|
function doSendLong(array $body): array
|
||||||
|
{
|
||||||
|
global $clients;
|
||||||
|
|
||||||
|
$client = $clients[$body['name']];
|
||||||
|
Client::$csmsMethod = (int) $body['csmsMethod'];
|
||||||
|
$message = encodeBody($body['text'], SMPP::DATA_CODING_DEFAULT);
|
||||||
|
$from = new Address($body['from']);
|
||||||
|
$to = new Address($body['to']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$messageId = $client->sendSMS($from, $to, $message, null, SMPP::DATA_CODING_DEFAULT);
|
||||||
|
|
||||||
|
return ['lastMessageId' => $messageId, 'ok' => true];
|
||||||
|
} catch (SmppException $e) {
|
||||||
|
return ['ok' => false, 'status' => $e->getCode()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doReceive(array $body): array
|
||||||
|
{
|
||||||
|
global $clients;
|
||||||
|
|
||||||
|
$client = $clients[$body['name']];
|
||||||
|
$sms = $client->readSMS();
|
||||||
|
|
||||||
|
if ($sms === false) {
|
||||||
|
return ['ok' => true, 'received' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dataCoding' => $sms->dataCoding,
|
||||||
|
'esmClass' => $sms->esmClass,
|
||||||
|
'from' => $sms->source->value,
|
||||||
|
'hex' => bin2hex($sms->message),
|
||||||
|
'isReceipt' => $sms instanceof DeliveryReceipt,
|
||||||
|
'ok' => true,
|
||||||
|
'received' => true,
|
||||||
|
'text' => decodeBody($sms->message, $sms->dataCoding),
|
||||||
|
'to' => $sms->destination->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
'/bind' => 'doBind',
|
||||||
|
'/enquireLink' => 'doEnquireLink',
|
||||||
|
'/receive' => 'doReceive',
|
||||||
|
'/sendLong' => 'doSendLong',
|
||||||
|
'/submit' => 'doSubmit',
|
||||||
|
'/unbind' => 'doUnbind',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Minimal single-connection HTTP server: no framework, one request handled fully before the next
|
||||||
|
// is accepted - which is exactly what a synchronous, blocking SMPP client needs (see top note).
|
||||||
|
$listen = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
|
||||||
|
|
||||||
|
if ($listen === false) {
|
||||||
|
fwrite(STDERR, "listen failed: $errstr\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "php-smpp driver listening on 8080, target $targetHost:$targetPort\n");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$conn = @stream_socket_accept($listen, -1);
|
||||||
|
|
||||||
|
if ($conn === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestLine = fgets($conn);
|
||||||
|
$method = 'GET';
|
||||||
|
$path = '/';
|
||||||
|
|
||||||
|
if ($requestLine !== false && preg_match('#^(\\S+)\\s+(\\S+)#', $requestLine, $m)) {
|
||||||
|
$method = $m[1];
|
||||||
|
$path = parse_url($m[2], PHP_URL_PATH) ?? '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentLength = 0;
|
||||||
|
|
||||||
|
while (($line = fgets($conn)) !== false && trim($line) !== '') {
|
||||||
|
if (preg_match('/^Content-Length:\\s*(\\d+)/i', $line, $m)) {
|
||||||
|
$contentLength = (int) $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = '';
|
||||||
|
|
||||||
|
while (strlen($rawBody) < $contentLength) {
|
||||||
|
$chunk = fread($conn, $contentLength - strlen($rawBody));
|
||||||
|
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody .= $chunk;
|
||||||
|
}
|
||||||
|
$body = $rawBody === '' || $rawBody === false ? [] : (json_decode($rawBody, true) ?? []);
|
||||||
|
|
||||||
|
if ($path === '/health') {
|
||||||
|
$payload = ['ok' => true];
|
||||||
|
$status = 200;
|
||||||
|
} elseif (isset(ROUTES[$path])) {
|
||||||
|
try {
|
||||||
|
$payload = ROUTES[$path]($body);
|
||||||
|
$status = 200;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$payload = ['error' => get_class($e) . ': ' . $e->getMessage()];
|
||||||
|
$status = 500;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$payload = ['error' => 'no such route'];
|
||||||
|
$status = 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($payload);
|
||||||
|
$statusText = $status === 200 ? 'OK' : ($status === 404 ? 'Not Found' : 'Internal Server Error');
|
||||||
|
fwrite(
|
||||||
|
$conn,
|
||||||
|
"HTTP/1.1 $status $statusText\r\nContent-Type: application/json\r\nContent-Length: "
|
||||||
|
. strlen($json) . "\r\nConnection: close\r\n\r\n" . $json
|
||||||
|
);
|
||||||
|
fclose($conn);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12.14-slim-bookworm
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir smpplib==2.2.4
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY driver.py .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["python3", "/app/driver.py"]
|
||||||
|
CMD ["node", "2775"]
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HTTP-driven python-smpplib ESME: binds named sessions against the target SMPP server and
|
||||||
|
performs one action per request, answering with the result as JSON. Kept alive as one process so a
|
||||||
|
session survives across requests, the way jsmpp's Java driver does (see AGENTS.md)."""
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import smpplib.client
|
||||||
|
import smpplib.consts
|
||||||
|
import smpplib.exceptions
|
||||||
|
import smpplib.gsm
|
||||||
|
import smpplib.smpp
|
||||||
|
|
||||||
|
TARGET_HOST = sys.argv[1] if len(sys.argv) > 1 else "node"
|
||||||
|
TARGET_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 2775
|
||||||
|
|
||||||
|
GSM_TABLE = smpplib.gsm.GSM_CHARACTER_TABLE
|
||||||
|
|
||||||
|
MODES = {
|
||||||
|
"receiver": "bind_receiver",
|
||||||
|
"transceiver": "bind_transceiver",
|
||||||
|
"transmitter": "bind_transmitter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def as_text(value):
|
||||||
|
return value.decode() if isinstance(value, bytes) else value
|
||||||
|
|
||||||
|
|
||||||
|
def gsm_decode(data: bytes) -> str:
|
||||||
|
"""Inverse of smpplib's own gsm_encode(), through its own (vendor-specific) table - used to
|
||||||
|
decode what this driver receives, so a mismatch against what was sent is smpplib's own table,
|
||||||
|
not a guess at the real GSM 03.38 one."""
|
||||||
|
chars = []
|
||||||
|
i = 0
|
||||||
|
while i < len(data):
|
||||||
|
byte = data[i]
|
||||||
|
if byte == 0x1B and i + 1 < len(data):
|
||||||
|
chars.append(GSM_TABLE[0x80 + data[i + 1]])
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
chars.append(GSM_TABLE[byte])
|
||||||
|
i += 1
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_body(data: bytes, data_coding: int) -> str:
|
||||||
|
if data_coding in (0, 1):
|
||||||
|
return gsm_decode(data)
|
||||||
|
if data_coding == 3:
|
||||||
|
return data.decode("latin-1")
|
||||||
|
if data_coding == 8:
|
||||||
|
whole = len(data) - (len(data) % 2)
|
||||||
|
return data[:whole].decode("utf-16-be")
|
||||||
|
return data.hex()
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, client):
|
||||||
|
self.client = client
|
||||||
|
self.send_lock = threading.Lock()
|
||||||
|
self.received = []
|
||||||
|
self.acks = {}
|
||||||
|
self.ack_events = {}
|
||||||
|
self.reader_thread = None
|
||||||
|
self.reader_running = False
|
||||||
|
self.reader_error = None
|
||||||
|
|
||||||
|
def wait_ack(self, sequence, budget=8.0):
|
||||||
|
event = self.ack_events.setdefault(sequence, threading.Event())
|
||||||
|
event.wait(budget)
|
||||||
|
return self.acks.get(sequence)
|
||||||
|
|
||||||
|
|
||||||
|
SESSIONS = {}
|
||||||
|
SESSIONS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def session_for(name):
|
||||||
|
with SESSIONS_LOCK:
|
||||||
|
return SESSIONS[name]
|
||||||
|
|
||||||
|
|
||||||
|
def do_bind(body):
|
||||||
|
name = body["name"]
|
||||||
|
mode = body["mode"]
|
||||||
|
timeout_secs = float(body.get("timeoutSecs", 5))
|
||||||
|
|
||||||
|
client = smpplib.client.Client(TARGET_HOST, TARGET_PORT, timeout=timeout_secs, allow_unknown_opt_params=True)
|
||||||
|
client.connect()
|
||||||
|
|
||||||
|
kwargs = {"system_id": body["systemId"], "password": body["password"]}
|
||||||
|
if body.get("interfaceVersion") is not None:
|
||||||
|
kwargs["interface_version"] = int(body["interfaceVersion"])
|
||||||
|
|
||||||
|
getattr(client, MODES[mode])(**kwargs)
|
||||||
|
|
||||||
|
session = Session(client)
|
||||||
|
|
||||||
|
def on_received(pdu, **_kwargs):
|
||||||
|
data = pdu.short_message or b""
|
||||||
|
session.received.append({
|
||||||
|
"dataCoding": pdu.data_coding,
|
||||||
|
"esmClass": pdu.esm_class,
|
||||||
|
"from": as_text(pdu.source_addr),
|
||||||
|
"hex": data.hex(),
|
||||||
|
"text": decode_body(data, pdu.data_coding),
|
||||||
|
"to": as_text(pdu.destination_addr),
|
||||||
|
})
|
||||||
|
return smpplib.consts.SMPP_ESME_ROK
|
||||||
|
|
||||||
|
def on_sent(pdu, **_kwargs):
|
||||||
|
session.acks[pdu.sequence] = {
|
||||||
|
"messageId": as_text(getattr(pdu, "message_id", None)),
|
||||||
|
"status": int(pdu.status),
|
||||||
|
}
|
||||||
|
session.ack_events.setdefault(pdu.sequence, threading.Event()).set()
|
||||||
|
|
||||||
|
def on_error_pdu(pdu):
|
||||||
|
# Overrides the default handler, which raises: a refusing status must reach
|
||||||
|
# message_sent_handler like any other response, not tear down the read loop.
|
||||||
|
if pdu.command == "submit_sm_resp":
|
||||||
|
on_sent(pdu)
|
||||||
|
|
||||||
|
client.set_message_received_handler(on_received)
|
||||||
|
client.set_message_sent_handler(on_sent)
|
||||||
|
client.set_error_pdu_handler(on_error_pdu)
|
||||||
|
|
||||||
|
with SESSIONS_LOCK:
|
||||||
|
SESSIONS[name] = session
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def do_start_reader(body):
|
||||||
|
session = session_for(body["name"])
|
||||||
|
auto_send_enquire_link = bool(body.get("autoSendEnquireLink", True))
|
||||||
|
|
||||||
|
if session.reader_running:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
session.reader_running = True
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session.client.read_once(auto_send_enquire_link=auto_send_enquire_link)
|
||||||
|
except Exception as exc: # noqa: BLE001 - recorded, not raised: this is a driver thread
|
||||||
|
session.reader_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
finally:
|
||||||
|
session.reader_running = False
|
||||||
|
|
||||||
|
session.reader_thread = threading.Thread(target=run, daemon=True)
|
||||||
|
session.reader_thread.start()
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_body(text, data_coding):
|
||||||
|
if data_coding == 0:
|
||||||
|
return smpplib.gsm.gsm_encode(text)
|
||||||
|
if data_coding == 3:
|
||||||
|
return text.encode("latin-1")
|
||||||
|
if data_coding == 8:
|
||||||
|
return text.encode("utf-16-be")
|
||||||
|
raise ValueError(f"unsupported dataCoding {data_coding}")
|
||||||
|
|
||||||
|
|
||||||
|
def do_submit(body):
|
||||||
|
# Does not wait for the submit_sm_resp: a single-segment message is only answered once the
|
||||||
|
# caller's own "sms" handler calls sendResp(), which the caller can only do after seeing this
|
||||||
|
# call return - waiting here would deadlock exactly that handshake. Poll /ack for the result.
|
||||||
|
session = session_for(body["name"])
|
||||||
|
data_coding = int(body["dataCoding"])
|
||||||
|
payload = encode_body(body.get("text", ""), data_coding) if "text" in body else b""
|
||||||
|
|
||||||
|
if body.get("extraHex"):
|
||||||
|
payload += bytes.fromhex(body["extraHex"])
|
||||||
|
|
||||||
|
with session.send_lock:
|
||||||
|
pdu = session.client.send_message(
|
||||||
|
source_addr=body["from"],
|
||||||
|
destination_addr=body["to"],
|
||||||
|
short_message=payload,
|
||||||
|
data_coding=data_coding,
|
||||||
|
esm_class=int(body.get("esmClass", 0)),
|
||||||
|
)
|
||||||
|
sequence = pdu.sequence
|
||||||
|
|
||||||
|
return {"ok": True, "sequence": sequence}
|
||||||
|
|
||||||
|
|
||||||
|
def do_ack(query):
|
||||||
|
session = session_for(query["name"][0])
|
||||||
|
sequence = int(query["sequence"][0])
|
||||||
|
ack = session.acks.get(sequence)
|
||||||
|
|
||||||
|
if ack is None:
|
||||||
|
return {"found": False, "ok": True}
|
||||||
|
|
||||||
|
return {"found": True, "messageId": ack["messageId"], "ok": True, "status": ack["status"]}
|
||||||
|
|
||||||
|
|
||||||
|
def do_submit_long(body):
|
||||||
|
session = session_for(body["name"])
|
||||||
|
data_coding = int(body["dataCoding"])
|
||||||
|
parts, encoding, esm_class = smpplib.gsm.make_parts(body["text"], encoding=data_coding, use_udhi=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
with session.send_lock:
|
||||||
|
pdu = session.client.send_message(
|
||||||
|
source_addr=body["from"],
|
||||||
|
destination_addr=body["to"],
|
||||||
|
short_message=part,
|
||||||
|
data_coding=encoding,
|
||||||
|
esm_class=esm_class,
|
||||||
|
)
|
||||||
|
sequence = pdu.sequence
|
||||||
|
|
||||||
|
ack = session.wait_ack(sequence, budget=8)
|
||||||
|
results.append(ack)
|
||||||
|
|
||||||
|
return {"ok": all(results), "parts": len(parts), "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def do_enquire_link(body):
|
||||||
|
session = session_for(body["name"])
|
||||||
|
|
||||||
|
with session.send_lock:
|
||||||
|
pdu = smpplib.smpp.make_pdu("enquire_link", client=session.client)
|
||||||
|
session.client.send_pdu(pdu)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def do_received(name):
|
||||||
|
session = session_for(name)
|
||||||
|
|
||||||
|
return {"ok": True, "received": session.received}
|
||||||
|
|
||||||
|
|
||||||
|
def do_status(name):
|
||||||
|
session = session_for(name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"readerError": session.reader_error,
|
||||||
|
"readerRunning": session.reader_running,
|
||||||
|
"receivedCount": len(session.received),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def do_idle_silent(body):
|
||||||
|
"""Sleeps `seconds` sending nothing at all - no reader thread, no enquire_link - then does one
|
||||||
|
read attempt to say whether the peer (our server) closed the link while it was silent."""
|
||||||
|
session = session_for(body["name"])
|
||||||
|
time.sleep(float(body["seconds"]))
|
||||||
|
|
||||||
|
session.client._socket.settimeout(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.client.read_pdu()
|
||||||
|
|
||||||
|
return {"closed": False, "ok": True}
|
||||||
|
except socket.timeout:
|
||||||
|
return {"closed": False, "ok": True}
|
||||||
|
except smpplib.exceptions.ConnectionError:
|
||||||
|
return {"closed": True, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def do_unbind(body):
|
||||||
|
session = session_for(body["name"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.client.unbind()
|
||||||
|
except Exception: # noqa: BLE001 - best-effort teardown
|
||||||
|
pass
|
||||||
|
|
||||||
|
session.client.disconnect()
|
||||||
|
|
||||||
|
with SESSIONS_LOCK:
|
||||||
|
del SESSIONS[body["name"]]
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
ROUTES = {
|
||||||
|
"/bind": lambda body, _query: do_bind(body),
|
||||||
|
"/enquireLink": lambda body, _query: do_enquire_link(body),
|
||||||
|
"/idleSilent": lambda body, _query: do_idle_silent(body),
|
||||||
|
"/startReader": lambda body, _query: do_start_reader(body),
|
||||||
|
"/submit": lambda body, _query: do_submit(body),
|
||||||
|
"/submitLong": lambda body, _query: do_submit_long(body),
|
||||||
|
"/unbind": lambda body, _query: do_unbind(body),
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_ROUTES = {
|
||||||
|
"/ack": do_ack,
|
||||||
|
"/received": lambda query: do_received(query["name"][0]),
|
||||||
|
"/status": lambda query: do_status(query["name"][0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
|
def _respond(self, status, payload):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/health":
|
||||||
|
self._respond(200, {"ok": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
handler = GET_ROUTES.get(parsed.path)
|
||||||
|
|
||||||
|
if handler is None:
|
||||||
|
self._respond(404, {"error": "no such route"})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._respond(200, handler(parse_qs(parsed.query)))
|
||||||
|
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
|
||||||
|
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
handler = ROUTES.get(self.path)
|
||||||
|
|
||||||
|
if handler is None:
|
||||||
|
self._respond(404, {"error": "no such route"})
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
body = json.loads(raw or b"{}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._respond(200, handler(body, None))
|
||||||
|
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
|
||||||
|
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
|
||||||
|
print(f"python-smpplib driver listening on 8080, target {TARGET_HOST}:{TARGET_PORT}", file=sys.stderr)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# smppload has no published image; built from source at a pinned commit (tag 2.5.3). Its deps
|
||||||
|
# resolve some sub-deps over git:// (rebar.config), rewritten to https below - see findings/07-load.md.
|
||||||
|
FROM --platform=linux/amd64 erlang:27.3.4.17-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl git make build-base ca-certificates \
|
||||||
|
&& git config --global url."https://github.com/".insteadOf "git://github.com/"
|
||||||
|
|
||||||
|
ARG SMPPLOAD_COMMIT=49fb653966081052fa5d36fbadbdb0972f25ded5
|
||||||
|
ARG REBAR3_VERSION=3.27.0
|
||||||
|
|
||||||
|
# The commit's own vendored ./rebar3 escript predates OTP 27 and cannot even load under it
|
||||||
|
# ("please re-compile this module with an Erlang/OTP 27 compiler") - issue #8's BEAM load error.
|
||||||
|
# A current rebar3 release, which still reads this project's rebar.config, replaces it.
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/rebar3 "https://github.com/erlang/rebar3/releases/download/${REBAR3_VERSION}/rebar3" \
|
||||||
|
&& chmod +x /usr/local/bin/rebar3
|
||||||
|
|
||||||
|
RUN git clone https://github.com/PowerMeMobile/smppload.git /build \
|
||||||
|
&& cd /build \
|
||||||
|
&& git checkout "${SMPPLOAD_COMMIT}" \
|
||||||
|
&& cp /usr/local/bin/rebar3 ./rebar3 \
|
||||||
|
&& make escriptize
|
||||||
|
|
||||||
|
FROM --platform=linux/amd64 erlang:27.3.4.17-alpine AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache netcat-openbsd
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/_build/default/bin/smppload /app/smppload
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/smppload
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# smppload is one-shot: it needs node:2775 already accepting connections, but compose starts this
|
||||||
|
# container first (its healthcheck is a bare marker file, not the SMPP link) so node can depend on
|
||||||
|
# it the same way compose.kannel.yaml's bearerbox does. This loop is the retry Kannel's own client
|
||||||
|
# gives it for free.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
touch /tmp/healthy
|
||||||
|
|
||||||
|
host="${SMPP_HOST:-node}"
|
||||||
|
port="${SMPP_PORT:-2775}"
|
||||||
|
|
||||||
|
until nc -z "$host" "$port"; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
exec /app/smppload "$@"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SMPPSim's own site (seleniumsoftware.com) answers 522; kwahome/smpp-sim-docker vendors the last
|
||||||
|
# surviving 2.6.11 build (see ../../research/smsc-simulators.md #1). Cloned at a pinned commit here
|
||||||
|
# rather than vendored into this repo.
|
||||||
|
FROM debian:13.2-slim AS source
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG SMPPSIM_COMMIT=bc299828af9046ab290da3b4957dfbc472f02bfb
|
||||||
|
|
||||||
|
RUN git clone https://github.com/kwahome/smpp-sim-docker.git /src \
|
||||||
|
&& cd /src && git checkout "${SMPPSIM_COMMIT}"
|
||||||
|
|
||||||
|
# SMPPSim 2.6.x targets Java 6/7; runs unmodified on this 8 JRE.
|
||||||
|
FROM eclipse-temurin:8u452-b09-jre
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=source /src/SMPPSim/smppsim.jar ./smppsim.jar
|
||||||
|
COPY --from=source /src/SMPPSim/lib ./lib
|
||||||
|
COPY --from=source /src/SMPPSim/www ./www
|
||||||
|
COPY --from=source /src/SMPPSim/mo ./mo
|
||||||
|
COPY --from=source /src/SMPPSim/conf/logging.properties ./conf/logging.properties
|
||||||
|
COPY *.props ./conf/
|
||||||
|
|
||||||
|
EXPOSE 2775 8884
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-Djava.util.logging.config.file=conf/logging.properties", "-jar", "smppsim.jar"]
|
||||||
|
CMD ["conf/smppsim.props"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=100
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=8000
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=true
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=node
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=100
|
||||||
|
MAX_TIME_ENROUTE=200
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=200
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=100
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=false
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=100
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=true
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { isCommand, server } from '../src/index.ts';
|
||||||
|
|
||||||
|
const DRIVER = process.env.PHP_DRIVER ?? 'php:8080';
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
const REFUSED_DEST = 'REFUSEME';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(50);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonBody = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function post(path: string, body: JsonBody): Promise<JsonBody> {
|
||||||
|
const res = await fetch(`http://${DRIVER}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json() as Promise<JsonBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSms: { sms: Sms; systemId: string }[] = [];
|
||||||
|
const systemIdBySession = new Map<Session, string>();
|
||||||
|
|
||||||
|
const { err: serverErr, server: smpp } = await server({
|
||||||
|
onRequest: async (session, pduObj) => {
|
||||||
|
if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false;
|
||||||
|
|
||||||
|
await session.sendReturn(pduObj, 'ESME_RTHROTTLED');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
|
||||||
|
systemIdBySession.set(session, paramText(pduObj.params.system_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('sms', sms => {
|
||||||
|
const systemId = systemIdBySession.get(session);
|
||||||
|
|
||||||
|
if (systemId) allSms.push({ sms, systemId });
|
||||||
|
|
||||||
|
// php-smpp's submit_sm() blocks synchronously reading the response on the same connection
|
||||||
|
// that sent it, so answering here (rather than after this event's own test observes the
|
||||||
|
// sms) is the only way that read ever completes - unlike python-smpplib's driver, this one
|
||||||
|
// has no separate reader thread to poll afterwards.
|
||||||
|
void sms.sendResp();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smppServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sessionFor(systemId: string): Session | undefined {
|
||||||
|
return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSession(systemId: string, budget = 8000): Promise<Session> {
|
||||||
|
const found = await waitFor(() => sessionFor(systemId), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSms(systemId: string, message: string, budget = 8000): Promise<Sms> {
|
||||||
|
const found = await waitFor(
|
||||||
|
() => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms,
|
||||||
|
budget,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindClient(name: string, mode: 'receiver' | 'transceiver' | 'transmitter', opts: Partial<JsonBody> = {}): Promise<void> {
|
||||||
|
const bound = await post('/bind', { mode, name, password: 'pw', systemId: name, ...opts });
|
||||||
|
|
||||||
|
assert.equal(bound.ok, true, JSON.stringify(bound));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('S2 - long messages (php-smpp, three CSMS spellings)', () => {
|
||||||
|
const modes: { csmsMethod: number; name: string }[] = [
|
||||||
|
{ csmsMethod: 0, name: 's2-16bit-tags' },
|
||||||
|
{ csmsMethod: 1, name: 's2-payload' },
|
||||||
|
{ csmsMethod: 2, name: 's2-8bit-udh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { csmsMethod, name } of modes) {
|
||||||
|
test(`csmsMethod=${String(csmsMethod)} (${name}) reassembles into one sms with the full text`, async () => {
|
||||||
|
await bindClient(name, 'transceiver');
|
||||||
|
|
||||||
|
const text = 'L'.repeat(350);
|
||||||
|
const sent = await post('/sendLong', { csmsMethod, from: '46700000001', name, text, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
await waitForSms(name, text, 15_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S4 - bind direction (php-smpp forces separate TX and RX binds)', () => {
|
||||||
|
test('php-smpp opens a transmitter bind and a receiver bind, both accepted', async () => {
|
||||||
|
await bindClient('s4-tx', 'transmitter');
|
||||||
|
await bindClient('s4-rx', 'receiver', { recvTimeoutMs: 8000 });
|
||||||
|
|
||||||
|
const tx = await waitForSession('s4-tx');
|
||||||
|
const rx = await waitForSession('s4-rx');
|
||||||
|
|
||||||
|
assert.equal(tx.boundAs, 'transmitter');
|
||||||
|
assert.equal(rx.boundAs, 'receiver');
|
||||||
|
assert.equal(tx.bindAllows('deliver_sm'), false);
|
||||||
|
assert.equal(rx.bindAllows('submit_sm'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a submit_sm on the receiver bind is answered ESME_RINVBNDSTS, and the peer keeps working', async () => {
|
||||||
|
const rx = await waitForSession('s4-rx');
|
||||||
|
|
||||||
|
assert.ok(rx);
|
||||||
|
|
||||||
|
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-rx', text: 'nope', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(refused.ok, false);
|
||||||
|
assert.equal(refused.status, 4); // ESME_RINVBNDSTS
|
||||||
|
|
||||||
|
const link = await post('/enquireLink', { name: 's4-rx' });
|
||||||
|
|
||||||
|
assert.equal(link.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit_sm on the transmitter bind works normally', async () => {
|
||||||
|
const text = 's4 tx works';
|
||||||
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-tx', text, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s4-tx', text);
|
||||||
|
|
||||||
|
assert.equal(sms.session, await waitForSession('s4-tx'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deliver_sm built on the receiver bind reaches php-smpp; the transmitter bind never gets one', async () => {
|
||||||
|
const rx = await waitForSession('s4-rx');
|
||||||
|
|
||||||
|
const text = 's4 mo on rx';
|
||||||
|
|
||||||
|
// php-smpp's readSMS() blocks synchronously reading and answering, so it has to be in flight
|
||||||
|
// before the deliver_sm goes out - awaiting rx.send() first would wait on an answer nothing
|
||||||
|
// is there yet to send.
|
||||||
|
const receivePromise = post('/receive', { name: 's4-rx' });
|
||||||
|
const sent = await rx.send({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: { destination_addr: '46700000001', short_message: Buffer.from(text), source_addr: '46700000002' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sent.pduObj);
|
||||||
|
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
const received = await receivePromise;
|
||||||
|
|
||||||
|
assert.equal(received.ok, true);
|
||||||
|
assert.equal(received.received, true);
|
||||||
|
assert.equal(received.text, text);
|
||||||
|
|
||||||
|
const nothingOnTx = await post('/receive', { name: 's4-tx' });
|
||||||
|
|
||||||
|
assert.equal(nothingOnTx.ok, true);
|
||||||
|
assert.equal(nothingOnTx.received, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Refusals via onRequest', () => {
|
||||||
|
test('a refusing status through onRequest is surfaced to php-smpp as a catchable status, not a hang or close', async () => {
|
||||||
|
await bindClient('refusal', 'transceiver');
|
||||||
|
|
||||||
|
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'nope', to: REFUSED_DEST });
|
||||||
|
|
||||||
|
assert.equal(refused.ok, false);
|
||||||
|
assert.equal(refused.status, 0x58); // ESME_RTHROTTLED
|
||||||
|
|
||||||
|
const link = await post('/enquireLink', { name: 'refusal' });
|
||||||
|
|
||||||
|
assert.equal(link.ok, true);
|
||||||
|
|
||||||
|
const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'still works', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(after1.ok, true, JSON.stringify(after1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Peer quirk: bindTransceiver() actually works, despite this fork\'s inherited README', () => {
|
||||||
|
test('a transceiver bind against our server is accepted', async () => {
|
||||||
|
await bindClient('trx-quirk', 'transceiver');
|
||||||
|
|
||||||
|
const session = await waitForSession('trx-quirk');
|
||||||
|
|
||||||
|
assert.equal(session.boundAs, 'transceiver');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { encodings } from '../src/defs/encodings.ts';
|
||||||
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { isCommand, server } from '../src/index.ts';
|
||||||
|
|
||||||
|
const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080';
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
const REFUSED_DEST = 'REFUSEME';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(50);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonBody = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function post(path: string, body: JsonBody): Promise<JsonBody> {
|
||||||
|
const res = await fetch(`http://${DRIVER}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json() as Promise<JsonBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path: string): Promise<JsonBody> {
|
||||||
|
const res = await fetch(`http://${DRIVER}${path}`);
|
||||||
|
|
||||||
|
return res.json() as Promise<JsonBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindReader(name: string, opts: Partial<JsonBody> = {}): Promise<void> {
|
||||||
|
const bound = await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, ...opts });
|
||||||
|
|
||||||
|
assert.equal(bound.ok, true, JSON.stringify(bound));
|
||||||
|
|
||||||
|
const started = await post('/startReader', { autoSendEnquireLink: true, name });
|
||||||
|
|
||||||
|
assert.equal(started.ok, true, JSON.stringify(started));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared infra: one server() for the whole file, one session per named python-smpplib bind,
|
||||||
|
// tracked by the system_id the driver binds with - the same pattern as kannel.test.ts's variant. ---
|
||||||
|
|
||||||
|
const allSms: { sms: Sms; systemId: string }[] = [];
|
||||||
|
const systemIdBySession = new Map<Session, string>();
|
||||||
|
|
||||||
|
const { err: serverErr, server: smpp } = await server({
|
||||||
|
onRequest: async (session, pduObj) => {
|
||||||
|
if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false;
|
||||||
|
|
||||||
|
await session.sendReturn(pduObj, 'ESME_RTHROTTLED');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
|
||||||
|
systemIdBySession.set(session, paramText(pduObj.params.system_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('sms', sms => {
|
||||||
|
const systemId = systemIdBySession.get(session);
|
||||||
|
|
||||||
|
if (systemId) allSms.push({ sms, systemId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smppServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sessionFor(systemId: string): Session | undefined {
|
||||||
|
return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSession(systemId: string, budget = 8000): Promise<Session> {
|
||||||
|
const found = await waitFor(() => sessionFor(systemId), budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSms(systemId: string, message: string, budget = 8000): Promise<Sms> {
|
||||||
|
const found = await waitFor(
|
||||||
|
() => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms,
|
||||||
|
budget,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceivedEntry = { dataCoding: number; esmClass: number; from: string; hex: string; text: string; to: string };
|
||||||
|
|
||||||
|
async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => boolean, budget = 8000): Promise<ReceivedEntry> {
|
||||||
|
const found = await waitFor(async () => {
|
||||||
|
const status = await get(`/received?name=${name}`);
|
||||||
|
const list = status.received as ReceivedEntry[];
|
||||||
|
|
||||||
|
return list.find(predicate);
|
||||||
|
}, budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no matching received entry for ${name} within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AckResult = { messageId?: string; status?: number };
|
||||||
|
|
||||||
|
// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so
|
||||||
|
// /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards.
|
||||||
|
async function waitForAck(name: string, sequence: number, budget = 8000): Promise<AckResult> {
|
||||||
|
const found = await waitFor(async () => {
|
||||||
|
const status = await get(`/ack?name=${name}&sequence=${String(sequence)}`);
|
||||||
|
|
||||||
|
return status.found ? status : undefined;
|
||||||
|
}, budget);
|
||||||
|
|
||||||
|
assert.ok(found, `no ack for sequence ${String(sequence)} (${name}) within ${String(budget)}ms`);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise<void> {
|
||||||
|
const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII';
|
||||||
|
const buf = encodings[encName].encode(text);
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: { data_coding: dataCoding, destination_addr: sms.from, short_message: buf, source_addr: sms.to },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sent.pduObj);
|
||||||
|
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real GSM 03.38 basic table (128 unique chars), as this library decodes it.
|
||||||
|
const ourTable =
|
||||||
|
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
||||||
|
// python-smpplib's own gsm.GSM_CHARACTER_TABLE[:128] - identical except at 0x5F, a backtick where
|
||||||
|
// the real table (and this library) has a section sign.
|
||||||
|
const theirTable =
|
||||||
|
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
||||||
|
const ESC_INDEX = 27;
|
||||||
|
const sendBasic = theirTable.slice(0, ESC_INDEX) + theirTable.slice(ESC_INDEX + 1);
|
||||||
|
const expectBasic = ourTable.slice(0, ESC_INDEX) + ourTable.slice(ESC_INDEX + 1);
|
||||||
|
const extensionChars = '€[]{}\\|~^';
|
||||||
|
|
||||||
|
describe('S11 - encodings (python-smpplib)', () => {
|
||||||
|
test('GSM 03.38 basic table + extension table round trip (python -> server)', async () => {
|
||||||
|
await bindReader('s11-basic');
|
||||||
|
|
||||||
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-basic', text: sendBasic + extensionChars, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-basic', expectBasic + extensionChars);
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form feed (0x1B 0x0A), which smpplib\'s own gsm_encode() cannot build, round trips raw', async () => {
|
||||||
|
await bindReader('s11-ff');
|
||||||
|
|
||||||
|
const sent = await post('/submit', { dataCoding: 0, extraHex: '1b0a', from: '46700000001', name: 's11-ff', text: 'before-', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-ff', 'before-\f');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Latin-1 round trip', async () => {
|
||||||
|
await bindReader('s11-latin1');
|
||||||
|
|
||||||
|
const text = 'café £ ñ ¿¡ © ®';
|
||||||
|
const sent = await post('/submit', { dataCoding: 3, from: '46700000001', name: 's11-latin1', text, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-latin1', text);
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UCS-2 with 一 and an emoji round trip', async () => {
|
||||||
|
await bindReader('s11-ucs2');
|
||||||
|
|
||||||
|
const text = '一😀hello';
|
||||||
|
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-ucs2', text, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-ucs2', text);
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reverse direction: server sends GSM text back, python decodes it the same way', async () => {
|
||||||
|
await bindReader('s11-echo-gsm');
|
||||||
|
|
||||||
|
const text = 'hello from the server';
|
||||||
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-echo-gsm', text: 'seed', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-echo-gsm', 'seed');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0);
|
||||||
|
|
||||||
|
const session = sessionFor('s11-echo-gsm');
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
await echoBack(session, sms, 0, text);
|
||||||
|
|
||||||
|
const received = await waitForReceived('s11-echo-gsm', e => e.text === text);
|
||||||
|
|
||||||
|
assert.equal(received.dataCoding, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reverse direction: server sends UCS-2 text back, python decodes it the same way', async () => {
|
||||||
|
await bindReader('s11-echo-ucs2');
|
||||||
|
|
||||||
|
const seed = 'seed-ucs2';
|
||||||
|
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-echo-ucs2', text: seed, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-echo-ucs2', seed);
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0);
|
||||||
|
|
||||||
|
const session = sessionFor('s11-echo-ucs2');
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const text = '一😀back';
|
||||||
|
|
||||||
|
await echoBack(session, sms, 8, text);
|
||||||
|
|
||||||
|
const received = await waitForReceived('s11-echo-ucs2', e => e.text === text);
|
||||||
|
|
||||||
|
assert.equal(received.dataCoding, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('peer quirk, documented both ways: byte 0x5F is section sign here, backtick in smpplib\'s own table', async () => {
|
||||||
|
await bindReader('s11-quirk');
|
||||||
|
|
||||||
|
// python encodes a literal backtick through its own gsm_encode(), landing on byte 0x5F -
|
||||||
|
// this library decodes that byte to SECTION SIGN, the real GSM 03.38 value.
|
||||||
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-quirk', text: '`', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
|
||||||
|
const sms = await waitForSms('s11-quirk', '§');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0);
|
||||||
|
|
||||||
|
// Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads
|
||||||
|
// the same byte back as a backtick, not a section sign - the mismatch shows up both ways.
|
||||||
|
const session = sessionFor('s11-quirk');
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
await echoBack(session, sms, 0, '§');
|
||||||
|
|
||||||
|
const received = await waitForReceived('s11-quirk', e => e.dataCoding === 0);
|
||||||
|
|
||||||
|
assert.equal(received.text, '`');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S2 - long messages (python-smpplib, UDH)', () => {
|
||||||
|
for (const segments of [2, 3, 10]) {
|
||||||
|
test(`${String(segments)} segments reassemble whole, each answered on arrival`, async () => {
|
||||||
|
const name = `s2-udh-${String(segments)}`;
|
||||||
|
|
||||||
|
await bindReader(name);
|
||||||
|
|
||||||
|
const text = Array.from({ length: 153 * (segments - 1) + 10 }, (_, i) => String(i % 10)).join('');
|
||||||
|
|
||||||
|
const sent = await post('/submitLong', { dataCoding: 0, from: '46700000001', name, text, to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
||||||
|
assert.equal(sent.parts, segments);
|
||||||
|
|
||||||
|
const sms = await waitForSms(name, text, 15_000);
|
||||||
|
|
||||||
|
assert.equal(sms.answeredOnArrival, true);
|
||||||
|
|
||||||
|
const results = sent.results as { messageId: string; status: number }[];
|
||||||
|
|
||||||
|
assert.equal(results.length, segments);
|
||||||
|
|
||||||
|
for (const [i, result] of results.entries()) {
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(result.messageId, `${sms.smsId}-${String(i + 1)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keepalive - python-smpplib\'s reactive enquire_link', () => {
|
||||||
|
test('idle past idleTimeout (40s) with no keepalive: our server drops the session', async () => {
|
||||||
|
const name = 'keepalive-none';
|
||||||
|
|
||||||
|
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 50 });
|
||||||
|
|
||||||
|
const session = await waitForSession(name);
|
||||||
|
const closed: unknown[] = [];
|
||||||
|
|
||||||
|
session.on('close', () => { closed.push(undefined); });
|
||||||
|
|
||||||
|
const idle = await post('/idleSilent', { name, seconds: 45 });
|
||||||
|
|
||||||
|
assert.equal(idle.ok, true, JSON.stringify(idle));
|
||||||
|
assert.equal(idle.closed, true);
|
||||||
|
assert.deepEqual(closed, [undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle past idleTimeout (40s) with auto_send_enquire_link: the link survives', async () => {
|
||||||
|
const name = 'keepalive-auto';
|
||||||
|
|
||||||
|
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 10 });
|
||||||
|
await waitForSession(name);
|
||||||
|
await post('/startReader', { autoSendEnquireLink: true, name });
|
||||||
|
|
||||||
|
const closed: unknown[] = [];
|
||||||
|
const session = sessionFor(name);
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
session.on('close', () => { closed.push(undefined); });
|
||||||
|
|
||||||
|
await delay(45_000);
|
||||||
|
|
||||||
|
const status = await get(`/status?name=${name}`);
|
||||||
|
|
||||||
|
assert.equal(status.readerRunning, true);
|
||||||
|
assert.equal(status.readerError, null);
|
||||||
|
assert.deepEqual(closed, []);
|
||||||
|
assert.ok(sessionFor(name), 'session should still be bound');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Refusals via onRequest', () => {
|
||||||
|
test('a refusing status through onRequest is surfaced to python-smpplib, not a hang or close', async () => {
|
||||||
|
const name = 'refusal';
|
||||||
|
|
||||||
|
await bindReader(name);
|
||||||
|
|
||||||
|
// Refused through onRequest, before reassembly and before the sms event, so this is answered
|
||||||
|
// without any sendResp() call - unlike every other submit in this file.
|
||||||
|
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST });
|
||||||
|
|
||||||
|
assert.equal(refused.ok, true, JSON.stringify(refused));
|
||||||
|
assert.equal((await waitForAck(name, refused.sequence as number)).status, 0x58); // ESME_RTHROTTLED
|
||||||
|
|
||||||
|
const link = await post('/enquireLink', { name });
|
||||||
|
|
||||||
|
assert.equal(link.ok, true);
|
||||||
|
|
||||||
|
const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'still works', to: '46700000002' });
|
||||||
|
|
||||||
|
assert.equal(after1.ok, true, JSON.stringify(after1));
|
||||||
|
|
||||||
|
const sms = await waitForSms(name, 'still works');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
assert.equal((await waitForAck(name, after1.sequence as number)).status, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# SMPP ESME clients, load generators, and independent PDU validators — interop-testing `@larvit/smpp`
|
||||||
|
|
||||||
|
Research note: this session's shared WebSearch budget (200 calls, spent across parallel research threads on this task) was exhausted partway through. Sections B and C below are fully search-backed. Section A was completed with WebFetch only (direct GitHub API / raw-file / PyPI / crates.io / RubyGems / Hex.pm requests) after the search budget ran out and a parallel research agent covering Go/Erlang/other-language clients was lost to a session rate limit. Where a claim could not be independently re-verified this session, it is marked **unverified** rather than guessed — this hits Kannel hardest (see A18): `kannel.org`'s TLS certificate failed validation on every fetch attempt this session, so its exact config keys could not be re-confirmed against a live primary source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. ESME client libraries / tools
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| # | Library | Language | Licence | Repo | Last activity | Maintained? |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| A1 | Cloudhopper SMPP | Java | Apache-2.0 per README; GitHub shows `NOASSERTION` | [fizzed/cloudhopper-smpp](https://github.com/fizzed/cloudhopper-smpp) (fork of [twitter-archive/cloudhopper-smpp](https://github.com/twitter-archive/cloudhopper-smpp), archived) | fork: no commits since 2018-10-03, issues triaged to 2026-08-24; original archived 2021-08-18 | Fork only, lightly |
|
||||||
|
| A2 | jsmpp | Java | Apache-2.0 | [opentelecoms-org/jsmpp](https://github.com/opentelecoms-org/jsmpp) | pushed 2026-06-17, 68 open issues | Yes |
|
||||||
|
| A3 | OpenSMPP (Logica) | Java | BSD (per README) | [OpenSmpp/opensmpp](https://github.com/OpenSmpp/opensmpp) | pushed 2023-11-01 | Weakly |
|
||||||
|
| A4 | python-smpplib | Python | LGPL-3.0 | [python-smpplib/python-smpplib](https://github.com/python-smpplib/python-smpplib) | PyPI 2.2.4, 2025-01-17 | Yes |
|
||||||
|
| A5 | smpp.twisted | Python | LICENSE file present, text unverified | [jookies/smpp.twisted](https://github.com/jookies/smpp.twisted) | 7 commits total | Stable/frozen |
|
||||||
|
| A6 | smpp.pdu | Python | LICENSE file present, text unverified | [jookies/smpp.pdu](https://github.com/jookies/smpp.pdu) | 7 commits, 2 open issues | Stable/frozen |
|
||||||
|
| A7 | Jasmin (`smppccm`) | Python/Twisted | `NOASSERTION` on GitHub | [jookies/jasmin](https://github.com/jookies/jasmin) | pushed 2026-04-26 | Yes |
|
||||||
|
| A8 | fiorix/go-smpp | Go | MIT | [fiorix/go-smpp](https://github.com/fiorix/go-smpp) | pushed 2026-04-25 | Yes |
|
||||||
|
| A9 | linxGnu/gosmpp | Go | Apache-2.0 | [linxGnu/gosmpp](https://github.com/linxGnu/gosmpp) | pushed 2026-07-13, 21 open issues | Yes |
|
||||||
|
| A10 | oserl | Erlang | unverified | [archaelus/oserl](https://github.com/archaelus/oserl) | last updated 2009-04-03 | No — frozen, embedded in smppload |
|
||||||
|
| A11 | libsmpp34 | C | LGPL-2.1 | [osmocom/libsmpp34](https://github.com/osmocom/libsmpp34) | pushed 2026-08-20 | Yes |
|
||||||
|
| A12 | smpp34 (crate) | Rust | unverified | [crates.io/crates/smpp34](https://crates.io/crates/smpp34) | v1.4.1 on crates.io | unverified |
|
||||||
|
| A13 | JamaaSMPP | C# | unverified | [AdhamAwadhi/JamaaSMPP](https://github.com/AdhamAwadhi/JamaaSMPP) | pushed 2026-08-18 | Yes |
|
||||||
|
| A14 | php-smpp | PHP | none declared | [alexandr-mironov/php-smpp](https://github.com/alexandr-mironov/php-smpp) (fork of [OnlineCity/php-smpp](https://github.com/OnlineCity/php-smpp), unmaintained) | fork pushed 2022-12-02 | Weakly |
|
||||||
|
| A15 | Net::SMPP | Perl | unknown | [metacpan.org/pod/Net::SMPP](https://metacpan.org/pod/Net::SMPP) | v1.19, 2011-06-01 | No |
|
||||||
|
| A16 | ruby-smpp | Ruby | unverified | [rubygems.org/gems/ruby-smpp](https://rubygems.org/gems/ruby-smpp) | v0.6.0, 108,794 downloads | unverified |
|
||||||
|
| A17 | smppex | Elixir | unverified | [hex.pm/packages/smppex](https://hex.pm/packages/smppex) | v3.3.0, updated ~2 months ago | Yes |
|
||||||
|
| A18 | Kannel `bearerbox` (SMPP client) | C | Kannel Software License 1.0 (Apache-1.1-like) | kannel.org — canonical source unreachable this session | unverified this session | unverified this session |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A1. Cloudhopper SMPP
|
||||||
|
[fizzed/cloudhopper-smpp](https://github.com/fizzed/cloudhopper-smpp) is the maintained fork of Twitter's archived [twitter-archive/cloudhopper-smpp](https://github.com/twitter-archive/cloudhopper-smpp) (archived 2021-08-18). The fork itself has had no new commits since 2018-10-03 but still receives issue triage (last activity 2026-08-24, 24 open issues) — treat as lightly maintained, not actively developed. Netty-based session library.
|
||||||
|
|
||||||
|
- **Docker**: no official image. Build from source with Maven inside a JDK image you pin yourself (never guess a tag) — the repo's own instructions run demos via `make client`/`make server` wrapping the Maven build.
|
||||||
|
- **Bind/send** (from the demo client, [`ClientMain.java`](https://raw.githubusercontent.com/fizzed/cloudhopper-smpp/master/src/test/java/com/cloudhopper/smpp/demo/ClientMain.java)):
|
||||||
|
```java
|
||||||
|
config0.setSystemId("1234567890");
|
||||||
|
config0.setPassword("password");
|
||||||
|
config0.setType(SmppBindType.TRANSCEIVER);
|
||||||
|
config0.setWindowSize(1);
|
||||||
|
config0.setRequestExpiryTimeout(30000);
|
||||||
|
config0.setWindowMonitorInterval(15000);
|
||||||
|
SubmitSm submit0 = new SubmitSm();
|
||||||
|
submit0.setShortMessage(textBytes);
|
||||||
|
session0.submit(submit0, 10000);
|
||||||
|
```
|
||||||
|
`systemType`/`interfaceVersion` are not set in the demo (library defaults apply — not confirmed further this session).
|
||||||
|
- **Encoding**: demo encodes with `CharsetUtil.encode(text160, CharsetUtil.CHARSET_GSM)` for data_coding 0. Packed vs. unpacked septets not confirmed from the demo alone — **unverified**.
|
||||||
|
- **Long messages**: demo sends a single ≤160-char GSM segment only; no UDH/SAR/message_payload shown — **unverified** whether the library auto-splits.
|
||||||
|
- **enquire_link**: demo only sends it synchronously on a keypress (`session0.enquireLink(new EnquireLink(), 10000)`); no periodic automatic keepalive demonstrated.
|
||||||
|
- **Windowing**: first-class — `setWindowSize()`, `setRequestExpiryTimeout()`, `setWindowMonitorInterval()` are real, documented session-config knobs. This is the strongest windowing/backpressure story of any Java client here.
|
||||||
|
- **Strictness / quirk**: [twitter/cloudhopper-smpp#39](https://github.com/twitter/cloudhopper-smpp/issues/39) — a user reports throughput capped at ~200 msg/s even after raising window size ("window wait time is always zero"), i.e. windowing alone won't reveal a throughput bug if your server's per-PDU response latency dominates. Unresolved, on an archived repo.
|
||||||
|
|
||||||
|
### A2. jsmpp
|
||||||
|
[opentelecoms-org/jsmpp](https://github.com/opentelecoms-org/jsmpp), Apache-2.0, actively maintained (pushed 2026-06-17). README: "supports SMPP v3.3, v3.4 and v5.0" and explicitly "is not a high-level library" — it does not auto-split long messages; the caller owns UDH/SAR/message_payload chunking, which makes it good for controlled wire-level testing.
|
||||||
|
|
||||||
|
- **interface_version negotiation** (confirmed directly from [`SMPPSession.java`](https://raw.githubusercontent.com/opentelecoms-org/jsmpp/master/jsmpp/src/main/java/org/jsmpp/session/SMPPSession.java)):
|
||||||
|
```java
|
||||||
|
InterfaceVersion commonInterfaceVersion = scVersion != null
|
||||||
|
? InterfaceVersion.IF_50.min(InterfaceVersion.valueOf(scVersion.getValue()))
|
||||||
|
: InterfaceVersion.IF_34;
|
||||||
|
```
|
||||||
|
If the server's `bind_resp` omits the `sc_interface_version` optional TLV, jsmpp silently assumes 3.4 — it never rejects a bind over a missing/absent TLV. If the TLV **is** present with a value `InterfaceVersion.valueOf()` doesn't recognize, that call is an enum-style lookup that can throw — a server sending a bogus `sc_interface_version` byte is a good way to probe jsmpp-based tooling for a crash vs. graceful handling.
|
||||||
|
- **Docker**: no official image found — **unverified**; build with Maven/Gradle inside a self-pinned Java image.
|
||||||
|
- **Long message / encoding**: the bundled `StressClient.java` example hardcodes `DataCodings.ZERO` and calls `submitShortMessage()` directly — no UDH/SAR/message_payload, confirming the library leaves long-message strategy entirely to the caller.
|
||||||
|
- **enquire_link**: known quirk — [opentelecoms-org/jsmpp#142](https://github.com/opentelecoms-org/jsmpp/issues/142) "Enquire time not respected?" reports the library's enquire-link-interval not firing as configured.
|
||||||
|
- **Windowing**: no true max-outstanding window found in the stress example (`maxOutstanding` there is a fixed thread-pool size for response processing, not a protocol window) — **unverified** whether the core session API exposes a real window elsewhere.
|
||||||
|
|
||||||
|
### A3. OpenSMPP (Logica)
|
||||||
|
[OpenSmpp/opensmpp](https://github.com/OpenSmpp/opensmpp), Java, pushed 2023-11-01. Per its README: "originally issued under the Logica Open Source License Version 1.0, but was subsequently put in the public domain under the current BSD licence." This is the historical root that much SMPP tooling's PDU model descends from. Related forks found: [cornet/logica-smpp](https://github.com/cornet/logica-smpp) and a companion simulator [cornet/logica-smscsim](https://github.com/cornet/logica-smscsim) (server-side, out of scope here). Bind/send example, Docker packaging, long-message defaults, encoding, and enquire_link behaviour were **not independently re-verified this session** (search budget exhausted before a source read) — mark all operational specifics **unverified**.
|
||||||
|
|
||||||
|
### A4. python-smpplib
|
||||||
|
[python-smpplib/python-smpplib](https://github.com/python-smpplib/python-smpplib), LGPL-3.0 (confirmed via both GitHub API and the [PyPI JSON API](https://pypi.org/pypi/smpplib/json): latest `2.2.4`, released 2025-01-17).
|
||||||
|
|
||||||
|
- **Bind**: `client.bind_transmitter(**kwargs)` / `bind_transceiver(**kwargs)` — `system_id`/`password`/`system_type`/`interface_version` all pass through as kwargs into the PDU (confirmed from [`smpplib/client.py`](https://raw.githubusercontent.com/python-smpplib/python-smpplib/master/smpplib/client.py)).
|
||||||
|
- **Encoding — notable**: [`smpplib/consts.py`](https://raw.githubusercontent.com/python-smpplib/python-smpplib/master/smpplib/consts.py) defines `SMPP_ENCODING_DEFAULT = 0x00`. [`smpplib/gsm.py`](https://raw.githubusercontent.com/python-smpplib/python-smpplib/master/smpplib/gsm.py)'s `gsm_encode()` maps each character to **one unpacked byte** (with `\x1B`-escape for the extension table) rather than packing 8 characters into 7 septet bytes:
|
||||||
|
```python
|
||||||
|
def gsm_encode(plaintext):
|
||||||
|
return b''.join(
|
||||||
|
six.int2byte(index) if index < 0x80 else b'\x1B' + six.int2byte(index - 0x80)
|
||||||
|
for index in map(GSM_CHARACTER_TABLE.index, plaintext))
|
||||||
|
```
|
||||||
|
**This is a real cross-check opportunity**: if `@larvit/smpp` decodes data_coding 0 as standard packed 7-bit, a raw submit_sm from this client will decode as garbage unless the harness explicitly unpacks/repacks — worth testing deliberately either way.
|
||||||
|
- **Long messages**: `make_parts_encoded()` in the same file builds UDH concatenation (`\x05\x00\x03` + random ref id + total-parts + this-part), i.e. UDH is the default splitting method, not `sar_*`/`message_payload`.
|
||||||
|
- **enquire_link**: opt-in and reactive, not a fixed timer — on a socket read timeout, if `auto_send_enquire_link` is set the client sends `enquire_link` instead of raising; otherwise it re-raises the timeout.
|
||||||
|
- **Docker**: no official image; `python:3.12.14-slim-bookworm` (verified current full-patch tag via [Docker Hub's tag API](https://hub.docker.com/v2/repositories/library/python/tags?name=3.12) as of this research) + `pip install smpplib` is a safe base.
|
||||||
|
|
||||||
|
### A5–A6. Jasmin's `smpp.twisted` / `smpp.pdu`
|
||||||
|
[jookies/smpp.twisted](https://github.com/jookies/smpp.twisted) (Twisted-based SMPP 3.4 client/session engine) and [jookies/smpp.pdu](https://github.com/jookies/smpp.pdu) (the PDU encode/decode layer underneath it) are Jasmin's internal SMPP engine, each with only 7 commits on `master` — essentially frozen/stable rather than actively developed, which is consistent with being a settled dependency rather than a product. Both repos confirm a `LICENSE` file exists but its exact text wasn't fetched this session — **unverified** licence specifics. TLV/long-message/encoding details for these two packages specifically were not independently confirmed this session — for practical testing, use them via Jasmin itself (A7) rather than standalone.
|
||||||
|
|
||||||
|
### A7. Jasmin SMPP client connector (`smppccm`)
|
||||||
|
[jookies/jasmin](https://github.com/jookies/jasmin), GitHub shows licence `NOASSERTION` (a `LICENSE` file exists in-repo; exact terms not re-confirmed this session), actively maintained (pushed 2026-04-26).
|
||||||
|
|
||||||
|
- **Docker**: official image `jookies/jasmin` on Docker Hub. [Tag listing](https://hub.docker.com/v2/repositories/jookies/jasmin/tags) shows the latest tagged release is **`0.11.0`** (pushed 2023-11-10) — use `jookies/jasmin:0.11.0`. Note the image lags the git repo (repo pushed as recently as 2026-04-26, image not retagged since 2023).
|
||||||
|
- **Configuring + driving `smppccm`** (exact key names confirmed from source, [`jasmin/protocols/cli/smppccm.py`](https://raw.githubusercontent.com/jookies/jasmin/master/jasmin/protocols/cli/smppccm.py) — full key list: `cid, host, port, username, password, systype, bind, bind_ton, bind_npi, logfile, loglevel, logrotate, logprivacy, bind_to, elink_interval, res_to, pdu_red_to, trx_to, con_loss_retry, con_loss_delay, con_fail_retry, con_fail_delay, src_addr, src_ton, src_npi, dst_ton, dst_npi, addr_range, proto_id, priority, validity, ripf, def_msg_id, coding, requeue_delay, submit_throughput, dlr_expiry, dlr_msgid, ssl, custom_tlvs`):
|
||||||
|
```
|
||||||
|
jcli
|
||||||
|
smppccm -a
|
||||||
|
cid myconnector
|
||||||
|
host <target-host>
|
||||||
|
port <target-port>
|
||||||
|
username <system_id>
|
||||||
|
password <password>
|
||||||
|
bind transceiver
|
||||||
|
ok
|
||||||
|
smppccm -1 myconnector
|
||||||
|
```
|
||||||
|
To actually push a `submit_sm`, Jasmin doesn't send it from a jCli command directly — you add an MT route (`mtrouter -a`) pointing traffic at the connector, then trigger it via Jasmin's own HTTP send API (`GET/POST /send`), which the MT router forwards to the connector as an outbound `submit_sm`.
|
||||||
|
- **enquire_link**: `elink_interval` config key controls the periodic timer per-connector — confirmed directly from source.
|
||||||
|
- **Windowing**: only `submit_throughput` (a rate cap) was found in the confirmed key list — no distinct max-outstanding-window key surfaced. **Unverified** whether one exists elsewhere in the connector implementation.
|
||||||
|
- **Long messages / encoding**: `coding` sets the connector's default data_coding; long-message splitting strategy inherited from `smpp.pdu`/`smpp.twisted` beneath it — not independently confirmed this session.
|
||||||
|
- Being Python/Twisted and used in real MNO/aggregator deployments, Jasmin exercises a genuinely different stack/style from every Java option here — see recommendation notes below.
|
||||||
|
|
||||||
|
### A8. fiorix/go-smpp
|
||||||
|
[fiorix/go-smpp](https://github.com/fiorix/go-smpp), MIT, active (pushed 2026-04-25). Ships a one-shot CLI, `cmd/sms` (`sms send <from> <to> <text>`, env `SMPP_USER`/`SMPP_PASSWD`, flags `--addr`/`--tls`) — good for a single bind+submit+deliver smoke test, not a load driver (no count/rate/window flags). Long-message default, data_coding behaviour, enquire_link interval, and windowing were not independently confirmed this session beyond the CLI's absence of relevant flags — **unverified** at the library level.
|
||||||
|
|
||||||
|
### A9. linxGnu/gosmpp
|
||||||
|
[linxGnu/gosmpp](https://github.com/linxGnu/gosmpp), Apache-2.0, active (pushed 2026-07-13, 21 open issues), described in its own README as "porting from Java OpenSMPP Library." Bind example:
|
||||||
|
```go
|
||||||
|
trans, err := gosmpp.NewSession(
|
||||||
|
gosmpp.TRXConnector(gosmpp.NonTLSDialer, auth),
|
||||||
|
gosmpp.Settings{ /* EnquireLink: 5*time.Second, OnPDU, OnClosed, ... */ },
|
||||||
|
5*time.Second)
|
||||||
|
```
|
||||||
|
`Settings.EnquireLink` sets a real periodic keepalive interval; `OnClosed`/rebind hooks suggest built-in auto-reconnect behaviour on connection loss (worth probing — does it reconnect and rebind automatically if your server closes on `idleTimeout`?). Supported-PDU list includes `submit_sm_multi`/`data_sm`; exact UDH/SAR default-splitting and data_coding behaviour not confirmed from the README excerpt fetched — **unverified**. No bundled load tool.
|
||||||
|
|
||||||
|
### A10. oserl
|
||||||
|
Canonical repo [archaelus/oserl](https://github.com/archaelus/oserl) — "Enrique Marcote Peña's SMPP for Erlang (mirrored from sourceforge with minor patches)," last updated 2009-04-03: effectively abandoned for 15+ years. Its practical relevance today is that PowerMeMobile's `smppload` (Section B) depends on it under the hood (per `smppload`'s `rebar.config`). Minor satellites found: [dergraf/smpp](https://github.com/dergraf/smpp) (2011, thin Erlang wrapper) and [netDalek/smppex_oserl](https://github.com/netDalek/smppex_oserl) (2019, Elixir SMPPEX↔oserl PDU converter). Treat oserl as "the engine inside smppload," not a library to stand up fresh — use `smppload` itself, or `smppex` (A17) for a genuinely maintained Erlang-VM option.
|
||||||
|
|
||||||
|
### A11. libsmpp34
|
||||||
|
[osmocom/libsmpp34](https://github.com/osmocom/libsmpp34) (mirrored from `gitea.osmocom.org`), C, LGPL-2.1, actively maintained (pushed 2026-08-20, i.e. 16 days before this research). It's a PDU encode/decode codec used inside Osmocom's telecom stack (e.g. OsmoSMSC), not a ready bind/submit/receive CLI. Docker packaging, long-message defaults, and encoding behaviour need a source read not completed this session — **unverified**.
|
||||||
|
|
||||||
|
### A12. Rust: smpp34 crate
|
||||||
|
Of the Rust crates found on [crates.io](https://crates.io/api/v1/crates?q=smpp) — `smpp` (client+server, v0.1.2), **`smpp34`** ("Pure-Rust SMPP 3.4 codec with an async (tokio) client and server", v1.4.1), `smpp-pdu` (parsing only, v0.1.4), `smpp-codec` (SMPP v5 codec, v0.2.1), and the `rusmpp`/`rusmppc`/`rusmppz`/`rusmpp-macros` family (all v0.4.0, modular core + a dedicated client crate `rusmppc`) — **`smpp34`** is the most complete single credible pick (highest version number, explicit client+server+async claim). Repo URL, licence, and operational details (bind example, long-message/encoding defaults) were not independently fetched this session beyond the crates.io registry metadata — **unverified**; read its crates.io page/repo before relying on it.
|
||||||
|
|
||||||
|
### A13. C#: JamaaSMPP
|
||||||
|
[AdhamAwadhi/JamaaSMPP](https://github.com/AdhamAwadhi/JamaaSMPP), a fork/continuation of the original Jamaa Technologies SmppClient, actively maintained (pushed 2026-08-18). NuGet: `Install-Package JamaaSMPP`. README confirms: concatenated/long-message handling via **UDH, SAR, and message_payload — all three, developer's choice**; GSM 03.38 alphabet encoding; custom encoding configurable per client instance; separate-or-combined TX/RX connections. v2.0.0 dropped .NET Framework 4.0 and reworked response-handler threading reliability (worth checking current issues for residual races — not reviewed this session). Licence not confirmed this session — **unverified**, check the repo's `LICENSE` before adoption.
|
||||||
|
|
||||||
|
### A14. PHP: php-smpp
|
||||||
|
Original [OnlineCity/php-smpp](https://github.com/OnlineCity/php-smpp) (pushed 2022-09-18, no licence declared) explicitly states in its README: **"THIS REPO IS NO LONGER MAINTAINED!"**, pointing to the fork [alexandr-mironov/php-smpp](https://github.com/alexandr-mironov/php-smpp) (pushed 2022-12-02, 6 open issues, also no licence declared — flag this as a legal gap before adopting either).
|
||||||
|
|
||||||
|
- **Bind**: `bindTransmitter()` / `bindReceiver()` with host/port/username/password. **No `bind_transceiver` support** — README: *"You can't connect as a transceiver, otherwise supported by SMPP v.3.4."* This forces you to run separate TX and RX connections against the server under test, directly exercising direction-enforcement (e.g. your server's `ESME_RINVBNDSTS` handling) on genuinely separate binds rather than one combined TRX session.
|
||||||
|
- **submit_sm**: `sendSMS()`.
|
||||||
|
- **Long messages**: three built-in modes — `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD` (`message_payload`), `CSMS_8BIT_UDH` — the broadest single-library choice of long-message wire strategy found in this research.
|
||||||
|
- **Encoding**: assumes GSM 03.38; ships `GsmEncoder::utf8_to_gsm0338()`.
|
||||||
|
- **enquire_link**: application-driven on an inactivity timeout (README example: "every 30 seconds of inactivity"), not automatic by default.
|
||||||
|
|
||||||
|
### A15. Perl: Net::SMPP
|
||||||
|
[metacpan.org/pod/Net::SMPP](https://metacpan.org/pod/Net::SMPP), last release **1.19, 2011-06-01** — unmaintained 14+ years, licence unknown per MetaCPAN.
|
||||||
|
- `bind_transceiver()` and `submit_sm()` documented directly.
|
||||||
|
- **Long messages**: docs recommend `message_payload` for bodies over 254 bytes (leaving `short_message` empty) — its documented path is message_payload, not UDH/SAR.
|
||||||
|
- **Encoding**: explicitly does **not** auto-encode — *"Net::SMPP also does not automatically perform the encoding"* — ships `pack_7bit()`/`unpack_7bit()` helpers the caller must invoke explicitly. Useful precisely because you control packed-vs-unpacked GSM 7-bit deliberately, letting you test both against the server on purpose.
|
||||||
|
|
||||||
|
### A16. Ruby: ruby-smpp
|
||||||
|
[ruby-smpp](https://rubygems.org/gems/ruby-smpp) (EventMachine-based), v0.6.0, 108,794 downloads — the most-downloaded of seven SMPP-related Ruby gems found, ahead of its own fork `anjlab-ruby-smpp` (33,740 downloads, v0.6.4) and smaller options (`smpp_encoding`, `rocket_sms`, `Crota`, `rock-queue-smpp`, `anthill_smpp_ruby`). Bind/long-message/encoding specifics not independently fetched this session beyond gem metadata — **unverified**.
|
||||||
|
|
||||||
|
### A17. Elixir: smppex
|
||||||
|
[smppex on hex.pm](https://hex.pm/packages/smppex), "SMPP 3.4 protocol and framework implemented in Elixir," v3.3.0, updated roughly 2 months before this research, 200,620 total downloads — clearly the maintained choice over `esmpp` (v0.0.13, ~9 years stale) and the companion `smppex_telemetry` package (~5 years stale, but its existence confirms a real surrounding ecosystem). Bind/config specifics not independently fetched this session — **unverified**.
|
||||||
|
|
||||||
|
### A18. Kannel `bearerbox` as an SMPP client
|
||||||
|
Kannel is confirmed (via [Wikipedia](https://en.wikipedia.org/wiki/Kannel_(telecommunications))) to be licensed under the "Kannel Software License 1.0" (Apache-1.1-like), official site `kannel.org`. **This session could not independently verify any operational SMPP-client detail**: every fetch attempt against `kannel.org`'s user guide failed with a TLS certificate validation error, and no working canonical GitHub/GitLab mirror of the primary (historically SVN-hosted) repo was confirmed — third-party forks found (e.g. `pruiz/kannel`) are stale (last touched 2014) and not authoritative. The well-known architecture (a `group = smsc` / `smsc = smpp` config stanza binding `bearerbox` as an SMPP client, driven by `smsbox`'s HTTP `sendsms` interface) is widely documented under normal circumstances, but restating its exact config keys from memory here would violate this report's "verify or mark unverified" rule — **treat all Kannel specifics as unverified pending a follow-up session with working TLS to kannel.org (or a fixed CA bundle)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Load / soak generators
|
||||||
|
|
||||||
|
*(Fully researched with WebSearch+WebFetch by a parallel research thread this session.)*
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Tool | Repo | Licence | Language | Maintained? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| smppload | [PowerMeMobile/smppload](https://github.com/PowerMeMobile/smppload) | none found | Erlang | Sporadic (tags 2014→2.5.3; 1 open issue since 2023) |
|
||||||
|
| SMPPSim | e.g. [haifzhan/SMPPSim](https://github.com/haifzhan/SMPPSim) (unofficial mirror) | custom freeware, non-OSI | Java | Server-only — **not a client load tool** |
|
||||||
|
| fiorix/go-smpp (`cmd/sms`) | [fiorix/go-smpp](https://github.com/fiorix/go-smpp) | MIT | Go | Active, but one-shot only, no load mode |
|
||||||
|
| linxGnu/gosmpp | [linxGnu/gosmpp](https://github.com/linxGnu/gosmpp) | Apache-2.0 | Go | Active; no bundled load tool |
|
||||||
|
| veoo/smpperf | [veoo/smpperf](https://github.com/veoo/smpperf) | MIT | Go | Dead since 2020-06-10; sends one message and exits despite the name |
|
||||||
|
| vponomarev/libsmpp (`smpp-dumb-client`) | [vponomarev/libsmpp](https://github.com/vponomarev/libsmpp) | LGPL-3.0 | Go | Low activity (2025-07-01) — **the one Go tool with a real bounded window** |
|
||||||
|
| AShabana/smpp-load-test | [AShabana/smpp-load-test](https://github.com/AShabana/smpp-load-test) | none found | Go | Dead since 2022-08-10 |
|
||||||
|
| cloudhopper-smpp (library) | [fizzed/cloudhopper-smpp](https://github.com/fizzed/cloudhopper-smpp) | Apache-2.0 per README | Java | Basis for bespoke load tools, not one itself |
|
||||||
|
| Java-SMPP-Load-GUI | [shaf2k/Java-SMPP-Load-GUI](https://github.com/shaf2k/Java-SMPP-Load-GUI) | none found | Java | Dead, 1 commit (2015) |
|
||||||
|
| jsmpp StressClient/StressServer | [opentelecoms-org/jsmpp](https://github.com/opentelecoms-org/jsmpp) | Apache-2.0 | Java | Active; bundled example, not a packaged tool |
|
||||||
|
| wizardjedi/smpp-test-tools | [wizardjedi/smpp-test-tools](https://github.com/wizardjedi/smpp-test-tools) | none found | Java | Low activity (2023-02-23) |
|
||||||
|
| emgload/emgsink | [smpp.com/smpp-benchmarking.html](https://smpp.com/smpp-benchmarking.html) | proprietary | native, unverified | Actively sold |
|
||||||
|
| Melrose Labs Load Test Tool | [melroselabs.com/tools/smpploadtest](https://melroselabs.com/tools/smpploadtest/) | proprietary, hosted | n/a | Active service |
|
||||||
|
|
||||||
|
### smppload (PowerMeMobile) — the practical free/open pick
|
||||||
|
- CLI: `-H/--host -P/--port -B/--bind_type(TX|TRX|RX) -i/--system_id -p/--password -r/--rps(1000) -T/--thread_count(10) -c/--count(1) -s/--source -d/--destination -l/--length(140) -D/--delivery -C/--data_coding(3=Latin1)` (from [`src/smppload.erl`](https://raw.githubusercontent.com/PowerMeMobile/smppload/master/src/smppload.erl)):
|
||||||
|
```
|
||||||
|
smppload -H smsc.example.com -P 2775 -B trx -i myuser -p mypass \
|
||||||
|
-s 1234:1,1 -d 15555550100 -c 100000 -r 500 -T 20 -l 140 -D 1
|
||||||
|
```
|
||||||
|
- **Windowing**: no true bounded in-flight window — "window" is really `thread_count × rps` (confirmed from [`smppload_esme.erl`](https://raw.githubusercontent.com/PowerMeMobile/smppload/master/src/smppload_esme.erl)).
|
||||||
|
- **Throttling**: all non-zero `command_status` responses fold into one `send_fail` counter ([`smppload_stats.erl`](https://raw.githubusercontent.com/PowerMeMobile/smppload/master/src/smppload_stats.erl)) — no ESME_RTHROTTLED-specific backoff.
|
||||||
|
- **Long messages**: yes, UDH-based multipart; UCS2-BE for Unicode.
|
||||||
|
- **enquire_link**: not implemented at all.
|
||||||
|
- **Docker**: no official image; needs a hand-built Erlang/OTP 19+ image, tag chosen and pinned by you.
|
||||||
|
- **Known issues**: [#8](https://github.com/PowerMeMobile/smppload/issues/8) rebar3/BEAM load errors (open); a closed issue documents Erlang 22.3.2 compile failures.
|
||||||
|
|
||||||
|
### The one tool with a real bounded window: `vponomarev/libsmpp`'s `smpp-dumb-client`
|
||||||
|
YAML-driven ([`config.yml`](https://raw.githubusercontent.com/vponomarev/libsmpp/master/app/smpp-dumb-client/config.yml)):
|
||||||
|
```yaml
|
||||||
|
smpp:
|
||||||
|
remote: 127.0.0.1:2500
|
||||||
|
bind: { systemID: test, systemType: test, password: test, mode: TRX }
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
count: 0 # total messages
|
||||||
|
rate: 100 # messages/sec
|
||||||
|
window: 2000 # max outstanding unacked submit_sm — real, enforced
|
||||||
|
stayConnected: yes
|
||||||
|
```
|
||||||
|
Confirmed enforced in [`generator.go`](https://raw.githubusercontent.com/vponomarev/libsmpp/master/app/smpp-dumb-client/generator.go): sending is skipped once in-flight count reaches `window`. No ESME_RTHROTTLED-aware backoff; no long-message (UDH/SAR) support; plain `ShortMessage` body only. Same repo also ships a matching test SMSC (`smpp-dumb-server`) and an `smpp-lb` load balancer.
|
||||||
|
|
||||||
|
### SMPPSim — out of scope as a *client* tool
|
||||||
|
Confirmed server-only from its [official README](https://github.com/haifzhan/SMPPSim/blob/master/SMPPSim_OFFICIAL_README): "a testing utility which mimics the behaviour of an SMPP based SMSC." No client-side load mode exists. Docker mirrors found (`balsagoth/smppsim` last pushed 2017-08-03; `jookies/smppsim` last pushed 2022-08-07) are unofficial and stale.
|
||||||
|
|
||||||
|
### Other notes
|
||||||
|
- **Cloudhopper's demo** (`ClientMain.java`) has real windowing (`setWindowSize`) but is a one-message demo, not a load driver; a bespoke load tool would need to be built on top.
|
||||||
|
- **jsmpp's `StressClient`/`StressServer`** examples ([source](https://raw.githubusercontent.com/opentelecoms-org/jsmpp/master/jsmpp-examples/src/main/java/org/jsmpp/examples/StressClient.java)) fire `bulkSize` (default 100,000) submits bounded only by a `pduProcessorDegree`-sized thread pool — no rate control, no long messages, no differentiated throttle handling.
|
||||||
|
- **emgload/emgsink** ([smpp.com](https://smpp.com/smpp-benchmarking.html)) is the one tool here with explicit UDH-via-TLV long-message load support (`--smpp_udh_via_optional`) and very high claimed throughput (~25,000 msg/s), but it's commercial (~599 EUR/yr/host, unlicensed use capped at 10 msg/s) and closed-source.
|
||||||
|
- **Melrose Labs' hosted Load Test Tool** exposes TPS, concurrent binds, a distinct "submit window," message quantity, and long-message splitting all as separate dials — useful as terminology/prior-art confirmation that "window" and "rate" are properly orthogonal knobs, even though it's not automatable in CI (paid, web-UI only).
|
||||||
|
|
||||||
|
**Bottom line for Section B**: `smppload` is the only free, scriptable, protocol-real load generator with long-message support; pair it with `vponomarev/libsmpp`'s `smpp-dumb-client` specifically when you need to test a genuine bounded-window/backpressure scenario against `maxOutstanding`, since `smppload` cannot do that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Independent PDU validators
|
||||||
|
|
||||||
|
*(Fully researched with WebSearch+WebFetch by a parallel research thread this session.)*
|
||||||
|
|
||||||
|
### C1. Wireshark / tshark — the one to always run
|
||||||
|
- Built-in dissector: `epan/dissectors/packet-smpp.c` ([source](https://raw.githubusercontent.com/wireshark/wireshark/master/epan/dissectors/packet-smpp.c)); official reference: [wiki.wireshark.org/SMPP](https://wiki.wireshark.org/SMPP). Decodes "most of the version 3.4 specific fields," ~48 distinct TLV tags (`0x0005`–`0x1383`), and independently reassembles UDH/concatenation by handing `short_message`/`message_payload` off to Wireshark's own GSM-SMS dissector once it sees the `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` TLVs.
|
||||||
|
- **Port handling**: SMPP has an IANA-registered port (2775/tcp, per [IANA's service-names registry](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml)), but per the wiki, Wireshark's dissector does **not** rely on it — "No well known port is defined for this protocol. The dissector will use heuristics." Force it explicitly on a non-standard port with `-d`.
|
||||||
|
- **Capture + decode** (flag syntax confirmed against the official [dumpcap](https://www.wireshark.org/docs/man-pages/dumpcap.html)/[tshark](https://www.wireshark.org/docs/man-pages/tshark.html) man pages):
|
||||||
|
```bash
|
||||||
|
dumpcap -i lo -f "tcp port 2775" -w smpp.pcapng
|
||||||
|
tshark -r smpp.pcapng -d tcp.port==2775,smpp -Y smpp -V # verbose text
|
||||||
|
tshark -r smpp.pcapng -d tcp.port==2775,smpp -Y smpp -T json # JSON, CI-diffable
|
||||||
|
```
|
||||||
|
- **Malformed-PDU flagging**: two SMPP-specific expert-info fields were confirmed in source (`ei_smpp_message_payload_duplicate`, `ei_smpp_date_time_decoding_failed`); a full sweep for a generic SMPP "Malformed Packet" entry wasn't completed (file too large to fetch whole) — but Wireshark's core engine independently raises a generic "Malformed Packet" item on any dissector exception (truncated/out-of-bounds read) regardless of protocol, so malformed SMPP still typically surfaces this way.
|
||||||
|
- **Docker**: no official `wireshark`/`tshark` image under Docker Hub's `library/` namespace (confirmed 404). Verified working: [`nicolaka/netshoot`](https://hub.docker.com/r/nicolaka/netshoot) (tag `v0.16`, pushed 2026-07-01) — its [Dockerfile](https://raw.githubusercontent.com/nicolaka/netshoot/master/Dockerfile) does `apk add tshark`. Fallback (verified package + current tags):
|
||||||
|
```dockerfile
|
||||||
|
FROM debian:13.6-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends tshark \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
ENTRYPOINT ["tshark"]
|
||||||
|
```
|
||||||
|
(Debian `trixie`'s `tshark` package is confirmed at Wireshark 4.4.18 per [packages.debian.org](https://packages.debian.org/trixie/tshark); `debian:13.6-slim` and `alpine:3.24.1` confirmed as current full-patch tags via the Docker Hub tag API.)
|
||||||
|
|
||||||
|
### C2. sngrep-like alternatives
|
||||||
|
[sngrep](https://github.com/irontec/sngrep/blob/master/README) is confirmed SIP/RTP-only ("displaying SIP calls message flows... SIP packets... PCAP viewer") — no SMPP support, no SMPP mention anywhere. No sngrep-style interactive TUI analyzer specifically for SMPP was found — **none found / unverified**, not ruled out, but nothing credible surfaced.
|
||||||
|
|
||||||
|
### C3. Standalone SMPP PDU decoders
|
||||||
|
|
||||||
|
| Name | What it is | Maintenance/Licence |
|
||||||
|
|---|---|---|
|
||||||
|
| [SMPP PDU Decoder (gurk4n)](https://smpp.gurk4n.com/) | Browser tool, decodes raw hex PDUs; GSM 7-bit/8-bit, UCS2, Latin-1, ASCII; 100% client-side | Unverified |
|
||||||
|
| [isimplelab SMPP PDU decoder](https://smpp.isimplelab.com/pdu?lang=en) | Part of a broader SMPP server-emulator site | Unverified |
|
||||||
|
| sysop.fr / Ozeki decoder pages | Found in search, pages unreachable this session (TLS/connection errors) | Unverified |
|
||||||
|
| jsmpp | Java library; per a (not re-fetched) Google Groups thread, newer versions log all sent/received PDUs at DEBUG — usable as a cross-check dumper | Apache-2.0, active |
|
||||||
|
| cloudhopper-smpp demo (`ClientMain.java`) | `make client`/`make parser`, logs raw PDU bytes (`setLogBytes(true)`) | Apache-2.0 per README |
|
||||||
|
| SMPPSim | `smppsim.props` independently exposes `DECODE_PDUS_IN_LOG`, `CAPTURE_SME_BINARY[_TO_FILE]`, `CAPTURE_SMPPSIM_BINARY[_TO_FILE]`, `CAPTURE_SME_DECODED[_TO_FILE]`, `CAPTURE_SMPPSIM_DECODED[_TO_FILE]` — a genuine independent PDU log for cross-checking both directions ([`conf/smppsim.props`](https://raw.githubusercontent.com/haifzhan/SMPPSim/master/conf/smppsim.props)) | Custom freeware, non-OSI |
|
||||||
|
| [Black Duck Defensics SMPP Server test suite](https://www.blackduck.com/fuzz-testing/defensics/protocols/smpp-server.html) | Commercial fuzzer/conformance suite, 14 SMPP v3.4 message categories | Commercial |
|
||||||
|
| [Melrose Labs SMPP Analyser / conformance testing](https://melroselabs.com/services/smpp-testing/) | Commercial SMPP-packet analysis + PICS-based conformance service | Commercial |
|
||||||
|
|
||||||
|
Note: several near-identical-sounding tools (Diafaan, smspdu.be, smsdeliverer.com) decode the unrelated **GSM SMS-PDU** (3GPP TS 23.040 AT-command) format, not SMPP protocol PDUs — excluded above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ranked recommendation
|
||||||
|
|
||||||
|
**Run these 3–4 clients — each catches a different class of bug, deliberately non-overlapping:**
|
||||||
|
|
||||||
|
1. **jsmpp (Java)** — for PDU-level/version-negotiation correctness. It silently defaults to interface_version 3.4 if your `bind_resp` omits `sc_interface_version`, but a bogus TLV value there risks an enum-lookup exception client-side — cheap way to probe how forgiving vs. fragile your bind_resp encoding is. Being "not a high-level library," it also gives you full manual control over UDH/SAR/message_payload bytes on the wire, unlike libraries that pick a strategy for you.
|
||||||
|
2. **php-smpp (`alexandr-mironov` fork)** — for direction-enforcement and long-message wire variety. It refuses `bind_transceiver` outright, forcing separate TX/RX binds against your server (a different code path than a single TRX session) and is the only library found here offering all three long-message strategies (UDH, 16-bit-tag SAR, `message_payload`) from one client.
|
||||||
|
3. **Cloudhopper-smpp (fizzed fork, Java/Netty)** — for windowing/backpressure. First-class `setWindowSize`/`setRequestExpiryTimeout`/`setWindowMonitorInterval`, and an async `WindowFuture` submission model architecturally distinct from jsmpp's synchronous style — best tool here to stress-test `maxOutstanding` and slow-response handling.
|
||||||
|
4. **python-smpplib (Python)** — for encoding correctness. It sends GSM 7-bit **unpacked** for data_coding 0 by default (confirmed from source), a genuine, checkable divergence from packed-septet encoding — deliberately useful for catching a data_coding=0 decode bug either way. Its enquire_link is reactive (opt-in, fired on read-timeout) rather than timer-driven, exercising idle-handling differently than the Java options.
|
||||||
|
|
||||||
|
Honorable mention: **Jasmin's `smppccm`** (Python/Twisted, real MNO-style production tooling) is worth adding as a 5th if you want production-representative behaviour, but this session couldn't verify enough of its wire-level specifics (long-message default, exact windowing) to rank it with confidence above.
|
||||||
|
|
||||||
|
**Validator to always run: Wireshark/tshark (C1).** It is implementation-independent of every client above, decodes every PDU field including TLVs and UDH/concatenation reassembly, and its `-T json` output is scriptable for CI diffing against your own encoder's expected bytes — the only tool in this report that verifies wire encoding rather than exercising server behaviour.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Real-world SMPP behaviours & quirks checklist
|
||||||
|
|
||||||
|
For `@larvit/smpp` (ESME client + SMSC server, SMPP 3.4 default) pre-1.0 testing. Every item: behaviour, who, source URL, one-line test. Unsourced items are marked **unverified — folklore**.
|
||||||
|
|
||||||
|
Legend for repeat sources: Kannel = Kannel User's Guide 1.4.5 (kannel.org); spec = SMPP v3.4 Issue 1.2 (smpp.org/SMPP_v3_4_Issue1_2.pdf, cross-read via sysop.fr HTML mirror where the PDF extractor cut off).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bind
|
||||||
|
|
||||||
|
- interface_version `0x34` = SMPP 3.4; `0x00`–`0x33` = "3.3 or earlier" — spec. Test: bind with 0x50 (v5) against a 3.4-only server, confirm reject or negotiate via `sc_interface_version`.
|
||||||
|
- `sc_interface_version` optional TLV in bind_resp reports the SMSC's actual max supported version, independent of what was requested — spec. Test: assert bind_resp TLV reflects server's true version even if client requested something else.
|
||||||
|
- Kannel: default source-addr TON=2/NPI=1 if left unset in `smsc smpp` group — Kannel User's Guide 1.4.5, https://www.kannel.org/download/1.4.5/userguide-1.4.5/userguide.html. Test: bind without explicit TON/NPI, confirm defaults appear on outgoing PDUs.
|
||||||
|
- Melrose Labs SMSC Simulator: rejects destination_address <8 chars with `ESME_RINVDSTADR` (0x0B); requires TLS 1.1+ (no SSL/TLS1.0) — https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/. Test: submit_sm with a short dest address, expect rejection; attempt TLS1.0 handshake, expect refusal.
|
||||||
|
- Sinch (current SMPP product): TLS port 3601, host `<host>.smpp.api.sinch.com`; IP restricted to pre-registered addresses by default; only 2 parallel connections per host/system_id — https://developers.sinch.com/docs/sms/smpp/connectivity. Test: bind a 3rd session on the same system_id, expect rejection.
|
||||||
|
- Sinch (legacy "Cloud SMPP"): max 3 sessions; TLS on **different ports per encoding** — 8443 standard, 9443 Latin-1 — https://developers.sinch.com/docs/sms/other/sms-other-cloud-smpp. Test: connect TLS to 8443 vs 9443, confirm encoding-dependent routing.
|
||||||
|
- Infobip: bind as transceiver or receiver to get DLRs (transmitter-only gets none); max 4 sessions by default; only SMPP **3.4** accepted (3.3/5.0 rejected); hosts `smpp3.infobip.com:8888` primary / `smpp1.infobip.com:8888` secondary, TLS `smpp2.infobip.com:8887`; `system_type=HLR` required for Number Lookup bind — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: bind interface_version 0x33/0x50, expect failure; bind TX-only, confirm no DLR arrives.
|
||||||
|
- Telnyx: host `smpp.telnyx.com:2775`, TLS required; bind requires `addr_ton=1`/`addr_npi=1`; gated to contracted accounts ($5000/month, 12mo min) — https://support.telnyx.com/en/articles/1667062-short-message-peer-to-peer-set-up-guide. Test: bind with other addr_ton/npi values, expect rejection.
|
||||||
|
- Vonage: SMPP 3.4; default 2 connections/account — 3rd bind rejected with `command_status=0x00000005` (ESME_RALYBND); recommends clustered host pairs; unique `system_type` per host needed for multi-host inbound; error 13 decimal/`0x0d` (ESME_RBINDFAIL) = wrong zone/host or bad creds — https://api.support.vonage.com/hc/en-us/articles/204015713 , https://api.support.vonage.com/hc/en-us/articles/204015783 , https://api.support.vonage.com/hc/en-us/articles/204591147. Test: open a 3rd bind, assert command_status 0x00000005.
|
||||||
|
- Bird: server only actually validates `system_id`/`password`/`interface_version` at bind — other fields are ignored; supports 3.3/3.4/5.0 (3.3 loses TLV info in deliver_sm); per-account bind-count/throughput enforced **per SMPP server** (so binding to N servers multiplies the effective cap); plaintext TCP 2775, TLS 2776 (SSLv3+ only) — https://docs.bird.com/connectivity-platform/other-integrations/how-can-i-use-the-short-message-peer-to-peer-smpp-network-protocol. Test: bind with garbage in address_range, confirm still accepted; open the (N+1)th bind on one server and confirm it's rejected while a bind to a second server still works.
|
||||||
|
- Clickatell: 3.3/3.4 supported; new integrations must pass a **compliance test SMSC** before being switched to production — https://archive.clickatell.com/developers/api-docs/using-the-smpp-api/. Test: model two distinct SMSC endpoints (test/prod) with separate credentials.
|
||||||
|
- Kaleyra: `interface_version` must be `0x34` for v3.4 features, else silently defaults to 3.3 behaviour; `system_type` should be `smpp`; TRX bind only available when negotiating 3.4 — https://messaging.kaleyra.com/support/solutions/articles/3000091795-introduction-to-smpp-interface. Test: bind at 0x33, confirm TRX-gated features (e.g. TLVs) are withheld vs 0x34.
|
||||||
|
- tyntec: 4 connection modes — plain SMPP, SSL-SMPP, SMPP-in-VPN, SMPP-with-TLS (on request) — https://www.tyntec.com/helpcenter/docs/faqs/sms/getting-started/what-type-of-connections-does-tyntec-support-for-smpp/. Test: model plain and TLS as distinct configured listeners, not one socket with a flag.
|
||||||
|
- CM.com: endpoints `smpp_gw.messaging.cm.com:30006` / `smpp_sgw.messaging.cm.com:30007` behind a global load balancer — https://developers.cm.com/messaging/docs/smpp. Test: bind against both, confirm 0x34 accepted on each.
|
||||||
|
- LINK Mobility (Implementation Guide): interface_version must be 3.4 — anything else fails the bind; `system_type`/`address_range` accepted but ignored; recommends separate TX+RX binds over a single TRX; requires client IP pre-registered (firewalled otherwise) — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: bind with interface_version ≠0x34, expect failure; bind from an unregistered IP, expect connection refused.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide, separate product): TX/RX/TRX all supported; `address_range` **not supported at all**; TLS 1.2/1.3 required (1.0/1.1 discontinued 2020-11-15); TLS port 3601 vs plaintext 3600 — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: connect on 3600 without TLS vs 3601 with TLS1.2+; attempt TLS1.1, expect rejection.
|
||||||
|
- Telia: interface_version must be 0x34; `system_type`/`address_range` "not in use" — send NULL; static IP must be ACL-whitelisted; TLS ≥1.2 with SNI required; endpoint `smpp.messaging.teliacompany.com:3550` — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: bind with non-null system_type (still succeeds, field ignored); connect from a non-whitelisted IP, expect refusal.
|
||||||
|
- Telenor: SMSC "supports the most important operations... but does not fully support the protocols" of 3.3/3.4; **OUTBIND, SUBMIT_MULTI and ALERT_NOTIFICATION explicitly not supported** — https://developer.telenor.no/images/97-322719.pdf. Test: send those 3 PDU types to a Telenor-modeled mock, expect no support/rejection.
|
||||||
|
- Twilio: does offer an SMPP API but with **no self-service onboarding** — enterprise-only, provisioned via account manager for existing SMPP deployments — https://help.twilio.com/articles/31537798746267. Test: not directly testable; document as enterprise-gated rather than a public bind target.
|
||||||
|
- Telesign: SMPP 3.4/5.0; TX/RX/TRX (persistent TRX preferred); `system_id` max 16 octets, `password` max 9 octets; TLS 1.2 mandatory; Session Init Timer 10s (time to bind after TCP connect); explicitly advises against IP-allowlisting Telesign's own gateway IP (it can change) — https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: assert bind fails if not completed within 10s of TCP connect; reject system_id>16 or password>9 octets.
|
||||||
|
- Route Mobile: interface_version must be 0x34 to enable v3.4-only fields (long SMS, message_payload) — omitted/0x33 disables them silently rather than erroring; `system_type` unused/must be null; multiple simultaneous binds allowed — https://routemobile.com/pdf_files/developer/api/routemobilesmpp.pdf. Test: bind at 0x33, then attempt message_payload/UDH long-SMS, confirm treated as unavailable (not rejected loudly).
|
||||||
|
- Bandwidth: bind mode is one of Transceiver (recommended, "for proper DLR delivery")/Sender Only/Receiver Only — DLR delivery is tied to the chosen mode, not forced-TRX; IP allowlisting via priority-weighted groups, highest weight used first — https://www.bandwidth.com/support/en/articles/12823933. Test: bind Receiver Only, submit_sm, confirm rejected/behaves differently than under Transceiver.
|
||||||
|
- Syniverse: TLS version/ports/certs/IP-allowlisting for the raw SMPP bind live only in a private per-customer "Detail Technical Plan" — not publicly documented — https://sdcsupport.syniverse.com/hc/en-us/articles/18883083316247. Test: document as an untestable gap rather than guess values.
|
||||||
|
|
||||||
|
## 2. Keepalive
|
||||||
|
|
||||||
|
- spec itself only mandates "reliable data transfer" at the transport layer and defines enquire_link as a no-op PDU — it does **not** mandate a specific interval or disconnect timeout (§2.4) — spec via smpp.org PDF. Test: none — confirms interval/timeout is vendor policy, not a spec requirement.
|
||||||
|
- Kannel `enquire-link-interval`: default 30 seconds — https://www.kannel.org/download/1.4.5/userguide-1.4.5/userguide.html. Test: idle a bound session 30s+, assert an enquire_link is sent.
|
||||||
|
- Melrose Labs simulator: sends enquire_link to the ESME after 45s of inactivity (server-initiated keepalive) — https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/. Test: idle a session against the simulator, assert a server-initiated enquire_link at ~45s.
|
||||||
|
- Sinch (current product): recommends enquire_link every 30s — https://developers.sinch.com/docs/sms/smpp/connectivity. Test: send enquire_link every 30s, confirm connection survives.
|
||||||
|
- Sinch (legacy Cloud SMPP): recommends 60s instead of 30s — https://developers.sinch.com/docs/sms/other/sms-other-cloud-smpp. Test: verify a different cadence is accepted on this endpoint vs. the current product's.
|
||||||
|
- Infobip: 30-second keep-alive timeout — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: withhold all traffic (incl. enquire_link) >30s, expect session closed.
|
||||||
|
- LINK Mobility (Implementation Guide): SMSC sends enquire_link if idle 60s; read timeout 15s for DLR acks / 30s for other requests; recommended submit_sm_resp timeout 10 minutes; after receiving unbind, client should wait ≥30s before reconnecting — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: idle >60s, expect SMSC-initiated enquire_link; reconnect <30s after an unbind, check for rejection.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide): client should call enquire_link every 60 seconds — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: send at >60s intervals and confirm no disconnect vs sending too rarely.
|
||||||
|
- Telia: recommended connect/read timeout 10 seconds — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: set client socket read timeout to 10s baseline.
|
||||||
|
- Telenor: general SMSC doc states 300s (5 min) default TCP inactivity timeout (scope not confirmed SMPP-exclusive — lower confidence) — https://developer.telenor.no/images/97-322719.pdf. Test: idle 5 min, expect disconnect.
|
||||||
|
- Clickatell: must send ≥1 enquire_link every **5 minutes** or be disconnected; recommends sending one every 1 minute for prompt lost-connection detection — https://archive.clickatell.com/developers/api-docs/connection-details/. Test: assert connection survives at 4:59 idle with no enquire_link, torn down by ~5:00.
|
||||||
|
- Kaleyra: client should enquire_link every 60s; platform auto-disconnects any link idle >5 minutes — https://messaging.kaleyra.com/support/solutions/articles/3000091795-introduction-to-smpp-interface. Test: same 5-minute idle-disconnect boundary assertion.
|
||||||
|
- Telesign: Enquire Link Timer = 30s; Response Timer = 30s for awaiting responses — https://developer.telesign.com/enterprise/docs/additional-features-smpp-protocol. Test: assert client sends enquire_link at ≤30s idle; assert a pending response times out at 30s.
|
||||||
|
- Route Mobile: client should enquire_link every minute; platform auto-disconnects any link inactive >5 minutes — routemobile.com PDF above. Test: simulate 5+ min silence, assert server closes socket.
|
||||||
|
- Bandwidth, Syniverse: no public keepalive numbers found — skipped.
|
||||||
|
|
||||||
|
## 3. Windowing / throughput
|
||||||
|
|
||||||
|
- Kannel `max-pending-submits`: max outstanding unacked SMPP ops between ESME/SMSC, default (recommended) 10 — Kannel User's Guide 1.4.5. Test: submit >10 concurrently without waiting for resp, assert Kannel throttles/queues further sends.
|
||||||
|
- Kannel `wait-ack` (default 60s) + `wait-ack-expire`: `0x00` disconnect/reconnect (default), `0x01` requeue ("could result in the msg arriving twice"), `0x02` keep waiting — Kannel User's Guide 1.4.5. Test: hold back a submit_sm_resp past `wait-ack` seconds and assert the configured expire action.
|
||||||
|
- Melrose Labs: deliver_sm window size = 25 (default) — https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/. Test: have the simulator queue >25 unacked deliver_sm and confirm it stalls further sends until acked.
|
||||||
|
- smpp.org free public test simulator: max 2 SMS/sec; paid tiers up to 100/sec or 10,000/sec — https://smpp.org/smpp-testing-development.html. Test: burst >2/sec against the free tier, expect throttling.
|
||||||
|
- Jasmin `submit_throughput`: MPS cap per connector, 0 = unlimited — https://docs.jasminsms.com/en/latest/management/jcli/modules.html. Test: exceed configured MPS on a connector, assert submissions delayed/rejected.
|
||||||
|
- `ESME_RTHROTTLED` (0x00000058/88, "ESME exceeded allowed message limits") vs `ESME_RMSGQFUL` (0x00000014/20, "Message Queue Full") — https://smpp.org/smpp-error-codes.html. Test: assert the library distinguishes these and applies different backoff (throttled = slow down; queue full = far-end resource, not rate, exhausted).
|
||||||
|
- SMPPSim: on receiving `ESME_RMSGQFUL` from the ESME for a deliver_sm, retries after a delay governed by `DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD`/`DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS` — https://github.com/haifzhan/SMPPSim/blob/master/SMPPSim_OFFICIAL_README. Test: reply ESME_RMSGQFUL to a deliver_sm, assert SMPPSim retries rather than dropping.
|
||||||
|
- Sinch: default throughput 10 msg/s per bind; recommended default window size 10 — https://developers.sinch.com/docs/sms/smpp/connectivity. Test: submit 11 concurrent unacked submit_sm, confirm throttling/rejection.
|
||||||
|
- Infobip: "no other TPS limitations besides internet speed"; scale via more binds (up to 4) rather than a hard cap — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: ramp TPS on one bind and confirm no ESME_RTHROTTLED absent an explicit account limit.
|
||||||
|
- Telnyx: rate limits are **per-number**, not per-bind/window — 10 msg/number/min long code, 1200 msg/number/min toll-free — https://support.telnyx.com/en/articles/1667062-short-message-peer-to-peer-set-up-guide. Test: submit >10/min to the same long code sender, confirm per-number (not per-connection) throttling.
|
||||||
|
- Vonage: default 30 SMS/s per account; `ESME_RTHROTTLED`=0x00000058 returned on excess; no server-side queueing beyond this — https://api.support.vonage.com/hc/en-us/articles/204015703. Test: submit >30/s, assert 0x00000058 on the excess.
|
||||||
|
- Bird: provisioned bind-count + msg/s per account, enforced per SMPP server — https://docs.bird.com/connectivity-platform/other-integrations/how-can-i-use-the-short-message-peer-to-peer-smpp-network-protocol. Test: submit above provisioned rate, confirm a throttling status rather than silent unbounded queueing.
|
||||||
|
- Clickatell: recommends configuring the client with the account's max bind count and using async "windowing" (pipeline submit_sm without waiting per-resp) to reach full throughput — https://archive.clickatell.com/developers/api-docs/using-the-smpp-api/. Test: pipeline N outstanding submit_sm before the first resp arrives, confirm client doesn't serialize.
|
||||||
|
- LINK Mobility (Implementation Guide): default max window = 20 outstanding ops; default max throughput 10 msg/s per account (both configurable); exceeding either triggers throttling (ESME_RTHROTTLED=88), sometimes **without the vendor's own extension TLV present** — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: send >20 outstanding or >10/s, expect ESME_RTHROTTLED, possibly missing the vendor TLV.
|
||||||
|
- Telia: default window size 10, adjustable by contacting support — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: send 11+ outstanding requests against the default window, expect throttling.
|
||||||
|
- Telenor: window size configurable 1–99, defaulting to 1 (stop-and-wait) — general SMSC doc, scope not confirmed SMPP-exclusive — https://developer.telenor.no/images/97-322719.pdf. Test: with window=1, verify strict request/response serialization required.
|
||||||
|
- Route Mobile: `ESME_RTHROTTLED`=0x00000058, no published numeric window/TPS — routemobile.com PDF. Test: assert 0x58 in submit_sm_resp triggers retry-with-backoff, not a hard failure.
|
||||||
|
- Bandwidth: baseline 1 MPS; Local A2P/10DLC default 1 MPS; toll-free default 3 MPS; short codes default 100 MPS; account-level limit overrides per-number limit; queue length default 15 min, max 4h; T-Mobile P2P capped at 300 unique recipients/day/sending number; **inbound (MO) has no rate limit** — https://www.bandwidth.com/support/en/articles/12823226 , https://www.bandwidth.com/support/en/articles/12823224. Test: burst MO traffic at the client, confirm no artificial inbound throttling expected; assert MT caps match documented MPS per number class.
|
||||||
|
- Syniverse: distinct codes 1039 "Throttling error" vs 1038 "Outgoing queue is full" (conceptually mirrors RTHROTTLED vs RMSGQFUL) — https://sdcsupport.syniverse.com/hc/en-us/articles/360038257473. Test: assert distinct handling paths exist for "throttled" vs "queue full" outcomes.
|
||||||
|
|
||||||
|
## 4. Message ids
|
||||||
|
|
||||||
|
- Kannel `msg-id-type` (0–3): `0x00` both decimal, `0x01` deliver_sm decimal/submit_sm_resp hex, `0x02` deliver_sm hex/submit_sm_resp decimal, `0x03` both hex — Kannel User's Guide 1.4.5. Test: set each value and assert submit_sm_resp id vs. DLR `id:` field parse in the matching base.
|
||||||
|
- Jasmin `dlr_msgid`: `0` = identical id in both submit_sm_resp and deliver_sm, `1` = submit_sm_resp hex / deliver_sm decimal, `2` = reverse — https://docs.jasminsms.com/en/latest/management/jcli/modules.html. Test: mirrors Kannel's quirk under a different config name — same base-mismatch bug class, two vendors, two spellings.
|
||||||
|
- Jasmin: internal message id is a UUID (gateway-side tracking) distinct from `id_smsc` (an integer — what the SMSC actually returned) — https://docs.jasminsms.com/en/latest/apis/http/index.html. Test: verify a test double returning a UUID as submit_sm_resp message_id is accepted (spec only requires a C-octet string, no fixed format).
|
||||||
|
- Melrose Labs simulator: message_id is 8 chars for bound v3.3 sessions, 64 chars for v3.4/v5 — https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/. Test: bind at 3.3 vs 3.4 against the simulator, assert id length differs.
|
||||||
|
- SMPPSim: randomised message ID start value with a configurable prefix (v2.6.0+) — not sequential/predictable — https://github.com/haifzhan/SMPPSim/blob/master/SMPPSim_OFFICIAL_README. Test: assert client doesn't assume sequential/incrementing ids from this simulator.
|
||||||
|
- Sinch (legacy Cloud SMPP): message_id is **hex on SMPP 3.3 binds, standard ASCII integer (decimal) on SMPP 3.4 binds** — same backend, format keyed off negotiated interface_version — https://developers.sinch.com/docs/sms/other/sms-other-cloud-smpp. Test: bind at 0x33 vs 0x34, confirm message_id format in submit_sm_resp changes accordingly.
|
||||||
|
- Vonage: by default, `submit_sm_resp.message_id` is **hex** while the corresponding `deliver_sm` DLR `id:` field is **decimal** — the two don't match without conversion unless the account is reconfigured; hex deprecated in 2023 in favor of UUID format for both — https://api.support.vonage.com/hc/en-us/articles/204015803 , https://api.support.vonage.com/hc/en-us/articles/204015663. Test: correlate submit_sm_resp message_id (hex) against DLR `id:` (decimal), confirm conversion is required to match; also test UUID-format ids on newer accounts.
|
||||||
|
- Bird: `receipted_message_id` TLV (tag 0x001E) in the DLR carries the **same value** as `submit_sm_resp.message_id` — https://developers.messagebird.com/api/sms-messaging/. Test: capture submit_sm_resp message_id, assert the DLR's receipted_message_id TLV matches byte-for-byte.
|
||||||
|
- Clickatell: message identity is carried in the DLR's `short_message` text (`id:xxx`) as well as via TLV — https://archive.clickatell.com/developers/api-docs/pdu-details/. Test: parse the DLR text body's `id:` token and confirm it matches submit_sm_resp's message_id.
|
||||||
|
- Telesign: `message_id` is a 32-digit hex value, random per request; for a concatenated message, submit_sm_resp returns the reference id **only for the first part** (a `message_parts_count` TLV covers the rest) — https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: submit a message that auto-splits into N parts, assert only one message_id returns while `message_parts_count`=N.
|
||||||
|
- Bandwidth: `submit_sm_resp.message_id` is a **36-char UUID** (migrated from a legacy 10-char id), echoed back in the `receipted_message_id` TLV (0x1E) of the DLR — https://support.bandwidth.com/hc/en-us/articles/360046149833. Test: assert the library accepts a 36-char UUID-format message_id (not just numeric/short ids) and correlates via the TLV, not string-matching the DLR body.
|
||||||
|
- Route Mobile: only documents `ESME_RINVMSGID` (0x0C); no explicit hex/decimal format published — routemobile.com PDF. Test: N/A — document as an unpublished detail.
|
||||||
|
|
||||||
|
## 5. Delivery receipts
|
||||||
|
|
||||||
|
- spec: `sm_length`/`short_message` max 254 octets; `sm_length` must be 0 when `message_payload` TLV is used to carry >254 octets of user data — spec §1.2.21/1.2.22 via sysop.fr mirror. Test: assert a client switches to `message_payload` with `sm_length=0` once payload exceeds 254 octets rather than truncating.
|
||||||
|
- spec `esm_class` bit meanings for MC→ESME (deliver_sm): `0x00` normal message, `0x04` "Short Message contains SMSC Delivery Receipt", `0x08` SME delivery acknowledgement, `0x10` SME manual/user acknowledgement, `0x20` "Intermediate Delivery Notification" — spec via sysop.fr mirror. Test: assert a DLR arrives with esm_class 0x04 (not 0x00) and an intermediate notification with 0x20 — a naive "esm_class != 0 means DLR" check wrongly classifies intermediates as final.
|
||||||
|
- spec `registered_delivery` bits 1,0: `01` receipt on success or failure, `10` receipt on failure only, `00` none (default); intermediate notification requested via `registered_delivery & 0x10 == 0x10` — corroborated by SMPPSim ("set to 0x11 for both intermediate and final") — spec via sysop.fr mirror; https://github.com/haifzhan/SMPPSim/blob/master/SMPPSim_OFFICIAL_README. Test: request 0x01 vs 0x02 vs 0x11, assert the right receipt combination arrives.
|
||||||
|
- smpp.org receipt text format: `id` (variable), `sub` (3 octets, zero-padded), `dlvrd` (3 octets, zero-padded), `submit date`/`done date` (10 octets, YYMMDDhhmm), `stat` (7 octets), `err` (3 octets), `text` (**20 octets, documented as "unused field, result will be blank"** — not truncated message content, contrary to some vendor implementations) — https://smpp.org/smpp-delivery-receipt.html. Test: assert `text:` is blank by default and that a parser doesn't assume it always carries 20 chars of truncated body text.
|
||||||
|
- smpp.org's own `message_state`-numbered stat list: 0 SCHEDULED (intermediate), 1 ENROUTE (intermediate), 2 DELIVERED, 3 EXPIRED, 4 DELETED, 5 UNDELIVERABLE — https://smpp.org/smpp-delivery-receipt.html — cross-validated against Telesign's TLV table (`message_state`: 2=DELIVRD, 3=EXPIRED, 4=DELETED, 5=UNDELIV, 6=ACCEPTD, 7=UNKNOWN, 8=REJECTD, 9=SKIPPED vendor-added) — https://developer.telesign.com/enterprise/docs/sms-smpp-tlvs. Test: assert the numeric `message_state` TLV and the 7-char `stat:` text field are kept in sync by any test double (they're two encodings of the same fact and can drift).
|
||||||
|
- Melrose Labs simulator: DLR TLVs `receipted_message_id`, `message_state` (2=DELIVERED), `network_error_code` (0=no error); DLR "returned <1s after submit_sm_resp"; MO deliver_sm echoes the `data_coding` used on the original submit — https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/. Test: assert DLR arrives within ~1s and that MO deliver_sm's data_coding matches the original submit's.
|
||||||
|
- Jasmin `dlr_level`: `1`=SMS-C level, `2`=Terminal level, `3`=Both — https://docs.jasminsms.com/en/latest/apis/http/index.html. Test: request each level, confirm the number/timing of DLR callbacks differs (SMSC-level fires once at ack; terminal-level needs a simulated MO ack).
|
||||||
|
- Sinch: DLR body follows spec Appendix B; deliver_sm inbound fields (service_type, esm_class, protocol_id, priority_flag, schedule_delivery_time, validity_period, registered_delivery, replace_if_present_flag, sm_default_msg_id) are "always 0"; optional vendor TLV 0x1403 carries destination MCC+MNC — https://developers.sinch.com/docs/sms/smpp/send-message , https://developers.sinch.com/docs/sms/smpp/receive-message. Test: parse deliver_sm and confirm those fields are always zero; check for optional TLV 0x1403.
|
||||||
|
- Infobip: DLR format `id: sub: dlvrd: submit date: done date: stat: err:`; documented stat values include **DELIVRD, EXPIRED, UNDELIV, ACCEPTD, UNKNOWN, ENROUTE, REJECTD** — note ENROUTE appears as a possible DLR stat here, not just an intermediate flag — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: assert the parser accepts `stat:ENROUTE` as a valid value, not just the plain 6-value spec set.
|
||||||
|
- Vonage: DLR format `id: sub:001 dlvrd:000 submit date: done date: stat: err: text:none`; `err:` only populated (non-000) when state ≠ DELIVRD/ACCEPTD; documented stat values: **ACCEPTD, DELIVRD, UNDELIV, EXPIRED, FAILED, REJECTD, DELETED, UNKNOWN** (8 values, including FAILED/DELETED beyond the plain spec set); **one DLR per segment** for concatenated messages, not one per logical message; retries deliver_sm toward the ESME on temporary-failure command_status, up to 130 attempts ≥62s apart, discarding after 24h — https://api.support.vonage.com/hc/en-us/articles/204015663 , https://api.support.vonage.com/hc/en-us/articles/204015773 , https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts. Test: (a) assert err:000 only on DELIVRD/ACCEPTD; (b) return a temporary-failure status on deliver_sm_resp, confirm Vonage retries rather than treating it as final; (c) confirm one DLR per segment on a multi-part SMS.
|
||||||
|
- Bird: `message_state` TLV (0x0427) = final receipt state; `network_error_code` TLV (0x0423) = carrier-level error, distinct from the text `err:` value — both map into Bird's own numeric error table (0–131) — https://developers.messagebird.com/api/sms-messaging/. Test: build DLR fixtures with both TLVs set and assert the parser surfaces them independently rather than conflating carrier code with SMPP err digits.
|
||||||
|
- Clickatell: DLR body exactly `id:xxx sub:001 dlvrd:NNN submit date:YYMMDDHHMMSS done date:YYMMDDHHMMSS stat:xxx err:000 text:`; observed stat values DELIVRD/ACCEPTD/REJECTD/UNDELIV — https://archive.clickatell.com/developers/api-docs/pdu-details/. Test: parse against this exact field order, reject malformed variants (missing `sub:`, wrong date length).
|
||||||
|
- Kaleyra: DLR only returned if `registered_delivery=1` **and** bound as receiver/transceiver (TX-only gets none); stat values DELIVRD/FAILED/EXPIRED/UNDELIV with an extensive per-operator err code table (000 DELIVRD, 001 unknown subscriber, 003/010 absent subscriber, 004/006/007 handset error, 005 barred, 008/012 net error, 011 SMSC system failure, 013 mobile off, 016 handset busy, 023–032 DLT/template failures, hex 1024–1911 for blacklisting/DND/etc) — https://messaging.kaleyra.com/support/solutions/articles/3000091798-delivery-reports , https://messaging.kaleyra.com/support/solutions/articles/3000091797-error-codes-and-descriptions. Test: (a) submit with registered_delivery=0, assert no DLR ever arrives; (b) submit with registered_delivery=1 on a TX-only bind, assert none arrives there; (c) table-driven test mapping numeric err code to stat value.
|
||||||
|
- tyntec: sends a **temporary "buffered" DLR shortly after submission, followed by a final DLR later** — two receipts per message is expected, not a bug; large GSM error code table (0x0000 no error, 0x0001 unknown subscriber, 0x0006 absent subscriber, 0x000d call barred, 0x0021 message-waiting-list full, 0x0022 system failure, 0x6000 SIM memory full, 0xa001 no network response, 0xa002 message too long) — https://www.tyntec.com/helpcenter/docs/faqs/sms/sms-delivery/how-can-i-troubleshoot-sms-delivery/ , https://www.tyntec.com/helpcenter/docs/faqs/sms/sms-delivery/gsm-error-codes/. Test: assert two deliver_sm's for one submit_sm are accepted, not treated as a duplicate.
|
||||||
|
- CM.com: DLR format `id:(32 chars) submit date: done date: stat: err:`; confirmed 7-char stat values DELIVRD/EXPIRED/DELETED/UNDELIV/ACCEPTD/UNKNOWN/REJECTD; sent via deliver_sm's short_message OR via data_sm's message_payload depending on account config; also carries a `message_state` TLV; proprietary TLVs `operator` (0x1401, MCC/MNC) and `tariff` (0x140A) — https://developers.cm.com/messaging/docs/smpp. Test: parse both text-body stat and message_state TLV, assert agreement; assert exactly 7-char stat.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide): stat values DELIVRD/EXPIRED/REJECTD/UNDELIV/DELETED; extended format `sub:000 dlvrd:000 submit date: done date: stat: err: text:` where **`sub`/`dlvrd` are always literally "000" and `text` is always empty**; only final delivery reports supported, **no intermediate receipts** — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: assert sub/dlvrd are literally "000" and text is empty on every DLR from this profile; assert requesting an intermediate notification has no effect.
|
||||||
|
- LINK Mobility (Implementation Guide): `esm_class` always 0x04 for DLRs (unless configured for legacy "Logica style" text-only DRs); TLVs `message_state` (0x0427), `network_error_code` (0x0423), `receipted_message_id` (0x001E); vendor extension TLVs 0x1700–0x1706 (timestamp/operator/operator_timestamp/status_code/reason_code/status_text/message_id); non-standard `registered_delivery=0x21` ("request server delivery report") beyond the spec's normal value set — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: bind with/without "Logica style DR" config, assert DR shape differs (TLV-based vs legacy text); test 0x21 as a vendor-extension registered_delivery value.
|
||||||
|
- Telia: `network_error_code` TLV values SUBSCRIBER_UNKNOWN(201), SUBSCRIBER_TEMPORARILY_BARRED(202), REFUSED_BY_SERVICE_PROVIDER(207), SUBSCRIBER_ABSENT(208), ILLEGAL_EQUIPMENT(209) — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: assert network_error_code values map to this reason set on simulated failure receipts.
|
||||||
|
- Twilio: supports DLRs across short/long code, 10DLC, alphanumeric via its enterprise SMPP API but explicitly **not** Messaging-Service-specific REST features (link shortening, scheduling, advanced opt-out) via that path — https://help.twilio.com/articles/31537798746267. Test: assert SMPP-submitted messages don't trigger REST-only status-callback features.
|
||||||
|
- Telesign: DLR presence signaled by esm_class bit 2; `err` is a 3-octet hex code with 40+ vendor values (e.g. 0x000004A6 blocked, 0x000004C2 sender-ID restriction); status given redundantly in both text body and TLVs; `message_parts_count` TLV reports segment count; DLRs route to a random connected data-center unless `system_type` targets one — https://developer.telesign.com/enterprise/docs/smpp-protocol , https://developer.telesign.com/enterprise/docs/sms-smpp-tlvs. Test: assert DLR text and TLV state never disagree; assert message_state=9 maps to a SKIPPED state most other vendors lack.
|
||||||
|
- Route Mobile: DLR only if bound RX/TRX and registered_delivery=1; base stats DELIVRD/FAILED/EXPIRED/UNDELIV/REJECTD plus a numeric 000–412 status-error table (e.g. 001 unidentified subscriber→UNDELIV, 027 absent subscriber→EXPIRED, 408 DND→REJECTD, 411 duplicate submission→REJECTD) — routemobile.com PDF. Test: fixture-test each numeric code against its documented stat bucket.
|
||||||
|
- Bandwidth: `ACCEPTD` is "the most precise indicator of a successful message" (vs `DELIVRD` = actual handset confirmation); DLR listening window is **73 hours**, after which error 902/9902 (not a failure) or 4794 ("expired by carrier") if a late DLR does arrive; international "Intermediate DLR" only confirms carrier hand-off, not handset delivery, and DLRs aren't guaranteed internationally — https://www.bandwidth.com/support/en/articles/12823143 , https://www.bandwidth.com/support/en/articles/12823161 , https://www.bandwidth.com/support/en/articles/12823152. Test: assert a DLR arriving after 73h is flagged distinctly (902/9902/4794), not as generic UNDELIV; assert an intermediate DLR isn't conflated with terminal DELIVRD.
|
||||||
|
- Syniverse: explicit code table — DELIVRD=0, DELIVERED-TO-CARRIER=3/ACCEPTED-BUFFERED=4 (interim success), DELETED=5, UNDELIV=7/9/90/181/190, EXPIRED=8, REJECTD=23/35, INTERNAL ERROR=98, BAD ADDRESS=90, INVALID ROUTING=91, CARRIER GATEWAY ERROR=93, intermediate carrier states 184–191 ("UNDELIV-INTERIM"), UNKNOWN=999 — https://sdcsupport.syniverse.com/hc/en-us/articles/360038257473. Test: fixture-test each numeric code against its documented interim-vs-terminal classification.
|
||||||
|
|
||||||
|
## 6. Long messages
|
||||||
|
|
||||||
|
- spec: `message_payload` TLV holds up to 64K octets — spec via smpp.org PDF fetch. Test: cap concatenation/payload tests at 64K.
|
||||||
|
- spec segmentation TLV tags: `sar_msg_ref_num`=0x020C, `sar_total_segments`=0x020E, `sar_segment_seqnum`=0x020F — spec via sysop.fr mirror. Test: submit a 3-segment message via sar_* TLVs (not UDH), confirm the receiver reassembles using the TLVs.
|
||||||
|
- UDH 8-bit vs 16-bit reference number, MO reassembly rules, max-segment limits, and a hard ">160 char rejection" rule: **not documented in the SMPP spec itself** (GSM 03.40 territory) — spec review. unverified — folklore beyond vendor-specific figures below.
|
||||||
|
- Sinch: auto-splits oversized single submissions server-side; client is not required to pre-segment — https://developers.sinch.com/docs/sms/smpp/send-message. Test: submit a single long body exceeding one segment, confirm Sinch splits it rather than rejecting.
|
||||||
|
- Infobip: **long SMS not supported for Flash notifications** — a concatenated + flash (data_coding 0x10/0x18) combination is unsupported — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: submit a >1-segment flash SMS, confirm rejection/silent truncation.
|
||||||
|
- Vonage: UDH concatenation via esm_class=0x40 (UDHI bit); char budgets 160/306/459/612 for 1–4 part GSM-7 (≈153/part after UDH), 70 for single UCS-2 segment; accepts up to 3200 chars but "not all carriers do" — recommends max 6 parts; **all parts must go through the same SMPP server/gateway** or the handset never reassembles; **20-minute window** to submit all parts of one multipart SMS; MO concat params (`concat`/`concat-ref`/`concat-total`/`concat-part`) absent for US Sprint/Verizon — recommends grouping by same-sender + close timestamp as fallback — https://developer.vonage.com/en/messaging/sms/guides/concatenation-and-encoding , https://api.support.vonage.com/hc/en-us/articles/360046874352 , https://api.support.vonage.com/hc/en-us/articles/11615117187484 , https://api.support.vonage.com/hc/en-us/articles/205704158. Test: (a) submit segments with the same UDH ref to two different binds, confirm no reassembly; (b) delay a segment past 20 min, confirm the multipart message fails; (c) simulate MO concat without concat-ref params, confirm the client falls back to a heuristic.
|
||||||
|
- Bird: no public max-segment/UDH-vs-sar specifics beyond what's in topic 5/7 — skip.
|
||||||
|
- Clickatell: `message_payload` (up to 64K) as an alternative to short_message; optional `sar_msg_ref_num`/`sar_segment_seqnum`/`sar_total_segments` TLVs supported on submit_sm — https://archive.clickatell.com/developers/api-docs/using-the-smpp-api/ , https://archive.clickatell.com/developers/api-docs/pdu-details/. Test: submit >160 chars via message_payload only (short_message len 0), confirm accepted; submit the same via sar_* TLVs, confirm equivalent behaviour.
|
||||||
|
- Kaleyra: no `submit_multi`; concatenation via esm_class=0x43 ("Store and Forward with UDHI") with UDH inside short_message; `message_payload` (≤64K) **only available at interface_version 0x34** — requesting it under 3.3 silently has no effect — https://messaging.kaleyra.com/support/solutions/articles/3000091796-submitting-messages-through-smpp. Test: bind at 0x33, attempt message_payload, confirm rejected/ignored; works after rebinding at 0x34.
|
||||||
|
- tyntec: without UDH-based concatenation, MO/MT segment **order is not guaranteed**; fallback is appending a page marker (`1/3, 2/3, 3/3`) to each part's text — https://www.tyntec.com/helpcenter/docs/faqs/sms/sms-features/will-my-sms-messages-arrive-in-order-in-case-of-concatenated-sms/. Test: send 3 segments out of order from a mock SMSC, confirm the client reassembles by sar/UDH sequence number rather than arrival order.
|
||||||
|
- CM.com: no long-message specifics beyond DLR/payload routing already in topic 5 — skip.
|
||||||
|
- LINK Mobility (Implementation Guide): UDH only, signaled purely via esm_class bit 6 (0x40 present / 0x00 absent) — **no sar_* TLVs, no message_payload** supported; non-GSM chars force a 70-char UCS2 segment, not 160-char GSM — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: assert non-GSM chars force UCS2 70-char splitting, not GSM 160.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide): `message_payload` TLV **not supported** — "only one SMS may be delivered per call" — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: submit with message_payload against this profile, expect rejection/ignored.
|
||||||
|
- Telia: `message_payload` explicitly supported to avoid splitting into separate submit_sm; 153 GSM chars/segment when concatenating; REST API (separate from SMPP) caps at 16 segments — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: assert 153-char GSM segment boundary for concatenated (not single) SMS via SMPP.
|
||||||
|
- Telesign: base SMS 140 bytes; single-message limits GSM7=160/ASCII=140/Latin-1=140/UTF-16BE=70; per-segment limits when concatenated GSM7=153/ASCII=134/Latin-1=134/UTF-16BE=67 (6-byte UDH overhead); **max 10 segments**; "smart splitting" avoids breaking words/URLs across segments; auto-splits an oversized single message and adds UDH per part, billed per part — https://developer.telesign.com/enterprise/docs/understand-sms-encoding-character-limits-and-splitting , https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: assert segmentation breakpoints match 160/153 (GSM7) and 70/67 (UCS2); refuse an 11th segment.
|
||||||
|
- Route Mobile: **no `submit_multi` or `sar_*` TLVs at all**; concatenation only via UDH with esm_class=0x43; `message_payload` up to 64K but only at interface_version=0x34; strict UDH length validation — `ESME_RINVUDHLEN` (0x406) unless UDH length is 05/06 (any data_coding) or 0b (only data_coding=245) — routemobile.com PDF. Test: submit with sar_msg_ref_num TLV set, assert rejection (`ESME_ROPTPARNOTALLWD`); submit UDH length 07, assert `ESME_RINVUDHLEN`.
|
||||||
|
- Syniverse: GSM7 single=160/fragment=152 chars; UCS-2 single=70/fragment=67 chars; max **10 fragments**; extended GSM chars (€, ^, |, etc.) count as **2 septets** each — https://sdcsupport.syniverse.com/hc/en-us/articles/360010947674. Test: assert a message containing `€` consumes 2 GSM7 code units for segmentation-count purposes.
|
||||||
|
|
||||||
|
## 7. Encodings
|
||||||
|
|
||||||
|
- spec `data_coding` table: `0x00` SMSC Default Alphabet, `0x01` IA5/ASCII, `0x02` Octet unspecified (8-bit binary), `0x03` Latin-1, `0x04` Octet unspecified (8-bit binary) — **spec itself lists 0x02 and 0x04 as duplicate "binary" entries**; `0x05` JIS, `0x06` Cyrillic, `0x07` Latin/Hebrew, `0x08` UCS2, `0x09` Pictogram, `0x0A` ISO-2022-JP, `0x0D` Extended Kanji JIS, `0x0E` KS C 5601, `0xCC`–`0xDF` GSM MWI control, `0xF0`–`0xFF` GSM message class control (where flash/class-0 lives — the common "0x10/0x18" shorthand is a non-standard vendor simplification of this range) — spec via sysop.fr mirror. Test: verify the library picks one binary code (most vendors use 4, not 2) and documents the choice; verify flash handling covers the 0xF0-0xFF range, not just the two literal bytes 0x10/0x18.
|
||||||
|
- spec `esm_class` UDHI bit 0x40 ("UDHI Indicator") — spec via sysop.fr mirror. Test: an 8-bit binary message (data_coding=4) carrying UDH must set esm_class 0x40; a decoder should flag UDH-shaped payload bytes arriving without this bit set.
|
||||||
|
- Jasmin's `coding` parameter mirrors the spec's DCS table 1:1 (0=SMSC default…8=UCS2…13=Extended Kanji JIS, 14=KS C 5601) — https://docs.jasminsms.com/en/latest/management/jcli/modules.html. Test: round-trip all Jasmin-listed DCS values through the parser.
|
||||||
|
- National language shift tables: not found documented in any researched source — unverified — folklore.
|
||||||
|
- Sinch: data_coding 0x00 GSM-7 default, 0x01 US-ASCII, 0x02/0x04 binary, 0x03 Latin-1, 0x08 UCS2/UTF-16BE — **but "only characters within the GSM-7 table can be parsed to handset" for DCS 3**, i.e. Latin-1 bytes outside GSM-7 mis-render on many handsets — https://developers.sinch.com/docs/sms/smpp/message-encoding. Test: send data_coding=3 with non-GSM-7 chars, confirm mis-rendering is documented/expected, not a bug to "fix".
|
||||||
|
- Infobip: DCS 0/1 = GSM7/IA5 default, 3 = Latin-1, 8 = Unicode/UCS-2; flash uses DCS **16 or 24** (both valid) — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: submit flash with both 0x10 and 0x18, confirm both accepted.
|
||||||
|
- Vonage: **default encoding for new accounts is Latin-1 (ISO-8859-1), not GSM-7** — a real provisioning-default quirk; example DCS: Latin-1=3, Cyrillic=6, Hebrew=7, Unicode=8 — https://api.support.vonage.com/hc/en-us/articles/204015683 , https://api.support.vonage.com/hc/en-us/articles/204015813. Test: send GSM-7-only text on a fresh Vonage account without specifying data_coding, confirm it defaults to 0x03, not 0x00.
|
||||||
|
- Bird: supported data_coding {0,1,2,8}; any other value received is **remapped to the "most appropriate" of 0/2/8** before forwarding to the operator — https://developers.messagebird.com/api/sms-messaging/. Test: submit with data_coding=3, assert the outbound MT is remapped to one of {0,2,8}, not passed through verbatim.
|
||||||
|
- Kaleyra: data_coding=0 GSM 03.38, =3 Latin-1, =8 expects **UTF-16 Big Endian**; data_coding=1 (ASCII) explicitly "NOT RECOMMENDED, known to cause problems" — https://messaging.kaleyra.com/support/solutions/articles/3000091796-submitting-messages-through-smpp. Test: submit UCS-2 as little-endian vs big-endian, confirm only big-endian round-trips; lint against data_coding=1.
|
||||||
|
- LINK Mobility (Implementation Guide): data_coding table 0x00 default/GSM, 0x01 IA5→GSM, 0x02/0x04 binary, 0x03 Latin-1, **0x05–0x07 not supported**, 0x08 UCS2; regardless of requested DCS, everything is **transcoded into GSM or Unicode only** before reaching the handset (arbitrary DCS is not preserved on the wire) — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: submit data_coding=3 (Latin-1), assert the network delivers transcoded GSM/Unicode, not raw Latin-1.
|
||||||
|
- Telia: configurable default among GSM8/GSM7/Latin-1/Latin-9; UCS-2 for non-GSM content — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: assert UCS-2 required/accepted for chars outside the GSM 7-bit default alphabet.
|
||||||
|
- Telesign: data_coding 0/1 = GSM 03.38 default, 3 = Latin-1 (SMPP-only), 8 = Unicode/UTF-16BE; 0/1/3 "may result in ASCII encoding if the input stream can't be decoded" as GSM/Latin-1 — https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: send non-GSM7-valid bytes with data_coding=0, confirm documented ASCII fallback rather than a hard failure.
|
||||||
|
- Route Mobile: data_coding=0 default GSM 03.38; =1 also GSM 03.38 but **explicitly flagged "NOT RECOMMENDED... known to cause problems"**; =3 Latin-1 "if and only if told so explicitly"; =8 Unicode, body expected **UTF-16 Big Endian** — routemobile.com PDF. Test: assert the library warns against data_coding=1 and defaults new integrations to 0; assert UCS-2 bytes are big-endian.
|
||||||
|
- Syniverse: customers submit UTF-8; the system auto-detects and, if **any** character isn't GSM-7, converts the **entire message** to UCS-2 (no partial/mixed encoding) — https://sdcsupport.syniverse.com/hc/en-us/articles/360010947674. Test: send one emoji mixed into otherwise-GSM7 text, confirm the whole message is segmented under UCS-2 limits, not GSM7's.
|
||||||
|
|
||||||
|
## 8. Addresses
|
||||||
|
|
||||||
|
- spec TON table: 0 Unknown, 1 International, 2 National, 3 Network Specific, 4 Subscriber Number, 5 Alphanumeric, 6 Abbreviated. NPI table: 0 Unknown, 1 ISDN(E163/E164), 3 Data(X.121), 4 Telex(F.69), 6 Land Mobile(E.212), 8 National, 9 Private, 10 ERMES, 14 Internet(IP), 18 WAP Client Id — spec via sysop.fr mirror. Test: alphanumeric sender should be TON=5; the spec treats address as a C-octet digit string and doesn't mandate '+' handling — test both '+' and bare-digit forms are accepted.
|
||||||
|
- Melrose Labs: destination_address <8 chars → `ESME_RINVDSTADR` — see topic 1. Test: useful edge case specifically for short-code-length destination addresses.
|
||||||
|
- Kannel: default source-addr TON=2/NPI=1 — Kannel User's Guide 1.4.5 (see topic 1).
|
||||||
|
- Sinch: alphanumeric sender TON=5/NPI=0, max 11 chars; MSISDN sender TON=1/NPI=1, max 18 chars; destination must be E.164 — https://developers.sinch.com/docs/sms/smpp/send-message. Test: submit a 12-char alphanumeric sender or non-E.164 destination, confirm rejection/truncation.
|
||||||
|
- Infobip: shortcode→TON 3/NPI 0; alphanumeric→TON 5/NPI 0; "+"-prefixed→TON 1/NPI 1; else→TON 0/NPI 1; recommends TON/NPI=1 + E.164 as most reliable; `address_range` optional unless the account requires it — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: submit each TON/NPI combo against its matching address shape, confirm acceptance; submit mismatched combos, check rejection vs silent tolerance.
|
||||||
|
- Telnyx: bind requires addr_ton=1/addr_npi=1, fixed — https://support.telnyx.com/en/articles/1667062-short-message-peer-to-peer-set-up-guide. Test: bind with different values, confirm only 1/1 accepted.
|
||||||
|
- Vonage: documented alphanumeric-sender example uses **TON=5, NPI=1** — notably NPI=1, not the NPI=0 convention used by Infobip/Sinch for alphanumeric senders — https://api.support.vonage.com/hc/en-us/articles/204015853. Test: submit alphanumeric sender with NPI=0 vs NPI=1 against Vonage, confirm which the documented example actually expects (cross-vendor inconsistency worth its own regression test).
|
||||||
|
- Kaleyra: TON 1=International MSISDN, 3=National/Network Short Code, 5=Alphabetic — https://messaging.kaleyra.com/support/solutions/articles/3000091796-submitting-messages-through-smpp. Test: submit ton=5 with an alphanumeric source_addr, confirm accepted.
|
||||||
|
- Clickatell: TON/NPI "auto detected" rather than strictly validated; alphanumeric sender "not available on all networks" — downgraded/rejected per destination operator rather than at the SMPP layer — https://archive.clickatell.com/developers/api-docs/using-the-smpp-api/. Test: submit an alphanumeric sender to an operator that doesn't support it, confirm the failure surfaces as a per-operator DLR err, not an SMPP submit_sm_resp rejection.
|
||||||
|
- LINK Mobility (Implementation Guide): source addr_ton restricted to 1 (MSISDN Int'l)/2 (national short code)/5 (alphanumeric); addr_npi ignored entirely — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: submit with an unlisted TON (e.g. 3), check rejection/fallback.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide): source TON options Alphanumeric(5)/International(1)/National(2)/Network-specific(3)/Subscriber(4)/Abbreviated(6), each NPI Unknown(0) or ISDN(1); **destination restricted to International(1) TON only** — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: submit a national-format (non-E.164) destination TON, expect rejection.
|
||||||
|
- Telia: alphanumeric sender TON=5/NPI=0, max 11 chars; International TON=1/NPI=1; Short number TON=3/NPI=0 — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: assert alphanumeric sender rejected/truncated beyond 11 chars.
|
||||||
|
- Telesign: destination requires dest_addr_ton=1/npi=1; source addresses require prior whitelisting/approval before use — https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: submit with dest_addr_ton=0, confirm rejection per this vendor's stated model.
|
||||||
|
- Route Mobile: source_addr_ton Alphanumeric=5, National/Network Short Code=3, International MSISDN=1 (default); **destination TON/NPI not enforced at all** — "always interpreted as 1," destination must be international format **without leading "00"** — routemobile.com PDF. Test: submit dest_addr_ton=9 (garbage), confirm still accepted and treated as TON=1; submit a destination with leading "00", confirm rejection/mis-routing.
|
||||||
|
- Syniverse: alphanumeric sender requires per-operator registration/approval (no TON/NPI published); short codes 5–6 digits — https://sdcsupport.syniverse.com/hc/en-us/articles/360019714593 , https://sdcsupport.syniverse.com/hc/en-us/articles/229476208. Test: validate short-code source addresses are 5–6 digits.
|
||||||
|
|
||||||
|
## 9. Time fields
|
||||||
|
|
||||||
|
- Absolute format `YYMMDDhhmmsstnnp`: YY year, MM month, DD day, hh hour, mm minute, ss second, t tenths of a second, nn UTC time-differential in quarter-hour units, p = `+`/`-` — Kannel devel mailing list quoting the spec, https://www.kannel.org/pipermail/devel/2009-April/002384.html. Test: build an absolute validity_period string per this layout and confirm it parses.
|
||||||
|
- Relative format: replace `p` with `R`; example `000000000500000R` = "5 minutes validity from message receipt" — same source. Test: submit a relative validity_period, confirm the SMSC computes expiry from its own receipt time, not the client's clock.
|
||||||
|
- Kannel real-world quirk: "will send validity in utc therefore 00+ is hardcoded" — Kannel never emits a non-zero UTC offset even though the format supports one — same Kannel devel-list source. Test: assert a test against Kannel doesn't expect timezone-aware absolute validity_period.
|
||||||
|
- Sinch: schedule_delivery_time supports both absolute and relative; max 168 hours (1 week) ahead — https://developers.sinch.com/docs/sms/other/sms-other-cloud-smpp. Test: schedule >168h out, confirm rejection.
|
||||||
|
- Infobip: relative format worked example `"070605040302100R"` = 7y 6mo 5d 4h 3m 2s 1 tenth-sec, relative — https://www.infobip.com/docs/essentials/api-essentials/smpp-specification. Test: submit that exact string in schedule_delivery_time/validity_period, confirm Infobip parses it as relative.
|
||||||
|
- Telesign: validity_period is a 17-octet C-octet string; absolute form uses `+`/`-` UTC offset in quarter-hours as the last char; relative form uses `R` there instead with unused fields zeroed ("relative to current MC time") — https://developer.telesign.com/enterprise/docs/smpp-protocol. Test: build both absolute (+/-) and relative (R) strings per this layout, assert both parse.
|
||||||
|
- Route Mobile / Syniverse: only document the *error* codes (`ESME_RINVEXPIRY` 0x62 for Route Mobile; 1045 "Invalid Validity Period"/1040 "Invalid scheduled delivery time" for Syniverse) — no accepted-format detail published — routemobile.com PDF; https://sdcsupport.syniverse.com/hc/en-us/articles/360038257473. Test: assert malformed validity_period strings trigger these vendor-specific error codes.
|
||||||
|
- Clickatell: documents validity_period as a mechanism to stagger bulk-send delivery when the gateway has queueing delays — a throttling/scheduling tool, not just a TTL — https://archive.clickatell.com/developers/api-docs/validity-period-advanced-message-send/. Test: set a short validity_period on a message stuck behind a large queued batch, confirm it expires (stat:EXPIRED) rather than delivering late.
|
||||||
|
- LINK Mobility (SMSC-SMPP User Guide): recommends validity_period ≥15 minutes; `schedule_delivery_time` is **unsupported** on submit_sm for this profile — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: submit with schedule_delivery_time set, expect rejection/ignoring on this profile.
|
||||||
|
- Absolute-vs-relative rejection rules for the remaining vendors (Bird, Clickatell beyond above, Kaleyra, tyntec, CM.com, Bandwidth): not publicly documented — unverified — folklore.
|
||||||
|
|
||||||
|
## 10. Error handling
|
||||||
|
|
||||||
|
- spec `generic_nack` triggers: invalid `command_length` ("assume the data is corrupt... a generic_nack PDU must be returned") and unknown `command_id` ("a generic_nack PDU must also be returned") — spec via smpp.org PDF fetch. Test: send a PDU with a bogus command_id, assert generic_nack, not a silent drop or close.
|
||||||
|
- spec session states OPEN/BOUND_TX/BOUND_RX/BOUND_TRX/CLOSED gate which PDUs are valid when; out-of-state PDUs aren't given an explicit error rule in the spec text itself, but `ESME_RINVBNDSTS` (0x00000004, "Incorrect BIND Status for given command") is the closest documented code — spec via smpp.org PDF fetch. Test: send deliver_sm on an unbound connection, expect ESME_RINVBNDSTS or a connection close, not silent processing.
|
||||||
|
- spec `outbind`: lets the SMSC signal an ESME to originate a bind_receiver, typically because the SMSC has outstanding messages for it (§2.2.1) — spec via smpp.org PDF fetch. Test: simulate an SMSC-initiated outbind, assert the ESME responds by binding as receiver.
|
||||||
|
- spec `alert_notification`: SMSC-issued in BOUND_RX/BOUND_TRX, and is the **one PDU with no response** — spec via smpp.org PDF fetch. Test: after receiving alert_notification, assert the ESME sends back no response PDU (unlike every other request).
|
||||||
|
- spec `data_sm`: uniquely supports Transaction Message Mode; submit_sm does **not** support transaction mode (§2.10.2/2.10.3/§4.4) — spec via smpp.org PDF fetch. Test: assert the library rejects/flags an attempt to request transaction mode via submit_sm's esm_class instead of data_sm.
|
||||||
|
- Whether submit_sm_resp with non-zero status still carries a body: not confirmed either way in the reachable spec text — unverified — folklore.
|
||||||
|
- Vonage: `command_status=0x00000005` (ESME_RALYBND) beyond the 2-connection default; `0x0d`/13 decimal (ESME_RBINDFAIL) for wrong host/zone/creds; on deliver_sm, only status codes the spec's table marks "temporary failure" trigger a Vonage retry — everything else is final, no retry — https://api.support.vonage.com/hc/en-us/articles/204015713 , https://api.support.vonage.com/hc/en-us/articles/204015783 , https://api.support.vonage.com/hc/en-us/articles/204015773. Test: return each documented temporary vs non-temporary status in deliver_sm_resp, confirm retry only for the temporary set.
|
||||||
|
- Bird: DLR text `err:` and `network_error_code` TLV both index into Bird's own proprietary 0–131 error table, distinct from the underlying carrier's raw code — https://developers.messagebird.com/api/sms-messaging/. Test: table-driven test asserting err code → Bird meaning, kept separate from any carrier-code test.
|
||||||
|
- Kaleyra: successful submit_sm_resp returns error code 0 + non-null message reference; failure returns "a Kaleyra vendor specific error code" — not necessarily a standard SMPP ESME_* value — https://messaging.kaleyra.com/support/solutions/articles/3000091796-submitting-messages-through-smpp. Test: assert error handling tolerates/logs unrecognized vendor codes rather than assuming the standard ESME_* enum is exhaustive.
|
||||||
|
- LINK Mobility (Implementation Guide): generic_nack supported bidirectionally; client must handle SMSC-initiated unbind and respond before disconnecting, then wait ≥30s before reconnecting; outbind not mentioned (implicitly unsupported); ESME_RSYSERR(8)/ESME_RTHROTTLED(88) responses **lack the vendor extension TLV** even when the extension feature is enabled — https://a.storyblok.com/f/151608/x/bb282a50a7/link-mobility-implementation-guide-smpp.pdf. Test: assert reconnect within 30s of an unbind is refused; assert RSYSERR/RTHROTTLED responses lack the vendor extension even with it turned on.
|
||||||
|
- Telenor: OUTBIND/SUBMIT_MULTI/ALERT_NOTIFICATION unsupported (see topic 1); GENERIC_NACK is in the supported-operations list — https://developer.telenor.no/images/97-322719.pdf.
|
||||||
|
- Telia: ESME must send unbind before closing the TCP connection; SMSC can also unilaterally unbind for maintenance — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: close TCP without unbind vs proper unbind, compare vendor-side session-cleanup expectations.
|
||||||
|
- Route Mobile: rich vendor error-code extension beyond standard SMPP: `ESME_CREDIT_ERROR` 0x401, `ESME_SPAM_MESSAGE` 0x404, `ESME_RINVSMLEN` 0x405 (>160 char text message), `ESME_RINVUDHLEN` 0x406, `ESME_RINSMSEMPTY` 0x407, `ESME_RINDSTDND` 0x408 (destination in DND), 0x409–0x412 (invalid source/template, long-message-template error, duplicate submission, destination/source barred) — routemobile.com PDF. Test: assert each vendor-extension code round-trips through generic error parsing without falling back to "unknown error."
|
||||||
|
- Bandwidth: error ranges by origin — Bandwidth client 301-476, Bandwidth server 100-231, carrier client 700-795, carrier server 600-650, carrier ambiguous 902/999; 306 = registered_delivery out of range; 304-305 = invalid UDH; 720 = destination not in numbering plan — https://www.bandwidth.com/support/en/articles/12823160. Test: assert a registered_delivery value outside {0,1} on submission is flagged in the same class as Bandwidth's 306.
|
||||||
|
- Syniverse: 1032 invalid registered_delivery, 1033 system error, 1034 invalid service_type, 1035 invalid command length, 1036 wrong state for command, 1037 invalid message id, 1038 outgoing queue full, 1039 throttling, 1041 submit failed, 1042/1043 invalid source/dest TON, 1048 invalid data_coding — https://sdcsupport.syniverse.com/hc/en-us/articles/360038257473. Test: checklist that the library independently validates/reports each of command_length, session state, message id, service_type, registered_delivery, TON, data_coding.
|
||||||
|
|
||||||
|
## 11. Framing / transport
|
||||||
|
|
||||||
|
- spec §2.4: the underlying network connection is assumed to provide "reliable data transfer... including packet encoding, windowing, flow control and error handling," and segmentation/reassembly of PDUs across packets happens **below** the SMPP layer — spec via smpp.org PDF fetch. Test: feed a test harness PDUs deliberately split across multiple TCP writes and multiple PDUs coalesced into one read, assert the parser reassembles by `command_length` rather than assuming one PDU per read.
|
||||||
|
- spec §3.1 C-octet String: "a series of ASCII characters terminated with the NULL character... empty strings encode as a single NULL octet (0x00)" — spec via smpp.org PDF fetch. Test: a field that's non-null-terminated or contains embedded NULs should be rejected/flagged by a strict parser.
|
||||||
|
- spec PDU header: four 4-octet big-endian fields (command_length, command_id, command_status, sequence_number); command_id 0x00000000–0x000001FF requests / 0x80000000–0x800001FF responses; sequence_number 0x00000001–0x7FFFFFFF — spec via smpp.org PDF fetch. Test: fuzz a header with command_length smaller than actual PDU bytes present, confirm the generic_nack "invalid command_length" path (topic 10) fires.
|
||||||
|
- `sm_length=0` + `message_payload` combination is the standard way to carry >254-octet user data — spec via sysop.fr mirror (also topic 5/6). Test: assert a parser accepts short_message len 0 alongside a populated message_payload TLV.
|
||||||
|
- Telia: requires SNI on all SMPP TLS connections, lists 14 supported TLS cipher suites, min TLS1.2, port 3550 — https://cdn.messaging.teliacompany.com/documents/developer/index.html. Test: attempt a TLS handshake without SNI, expect rejection.
|
||||||
|
- LINK Mobility: TLS 1.2/1.3 required, ports 3600 (plain)/3601 (TLS), TLS1.0/1.1 discontinued since 2020-11-15 — https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf. Test: attempt TLS1.1, expect rejection.
|
||||||
|
- Kaleyra: `message_payload` explicitly capped at 64K octets, gated behind interface_version=0x34 — https://messaging.kaleyra.com/support/solutions/articles/3000091796-submitting-messages-through-smpp. Test: send message_payload right at and just over 64K, confirm boundary behaviour.
|
||||||
|
- Bird: plaintext SMPP on TCP 2775, TLS on TCP 2776 — https://docs.bird.com/connectivity-platform/other-integrations/how-can-i-use-the-short-message-peer-to-peer-smpp-network-protocol. Test: configure a mock SMSC on 2775/2776 to match a Bird-shaped client config.
|
||||||
|
- No vendor among the ~20 researched publicly documents multi-PDU-per-TCP-segment behaviour, `command_length`-lies-about-actual-size handling, or enquire_link burst tolerance by name — this whole topic is essentially spec-only + implementation-defined across all vendors; treat the spec §2.4/§3.1 items above as the ground truth and the rest as **unverified — folklore**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top 10 quirks most likely to break a fresh implementation (ranked)
|
||||||
|
|
||||||
|
1. **Message ID base/field mismatch between submit_sm_resp and DLR `id:`.** Kannel `msg-id-type`, Jasmin `dlr_msgid`, and Vonage's default hex-vs-decimal split all independently produce a submit_sm_resp id that doesn't string-match the DLR's `id:` field unless converted. A correlation layer that assumes "same string" will silently lose DLR-to-message mapping. (Kannel User's Guide; Jasmin docs; Vonage API Support.)
|
||||||
|
2. **`esm_class` DLR bit confusion: 0x04 (final receipt) vs 0x20 (intermediate) vs 0x00 (plain MO).** A naive "esm_class != 0 means DLR" check misclassifies intermediate notifications and even non-DLR SME acks (0x08/0x10) as final delivery receipts. (SMPP 3.4 spec.)
|
||||||
|
3. **Long-message mechanism varies by vendor and none is universal.** UDH-only (LINK Mobility, Vonage, Route Mobile via esm_class 0x43), `sar_*` TLVs (Clickatell, spec-native), and `message_payload` (Telia, Kaleyra — gated behind interface_version 0x34 — Route Mobile) are all real, mutually exclusive requirements. A library hardcoded to one mechanism won't interop with vendors requiring another. (Multiple vendor docs above.)
|
||||||
|
4. **UCS-2 byte order.** Kaleyra and Route Mobile explicitly require UTF-16 **big-endian**; a JS/TS implementation defaulting to native (often little-endian) UTF-16 encoding silently corrupts every non-GSM message. (Kaleyra, Route Mobile docs.)
|
||||||
|
5. **Throttling semantics: `ESME_RTHROTTLED` (0x58) vs `ESME_RMSGQFUL` (0x14) need different backoff, and window/TPS limits vary 10x across vendors** (2/s free SMPPSim vs 100+/s Infobip) with some vendors closing the socket instead of responding. A fixed retry policy will misbehave against most real SMSCs. (smpp.org error codes; LINK Mobility, Sinch, Vonage, Telia docs.)
|
||||||
|
6. **DLR text-body field variability is much wider than the "standard" format suggests.** `sub`/`dlvrd` are hardcoded "000" and `text` always blank at LINK Mobility; `stat` vocabularies extend beyond the core 7 values (Vonage adds FAILED/DELETED, Infobip allows ENROUTE as a stat, Telesign adds SKIPPED). A strict fixed-format parser breaks on the first non-conforming vendor. (LINK Mobility, Vonage, Infobip, Telesign, smpp.org.)
|
||||||
|
7. **Concatenated SMS must stay on one session/server, and MO concat params can be absent.** Vonage: all parts must go through the same SMPP server or the handset never reassembles, within a 20-minute submission window; MO concat TLVs are missing entirely for some US carriers, requiring a same-sender+timestamp heuristic. tyntec: without UDH, segment order isn't guaranteed at all. (Vonage, tyntec docs.)
|
||||||
|
8. **PDU framing over TCP is not 1:1 with `read()`/`write()` calls.** The spec explicitly pushes segmentation/reassembly below the SMPP layer, but no vendor publishes concrete "multiple PDUs per segment" or "PDU split across segments" test guidance — a stream parser that assumes one PDU per socket read will break under real-world TCP behavior (Nagle, MTU, coalescing) the first time it hits production traffic. (SMPP 3.4 spec §2.4/§3.1.)
|
||||||
|
9. **Interface-version-gated features fail silently, not loudly.** Binding at 0x33 instead of 0x34 silently disables `message_payload`/certain TLVs (Kaleyra, Route Mobile) or changes message_id format (Sinch legacy) rather than rejecting the bind — a library that doesn't track negotiated version per-session will send PDUs the far end simply ignores or misinterprets. (Kaleyra, Route Mobile, Sinch docs.)
|
||||||
|
10. **TON/NPI conventions for alphanumeric sender disagree across vendors.** NPI=0 (Infobip, Sinch, Telia) vs NPI=1 (Vonage's own documented example) for the same TON=5 alphanumeric-sender case — a library validating "correct" TON/NPI combinations against one vendor's convention will reject valid submissions to another. (Infobip, Sinch, Telia, Vonage docs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Coverage notes
|
||||||
|
- Kannel's `dlr-mask` (named in the brief) was searched for directly in the Kannel 1.4.5 User's Guide `smsc smpp` section and **does not appear there** — confirmed absent via targeted fetch, not merely unfound. It may exist under a different name/group in newer Kannel, or the brief may have conflated it with another gateway's option. Marked **unverified — folklore** rather than guessed.
|
||||||
|
- Twilio confirmed to offer SMPP, but enterprise-gated only (no public/self-service target) — see topic 1.
|
||||||
|
- Tele2 and Hi3G/Tre (3, Sweden) have no public SMPP interconnect technical spec discoverable in English or Swedish — explicitly no findings, not omitted by oversight.
|
||||||
|
- Esendex's public docs describe only capability-level facts (multi-bind, separate in/out binds); no protocol-level parameters (TON/NPI, data_coding, DLR format) could be recovered — its SMPP developer sub-pages are JS-rendered and returned empty content to fetching.
|
||||||
|
- Syniverse's raw SMPP bind-level technical manual is not publicly reachable; everything sourced instead comes from its SCG platform docs, which wrap SMPP in a REST layer but still publish SMPP-level status-code tables.
|
||||||
|
- Telenor's two public PDFs are business/operational overviews confirming supported-vs-unsupported PDU types but not publishing message_id format, DLR text format, or TON/NPI tables.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# SMPP SMSC-side test targets for `@larvit/smpp`
|
||||||
|
|
||||||
|
Research date 2026-09-05. All claims cite the URL they came from; anything not confirmed by a primary source is marked **unverified**.
|
||||||
|
|
||||||
|
## Quick comparison
|
||||||
|
|
||||||
|
| Candidate | Language | License | Maintained? | Docker tag (pinned) | SMPP port | SMPP versions |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| SMPPSim (Selenium Software) | Java | GPLv2 [sourceforge](https://sourceforge.net/projects/smppsim/) | Stalled — homepage `seleniumsoftware.com` returns Cloudflare 522 as of this research; last SourceForge file 2015-05-22; community fork last touched 2024-01-09 | none published — build your own | 2775 | 3.3/3.4 implied, not confirmed |
|
||||||
|
| Jasmin SMS Gateway | Python/Twisted | ISC-style (see repo) [github](https://github.com/jookies/jasmin) | Source active (last push 2026-04-26), last **tagged release** 0.11.0 (2023-11-10) | `jookies/jasmin:0.11.0` [dockerhub](https://hub.docker.com/r/jookies/jasmin/tags) | 2775 | 3.4 |
|
||||||
|
| Kannel + opensmppbox | C | BSD-style (Kannel) | Kannel core: last stable 1.4.5, 2018-06-19 [kannel.org](https://www.kannel.org/); opensmppbox (pruiz fork): last commit 2014-04-22 [github](https://github.com/pruiz/kannel-opensmppbox) | none — build from source | 2345 (configurable) | 3.3/3.4/5.0 |
|
||||||
|
| Kannel `fakesmsc` | C | BSD-style | same as Kannel | n/a | configurable (e.g. 10000) | **none — not SMPP at all** |
|
||||||
|
| Melrose Labs SMSC Simulator (hosted) | closed-source | proprietary | Actively run (commercial service) | n/a — hosted | 2775 (8775 TLS) | 3.3/3.4/5 |
|
||||||
|
| Melrose Labs SMSC Simulator (OSS, old code) | C++ | MIT [github](https://github.com/melroselabs/smpp-smsc-simulator) | Dormant, last push 2023-07-15, explicitly "old version" | none published | 2775 | subset of 3.4 |
|
||||||
|
| ukarim/smscsim | Go | MIT [github](https://github.com/ukarim/smscsim) | Source active (last push 2025-12-12); Docker tags stop at 0.2.0 | `ukarim/smscsim:0.2.0` (no newer semver tag) [dockerhub](https://hub.docker.com/r/ukarim/smscsim/tags) | 2775 (env `SMSC_PORT`) | subset of 3.4 |
|
||||||
|
| mdouchement/smsc3 | Go | MIT [github](https://github.com/mdouchement/smsc3) | Last push 2024-05-09, small project | none published — Dockerfile in repo | 20001 | 3.4 |
|
||||||
|
| K2InformaticsGmbH/smsc_simulator | Erlang | Apache-2.0 [github](https://github.com/K2InformaticsGmbH/smsc_simulator) | Abandoned, last commit 2018-08-31 | none | configurable | SMPP + UCP |
|
||||||
|
| MavoCz/smscsim (cloudhopper-based) | Java | unverified | unverified last-commit date | none | configurable (CLI `-p`) | 3.3/3.4/5.0 (via cloudhopper) |
|
||||||
|
| fizzed/cloudhopper-smpp | Java | Apache-2.0 [github](https://github.com/fizzed/cloudhopper-smpp) | Last push 2020-10-12 | none — library + demo, not a packaged sim | demo-configurable | 3.3/3.4/most of 5.0 |
|
||||||
|
| jsmpp `SMPPServerSimulator` example | Java | Apache-2.0 [github](https://github.com/opentelecoms-org/jsmpp) | jsmpp itself very active (last push 2026-06-17); the example is a demo class, not a packaged tool | none — sample code | example-configurable | 3.4/3.3 |
|
||||||
|
| node-smpp | JS | MIT [github](https://github.com/farhadi/node-smpp) | Last push 2023-12-28 | none — library, build your own server | n/a | 5.0 (back-compat with 3.4) |
|
||||||
|
| MikeSafonov/smpp-server-mock | Java (JUnit ext.) | MIT [github](https://github.com/MikeSafonov/smpp-server-mock) | Last push 2023-03-10 | none — embedded-in-test only, not standalone | n/a | unverified |
|
||||||
|
| Restcomm/TeleStax SMSC Gateway simulator | Java/JSLEE | unverified | project largely dormant post-TeleStax | none published | 2776 (sample) | unverified, full carrier-grade stack |
|
||||||
|
| OsmoMSC SMPP interface | C | AGPL (Osmocom) | Actively maintained as part of Osmocom NITB/MSC, but it's a *client* interface for external ESMEs bound to a live MSC core, not a standalone SMSC simulator you spin up in Docker | none | configurable | unverified |
|
||||||
|
| smscsim.smpp.org (public) | closed | n/a | unverified, listed on smpp.org | hosted | 2775 | unverified, 2 SMS/s cap |
|
||||||
|
|
||||||
|
Ozeki NG SMS Gateway and NowSMS both ship free/trial tiers with an SMPP server mode, but are Windows-first, closed-source products; skipped per task scope beyond this line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SMPPSim (Selenium Software)
|
||||||
|
|
||||||
|
1. **Source**: originally `seleniumsoftware.com/downloads.html`; that domain currently returns an HTTP 522 (Cloudflare "origin unreachable") when fetched directly — confirmed via both WebFetch and a raw `curl` from this research session, so the vendor's own site is effectively down right now. SourceForge mirror: [sourceforge.net/projects/smppsim](https://sourceforge.net/projects/smppsim/) — license **GPLv2**, "Currently hosted by Selenium Software", last file dated **2015-05-22**. A community fork with docs/props preserved: [komuw/smpp_server_docker](https://github.com/komuw/smpp_server_docker) (last push 2019-06-04), user-guide mirrored at [user-guide.htm](https://github.com/komuw/smpp_server_docker/blob/master/SMPPSim/docs/user-guide.htm). Not actively maintained by any single fork; version history in the mirrored guide references up to v2.6.6.
|
||||||
|
2. **Docker**: no maintainer-published image with a pinned version tag exists. Two build-yourself Dockerfiles found:
|
||||||
|
- [kwahome/smpp-sim-docker](https://github.com/kwahome/smpp-sim-docker) (repo last pushed 2024-01-09) — its own `Dockerfile` builds `openjdk:7-jre-alpine` + bundled `SMPPSim/`, but its `docker-compose.yml` instead pulls a pre-built `blackorder/smppsim:latest` — **only a `latest` tag exists** on that Docker Hub repo (pushed ~2 years ago, single tag) [dockerhub](https://hub.docker.com/r/blackorder/smppsim/tags). Since no full-patch-version tag is published anywhere, to comply with "never floating tags" you must build the image yourself from the Dockerfile and apply your own version tag.
|
||||||
|
- `bitsensedev/smpp-sim` on Docker Hub: single `latest` tag, pushed **9+ years ago** — effectively abandoned, do not use.
|
||||||
|
- Ports (from the Dockerfile env defaults): SMPP **2775**, HTTP admin **8884** (some older docs say port 88 — verify against the image you build).
|
||||||
|
- Default credentials (env `SYSTEM_IDS` / `PASSWORDS`): `smppclient1,smppclient2,smppclient3` / `password,password,password` [Dockerfile](https://raw.githubusercontent.com/kwahome/smpp-sim-docker/master/Dockerfile).
|
||||||
|
3. **SMPP versions**: not explicitly stated in any surviving doc; behavior suggests 3.3/3.4-era PDU set. Whether it validates `interface_version` at bind — **unverified**, no mention in the mirrored user guide.
|
||||||
|
4. **Delivery receipts**: yes, when `registered_delivery_flag` is set on `submit_sm`. Controlled by percentage knobs: `PERCENTAGE_DELIVERED`, `PERCENTAGE_UNDELIVERABLE`, `PERCENTAGE_ACCEPTED`, `PERCENTAGE_REJECTED`, plus `PERCENTAGE_THAT_TRANSITION` (chance of an intermediate state before final), `DELAY_DELIVERY_RECEIPTS_BY` (delayed DLR, ms), `MAX_TIME_ENROUTE`. TLV vs text-body format not documented; message-id format documented only as "randomised … with configurable prefix" — hex/decimal notation unverified.
|
||||||
|
5. **Long messages**: UDH/`sar_*`/`message_payload` handling not documented in any surviving guide fragment — unverified, treat as untested.
|
||||||
|
6. **Encodings**: only one hard fact recovered — binary MO messages (hex-prefixed with `0x` in the inject form) get `data_coding` auto-set to 4. GSM7 packed-vs-unpacked, Latin-1, UCS-2 behavior — unverified.
|
||||||
|
7. **MO injection**: three documented methods — (a) web form `/inject_mo.htm`, (b) "loopback" mode that turns a `submit_sm` back into a `deliver_sm`, (c) MO service reading a `deliver_messages.csv` file. Also supports SMSC-initiated **outbind** (`OUTBIND_ENABLED=true` + `OUTBIND_ESME_*` env vars) to test your library's outbind handling.
|
||||||
|
8. **Fault injection**: queue-full simulation via `INBOUND_QUEUE_MAX_SIZE`/`OUTBOUND_QUEUE_MAX_SIZE` and `DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS`; on `ESME_RMSGQFUL` the mirrored guide says SMPPSim "will attempt to deliver the MO message again, after a delay" (v2.6.0 note). `MO_DELIVERY_MESSAGES_PER_MINUTE`/`DELIVERY_MESSAGES_PER_MINUTE` throttles delivery rate. Explicit `ESME_RTHROTTLED` triggering — unverified.
|
||||||
|
9. **TLS**: not mentioned anywhere in the surviving docs — treat as unsupported.
|
||||||
|
10. **Minimal run** (build-yourself, since no pinned image exists):
|
||||||
|
```
|
||||||
|
git clone https://github.com/kwahome/smpp-sim-docker
|
||||||
|
cd smpp-sim-docker
|
||||||
|
docker build -t local/smppsim:<your-tag> .
|
||||||
|
docker run -p 2775:2775 -p 8884:8884 local/smppsim:<your-tag>
|
||||||
|
```
|
||||||
|
Bind with `system_id=smppclient1`, `password=password`, port 2775.
|
||||||
|
11. **Quirks**: primary vendor site down (522) at research time; ecosystem is a scatter of unofficial Docker forks with inconsistent port numbers (88 vs 8884) between older and newer docs — verify against whichever build you actually use.
|
||||||
|
|
||||||
|
## 2. Jasmin SMS Gateway
|
||||||
|
|
||||||
|
1. [jookies/jasmin](https://github.com/jookies/jasmin), license per repo (ISC-style, see `LICENSE`), Python/Twisted. Repo actively committed (last push 2026-04-26) but last **tagged release is 0.11.0, 2023-11-10** [releases](https://github.com/jookies/jasmin/releases). Docs: [docs.jasminsms.com](https://docs.jasminsms.com/).
|
||||||
|
2. **Docker**: `jookies/jasmin:0.11.0` (full patch tag exists and matches the GitHub release) [dockerhub tags](https://hub.docker.com/r/jookies/jasmin/tags). Needs RabbitMQ + Redis as separate containers — not standalone.
|
||||||
|
```
|
||||||
|
docker run -d -p 1401:1401 -p 2775:2775 -p 8990:8990 jookies/jasmin:0.11.0
|
||||||
|
```
|
||||||
|
Ports: **2775** SMPP server, 1401 HTTP API/panel, 8990 `jcli` telnet console [docker/README.md](https://github.com/jookies/jasmin/blob/master/docker/README.md). No default smpp-server credentials are pre-provisioned — you create a `system_id`/`password` via `jcli` (`smppccm`/`user -a` commands) after startup.
|
||||||
|
3. **SMPP version**: 3.4 [SMPP Server API docs](https://docs.jasminsms.com/en/latest/apis/smpp-server/index.html). `interface_version` enforcement at bind — not documented; **unverified**.
|
||||||
|
4. **Delivery receipts**: yes. Jasmin's DLR pipeline (`DLRLookup`/`DLRThrower`) tracks the message in Redis and, when the original submission came in over `smpps`, pushes the receipt back as a `deliver_sm`/`data_sm` on the same bind [messaging flows](https://docs.jasminsms.com/en/latest/messaging/index.html). Whether it's TLV (`receipted_message_id`/`message_state`) or text-body — not confirmed from docs; a `dlr_msgid` connector setting (0/1/2) exists specifically to reconcile hex-vs-decimal message-id mismatches between `submit_sm_resp` and `deliver_sm` — but that knob lives on **outbound SMPP client connectors** (Jasmin acting as ESME to an upstream real SMSC), not on the `smpps` server your library binds to [Google Groups thread](https://groups.google.com/g/jasmin-sms-gateway/c/KRqUA6e569w). When Jasmin itself is the SMSC (your library binds to its `smpps`), the message id it generates is its own internal **UUID**-based id [Google Groups](https://groups.google.com/g/jasmin-sms-gateway/c/KRqUA6e569w) and should therefore be self-consistent between `submit_sm_resp` and the DLR — good for testing your library's "normalise across submit_sm_resp vs receipt" logic in the *opposite* direction (assert it does NOT need to normalise when ids already match).
|
||||||
|
5. **Long messages**: two segmentation strategies documented for MT — **SAR** (`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum`, "preferred by most SMSCs") and **UDH** (prepended header + `UDHI_INDICATOR_SET` in `esm_class`, "for older system compatibility") [DeepWiki summary of Jasmin SMPP protocol support](https://deepwiki.com/jookies/jasmin/3.3-smpp-protocol-support) (secondary source — treat as indicative, not primary).
|
||||||
|
6. **Encodings**: capacity figures imply **packed** GSM7 (160 chars / 153 segmented = the standard packed math), 8-bit binary 140/134 bytes, UCS-2 70/67 chars [same DeepWiki page]. Not a primary source — verify empirically before relying on it.
|
||||||
|
7. **MO injection**: bind your library as receiver/transceiver on `smpps`; anything Jasmin routes to that connector arrives as `deliver_sm`. Separately, Jasmin's HTTP API/interceptor stack can push MO content to an HTTP webhook (`deliverSmHttpThrower`) instead — not needed for SMSC-side ESME testing, only relevant if you want Jasmin to also fan MO out to HTTP [interception docs](https://docs.jasminsms.com/en/latest/interception/index.html).
|
||||||
|
8. **Fault injection**: `submit_throughput` exists as a per-connector throttling parameter but its exact unit/effect and how to disable it are not resolved even in the project's own issue tracker — [issue #913](https://github.com/jookies/jasmin/issues/913) is open/stale with no documented answer. No documented way to force `ESME_RTHROTTLED`/`ESME_RMSGQFUL`, refuse binds, or drop the TCP connection on demand — unverified/likely absent.
|
||||||
|
9. **TLS**: the `[smpp-server]` config section in the shipped `jasmin.cfg` has **no TLS/SSL directives at all** [jasmin.cfg](https://github.com/jookies/jasmin/blob/master/misc/config/jasmin.cfg) — the SMPP *server* role does not support TLS. (An outbound SMPP *client* connector does have `useSSL`/`SSLCertificateFile` options, irrelevant here.)
|
||||||
|
10. **Minimal config** — `[smpp-server]` section, defaults: port 2775, `sessionInitTimerSecs=30`, `enquireLinkTimerSecs=30`, `inactivityTimerSecs=300`, `responseTimerSecs=60`, `pduReadTimerSecs=10`, `dlr_expiry=86400` [jasmin.cfg](https://github.com/jookies/jasmin/blob/master/misc/config/jasmin.cfg). After boot, create a user via `jcli`:
|
||||||
|
```
|
||||||
|
jcli -h 127.0.0.1 -p 8990 # telnet console
|
||||||
|
> user -a
|
||||||
|
> uid myesme
|
||||||
|
> gid mygroup
|
||||||
|
> username myesme
|
||||||
|
> password mypassword
|
||||||
|
> ok
|
||||||
|
```
|
||||||
|
11. **Quirks**: no packaged Docker Compose bundling RabbitMQ+Redis is officially shipped — you must wire those yourself. No release/Docker tag newer than late 2023 despite ongoing source commits — verify current `master` behavior differs from 0.11.0 before trusting docs literally.
|
||||||
|
|
||||||
|
## 3. Kannel + opensmppbox (and why `fakesmsc` doesn't help)
|
||||||
|
|
||||||
|
1. **Kannel** core: [kannel.org](https://www.kannel.org/), C, BSD-style license. Confirmed via the vendor's own homepage: last **stable release 1.4.5, 2018-06-19**; nothing newer since [kannel.org news list, fetched directly]. Effectively unmaintained upstream.
|
||||||
|
**opensmppbox** (the actual SMPP-server add-on): canonical doc is the [OpenSMPPBox User's Guide](https://www.kannel.org/download/1.4.4/gateway-1.4.4/addons/opensmppbox/doc/userguide.xml) ("developed by Chimit Ltd, maintained by the Kannel Group"). The most complete GitHub mirror, [pruiz/kannel-opensmppbox](https://github.com/pruiz/kannel-opensmppbox), has its last commit **2014-04-22** — 11+ years stale.
|
||||||
|
2. **Docker**: no official image. Build from source against a Kannel `bearerbox` build; no maintained Dockerfile found. Default port **2345** (`opensmppbox-port`); credentials live in a flat file set via `smpp-logins` (username/password/system-type/IP-restriction per line).
|
||||||
|
3. **SMPP versions**: "compliance to SMPP v3.3, SMPP v3.4 & SMPPv5.0" per the user guide. `interface_version` bind-time validation — not documented, unverified.
|
||||||
|
4. **Delivery receipts**: opensmppbox stores/forwards DLRs from Kannel's `bearerbox` (multiple backends: internal, MySQL, PostgreSQL, Oracle, SQLite3, MS-SQL) and "rewrites [them] to appear that they originated from OpenSMPPBox" back to the bound ESME. TLV vs text, exact `esm_class`, intermediate/failure/delayed-DLR simulation — not documented in the guide; unverified.
|
||||||
|
5. **Long messages**: supports `message_payload` TLV as an alternative to UDH concatenation when explicitly enabled; UDH re-splitting behavior on the MO side — unverified.
|
||||||
|
6. **Encodings**: guide only states it can "define the data coding type of the short message" — packed vs unpacked GSM7 behavior unverified.
|
||||||
|
7. **MO injection**: MO routing targets a specific bound ESME "based on shortcode, SMSC id, or randomly if unconfigured" — i.e. inbound traffic through Kannel's normal SMSC drivers gets forwarded to whichever ESME opensmppbox picks; no direct CLI/HTTP "inject one MO now" tool documented for opensmppbox itself (Kannel's own `fakesmsc`, see below, cannot fill this gap because it doesn't speak SMPP).
|
||||||
|
8. **Fault injection / TLS**: not documented for opensmppbox in the guide; unverified — treat as unsupported until proven otherwise.
|
||||||
|
9. **`fakesmsc` clarification (important)**: Kannel ships a **separate** testing tool `test/fakesmsc` that connects to `bearerbox`'s core `smsc = fake` group. Per Kannel's own 1.4.5 user guide (fetched directly from kannel.org): *"Fake SMSC is a simple protocol to test out Kannel. It is not a real SMS center, and cannot be used to send or receive SMS messages from real phones. So, it is ONLY used for testing purposes."* Its wire format is a trivial line-based text protocol (`sender receiver type message...`), confirmed from the `fakesmsc.c` usage text — **it does not speak SMPP at all**. It exists only to test Kannel's own HTTP `sendsms` interface end-to-end without a real carrier link. It is **not usable** to interoperability-test an external SMPP client library, despite superficially sounding like an SMSC simulator. Use opensmppbox for that instead.
|
||||||
|
10. **Minimal opensmppbox config** (from the [user guide](https://www.kannel.org/download/1.4.4/gateway-1.4.4/addons/opensmppbox/doc/userguide.xml)):
|
||||||
|
```
|
||||||
|
group = smpp-logins
|
||||||
|
smpp-logins = /etc/opensmppbox/smpp-logins.txt
|
||||||
|
opensmppbox-port = 2345
|
||||||
|
```
|
||||||
|
and in `smpp-logins.txt`: `myuser mypass VMA 0.0.0.0/0`
|
||||||
|
11. **Quirks**: Kannel's core `smpp` SMSC driver (Kannel acting as *client* connecting outbound to a real SMSC) is well documented and supports `interface-version` (hex string, default `"34"`), `msg-id-type` (bit-flags for hex/decimal `submit_sm_resp` vs `deliver_sm`), `max-pending-submits` (window, default 10), `enquire-link-interval` (default 30s), `use-ssl`/`ssl-client-cert` — but that's the *wrong direction* for this task (it's Kannel-as-ESME, not Kannel-as-SMSC). Given both Kannel and opensmppbox have had no significant commits in a decade, this path is the highest-effort, lowest-payoff of the "must include" list.
|
||||||
|
|
||||||
|
## 4. Melrose Labs SMSC Simulator
|
||||||
|
|
||||||
|
Two distinct things share the name:
|
||||||
|
|
||||||
|
- **Hosted public/commercial service** at `smscsim.melroselabs.com:2775` — closed-source, run by Melrose Labs.
|
||||||
|
- **Old open-source code** at [melroselabs/smpp-smsc-simulator](https://github.com/melroselabs/smpp-smsc-simulator), MIT license, C++11, single-file (`smscsimulator.cpp`) — repo explicitly says it "represents old version of existing SMSC Simulator service available online … newer version of code not published." Last push 2023-07-15.
|
||||||
|
|
||||||
|
1. Homepages: [melroselabs.com/services/smsc-simulator](https://melroselabs.com/services/smsc-simulator/), technical details at [smsc-simulator-technical-details](https://melroselabs.com/services/smsc-simulator/smsc-simulator-technical-details/), dedicated-instance doc at [ssg docs](https://ssgdocs.melroselabs.com/docs/smsc-simulator) / [scrollhelp](https://melroselabs.scrollhelp.site/dss/dedicated-smsc-simulator).
|
||||||
|
2. **Docker**: OSS repo has a `docker-compose.yml`; no image tag confirmed (repo has only source + compose, no published registry tag found). Default port 2775 in both the OSS code and the hosted service.
|
||||||
|
3. **SMPP versions**: "SMPP v3.3, v3.4 and v5" for the hosted service [technical details page]. `submit_sm_resp` message-id length differs by version: **8 characters for v3.3, 64 characters for v3.4 and v5** [same page] — hex/decimal notation not stated.
|
||||||
|
4. **Delivery receipts**: hosted shared service — text `short_message` receipt *plus* TLVs `receipted_message_id` and `message_state` (value 2 = DELIVERED) [technical details page]; shared/free tier is "delivered" status only, <1s after `submit_sm_resp`. **Dedicated** instances are configurable via a `dlr.conf`: DELIVERED(2)/EXPIRED(3)/DELETED(4)/UNDELIVERABLE(5)/ACCEPTED(6)/UNKNOWN(7)/REJECTED(8) with percentages, plus `--dlrlatency` (default 3s) for delayed DLRs, and a per-ESME `shouldReturnUndeliveredReceipts` override [dedicated simulator docs](https://melroselabs.scrollhelp.site/dss/dedicated-smsc-simulator).
|
||||||
|
5. **Long messages**: `message_payload` TLV (0x0424) supported on `submit_sm`/`deliver_sm`/`data_sm` [technical details page]. UDH / `sar_*` handling not documented — unverified.
|
||||||
|
6. **Encodings**: not documented for either tier — unverified; test empirically.
|
||||||
|
7. **MO injection**: on the shared simulator, MO is triggered by encoding **the bound system_id's digits into the destination address** (prepend/append ≥2 digits, min 8 chars total) [technical details page] — an unusual, non-obvious convention, worth automating carefully. Dedicated instances configure acceptable MSISDNs per-ESME via `esme_<systemid>.config`.
|
||||||
|
8. **Fault injection**: dedicated instance's `dlr.conf`/`shouldReturnUndeliveredReceipts` cover DLR-level faults; explicit `ESME_RTHROTTLED`/`ESME_RMSGQFUL`/bind-refusal/connection-drop simulation not documented for either tier — unverified.
|
||||||
|
9. **TLS**: shared service — "TLS 1.1 and up are supported for SMPP sessions. SSL and TLS 1.0 are not supported," on a separate port **8775** [main service page]. Dedicated instances also offer TLS as a paid option.
|
||||||
|
10. **Minimal use**: bind to `smscsim.melroselabs.com:2775` with credentials issued after signing up for a free developer account ([python tutorial](https://developers.melroselabs.com/docs/send-sms-with-smpp-using-python) shows the PDU shape but not the signup flow itself).
|
||||||
|
11. **Quirks / limits**: shared service capped at 100 SMS/sec; a separate, apparently-independent free public simulator is listed at `smscsim.smpp.org:2775` (max 2 SMS/sec) via [smpp.org's testing page](https://smpp.org/smpp-testing-development.html) — relationship to Melrose Labs unverified. Melrose Labs also offers standalone browser tools worth knowing about: an [SMPP client](https://melroselabs.com/) (bind/submit without installing anything), [SMPP Load Test](https://melroselabs.com/), and an [SMPP Analyser](https://melroselabs.com/) that captures a session to a downloadable pcap.
|
||||||
|
|
||||||
|
## 5. ukarim/smscsim — best lightweight OSS option
|
||||||
|
|
||||||
|
1. [github.com/ukarim/smscsim](https://github.com/ukarim/smscsim), Go, MIT, "Lightweight, zero-dependency and stupid SMSc simulator." GitHub push activity is current (last push 2025-12-12) — the most recently-touched OSS candidate found besides Jasmin/jsmpp.
|
||||||
|
2. **Docker**: `ukarim/smscsim` on Docker Hub. Highest **semver** tag is `0.2.0` (pushed ~3 years ago per Docker Hub); newer builds exist only as commit-hash tags (e.g. `a2c646d`, ~2 years ago) with no semver — **use `ukarim/smscsim:0.2.0` for a reproducible pinned tag**, or pin the specific commit-hash tag if you need the newer build and accept it's not semver.
|
||||||
|
```
|
||||||
|
docker run -p 2775:2775 -p 12775:12775 ukarim/smscsim:0.2.0
|
||||||
|
```
|
||||||
|
Ports: SMPP `2775` (env `SMSC_PORT`), web UI `12775` (env `WEB_PORT`). No auth/credentials required by default.
|
||||||
|
3. **SMPP version**: implements "only a small subset of the SMPP3.4 specification." PDUs: `bind_transmitter`, `bind_receiver`, `bind_transceiver`, `unbind`, `submit_sm`, `enquire_link`, `deliver_sm_resp`. Explicitly: **"simulator does not perform PDU validation"** — so it will not reject a bad `interface_version` or malformed PDU; not useful for negative-path bind testing.
|
||||||
|
4. **Delivery receipts**: fixed — DLR always returned ~2s after `submit_sm` with `message_state` **always DELIVERED**; no way to simulate other states except via the `FAILED_SUBMITS` fault-injection flag below. TLV vs text not detailed in the README.
|
||||||
|
5. **Long messages**: not documented — given the minimal PDU set, treat UDH/`sar_*`/`message_payload` support as unverified/likely absent.
|
||||||
|
6. **Encodings**: not documented — unverified.
|
||||||
|
7. **MO injection**: web page at `http://localhost:12775` sends a `deliver_sm` to the active session — simplest MO-injection UX of any candidate here.
|
||||||
|
8. **Fault injection**: `FAILED_SUBMITS=1` env var — even sequence numbers get a `submit_sm` system-error response, odd sequence numbers get an undeliverable DLR. No throttling, no bind refusal, no connection-drop simulation.
|
||||||
|
9. **TLS**: not mentioned — unsupported.
|
||||||
|
10. **Minimal run**: shown above; no config file, everything is env vars.
|
||||||
|
11. **Quirks**: "does not perform PDU validation" is explicit in the README — good for happy-path/throughput testing, useless for asserting your library correctly handles a *rejecting* SMSC.
|
||||||
|
|
||||||
|
## 6–13. Other candidates (condensed)
|
||||||
|
|
||||||
|
| Candidate | Notes |
|
||||||
|
|---|---|
|
||||||
|
| [mdouchement/smsc3](https://github.com/mdouchement/smsc3) | Go, MIT, SMPP3.4-based, port 20001 (SMPP)/6000 (HTTP). MO injection via `POST /deliver` with JSON `{session, sender, recipient, message}`. Has Dockerfile+compose but built around integrating with Kannel specifically. Last push 2024-05-09, low adoption (10 stars). |
|
||||||
|
| [K2InformaticsGmbH/smsc_simulator](https://github.com/K2InformaticsGmbH/smsc_simulator) | Erlang, Apache-2.0, speaks both SMPP and UCP. Abandoned — **last commit 2018-08-31**. Configured/driven from the Erlang shell (`smsc_simulator:start(smpp,10000)`), no Docker. Skip unless you specifically need UCP too. |
|
||||||
|
| [MavoCz/smscsim](https://github.com/MavoCz/smscsim) | Java, built on cloudhopper-smpp, so inherits 3.3/3.4/5.0 support and real PDU validation. Multi-port CLI (`-p 34567 34568 34569`), sends DLRs after a configurable fixed/random delay round-robin to connected RX/TRX binds. No Docker; requires Maven build + editing Spring XML for anything beyond port/log-level. Last-commit date unverified. |
|
||||||
|
| [fizzed/cloudhopper-smpp](https://github.com/fizzed/cloudhopper-smpp) (successor to [twitter-archive/cloudhopper-smpp](https://github.com/twitter-archive/cloudhopper-smpp), which is archived) | Java library, Apache-2.0, not a packaged simulator — but ships runnable demo classes (`make server`, `make server-echo`, `make simulator`, `make ssl-server`) covering SSL and PDU-dump. Good as a base to *write* a custom fixture, not a drop-in server. Last push 2020-10-12. |
|
||||||
|
| [jsmpp](https://github.com/opentelecoms-org/jsmpp) `jsmpp-examples/.../SMPPServerSimulator.java` | Java, Apache-2.0. jsmpp itself is very actively maintained (**last push 2026-06-17**) — best-maintained *library* in this whole survey. The bundled `SMPPServerSimulator` example demonstrates DLR sending and SSL, but is example code, not a packaged/dockerized tool — expect to fork and extend it. |
|
||||||
|
| [farhadi/node-smpp](https://github.com/farhadi/node-smpp) | Node.js, MIT, implements SMPP v5 (backward compatible with 3.4), includes both client and server APIs, supports `ssmpp://` TLS, UDH via `message_payload`, and encoding auto-detection (ASCII/Latin1/UCS2). No packaged simulator binary — you write ~30 lines of server code yourself. Last push 2023-12-28. Small existing forks built on it: [tiltroom/fakesmpp](https://github.com/tiltroom/fakesmpp), [theodorosidmar/smpp-server-simulator](https://github.com/theodorosidmar/smpp-server-simulator) (returns error on 1-in-10 `submit_sm`) — neither independently verified for maintenance/quality here. |
|
||||||
|
| [MikeSafonov/smpp-server-mock](https://github.com/MikeSafonov/smpp-server-mock) | Java, MIT, JUnit5-extension/Spring-Boot-starter mock server for *your own* test suite (assert on captured `SubmitSm`s) — not a standalone server you point an arbitrary client at. Only useful if you also write JVM-side interop tests. Last push 2023-03-10. |
|
||||||
|
| Restcomm/TeleStax SMSC Gateway "SMPP Simulator" | Bundled GUI test tool inside the RestComm SMSC Gateway product (`$SMSC_HOME/tools/TelScale-smpp-simulator/bin/run.sh`), default `system_id=test/password=test`, port 2776, transceiver bind, address range `6666` [docs](https://github.com/RestComm/smscgateway/blob/master/docs/adminguide/sources/src/main/resources/en-US/Chapter-smpp-simulator.xml). This is really a load-test client for RestComm's own gateway, not an installable-alone SMSC simulator — and the whole product requires a JSLEE stack. High setup cost for low unique coverage; skip unless you're specifically validating against a carrier-grade SS7-adjacent stack. |
|
||||||
|
| OsmoMSC SMPP interface | C, part of Osmocom's core-network stack (actively maintained as infrastructure, not as a "test double"). Its SMPP interface lets an ESME bind to a *real* (if simulated-radio) mobile-network MSC to send/receive SMS to subscribers — valuable only if you're also running an Osmocom test network; far too heavy just to test an SMPP library. Treat as out of scope for this project. |
|
||||||
|
| `smscsim.smpp.org` | Free public SMSC simulator, host/port from [smpp.org's testing page](https://smpp.org/smpp-testing-development.html): `smscsim.smpp.org:2775`, capped at 2 SMS/sec, delivered-only DLRs, per-IP connection limits. Ownership/maintenance unverified — treat as a convenience fallback, not a primary target. |
|
||||||
|
| Ozeki NG SMS Gateway / NowSMS | Both have a free/trial tier with SMPP server capability, both are closed-source and Windows-first (Ozeki explicitly; NowSMS also ships Linux/Docker in some tiers but is paid-oriented) — out of scope per task instructions beyond this line. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation — set these up first
|
||||||
|
|
||||||
|
1. **ukarim/smscsim** (Docker `ukarim/smscsim:0.2.0`) — near-zero setup cost, fastest possible CI smoke test: bind, submit, get a DLR, inject an MO from the web UI. Its explicit "no PDU validation" and single fixed DLR state are limitations, not blockers, for a first pass.
|
||||||
|
2. **Jasmin SMS Gateway** (Docker `jookies/jasmin:0.11.0` + RabbitMQ + Redis) — the only candidate here that is a real, still-developed production SMS gateway with a proper SMPP **server** role, HTTP management, and a DLR pipeline distinct from a toy simulator. Exercises your library against genuinely different internals (Python/Twisted, AMQP-backed message routing) than the Go/Java toys.
|
||||||
|
3. **Melrose Labs hosted SMSC Simulator** (`smscsim.melroselabs.com:2775`, free tier) — a public, independently-run, closed-box target you don't control, which is exactly the point: it validates your library against an implementation you cannot special-case for. Its TLS port (8775) also covers your TLS interop case for free without standing up your own CA. Follow up with a **dedicated** instance (paid) once you need to script specific DLR failure/latency states via `dlr.conf`.
|
||||||
|
4. **SMPPSim, built yourself from `kwahome/smpp-sim-docker`** — despite the dead upstream site and lack of a pinned tag, it's the only candidate with rich, config-file-driven **fault injection** (percentage-based delivered/undeliverable/rejected/accepted, intermediate-state transition chance, delayed DLRs, outbind support, MO queue-full retry behavior). Worth the one-time cost of building and tagging your own image specifically to cover the failure- and delay-injection matrix nothing else here offers as cleanly.
|
||||||
|
|
||||||
|
Skip Kannel/opensmppbox and `fakesmsc` for now: both Kannel and opensmppbox have had no meaningful commits in a decade, opensmppbox's interop behavior (TLS, throttling, interface_version validation) is entirely undocumented, and `fakesmsc` — despite the name — doesn't speak SMPP at all, so it cannot test this library regardless of effort spent.
|
||||||
Executable
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CAPTURES_DIR = REPO_ROOT / "interop-tests" / "captures"
|
||||||
|
NETSHOOT_IMAGE = "nicolaka/netshoot:v0.16"
|
||||||
|
EXPERT_SEVERITY_ERROR = "8388608"
|
||||||
|
|
||||||
|
# The SMPP port each peer's compose overlay exposes its SMSC on.
|
||||||
|
PORT_BY_PEER = {
|
||||||
|
"cloudhopper": 2775,
|
||||||
|
"dumbclient": 2775,
|
||||||
|
"jasmin": 2775,
|
||||||
|
"jsmpp": 2775,
|
||||||
|
"kannel": 2775,
|
||||||
|
"php": 2775,
|
||||||
|
"python": 2775,
|
||||||
|
"smppload": 2775,
|
||||||
|
"smppsim": 2775,
|
||||||
|
"smscsim": 2775,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every scenario in every peer's test file binds before doing anything else, so a capture missing
|
||||||
|
# either side of that handshake never saw real traffic - the same signal as an empty capture.
|
||||||
|
BIND_COMMANDS = ("bind_receiver", "bind_transceiver", "bind_transmitter")
|
||||||
|
|
||||||
|
# The 33 SMPP commands (SMPP 3.4), by numeric command_id, for the tshark histogram.
|
||||||
|
COMMAND_NAMES = {
|
||||||
|
0x00000001: "bind_receiver",
|
||||||
|
0x00000002: "bind_transmitter",
|
||||||
|
0x00000003: "query_sm",
|
||||||
|
0x00000004: "submit_sm",
|
||||||
|
0x00000005: "deliver_sm",
|
||||||
|
0x00000006: "unbind",
|
||||||
|
0x00000007: "replace_sm",
|
||||||
|
0x00000008: "cancel_sm",
|
||||||
|
0x00000009: "bind_transceiver",
|
||||||
|
0x0000000B: "outbind",
|
||||||
|
0x00000015: "enquire_link",
|
||||||
|
0x00000021: "submit_multi",
|
||||||
|
0x00000102: "alert_notification",
|
||||||
|
0x00000103: "data_sm",
|
||||||
|
0x00000111: "broadcast_sm",
|
||||||
|
0x00000112: "query_broadcast_sm",
|
||||||
|
0x00000113: "cancel_broadcast_sm",
|
||||||
|
0x80000000: "generic_nack",
|
||||||
|
0x80000001: "bind_receiver_resp",
|
||||||
|
0x80000002: "bind_transmitter_resp",
|
||||||
|
0x80000003: "query_sm_resp",
|
||||||
|
0x80000004: "submit_sm_resp",
|
||||||
|
0x80000005: "deliver_sm_resp",
|
||||||
|
0x80000006: "unbind_resp",
|
||||||
|
0x80000007: "replace_sm_resp",
|
||||||
|
0x80000008: "cancel_sm_resp",
|
||||||
|
0x80000009: "bind_transceiver_resp",
|
||||||
|
0x80000015: "enquire_link_resp",
|
||||||
|
0x80000021: "submit_multi_resp",
|
||||||
|
0x80000103: "data_sm_resp",
|
||||||
|
0x80000111: "broadcast_sm_resp",
|
||||||
|
0x80000112: "query_broadcast_sm_resp",
|
||||||
|
0x80000113: "cancel_broadcast_sm_resp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess:
|
||||||
|
print("+", " ".join(cmd), file=sys.stderr)
|
||||||
|
return subprocess.run(cmd, cwd=REPO_ROOT, check=False, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_cmd(peer: str, *args: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"docker", "compose",
|
||||||
|
"-f", "compose.yaml",
|
||||||
|
"-f", f"interop-tests/compose.{peer}.yaml",
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fix_capture_ownership() -> None:
|
||||||
|
# dumpcap's own file permissions (0750, root:root) mean the sidecar has to run as root, so
|
||||||
|
# captures/ comes out root-owned; fixed up here rather than with host sudo. The directory is
|
||||||
|
# left world-writable so the next run's root-owned dumpcap can still create files in it.
|
||||||
|
sh(["docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", "--entrypoint", "chown", NETSHOOT_IMAGE, "-R", "1000:1000", "/captures"])
|
||||||
|
sh(["docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", "--entrypoint", "chmod", NETSHOOT_IMAGE, "0777", "/captures"])
|
||||||
|
|
||||||
|
|
||||||
|
def walk(node: object):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
yield node
|
||||||
|
for value in node.values():
|
||||||
|
yield from walk(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
yield from walk(item)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(node: object, key: str) -> list[object]:
|
||||||
|
return [item[key] for item in walk(node) if isinstance(item, dict) and key in item]
|
||||||
|
|
||||||
|
|
||||||
|
def analyse_capture(peer: str, port: int) -> tuple[int, dict[str, object]]:
|
||||||
|
pcap = CAPTURES_DIR / f"{peer}.pcapng"
|
||||||
|
|
||||||
|
if not pcap.exists():
|
||||||
|
print(f"no capture file at {pcap}", file=sys.stderr)
|
||||||
|
|
||||||
|
return 1, {}
|
||||||
|
|
||||||
|
decoded = sh([
|
||||||
|
"docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", NETSHOOT_IMAGE,
|
||||||
|
"tshark", "-r", f"/captures/{peer}.pcapng",
|
||||||
|
"-d", f"tcp.port=={port},smpp",
|
||||||
|
"-Y", "smpp",
|
||||||
|
"-T", "json",
|
||||||
|
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
if decoded.returncode != 0:
|
||||||
|
print(decoded.stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return 1, {}
|
||||||
|
|
||||||
|
frames = json.loads(decoded.stdout or "[]")
|
||||||
|
(CAPTURES_DIR / f"{peer}.json").write_text(decoded.stdout)
|
||||||
|
|
||||||
|
histogram: collections.Counter = collections.Counter()
|
||||||
|
malformed = 0
|
||||||
|
expert_errors = 0
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
for command_id in collect(frame, "smpp.command_id"):
|
||||||
|
name = COMMAND_NAMES.get(int(str(command_id), 16), str(command_id))
|
||||||
|
histogram[name] += 1
|
||||||
|
|
||||||
|
if collect(frame, "_ws.malformed"):
|
||||||
|
malformed += 1
|
||||||
|
|
||||||
|
expert_errors += sum(
|
||||||
|
1 for severity in collect(frame, "_ws.expert.severity") if severity == EXPERT_SEVERITY_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
has_bind = any(histogram[name] for name in BIND_COMMANDS)
|
||||||
|
has_bind_resp = any(histogram[f"{name}_resp"] for name in BIND_COMMANDS)
|
||||||
|
|
||||||
|
print(f"frames: {len(frames)}")
|
||||||
|
print("commands:")
|
||||||
|
for name, count in sorted(histogram.items()):
|
||||||
|
print(f" {name}: {count}")
|
||||||
|
print(f"malformed: {malformed}")
|
||||||
|
print(f"expert errors: {expert_errors}")
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
print("empty capture: no frames decoded", file=sys.stderr)
|
||||||
|
if not has_bind or not has_bind_resp:
|
||||||
|
print("no bind/bind_resp pair in capture", file=sys.stderr)
|
||||||
|
|
||||||
|
status = 1 if any((
|
||||||
|
not frames,
|
||||||
|
not has_bind,
|
||||||
|
not has_bind_resp,
|
||||||
|
malformed > 0,
|
||||||
|
expert_errors > 0,
|
||||||
|
)) else 0
|
||||||
|
|
||||||
|
return status, {
|
||||||
|
"commands": dict(histogram),
|
||||||
|
"expertErrors": expert_errors,
|
||||||
|
"frames": len(frames),
|
||||||
|
"malformed": malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("peer")
|
||||||
|
parser.add_argument("--keep", action="store_true", help="leave the peer up for a manual look")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
port = PORT_BY_PEER.get(args.peer)
|
||||||
|
|
||||||
|
if port is None:
|
||||||
|
print(f"no SMPP port known for peer {args.peer!r}; add it to PORT_BY_PEER in run.py", file=sys.stderr)
|
||||||
|
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# dumpcap can't be given 1000:1000 (see fix_capture_ownership), so it creates this file as
|
||||||
|
# root; overwriting one from a previous run then fails, since root here has no DAC override
|
||||||
|
# either - so the stale file has to go before a fresh one can be written in its place.
|
||||||
|
(CAPTURES_DIR / f"{args.peer}.pcapng").unlink(missing_ok=True)
|
||||||
|
# Also clears smppsim's textdlr capture so its healthcheck can't pass against a stale leftover.
|
||||||
|
(CAPTURES_DIR / f"{args.peer}-textdlr.pcapng").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
tests = sh(compose_cmd(
|
||||||
|
args.peer, "run", "--rm", "--use-aliases", "node",
|
||||||
|
"node", "--test", f"interop-tests/{args.peer}.test.ts",
|
||||||
|
))
|
||||||
|
sh(compose_cmd(args.peer, "stop", "capture"))
|
||||||
|
fix_capture_ownership()
|
||||||
|
|
||||||
|
try:
|
||||||
|
analysis_status, _ = analyse_capture(args.peer, port)
|
||||||
|
finally:
|
||||||
|
if not args.keep:
|
||||||
|
sh(compose_cmd(args.peer, "down", "-v"))
|
||||||
|
|
||||||
|
return 1 if tests.returncode != 0 else analysis_status
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { err, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// smppload 2.5.3 (built at interop-tests/peers/smppload, its issue #8 rebar3/BEAM friction worked
|
||||||
|
// around with a current rebar3 release) is otherwise blocked: every bind_transceiver it puts on the
|
||||||
|
// wire is two octets short of what its own command_length declares, corrupting command_length and
|
||||||
|
// command_id both - findings/07-load.md carries the tshark capture. S6 and S8, both scoped to this
|
||||||
|
// peer in PLAN.md, could not run; dumbclient.test.ts carries the load and window scenarios instead.
|
||||||
|
describe('smppload (blocked)', () => {
|
||||||
|
test('the corrupted bind_transceiver is refused as an unframeable stream, not left to hang', async () => {
|
||||||
|
let session: Session | undefined;
|
||||||
|
let sessionErr: Error | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
smpp.on('session', incoming => {
|
||||||
|
session = incoming;
|
||||||
|
incoming.on('sessionError', sessionError => { sessionErr = sessionError; });
|
||||||
|
incoming.on('close', () => { closed = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bound = await waitFor(() => session, 20_000);
|
||||||
|
|
||||||
|
assert.ok(bound, 'smppload never opened a TCP connection to node:2775');
|
||||||
|
|
||||||
|
const refusal = await waitFor(() => sessionErr, 10_000);
|
||||||
|
|
||||||
|
assert.ok(refusal);
|
||||||
|
// maxPduLength (pdu-refusal.ts) is 1MiB; the corrupted command_length (0x2a shifted into the
|
||||||
|
// high bytes) reads as roughly 2.75M, so this is the "unreadable stream" teardown, not the
|
||||||
|
// "one bad PDU, link stays up" path - see AGENTS.md, "A stream this library cannot frame...".
|
||||||
|
assert.match(refusal.message, /Refusing a cmd_length of \d+/);
|
||||||
|
|
||||||
|
await waitFor(() => (closed ? true : undefined), 5000);
|
||||||
|
assert.equal(closed, true);
|
||||||
|
assert.ok(bound);
|
||||||
|
assert.equal(bound.loggedIn, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { Dlr } from '../src/dlr.ts';
|
||||||
|
import type { EncodingName } from '../src/defs/encodings.ts';
|
||||||
|
import type { MessageDlr } from '../src/dlr-merger.ts';
|
||||||
|
import type { PduObject } from '../src/pdu.ts';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
|
import { consts } from '../src/defs/constants.ts';
|
||||||
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const PEER_HOST = process.env.PEER_HOST ?? 'smppsim';
|
||||||
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
const TEXTDLR_HOST = process.env.TEXTDLR_HOST ?? 'smppsim-textdlr';
|
||||||
|
const TRANSITION_HOST = process.env.TRANSITION_HOST ?? 'smppsim-transition';
|
||||||
|
const UNDELIV_HOST = process.env.UNDELIV_HOST ?? 'smppsim-undeliv';
|
||||||
|
const REJECTED_HOST = process.env.REJECTED_HOST ?? 'smppsim-rejected';
|
||||||
|
const ACCEPTED_HOST = process.env.ACCEPTED_HOST ?? 'smppsim-accepted';
|
||||||
|
const DELAYED_HOST = process.env.DELAYED_HOST ?? 'smppsim-delayed';
|
||||||
|
const QUEUEFULL_HOST = process.env.QUEUEFULL_HOST ?? 'smppsim-queuefull';
|
||||||
|
const OUTBIND_HOST = process.env.OUTBIND_HOST ?? 'smppsim-outbind';
|
||||||
|
const OUTBIND_HTTP_PORT = Number(process.env.OUTBIND_HTTP_PORT ?? '8884');
|
||||||
|
const OUR_OUTBIND_PORT = Number(process.env.OUR_OUTBIND_PORT ?? '2776');
|
||||||
|
|
||||||
|
const USERNAME = 'smppclient1';
|
||||||
|
const PASSWORD = 'password';
|
||||||
|
const FROM = '46701113311';
|
||||||
|
const TO = '46709771337';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 3000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(host: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
|
||||||
|
return client({ host, password: PASSWORD, port: PEER_PORT, username: USERNAME, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GSM-7 message sized to a target septet count, always carrying the three extension-table
|
||||||
|
* characters (€, [, ]) that cost two septets each via an ESC prefix.
|
||||||
|
*/
|
||||||
|
function gsmFiller(targetSeptets: number): string {
|
||||||
|
const extension = '€[]';
|
||||||
|
|
||||||
|
return extension + 'a'.repeat(targetSeptets - extension.length * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delivery-receipt fields the wire actually carried, alongside the parsed Dlr. */
|
||||||
|
type Received = { dlr: Dlr; pduObj: PduObject };
|
||||||
|
|
||||||
|
function collectDlrs(session: Session): Received[] {
|
||||||
|
const received: Received[] = [];
|
||||||
|
|
||||||
|
session.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); });
|
||||||
|
|
||||||
|
return received;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSms(session: Session): Sms[] {
|
||||||
|
const collected: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', sms => { collected.push(sms); });
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DLR_RETRY_BUDGET_MS = 3000;
|
||||||
|
const DLR_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two concurrent submit_sm's on the same connection - which is how this library always sends a
|
||||||
|
* multi-segment message's segments, never one after the previous one's response - sometimes make
|
||||||
|
* SMPPSim interleave two PDUs it writes back without synchronising, corrupting one on the wire
|
||||||
|
* (tshark's own malformed/expert counts confirm it; see findings/02-smppsim.md). A corrupted
|
||||||
|
* receipt still reaches `dlr` with an empty or partial body. Sequential, awaited submit_sm's never
|
||||||
|
* see this.
|
||||||
|
*/
|
||||||
|
function dlrLooksIntact(received: Received | undefined): received is Received {
|
||||||
|
return received?.dlr.receipt?.stat !== undefined
|
||||||
|
&& received.pduObj.tlvs.receipted_message_id?.tagValue !== undefined
|
||||||
|
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMPPSim names an id for every segment, so an unnamed one is the finding, not the norm. */
|
||||||
|
function namedIds(smsIds: (string | undefined)[]): string[] {
|
||||||
|
const ids = smsIds.filter(id => id !== undefined);
|
||||||
|
|
||||||
|
assert.equal(ids.length, smsIds.length, 'expected SMPPSim to name a message id for every segment');
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resends a fresh message until one attempt's segments are every one matched by an intact `dlr`.
|
||||||
|
* Retrying works around the peer+library interaction above without hiding it: a scenario only
|
||||||
|
* fails here if it keeps missing well past what that alone explains.
|
||||||
|
*/
|
||||||
|
async function sendUntilAllDlrsArrive(
|
||||||
|
session: Session,
|
||||||
|
dlrs: Received[],
|
||||||
|
message: string,
|
||||||
|
encoding?: EncodingName,
|
||||||
|
): Promise<string[]> {
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: FROM,
|
||||||
|
message,
|
||||||
|
to: TO,
|
||||||
|
...(encoding ? { encoding } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = namedIds(sent.smsIds);
|
||||||
|
|
||||||
|
const complete = await waitFor(
|
||||||
|
() => (ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
|
||||||
|
DLR_RETRY_BUDGET_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (complete) return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`no attempt got an intact DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As above, but also waits for the loopback deliver_sm(s) to reassemble into the original text. */
|
||||||
|
async function sendUntilComplete(
|
||||||
|
session: Session,
|
||||||
|
dlrs: Received[],
|
||||||
|
sms: Sms[],
|
||||||
|
message: string,
|
||||||
|
encoding?: EncodingName,
|
||||||
|
): Promise<{ reassembled: Sms; smsIds: string[] }> {
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: FROM,
|
||||||
|
message,
|
||||||
|
to: TO,
|
||||||
|
...(encoding ? { encoding } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = namedIds(sent.smsIds);
|
||||||
|
|
||||||
|
const complete = await waitFor(() => {
|
||||||
|
const allIntact = ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
|
||||||
|
const reassembled = sms.find(s => s.message === message);
|
||||||
|
|
||||||
|
return allIntact && reassembled ? { reassembled } : undefined;
|
||||||
|
}, DLR_RETRY_BUDGET_MS);
|
||||||
|
|
||||||
|
if (complete) return { reassembled: complete.reassembled, smsIds: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||||||
|
const cases: { encoding?: EncodingName; expectedSegments: number; label: string; message: string }[] = [
|
||||||
|
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: gsmFiller(100) },
|
||||||
|
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) },
|
||||||
|
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) },
|
||||||
|
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: gsmFiller(1450) },
|
||||||
|
// SMPPSim writes this one's receipt body as text under the data_coding of the submit it
|
||||||
|
// reports on (8, UCS2), so it holds to the same bar as the GSM cases only if the body is
|
||||||
|
// read as the octets that arrived.
|
||||||
|
{
|
||||||
|
encoding: 'UCS2',
|
||||||
|
expectedSegments: 2,
|
||||||
|
label: '2-segment UCS2 with 一 and an emoji',
|
||||||
|
message: `一😀${'x'.repeat(70)}`,
|
||||||
|
},
|
||||||
|
// Names its encoding because detect() never chooses LATIN1 - latin1.match() is false so that
|
||||||
|
// auto-selection skips it - and unnamed, this text would go out as UCS2.
|
||||||
|
{
|
||||||
|
encoding: 'LATIN1',
|
||||||
|
expectedSegments: 2,
|
||||||
|
label: '2-segment Latin-1',
|
||||||
|
message: 'é'.repeat(200),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
test(testCase.label, async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
const { reassembled, smsIds } = await sendUntilComplete(
|
||||||
|
session,
|
||||||
|
dlrs,
|
||||||
|
sms,
|
||||||
|
testCase.message,
|
||||||
|
testCase.encoding,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, testCase.expectedSegments);
|
||||||
|
|
||||||
|
for (const id of smsIds) {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
// TLVs and body both present, and agree - "TLV wins" is unobservable when they
|
||||||
|
// match, which is what a well-behaved SMSC gives you (C3).
|
||||||
|
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
|
||||||
|
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
|
||||||
|
assert.ok(received.dlr.receipt);
|
||||||
|
assert.equal(received.dlr.receipt.stat, 'DELIVRD');
|
||||||
|
assert.equal(received.dlr.receipt.id, id);
|
||||||
|
assert.equal(received.dlr.receipt.err, '000');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal((await reassembled.sendResp()).err, undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-textdlr - C2 text-only receipts', () => {
|
||||||
|
test('no TLVs; dlr.smsId parsed from the body matches the submit_sm_resp id', async t => {
|
||||||
|
const { err, session } = await bind(TEXTDLR_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.equal(sent.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
assert.equal(received.pduObj.tlvs.receipted_message_id, undefined);
|
||||||
|
assert.equal(received.pduObj.tlvs.message_state, undefined);
|
||||||
|
assert.equal(received.dlr.receipt?.id, smsId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-transition - C4 intermediate then final', () => {
|
||||||
|
test('an intermediate report arrives, but registered_delivery 0x11 never gets a final one', async t => {
|
||||||
|
const { err, session } = await bind(TRANSITION_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const messageDlrs: unknown[] = [];
|
||||||
|
|
||||||
|
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||||
|
|
||||||
|
// registered_delivery 0x11: final (0x01) + intermediate (0x10). sendSms() only ever
|
||||||
|
// requests 0x01, so this scenario needs the raw passthrough - which also means
|
||||||
|
// dlrMerger.expect() is never called, so messageDlr cannot fire here (see findings).
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: 0,
|
||||||
|
destination_addr: TO,
|
||||||
|
registered_delivery: 0x11,
|
||||||
|
short_message: Buffer.from('transition test', 'latin1'),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sent.pduObj);
|
||||||
|
|
||||||
|
const messageId = paramText(sent.pduObj.params.message_id);
|
||||||
|
|
||||||
|
assert.notEqual(messageId, '');
|
||||||
|
|
||||||
|
const intermediate = await waitFor(() => dlrs.find(r => r.dlr.smsId === messageId));
|
||||||
|
|
||||||
|
assert.ok(intermediate, 'expected an intermediate report');
|
||||||
|
assert.equal(intermediate.dlr.intermediate, true);
|
||||||
|
assert.equal(intermediate.dlr.statusMsg, 'ENROUTE');
|
||||||
|
|
||||||
|
// SMPPSim's own user guide (v2.5 release notes): "Set to 0x11 for both intermediate
|
||||||
|
// notification and final delivery receipts". Its LifeCycleManager.setState() tests
|
||||||
|
// registered_delivery_flag == 1 or == 2 by exact equality rather than a bitmask, so 0x11
|
||||||
|
// (17) matches neither branch and no final receipt is ever queued - confirmed against
|
||||||
|
// source (see findings/02-smppsim.md). Recorded, not asserted as something to fix here.
|
||||||
|
const final = await waitFor(() => dlrs.find(r => r.dlr.smsId === messageId && !r.dlr.intermediate), 3000);
|
||||||
|
|
||||||
|
assert.equal(final, undefined);
|
||||||
|
assert.equal(messageDlrs.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim single-state variants - C5 failure states', () => {
|
||||||
|
const variants: { expected: Dlr['statusMsg']; host: string; label: string }[] = [
|
||||||
|
{ expected: 'UNDELIVERABLE', host: UNDELIV_HOST, label: 'PERCENTAGE_UNDELIVERABLE=100' },
|
||||||
|
{ expected: 'REJECTED', host: REJECTED_HOST, label: 'PERCENTAGE_REJECTED=100' },
|
||||||
|
{ expected: 'ACCEPTED', host: ACCEPTED_HOST, label: 'PERCENTAGE_ACCEPTED=100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
test(`${variant.label} maps to ${variant.expected}`, async t => {
|
||||||
|
const { err, session } = await bind(variant.host);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, variant.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a 2-segment message: both segments report the same status; messageDlr never fires', async t => {
|
||||||
|
const { err, session } = await bind(UNDELIV_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const messageDlrs: MessageDlr[] = [];
|
||||||
|
|
||||||
|
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||||
|
|
||||||
|
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, gsmFiller(200));
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
|
||||||
|
for (const id of smsIds) {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'UNDELIVERABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMPPSim assigns each segment its own, independent message_id rather than this
|
||||||
|
// library's own server's <base>-<n> convention, so DlrMerger can never recognise the
|
||||||
|
// pair as one message (README: "an SMSC that hands out unrelated ids per segment never
|
||||||
|
// fires it") - recorded here, not asserted as a defect.
|
||||||
|
await delay(500);
|
||||||
|
assert.equal(messageDlrs.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-delayed - C6 receipt delayed past a link drop', () => {
|
||||||
|
test('the merge survives a reconnect; the late receipt still reaches dlr', async t => {
|
||||||
|
const { err, session } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const disconnected: true[] = [];
|
||||||
|
const reconnected: true[] = [];
|
||||||
|
|
||||||
|
session.on('disconnected', () => { disconnected.push(true); });
|
||||||
|
session.on('reconnected', () => { reconnected.push(true); });
|
||||||
|
|
||||||
|
const sendStart = Date.now();
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
await delay(300);
|
||||||
|
session.sock.destroy();
|
||||||
|
|
||||||
|
assert.ok(await waitFor(() => (disconnected.length > 0 ? true : undefined), 2000), 'expected disconnected');
|
||||||
|
assert.ok(await waitFor(() => (reconnected.length > 0 ? true : undefined), 4000), 'expected reconnected');
|
||||||
|
|
||||||
|
// DELAY_DELIVERY_RECEIPTS_BY (8000ms) is a floor, not the actual delay: DelayedDrQueue's
|
||||||
|
// own poll loop only wakes every 5000ms (hardcoded, not a props knob), so delivery can
|
||||||
|
// land up to ~13s after submission - confirmed against source and empirically.
|
||||||
|
const remaining = Math.max(1000, 16_000 - (Date.now() - sendStart));
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId), remaining);
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the delayed receipt to still arrive on the new link');
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C11 bind refusal and reconnect backoff', () => {
|
||||||
|
test('wrong password on the very first bind: one attempt, no retry', async () => {
|
||||||
|
const refusals: { cmdStatus: unknown }[] = [];
|
||||||
|
const log = {
|
||||||
|
debug: () => undefined,
|
||||||
|
error: () => undefined,
|
||||||
|
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
|
||||||
|
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
|
||||||
|
},
|
||||||
|
verbose: () => undefined,
|
||||||
|
warn: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { err, session } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||||
|
|
||||||
|
assert.ok(err);
|
||||||
|
assert.equal(session, undefined);
|
||||||
|
await delay(2000);
|
||||||
|
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
|
||||||
|
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a closed port on the very first connect: one attempt, no retry', async () => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||||
|
|
||||||
|
assert.ok(err);
|
||||||
|
assert.equal(session, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rebind refused after a live link drops: backs off, never floods', async t => {
|
||||||
|
const options: Parameters<typeof client>[0] = {
|
||||||
|
host: PEER_HOST,
|
||||||
|
password: PASSWORD,
|
||||||
|
port: PEER_PORT,
|
||||||
|
reconnect: { maxDelay: 4000, minDelay: 1000 },
|
||||||
|
username: USERNAME,
|
||||||
|
};
|
||||||
|
const { err, session } = await client(options);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const disconnectedAt: number[] = [];
|
||||||
|
|
||||||
|
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
|
||||||
|
// Mutates the same object reference the reconnect loop's onConnected closure reads, so
|
||||||
|
// every rebind attempt from here on is refused - see interop-tests/findings/02-smppsim.md.
|
||||||
|
options.password = 'wrong-after-drop';
|
||||||
|
session.sock.destroy();
|
||||||
|
|
||||||
|
await delay(12_000);
|
||||||
|
|
||||||
|
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 6, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
|
||||||
|
|
||||||
|
for (let i = 1; i < disconnectedAt.length; i++) {
|
||||||
|
const previous = disconnectedAt[i - 1];
|
||||||
|
const current = disconnectedAt[i];
|
||||||
|
|
||||||
|
assert.ok(previous !== undefined && current !== undefined);
|
||||||
|
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => {
|
||||||
|
test('refuses when the queue is full; the session stays bound; a later send succeeds once it drains', async t => {
|
||||||
|
const { err, session } = await bind(QUEUEFULL_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const first = await session.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO });
|
||||||
|
|
||||||
|
assert.equal(first.err, undefined);
|
||||||
|
|
||||||
|
const second = await session.sendSms({ from: FROM, message: 'should be refused', to: TO });
|
||||||
|
|
||||||
|
assert.ok(second.err);
|
||||||
|
assert.match(second.err.message, /ESME_RMSGQFUL/);
|
||||||
|
|
||||||
|
const keepalive = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(keepalive.err, undefined);
|
||||||
|
assert.ok(keepalive.pduObj);
|
||||||
|
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
let drained: Awaited<ReturnType<typeof session.sendSms>> | undefined;
|
||||||
|
|
||||||
|
while (!drained && Date.now() < deadline) {
|
||||||
|
const attempt = await session.sendSms({ from: FROM, message: 'after drain', to: TO });
|
||||||
|
|
||||||
|
if (!attempt.err) drained = attempt;
|
||||||
|
else await delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(drained, 'expected a later send to succeed once the queue drained');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => {
|
||||||
|
test('every send is answered, in order, none lost', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { maxOutstanding: 1 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, async (_unused, index) => session.sendSms({
|
||||||
|
from: FROM,
|
||||||
|
message: `order test ${String(index)}`,
|
||||||
|
to: TO,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids: number[] = [];
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
assert.equal(result.err, undefined);
|
||||||
|
assert.equal(result.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = result.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
ids.push(Number(smsId));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
|
||||||
|
|
||||||
|
for (let i = 1; i < ids.length; i++) {
|
||||||
|
const previous = ids[i - 1];
|
||||||
|
const current = ids[i];
|
||||||
|
|
||||||
|
assert.ok(previous !== undefined && current !== undefined);
|
||||||
|
assert.ok(current > previous, 'expected ids in call order, proving maxOutstanding:1 serialised the sends');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C15 bind version negotiation', () => {
|
||||||
|
for (const interfaceVersion of [0x34, 0x50]) {
|
||||||
|
test(`interfaceVersion 0x${interfaceVersion.toString(16)}: SMPPSim's bind_resp never declares a version`, async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { interfaceVersion });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
// Confirmed from source (no BindXResp class ever sets sc_interface_version): SMPPSim
|
||||||
|
// never declares its own version, whatever we declared - so acceptsOptionalParams()
|
||||||
|
// reads it as pre-3.4, even though it happily sends us TLVs (see C3).
|
||||||
|
assert.equal(session.peerInterfaceVersion, 0x00);
|
||||||
|
assert.equal(session.acceptsOptionalParams(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C17 encodings round trip over loopback', () => {
|
||||||
|
test('Latin-1 (å ä ö)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'å ä ö'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UCS-2', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flash (data_coding records the message-class group)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'flash test'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, true);
|
||||||
|
assert.equal(received.pduObjs[0]?.params.data_coding, 0x10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
const body = 'message class test';
|
||||||
|
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: 0xF0,
|
||||||
|
destination_addr: TO,
|
||||||
|
short_message: Buffer.from(body, 'latin1'),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === body));
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the 0xF0-coded loopback message to arrive');
|
||||||
|
assert.equal(received.flash, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
// A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's
|
||||||
|
// reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which
|
||||||
|
// validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be
|
||||||
|
// 2 bytes, flagged malformed otherwise) - passes it through as opaque, unparsed data.
|
||||||
|
const udh = Buffer.from([0x03, 0x70, 0x01, 0xAA]);
|
||||||
|
const payload = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||||
|
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: consts.ENCODING.BINARY,
|
||||||
|
destination_addr: TO,
|
||||||
|
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||||
|
short_message: Buffer.concat([udh, payload]),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const expected = payload.toString('latin1');
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === expected));
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the UDH-stripped, Latin-1-decoded payload to arrive unchanged');
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
assert.deepEqual(Buffer.from(received.message, 'latin1'), payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-outbind - C18 SMSC-initiated outbind', () => {
|
||||||
|
test('arrives with no bogus response PDU; sessionError names it', async t => {
|
||||||
|
const { err, server: smpp } = await server({ port: OUR_OUTBIND_PORT });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
closeAfter(t, smpp);
|
||||||
|
|
||||||
|
const incoming: PduObject[] = [];
|
||||||
|
const sessionErrors: Error[] = [];
|
||||||
|
const closes: true[] = [];
|
||||||
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
|
||||||
|
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||||
|
session.on('close', () => { closes.push(true); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const injected = await fetch(`http://${OUTBIND_HOST}:${String(OUTBIND_HTTP_PORT)}/inject_mo?${
|
||||||
|
new URLSearchParams({
|
||||||
|
destination_addr: TO,
|
||||||
|
short_message: 'trigger outbind',
|
||||||
|
source_addr: FROM,
|
||||||
|
}).toString()
|
||||||
|
}`);
|
||||||
|
|
||||||
|
assert.equal(injected.status, 200);
|
||||||
|
|
||||||
|
const outbindPdu = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'outbind'), 10_000);
|
||||||
|
|
||||||
|
assert.ok(outbindPdu, 'expected SMPPSim to connect to our server() and send outbind');
|
||||||
|
assert.equal(outbindPdu.params.system_id, 'smppclient1');
|
||||||
|
|
||||||
|
const sessionError = await waitFor(() => sessionErrors[0], 2000);
|
||||||
|
|
||||||
|
assert.ok(sessionError, 'expected sessionError: outbind has no response command to send back');
|
||||||
|
assert.match(sessionError.message, /outbind/);
|
||||||
|
|
||||||
|
// Recorded, not asserted either way (the task: "record, do not judge"): SMPPSim's own
|
||||||
|
// outbind() closes its end right after writing, before waiting for anything back.
|
||||||
|
await waitFor(() => (closes.length > 0 ? true : undefined), 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { Dlr } from '../src/dlr.ts';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
|
|
||||||
|
const PEER_HOST = process.env.PEER_HOST ?? 'smscsim';
|
||||||
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
const PEER_WEB_PORT = Number(process.env.PEER_WEB_PORT ?? '12775');
|
||||||
|
const FAILING_PEER_HOST = process.env.FAILING_PEER_HOST ?? 'smscsim-failing';
|
||||||
|
const FAILING_PEER_PORT = Number(process.env.FAILING_PEER_PORT ?? '2775');
|
||||||
|
|
||||||
|
// smscsim keys its refusal on each submit_sm's own sequence number parity, so two sends minimum.
|
||||||
|
const PARITY_MAX_ATTEMPTS = 6;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends once and waits for every segment of it to be matched by a `dlr` event. */
|
||||||
|
async function sendAndAwaitDlrs(session: Session, dlrs: Dlr[], message: string): Promise<string[]> {
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const ids = sent.smsIds.filter(id => id !== undefined);
|
||||||
|
|
||||||
|
assert.equal(ids.length, sent.smsIds.length, 'expected smscsim to name a message id for every segment');
|
||||||
|
|
||||||
|
const complete = await waitFor(() => (
|
||||||
|
ids.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
|
||||||
|
));
|
||||||
|
|
||||||
|
assert.ok(complete, 'every segment of the first send should get a DLR');
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smscsim - C1 bind, keepalive, unbind', () => {
|
||||||
|
for (const bindType of ['transceiver', 'transmitter', 'receiver'] as const) {
|
||||||
|
test(`binds as ${bindType}, keeps the link, unbinds cleanly`, async t => {
|
||||||
|
const closes: unknown[] = [];
|
||||||
|
const sessionErrors: Error[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({
|
||||||
|
bindType,
|
||||||
|
enquireLinkInterval: 1000,
|
||||||
|
host: PEER_HOST,
|
||||||
|
port: PEER_PORT,
|
||||||
|
username: `c1-${bindType}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('close', () => { closes.push(undefined); });
|
||||||
|
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||||
|
|
||||||
|
// smscsim never sends an unsolicited enquire_link of its own - its ENQUIRE_LINK case
|
||||||
|
// only answers one (confirmed in its source, smsc.go). So the interval-driven
|
||||||
|
// keepalive is checked through its own response, not through `incomingPduObj`.
|
||||||
|
const enquired = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(enquired.err, undefined);
|
||||||
|
assert.ok(enquired.pduObj);
|
||||||
|
assert.equal(enquired.pduObj.cmdName, 'enquire_link_resp');
|
||||||
|
assert.equal(enquired.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
await delay(1500);
|
||||||
|
|
||||||
|
const unbound = await session.unbind();
|
||||||
|
|
||||||
|
assert.equal(unbound.err, undefined);
|
||||||
|
|
||||||
|
await delay(50);
|
||||||
|
|
||||||
|
assert.deepEqual(closes, [undefined]);
|
||||||
|
assert.deepEqual(sessionErrors, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - a single SMS', () => {
|
||||||
|
test('one id back, a DLR within 5s naming it DELIVERED', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
const smsIds = await sendAndAwaitDlrs(session, dlrs, 'hello world');
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const matched = dlrs.find(dlr => dlr.smsId === smsId);
|
||||||
|
|
||||||
|
assert.ok(matched);
|
||||||
|
assert.equal(matched.statusMsg, 'DELIVERED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - multipart segments', () => {
|
||||||
|
test('a 2-segment GSM message gets 2 ids and a DLR per id', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
// 200 plain GSM chars: over the 160-char single-segment budget, under the 306-char
|
||||||
|
// 2-segment one (153 septets each).
|
||||||
|
const smsIds = await sendAndAwaitDlrs(session, dlrs, 'a'.repeat(200));
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 2-segment UCS2 message (一 and an emoji) gets 2 ids and a DLR per id', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
// 一 (2 bytes) + an emoji (a surrogate pair, 4 bytes) + 70 padding chars (2 bytes each):
|
||||||
|
// 146 bytes, over the 140-byte single-segment budget, under the 268-byte 2-segment one.
|
||||||
|
const smsIds = await sendAndAwaitDlrs(session, dlrs, `一😀${'x'.repeat(70)}`);
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - MO injection through the web UI', () => {
|
||||||
|
test('a message posted to the web page arrives as an sms event', async t => {
|
||||||
|
const { err, session } = await client({
|
||||||
|
bindType: 'transceiver',
|
||||||
|
host: PEER_HOST,
|
||||||
|
port: PEER_PORT,
|
||||||
|
username: 'mo-inject',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const incoming: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', sms => { incoming.push(sms); });
|
||||||
|
|
||||||
|
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
|
||||||
|
body: new URLSearchParams({
|
||||||
|
message: 'hello from the web UI',
|
||||||
|
recipient: '46709771337',
|
||||||
|
sender: '46701113311',
|
||||||
|
system_id: 'mo-inject',
|
||||||
|
}),
|
||||||
|
method: 'POST',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 303);
|
||||||
|
assert.match(response.headers.get('location') ?? '', /message=/);
|
||||||
|
|
||||||
|
const sms = await waitFor(() => incoming[0]);
|
||||||
|
|
||||||
|
assert.ok(sms, 'the injected MO should arrive on the first attempt');
|
||||||
|
assert.equal(sms.from, '46701113311');
|
||||||
|
assert.equal(sms.to, '46709771337');
|
||||||
|
assert.equal(sms.message, 'hello from the web UI');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim-failing - C12 refusals', () => {
|
||||||
|
test('even sequence numbers are refused, odd ones get an undeliverable DLR', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
let refusedSeen = false;
|
||||||
|
let acceptedConfirmed = false;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < PARITY_MAX_ATTEMPTS && !(refusedSeen && acceptedConfirmed); attempt++) {
|
||||||
|
const before = dlrs.length;
|
||||||
|
|
||||||
|
// Sequential: smscsim keys its refusal on each submit_sm's own sequence number parity.
|
||||||
|
const result = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: '46701113311',
|
||||||
|
message: `refusal check ${String(attempt)}`,
|
||||||
|
to: '46709771337',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.err !== undefined) {
|
||||||
|
assert.match(result.err.message, /ESME_RSYSERR/);
|
||||||
|
refusedSeen = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(result.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = result.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const matched = await waitFor(() => dlrs.slice(before).find(dlr => dlr.smsId === smsId));
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
assert.equal(matched.statusMsg, 'UNDELIVERABLE');
|
||||||
|
acceptedConfirmed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(refusedSeen, 'no send was refused across the attempts');
|
||||||
|
assert.ok(acceptedConfirmed, 'no accepted send got a confirmed undeliverable DLR');
|
||||||
|
|
||||||
|
// The session must stay bound and usable after a refusal.
|
||||||
|
const enquired = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(enquired.err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const topLogPrefix = 'larvitsmpp: lib/client.js: ',
|
|
||||||
session = require(__dirname + '/session'),
|
|
||||||
LUtils = require('larvitutils'),
|
|
||||||
merge = require('utils-merge'),
|
|
||||||
net = require('net'),
|
|
||||||
tls = require('tls');
|
|
||||||
|
|
||||||
function login(options) {
|
|
||||||
const logPrefix = topLogPrefix + 'login() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
let loginPdu;
|
|
||||||
|
|
||||||
loginPdu = {
|
|
||||||
'cmdName': 'bind_transceiver',
|
|
||||||
'seqNr': that.ourSeqNr,
|
|
||||||
'params': {
|
|
||||||
'system_id': that.options.username,
|
|
||||||
'password': that.options.password
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
that.send(loginPdu, function (err, retPduObj) {
|
|
||||||
if (err) return that.emit('loginFailed');
|
|
||||||
|
|
||||||
if (retPduObj.cmdStatus === 'ESME_ROK') {
|
|
||||||
options.log.info(logPrefix + 'Successful login system_id: "' + loginPdu.params.system_id + '"');
|
|
||||||
that.loggedIn = true;
|
|
||||||
that.emit('loggedIn');
|
|
||||||
} else {
|
|
||||||
options.log.info(logPrefix + 'Login failed system_id: "' + loginPdu.params.system_id + '". Status msg: ' + retPduObj.cmdStatus);
|
|
||||||
that.emit('loginFailed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEnqLinkTimer() {
|
|
||||||
const logPrefix = topLogPrefix + 'resetEnqLinkTimer() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'Resetting the kill timer');
|
|
||||||
if (that.enqLinkTimer) {
|
|
||||||
clearTimeout(that.enqLinkTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
that.enqLinkTimer = setTimeout(function () {
|
|
||||||
that.send({
|
|
||||||
cmdName: 'enquire_link',
|
|
||||||
seqNr: that.ourSeqNr
|
|
||||||
});
|
|
||||||
}, that.options.enqLinkTiming);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client session function - inherits from session()
|
|
||||||
*
|
|
||||||
* @param {object} sock - socket object
|
|
||||||
* @param {object} options - as derived from client()
|
|
||||||
* @return {object} (returnObj)
|
|
||||||
*/
|
|
||||||
function clientSession(sock, options) {
|
|
||||||
const returnObj = session({'sock': sock, 'log': options.log}),
|
|
||||||
logPrefix = topLogPrefix + 'clientSession() - ';
|
|
||||||
|
|
||||||
returnObj.options = options;
|
|
||||||
returnObj.login = login;
|
|
||||||
returnObj.resetEnqLinkTimer = resetEnqLinkTimer;
|
|
||||||
|
|
||||||
returnObj.login({'log': options.log});
|
|
||||||
returnObj.resetEnqLinkTimer({'log': options.log});
|
|
||||||
|
|
||||||
// Handle incoming Pdu Objects
|
|
||||||
returnObj.on('incomingPduObj', function (pduObj) {
|
|
||||||
// Call the appropriate handleCmd function
|
|
||||||
|
|
||||||
if (typeof returnObj.handleCmd[pduObj.cmdName] === 'function') {
|
|
||||||
options.log.debug(logPrefix + 'returnObj.on(incomingPduObj) - Running cmd handling function returnObj.handleCmd.' + pduObj.cmdName + '()');
|
|
||||||
|
|
||||||
returnObj.handleCmd[pduObj.cmdName](pduObj);
|
|
||||||
} else {
|
|
||||||
// No command handling function is registered, return error "invalid command"
|
|
||||||
options.log.info(logPrefix + 'returnObj.on(incomingPduObj) - No handling function found for command: "' + pduObj.cmdName + '"');
|
|
||||||
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_RINVCMDID');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return returnObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup a client.
|
|
||||||
*
|
|
||||||
* @param {object} options - host, port, username, password, tls, enqLinkTiming, log
|
|
||||||
* @param {function} cb(err, session)
|
|
||||||
*/
|
|
||||||
function client(options, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'client() - ';
|
|
||||||
|
|
||||||
let sock;
|
|
||||||
|
|
||||||
if (typeof options === 'function') {
|
|
||||||
cb = options;
|
|
||||||
options = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
options = merge({
|
|
||||||
'host': 'localhost',
|
|
||||||
'port': 2775,
|
|
||||||
'username': 'user',
|
|
||||||
'password': 'pass',
|
|
||||||
'tls': false,
|
|
||||||
'enqLinkTiming': 20000, // 20 sec
|
|
||||||
'log': new (new LUtils()).Log()
|
|
||||||
}, options || {});
|
|
||||||
|
|
||||||
if (options && options.tls && options.tls === true) {
|
|
||||||
sock = new tls.Socket();
|
|
||||||
} else {
|
|
||||||
sock = new net.Socket();
|
|
||||||
}
|
|
||||||
|
|
||||||
options.log.debug(logPrefix + 'Connecting to ' + options.host + ':' + options.port);
|
|
||||||
sock.connect(options, function () {
|
|
||||||
const session = clientSession(sock, options);
|
|
||||||
|
|
||||||
options.log.info(logPrefix + 'Connected to ' + sock.remoteAddress + ':' + sock.remotePort);
|
|
||||||
|
|
||||||
session.on('loggedIn', function () {
|
|
||||||
cb(null, session);
|
|
||||||
});
|
|
||||||
|
|
||||||
session.on('loginFailed', function () {
|
|
||||||
const err = new Error('Remote host refused login.');
|
|
||||||
options.log.warn(logPrefix + err.message);
|
|
||||||
cb(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose some functions
|
|
||||||
exports = module.exports = client;
|
|
||||||
-1483
File diff suppressed because it is too large
Load Diff
-190
@@ -1,190 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const topLogPrefix = 'larvitsmpp: lib/server.js: ',
|
|
||||||
smppUtils = require(__dirname + '/utils'),
|
|
||||||
session = require(__dirname + '/session'),
|
|
||||||
LUtils = require('larvitutils'),
|
|
||||||
lUtils = new LUtils(),
|
|
||||||
merge = require('utils-merge'),
|
|
||||||
net = require('net'),
|
|
||||||
tls = require('tls');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to log a connecting peer in
|
|
||||||
*
|
|
||||||
* @param {object} pduObj
|
|
||||||
*/
|
|
||||||
function login(pduObj) {
|
|
||||||
const logPrefix = topLogPrefix + 'login() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.debug(logPrefix + 'Data received and session is not loggedIn');
|
|
||||||
|
|
||||||
// Pause socket so we do not receive any other commands until we have processed the login
|
|
||||||
that.sock.pause();
|
|
||||||
|
|
||||||
// Only bind_* is accepted when the client is not logged in
|
|
||||||
if (pduObj.cmdName !== 'bind_transceiver' && pduObj.cmdName !== 'bind_receiver' && pduObj.cmdName !== 'bind_transmitter') {
|
|
||||||
that.log.debug(logPrefix + 'Session is not loggedIn and no bind_* command is given. Return error "ESME_RINVBNDSTS');
|
|
||||||
|
|
||||||
smppUtils.pduReturn(pduObj, 'ESME_RINVBNDSTS', function (err, retPdu) {
|
|
||||||
if (err) return that.closeSocket();
|
|
||||||
|
|
||||||
that.sock.resume();
|
|
||||||
that.sockWrite(retPdu);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there is a checkuserpass(), use it to check system_id and password from the PDU
|
|
||||||
if (typeof that.options.checkuserpass === 'function') {
|
|
||||||
return that.options.checkuserpass(pduObj.params.system_id, pduObj.params.password, function (err, res, userData) {
|
|
||||||
if (err) return that.closeSocket();
|
|
||||||
|
|
||||||
if ( ! res) {
|
|
||||||
that.log.info(logPrefix + 'Login failed! Connected host: ' + that.sock.remoteAddress + ':' + that.sock.remotePort + ' system_id: "' + pduObj.params.system_id + '"');
|
|
||||||
|
|
||||||
that.sock.resume();
|
|
||||||
return that.sendReturn(pduObj, 'ESME_RBINDFAIL');
|
|
||||||
}
|
|
||||||
|
|
||||||
that.log.verbose(logPrefix + 'Login successful! Connected host: ' + that.sock.remoteAddress + ':' + that.sock.remotePort + ' system_id: "' + pduObj.params.system_id + '"');
|
|
||||||
that.loggedIn = true;
|
|
||||||
|
|
||||||
// Set additional user data to the session
|
|
||||||
if (userData !== undefined) {
|
|
||||||
that.userData = userData;
|
|
||||||
}
|
|
||||||
|
|
||||||
that.sock.resume();
|
|
||||||
that.emit('login');
|
|
||||||
that.sendReturn(pduObj);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we arrived here it means we are not logged in and that a bind_* event happened and no checkuserpass() method exists. Lets login!
|
|
||||||
that.loggedIn = true;
|
|
||||||
that.sock.resume();
|
|
||||||
that.emit('login');
|
|
||||||
that.sendReturn(pduObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the enquire link timer
|
|
||||||
* If this is not ran within options.timeout milliseconds, this session will self terminate
|
|
||||||
*/
|
|
||||||
function resetEnqLinkTimer() {
|
|
||||||
const logPrefix = topLogPrefix + 'resetEnqLinkTimer() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'Resetting the kill timer');
|
|
||||||
if (that.enqLinkTimer) {
|
|
||||||
clearTimeout(that.enqLinkTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
that.enqLinkTimer = setTimeout(function () {
|
|
||||||
that.log.info(logPrefix + 'Closing session from ' + that.sock.remoteAddress + ':' + that.sock.remotePort + ' due to timeout');
|
|
||||||
that.closeSocket();
|
|
||||||
}, that.options.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server session function - inherits from session()
|
|
||||||
*
|
|
||||||
* @param {object} sock - socket object
|
|
||||||
* @param {object} options - as derived from server()
|
|
||||||
* @return {object} (returnObj)
|
|
||||||
*/
|
|
||||||
function serverSession(sock, options) {
|
|
||||||
const returnObj = session({'sock': sock, 'log': options.log});
|
|
||||||
|
|
||||||
returnObj.options = options;
|
|
||||||
returnObj.login = login;
|
|
||||||
returnObj.resetEnqLinkTimer = resetEnqLinkTimer;
|
|
||||||
returnObj.log = options.log;
|
|
||||||
|
|
||||||
returnObj.resetEnqLinkTimer();
|
|
||||||
|
|
||||||
// Handle incoming Pdu Objects
|
|
||||||
returnObj.on('incomingPduObj', function (pduObj) {
|
|
||||||
const logPrefix = topLogPrefix + 'serverSession() - returnObj.handleIncomingPdu() - ';
|
|
||||||
// Call the appropriate handleCmd function
|
|
||||||
|
|
||||||
// Unbind is always ok
|
|
||||||
if (pduObj.cmdName === 'unbind') {
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_ROK', undefined, true);
|
|
||||||
|
|
||||||
// If client is not logged in, always run the login function
|
|
||||||
} else if (returnObj.loggedIn === false) {
|
|
||||||
options.log.debug(logPrefix + ' Not logged in, running login function');
|
|
||||||
returnObj.login(pduObj);
|
|
||||||
|
|
||||||
// Client is logged in, try to match a handling function
|
|
||||||
} else if (typeof returnObj.handleCmd[pduObj.cmdName] === 'function') {
|
|
||||||
options.log.debug(logPrefix + 'Running cmd handling function returnObj.handleCmd.' + pduObj.cmdName + '()');
|
|
||||||
|
|
||||||
returnObj.handleCmd[pduObj.cmdName](pduObj);
|
|
||||||
|
|
||||||
// No command handling function is registered, return error "invalid command"
|
|
||||||
} else {
|
|
||||||
options.log.info(logPrefix + 'No handling function found for command: "' + pduObj.cmdName + '"');
|
|
||||||
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_RINVCMDID');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return returnObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup a server
|
|
||||||
*
|
|
||||||
* @param {object} options - host, port, checkuserpass() etc (OPTIONAL), log
|
|
||||||
* @param {function} cb(err, session)
|
|
||||||
*/
|
|
||||||
function server(options, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'server() - ';
|
|
||||||
|
|
||||||
let tlsOrNet;
|
|
||||||
|
|
||||||
if (typeof options === 'function') {
|
|
||||||
cb = options;
|
|
||||||
options = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
options = merge({
|
|
||||||
'port': 2775,
|
|
||||||
'tls': false,
|
|
||||||
'timeout': 40000, // 40 sec
|
|
||||||
'log': new lUtils.Log()
|
|
||||||
}, options || {});
|
|
||||||
|
|
||||||
if (options && options.tls && options.tls === true) {
|
|
||||||
tlsOrNet = tls;
|
|
||||||
} else {
|
|
||||||
tlsOrNet = net;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a server instance, and chain the listen function to it
|
|
||||||
// The function passed to net.createServer() becomes the event handler for the 'connection' event
|
|
||||||
// The sock object the cb function receives UNIQUE for each connection
|
|
||||||
tlsOrNet.createServer(options, function (sock) {
|
|
||||||
const returnObj = serverSession(sock, options);
|
|
||||||
|
|
||||||
// We have a connection - a socket object is assigned to the connection automatically
|
|
||||||
options.log.verbose(logPrefix + 'Incoming connection! From: ' + sock.remoteAddress + ':' + sock.remotePort);
|
|
||||||
|
|
||||||
cb(null, returnObj);
|
|
||||||
}).listen(options.port, options.host);
|
|
||||||
|
|
||||||
if (options.host !== undefined) {
|
|
||||||
options.log.info(logPrefix + 'Up and listening at ' + options.host + ':' + options.port);
|
|
||||||
} else {
|
|
||||||
options.log.info(logPrefix + 'Up and listening at *:' + options.port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose some functions
|
|
||||||
exports = module.exports = server;
|
|
||||||
-751
@@ -1,751 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const topLogPrefix = 'larvitsmpp: lib/session.js: ',
|
|
||||||
LUtils = require('larvitutils'),
|
|
||||||
lUtils = new LUtils(),
|
|
||||||
events = require('events'),
|
|
||||||
moment = require('moment'),
|
|
||||||
utils = require('./utils'),
|
|
||||||
async = require('async'),
|
|
||||||
defs = require('./defs'),
|
|
||||||
uuid = require('uuid/v1');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a response to an sms
|
|
||||||
* This must be called from an sms object context
|
|
||||||
*
|
|
||||||
* @param {string} status - see list at defs.errors - defaults to 'ESME_ROK' - no error (OPTIONAL)
|
|
||||||
* @param {function} cb - cb(err, [retPdu, ...])
|
|
||||||
*/
|
|
||||||
function smsResp(status, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'smsResp() - ',
|
|
||||||
tasks = [],
|
|
||||||
sms = this;
|
|
||||||
|
|
||||||
let params;
|
|
||||||
|
|
||||||
if (typeof status === 'function') {
|
|
||||||
cb = status;
|
|
||||||
status = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
cb = function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sms.smsId === undefined) {
|
|
||||||
sms.smsId = uuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept a generic positive status
|
|
||||||
if (status === 'true' || status === true || status === 0) {
|
|
||||||
status = 'ESME_ROK';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept a generic negative status
|
|
||||||
if (status === 'false' || status === false || status === 1) {
|
|
||||||
status = 'ESME_RUNKNOWNERR'; // Set to unknown error in this case
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sms.pduObjs === undefined) {
|
|
||||||
const err = new Error('No pdu objects found to base return PDU upon');
|
|
||||||
sms.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build async tasks to run the responses in parallel
|
|
||||||
for (let i = 0; sms.pduObjs[i] !== undefined; i ++) {
|
|
||||||
if (sms.smsId) {
|
|
||||||
if (sms.pduObjs[i].pduObj.params.esm_class === 0x40) {
|
|
||||||
params = {'message_id': sms.smsId + '-' + (i + 1)};
|
|
||||||
} else {
|
|
||||||
params = {'message_id': sms.smsId};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
params = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks[i] = sms.session.sendReturn.bind(
|
|
||||||
sms.session,
|
|
||||||
sms.pduObjs[i].pduObj,
|
|
||||||
status,
|
|
||||||
params,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async.parallel(tasks, cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
function incOurSeqNr() {
|
|
||||||
this.ourSeqNr = this.ourSeqNr + 1;
|
|
||||||
|
|
||||||
// If we pass the maximum, start over at 1
|
|
||||||
if (this.ourSeqNr > 2147483646) {
|
|
||||||
this.ourSeqNr = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the socket
|
|
||||||
* Always use this function to close the socket so we get it on log
|
|
||||||
*/
|
|
||||||
function closeSocket() {
|
|
||||||
const logPrefix = topLogPrefix + 'closeSocket() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.verbose(logPrefix + 'Closing socket for ' + this.sock.remoteAddress + ':' + this.sock.remotePort);
|
|
||||||
if (that.enqLinkTimer) {
|
|
||||||
that.log.debug(logPrefix + 'enqLinkTimer found, clearing.');
|
|
||||||
clearTimeout(that.enqLinkTimer);
|
|
||||||
}
|
|
||||||
that.sock.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write PDU to socket
|
|
||||||
*
|
|
||||||
* @param {buffer|object} pdu - can also take PDU object
|
|
||||||
* @param {boolean} closeAfterSend - if true will close the socket after sending
|
|
||||||
*/
|
|
||||||
function sockWrite(pdu, closeAfterSend) {
|
|
||||||
const logPrefix = topLogPrefix + 'sockWrite() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
if ( ! Buffer.isBuffer(pdu)) {
|
|
||||||
utils.objToPdu(pdu, function (err, buffer) {
|
|
||||||
if (err) {
|
|
||||||
that.log.warn(logPrefix + 'Could not convert PDU to buffer');
|
|
||||||
return that.closeSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
that.sockWrite(buffer);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
that.log.verbose(logPrefix + 'sending PDU. SeqNr: ' + pdu.readUInt32BE(12) + ' cmd: ' + defs.cmdsById[pdu.readUInt32BE(4)].command + ' cmdStatus: ' + defs.errorsById[parseInt(pdu.readUInt32BE(8))] + ' hex: ' + pdu.toString('hex'));
|
|
||||||
} catch (err) {
|
|
||||||
that.log.error(logPrefix + 'PDU buffer is invalid. Buffer hex: "' + pdu.toString('hex') + '"');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
that.sock.write(pdu);
|
|
||||||
|
|
||||||
if (closeAfterSend) {
|
|
||||||
that.closeSocket();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PDU to the remote
|
|
||||||
*
|
|
||||||
* @param {buffer|object} pdu
|
|
||||||
* @param {boolean} closeAfterSend - Will close after return is fetched. Defaults to false (OPTIONAL)
|
|
||||||
* @param {function} cb - cb(err, retPdu) (OPTIONAL)
|
|
||||||
*/
|
|
||||||
function send(pdu, closeAfterSend, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'send() - ',
|
|
||||||
pduObj = pdu,
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
// Make sure the pdu is an object
|
|
||||||
if (Buffer.isBuffer(pdu)) {
|
|
||||||
utils.pduToObj(pdu, function (err, pduObj) {
|
|
||||||
if (err) return cb(err);
|
|
||||||
|
|
||||||
that.send(pduObj, closeAfterSend, cb);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the sequence number is set and is correct
|
|
||||||
pduObj.seqNr = this.ourSeqNr;
|
|
||||||
|
|
||||||
that.log.debug(logPrefix + 'Sending PDU to remote. pduObj: ' + JSON.stringify(pduObj));
|
|
||||||
|
|
||||||
// If closeAndSend is omitted, put cb in its place
|
|
||||||
if (typeof closeAfterSend === 'function') {
|
|
||||||
cb = closeAfterSend;
|
|
||||||
closeAfterSend = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the callack is a function
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
cb = function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response PDUs are not allowed with the send() command, they should use the sendReturn()
|
|
||||||
if (pduObj.cmdName.substring(pduObj.cmdName - 5) === '_resp') {
|
|
||||||
const err = new Error('Given pduObj is a response, use sendReturn() instead. cmdName: ' + pduObj.cmdName);
|
|
||||||
that.log.verbose(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the return is fetched, call the cb
|
|
||||||
that.on('incomingPduObj' + pduObj.seqNr, function (incPduObj) {
|
|
||||||
that.log.debug(logPrefix + 'this.on(incomingPduObj) - cmdName: ' + incPduObj.cmdName + ' seqNr: ' + incPduObj.seqNr + ' cmdStatus: ' + incPduObj.cmdStatus);
|
|
||||||
|
|
||||||
// Make sure this is the actual response to the sent PDU
|
|
||||||
if (incPduObj.isResp() && incPduObj.seqNr === pduObj.seqNr) {
|
|
||||||
cb(null, incPduObj);
|
|
||||||
|
|
||||||
if (closeAfterSend) {
|
|
||||||
that.closeSocket();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const err = new Error('Event triggered but incoming PDU is not a response or seqNr does not match. isResp: ' + incPduObj.isResp().toString() + ' incSeqNr: ' + incPduObj.seqNr + ' expected seqNr: ' + pduObj.seqNr);
|
|
||||||
that.log.warn(logPrefix + 'this.on(incomingPduObj) - ' + err.message);
|
|
||||||
cb(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Increase our internal sequence number
|
|
||||||
that.incOurSeqNr();
|
|
||||||
|
|
||||||
// Write the PDU to socket
|
|
||||||
that.sockWrite(pduObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a return to given PDU
|
|
||||||
*
|
|
||||||
* @param {buffer|object} pdu
|
|
||||||
* @param {string} status - see list at defs.errors - defaults to 'ESME_ROK' - no error (OPTIONAL)
|
|
||||||
* @param {object} [params]
|
|
||||||
* @param {boolean} closeAfterSend - if true will close the socket after sending (OPTIONAL)
|
|
||||||
* @param {function} [cb(err, retPdu)]
|
|
||||||
*/
|
|
||||||
function sendReturn(pdu, status, params, closeAfterSend, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'sendReturn() - ',
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'ran');
|
|
||||||
|
|
||||||
if (typeof params === 'function') {
|
|
||||||
cb = params;
|
|
||||||
params = undefined;
|
|
||||||
closeAfterSend = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof closeAfterSend === 'function') {
|
|
||||||
cb = closeAfterSend;
|
|
||||||
closeAfterSend = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
cb = function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.pduReturn(pdu, status, params, function (err, retPdu) {
|
|
||||||
if (err) {
|
|
||||||
that.log.error(logPrefix + 'Could not create return PDU: ' + err.message);
|
|
||||||
that.closeSocket();
|
|
||||||
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'Sending return PDU: ' + retPdu.toString('hex'));
|
|
||||||
that.sockWrite(retPdu, closeAfterSend);
|
|
||||||
cb(null, retPdu);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an SMS
|
|
||||||
*
|
|
||||||
* @param {object} smsOptions {
|
|
||||||
* from - alphanum or international format
|
|
||||||
* to - international format
|
|
||||||
* message - string
|
|
||||||
* dlr - boolean defaults to false
|
|
||||||
* flash - boolean defaults to false
|
|
||||||
* }
|
|
||||||
* @param {function} cb(err, smsIds, retPduObjs)
|
|
||||||
*/
|
|
||||||
function sendSms(smsOptions, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'sendSms() - ',
|
|
||||||
pduObj = {},
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
pduObj.cmdName = 'submit_sm';
|
|
||||||
pduObj.params = {
|
|
||||||
'source_addr_ton': 1, // Default to international format
|
|
||||||
'source_addr': smsOptions.from,
|
|
||||||
'destination_addr': smsOptions.to,
|
|
||||||
'short_message': smsOptions.message
|
|
||||||
};
|
|
||||||
|
|
||||||
// Flash messages overrides default data_coding
|
|
||||||
if (smsOptions.flash) {
|
|
||||||
that.log.debug(logPrefix + 'Flash SMS detected, set data_coding to 0x10!');
|
|
||||||
pduObj.params.data_coding = 0x10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request DLRs!
|
|
||||||
if (smsOptions.dlr) {
|
|
||||||
pduObj.params.registered_delivery = 0x01;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we must split this message into multiple
|
|
||||||
if (utils.bitCount(smsOptions.message) > 1120) {
|
|
||||||
that.log.debug(logPrefix + 'Message larger than 1120 bits, send it as long message!');
|
|
||||||
|
|
||||||
that.sendLongSms(smsOptions, cb);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
that.log.debug(logPrefix + 'pduObj: ' + JSON.stringify(pduObj));
|
|
||||||
|
|
||||||
that.send(pduObj, function (err, retPduObj) {
|
|
||||||
if (typeof cb === 'function') {
|
|
||||||
cb(err, [retPduObj.params.message_id], [retPduObj]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a longer SMS than 1120 bits
|
|
||||||
*
|
|
||||||
* @param {object} smsOptions {
|
|
||||||
* from - alphanum or international format
|
|
||||||
* to - international format
|
|
||||||
* message - string
|
|
||||||
* dlr - boolean defaults to false
|
|
||||||
* }
|
|
||||||
* @param {function} cb - cb(err, smsId, retPduObj)
|
|
||||||
*/
|
|
||||||
function sendLongSms(smsOptions, cb) {
|
|
||||||
const retPduObjs = [],
|
|
||||||
logPrefix = topLogPrefix + 'sendLongSms() - ',
|
|
||||||
encoding = defs.encodings.detect(smsOptions.message), // Set encoding once for all message parts
|
|
||||||
smsIds = [],
|
|
||||||
that = this,
|
|
||||||
msgs = utils.splitMsg(smsOptions.message);
|
|
||||||
|
|
||||||
function sendPart(i) {
|
|
||||||
const pduObj = {
|
|
||||||
'cmdName': 'submit_sm',
|
|
||||||
'params': {
|
|
||||||
'source_addr_ton': 1, // Default to international format
|
|
||||||
'esm_class': 0x40, // This indicates that there is a UDH in the short_message
|
|
||||||
'source_addr': smsOptions.from,
|
|
||||||
'destination_addr': smsOptions.to,
|
|
||||||
'data_coding': defs.consts.ENCODING[encoding],
|
|
||||||
'short_message': msgs[i],
|
|
||||||
'sm_length': msgs[i].length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Request DLRs!
|
|
||||||
if (smsOptions.dlr) {
|
|
||||||
pduObj.params.registered_delivery = 0x01;
|
|
||||||
}
|
|
||||||
|
|
||||||
that.log.debug(logPrefix + 'pduObj: ' + JSON.stringify(pduObj));
|
|
||||||
|
|
||||||
that.send(pduObj, function (err, retPduObj) {
|
|
||||||
smsIds.push(retPduObj.params.message_id);
|
|
||||||
retPduObjs.push(retPduObj);
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'Got cb from that.send()');
|
|
||||||
|
|
||||||
if (typeof cb === 'function' && smsIds.length === msgs.length) {
|
|
||||||
that.log.silly(logPrefix + 'All cbs returned, run the parent cb.');
|
|
||||||
cb(err, smsIds, retPduObjs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (msgs[i + 1] !== undefined) {
|
|
||||||
sendPart(i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPart(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store long smses in the temporary storage
|
|
||||||
function longSms(pduObj) {
|
|
||||||
// Fix: UDH values are stored in HEX and decoding them make it garbage. First OCTET contains
|
|
||||||
// the size of UDH data header. If UDH data header size is 0x05 then field 4 i.e. CSMS
|
|
||||||
// reference number is of one octet otherwise it consists of 2 octets. Other fields can
|
|
||||||
// be found from below reference.
|
|
||||||
// reference: https://en.wikipedia.org/wiki/Concatenated_SMS
|
|
||||||
|
|
||||||
const udhHeaderSize = pduObj.params.short_message[0], // First octet is the size of UDH Header
|
|
||||||
headerSize = pduObj.params.short_message[2], // Header size other than first 2 octets
|
|
||||||
csmsReference = pduObj.params.short_message.slice(3, 3 + headerSize - 2), // CSMS Reference starts from 4th octet and length is header size -2 octets
|
|
||||||
partsCount = pduObj.params.short_message[2 + csmsReference.length + 1],
|
|
||||||
longSmsId = pduObj.params.source_addr + '_' + pduObj.params.destination_addr + '_' + csmsReference,
|
|
||||||
partNr = pduObj.params.short_message[2 + csmsReference.length + 2],
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
if (that.longSmses[longSmsId] === undefined) {
|
|
||||||
that.longSmses[longSmsId] = {
|
|
||||||
'created': new Date(),
|
|
||||||
'partsCount': partsCount,
|
|
||||||
'udhSize': udhHeaderSize, // Saving udh size to remove garbage from message
|
|
||||||
'pduObjs': [{
|
|
||||||
'partNr': partNr, // We save this here to easier sort the array later on
|
|
||||||
'pduObj': pduObj
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
that.longSmses[longSmsId].pduObjs.push({
|
|
||||||
'partNr': partNr, // We save this here to easier sort the array later on
|
|
||||||
'pduObj': pduObj
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the long messages tmp storage to see if we should handle them
|
|
||||||
that.checkLongSmses();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort function to sort group parts
|
|
||||||
function sortLongSmsPdus(a, b) {
|
|
||||||
if (a.partNr < b.partNr) {
|
|
||||||
return - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.partNr > b.partNr) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk through the long sms storage to investigate if we can send complete messages along
|
|
||||||
// or should remove old ones
|
|
||||||
function checkLongSmses() {
|
|
||||||
const logPrefix = topLogPrefix + 'checkLongSmses() - ',
|
|
||||||
smsObj = {},
|
|
||||||
that = this;
|
|
||||||
|
|
||||||
that.log.silly(logPrefix + 'Running');
|
|
||||||
|
|
||||||
// Call when complete SMS is received
|
|
||||||
function smsReceived() {
|
|
||||||
that.emit('sms', smsObj);
|
|
||||||
|
|
||||||
// This needs to be ran if DLRs are sent for these messages
|
|
||||||
delete that.longSmses[smsObj.smsGroupId];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const smsGroupId in this.longSmses) {
|
|
||||||
const smsGroup = this.longSmses[smsGroupId],
|
|
||||||
udhSize = smsGroup.udhSize;
|
|
||||||
|
|
||||||
// All parts are accounted for! Emit sms event and clear from tmp storage
|
|
||||||
if (smsGroup.partsCount === smsGroup.pduObjs.length) {
|
|
||||||
that.log.debug(logPrefix + 'All parts accounted for in smsGroupId "' + smsGroupId + '", emitting sms event.');
|
|
||||||
|
|
||||||
// These are needed for references here and there in functions
|
|
||||||
smsObj.session = that;
|
|
||||||
smsObj.smsGroupId = smsGroupId;
|
|
||||||
smsObj.pduObjs = smsGroup.pduObjs;
|
|
||||||
smsObj.from = smsGroup.pduObjs[0].pduObj.params.source_addr;
|
|
||||||
smsObj.to = smsGroup.pduObjs[0].pduObj.params.destination_addr;
|
|
||||||
smsObj.submitTime = new Date();
|
|
||||||
smsObj.message = '';
|
|
||||||
smsObj.dlr = Boolean(smsGroup.pduObjs[0].pduObj.params.registered_delivery);
|
|
||||||
smsObj.sendResp = smsResp;
|
|
||||||
smsObj.sendDlr = utils.smsDlr;
|
|
||||||
smsObj.log = that.log;
|
|
||||||
|
|
||||||
// Concatenate all the parts messages to one and set references to the session
|
|
||||||
|
|
||||||
// First we need to sort the parts, since they can come in random order
|
|
||||||
smsObj.pduObjs.sort(sortLongSmsPdus);
|
|
||||||
|
|
||||||
for (let i = 0; smsObj.pduObjs[i] !== undefined; i ++) {
|
|
||||||
const curPduObj = smsObj.pduObjs[i].pduObj;
|
|
||||||
|
|
||||||
curPduObj.session = this;
|
|
||||||
|
|
||||||
smsObj.message += utils.decodeMsg(curPduObj.params.short_message, curPduObj.params.data_coding, udhSize + 1);
|
|
||||||
}
|
|
||||||
smsReceived();
|
|
||||||
} else if (moment(new Date()).diff(smsGroup.created, 'hours') > 24) {
|
|
||||||
that.log.info(logPrefix + 'smsGroupId "' + smsGroupId + '" is removed from this.longSmses due to being older than 24 hours.');
|
|
||||||
|
|
||||||
delete this.longSmses[smsGroupId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic session function
|
|
||||||
*
|
|
||||||
* @param {object} options - {sock, log}
|
|
||||||
* @return {object} (returnObj)
|
|
||||||
*/
|
|
||||||
function session(options) {
|
|
||||||
const logPrefix = topLogPrefix + 'session() - socket address: ' + options.sock.remoteAddress + ':' + options.sock.remotePort + ' - ',
|
|
||||||
returnObj = new events.EventEmitter();
|
|
||||||
|
|
||||||
if (! options.log) {
|
|
||||||
options.log = new lUtils.Log();
|
|
||||||
}
|
|
||||||
utils.log = options.log;
|
|
||||||
|
|
||||||
returnObj.log = options.log;
|
|
||||||
|
|
||||||
returnObj.log.silly(logPrefix + 'New session started');
|
|
||||||
|
|
||||||
returnObj.loggedIn = false;
|
|
||||||
returnObj.ourSeqNr = 1; // Sequence number used for commands initiated from us
|
|
||||||
returnObj.sock = options.sock; // Make the socket transparent via the returned emitter
|
|
||||||
returnObj.incOurSeqNr = incOurSeqNr;
|
|
||||||
returnObj.closeSocket = closeSocket;
|
|
||||||
returnObj.sockWrite = sockWrite;
|
|
||||||
returnObj.send = send;
|
|
||||||
returnObj.sendReturn = sendReturn;
|
|
||||||
returnObj.sendSms = sendSms;
|
|
||||||
returnObj.utils = utils;
|
|
||||||
|
|
||||||
// Temporary storage for long sms parts
|
|
||||||
// These should be cleared if they linger to long to avoid memory leaks
|
|
||||||
returnObj.longSmses = {};
|
|
||||||
|
|
||||||
// Temporary storage for DLRs to long SMSes
|
|
||||||
// We keep them like this to be able to simulate a single DLR when all parts have gotten DLRs
|
|
||||||
returnObj.longSmsDlrs = {};
|
|
||||||
|
|
||||||
returnObj.sendLongSms = sendLongSms;
|
|
||||||
returnObj.longSms = longSms;
|
|
||||||
returnObj.checkLongSmses = checkLongSmses;
|
|
||||||
|
|
||||||
// Handle incomming commands.
|
|
||||||
// This is intended to be extended
|
|
||||||
returnObj.handleCmd = {};
|
|
||||||
|
|
||||||
// Handle incoming deliver_sm
|
|
||||||
returnObj.handleCmd.deliver_sm = function deliver_sm(pduObj) {
|
|
||||||
const thisLogPrefix = logPrefix + 'deliver_sm() - ',
|
|
||||||
dlrObj = {};
|
|
||||||
|
|
||||||
// TLV message_state must exists
|
|
||||||
if (pduObj.tlvs.message_state === undefined) {
|
|
||||||
returnObj.log.info(thisLogPrefix + 'TLV message_state is missing. SeqNr: ' + pduObj.seqNr);
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLV message_state needs to be valid
|
|
||||||
if (defs.constsById.MESSAGE_STATE[pduObj.tlvs.message_state.tagValue] === undefined) {
|
|
||||||
returnObj.log.info(thisLogPrefix + 'Invalid TLV message_state: "' + pduObj.tlvs.message_state.tagValue + '". SeqNr: ' + pduObj.seqNr);
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLV receipted_message_id must exist
|
|
||||||
if (pduObj.tlvs.receipted_message_id === undefined) {
|
|
||||||
returnObj.log.info(thisLogPrefix + 'TLV receipted_message_id is missing. SeqNr: ' + pduObj.seqNr);
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dlrObj.statusMsg = defs.constsById.MESSAGE_STATE[pduObj.tlvs.message_state.tagValue];
|
|
||||||
dlrObj.statusId = pduObj.tlvs.message_state.tagValue;
|
|
||||||
dlrObj.smsId = pduObj.tlvs.receipted_message_id.tagValue;
|
|
||||||
|
|
||||||
returnObj.emit('dlr', dlrObj, pduObj);
|
|
||||||
returnObj.sendReturn(pduObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enquire link
|
|
||||||
returnObj.handleCmd.enquire_link = function enquire_link(pduObj) {
|
|
||||||
const thisLogPrefix = logPrefix + 'enquire_link() - ';
|
|
||||||
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Enquiring link');
|
|
||||||
returnObj.resetEnqLinkTimer();
|
|
||||||
returnObj.sendReturn(pduObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle incoming submit_sm
|
|
||||||
returnObj.handleCmd.submit_sm = function submit_sm(pduObj) {
|
|
||||||
const thisLogPrefix = logPrefix + 'submit_sm() - ',
|
|
||||||
smsObj = {};
|
|
||||||
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'ran');
|
|
||||||
|
|
||||||
// If esm_class is 0x40 it means this is just a part of a larger message
|
|
||||||
//if (pduObj.params.esm_class === 0x40) {
|
|
||||||
// Fix: esm_class can be combination of bits. We need to extract 0x40 and then compare
|
|
||||||
if ((pduObj.params.esm_class & 0x40) === 0x40) {
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'long sms detected, esm_class 0x40.');
|
|
||||||
returnObj.longSms(pduObj);
|
|
||||||
return; // Long messages should not get handled here at all, so cancel execution here
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are needed for references here and there in functions
|
|
||||||
smsObj.session = returnObj;
|
|
||||||
smsObj.pduObjs = [{'pduObj': pduObj}];
|
|
||||||
smsObj.from = pduObj.params.source_addr;
|
|
||||||
smsObj.to = pduObj.params.destination_addr;
|
|
||||||
smsObj.submitTime = new Date();
|
|
||||||
smsObj.message = pduObj.params.short_message;
|
|
||||||
smsObj.dlr = Boolean(pduObj.params.registered_delivery);
|
|
||||||
smsObj.sendResp = smsResp;
|
|
||||||
smsObj.sendDlr = utils.smsDlr;
|
|
||||||
smsObj.log = returnObj.log;
|
|
||||||
|
|
||||||
if (pduObj.params.data_coding === 0x10) {
|
|
||||||
smsObj.flash = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Emitting sms object');
|
|
||||||
|
|
||||||
returnObj.emit('sms', smsObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle incoming unbind
|
|
||||||
returnObj.handleCmd.unbind = function unbind(pduObj) {
|
|
||||||
returnObj.sendReturn(pduObj, 'ESME_ROK', undefined, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dummy, should be extended by serverSession or clientSession
|
|
||||||
returnObj.login = function login() {
|
|
||||||
const thisLogPrefix = logPrefix + 'login() - ';
|
|
||||||
|
|
||||||
returnObj.log.info(thisLogPrefix + 'Dummy login function ran, this might be a mistake');
|
|
||||||
returnObj.loggedIn = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dummy method - should be used by serverSession or clientSession
|
|
||||||
returnObj.resetEnqLinkTimer = function resetEnqLinkTimer() {
|
|
||||||
const thisLogPrefix = logPrefix + 'resetEnqLinkTimer() - ';
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Resetting the kill timer');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unbind this session
|
|
||||||
returnObj.unbind = function unbind() {
|
|
||||||
returnObj.send({
|
|
||||||
'cmdName': 'unbind'
|
|
||||||
}, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Setup a data queue in case we only get partial data on the socket
|
|
||||||
// This way we can concatenate them later on
|
|
||||||
returnObj.dataQueue = new Buffer(0);
|
|
||||||
|
|
||||||
// Add a 'data' event handler to this instance of socket
|
|
||||||
options.sock.on('data', function (data) {
|
|
||||||
const thisLogPrefix = logPrefix + 'sock.on(data) - ';
|
|
||||||
|
|
||||||
// Pass the data along to the returnObj
|
|
||||||
returnObj.emit('data', data);
|
|
||||||
|
|
||||||
// Reset the enquire link timer
|
|
||||||
returnObj.resetEnqLinkTimer();
|
|
||||||
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'Incoming data: ' + data.toString('hex'));
|
|
||||||
|
|
||||||
// Add this data to the dataQueue for processing
|
|
||||||
returnObj.dataQueue = Buffer.concat([returnObj.dataQueue, data]);
|
|
||||||
|
|
||||||
// Process queue
|
|
||||||
while (returnObj.dataQueue.length > 4) {
|
|
||||||
const cmdLength = parseInt(returnObj.dataQueue.readUInt32BE(0)); // Get this commands length
|
|
||||||
|
|
||||||
let pdu;
|
|
||||||
|
|
||||||
// Malformed PDU with command length 0
|
|
||||||
if (cmdLength <= 0) {
|
|
||||||
// Since PDU is Malformed we need to discard buffer.
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Malformed PDU with 0 Length. Discarding buffer.');
|
|
||||||
returnObj.dataQueue = returnObj.dataQueue.slice(0, returnObj.dataQueue.length);
|
|
||||||
|
|
||||||
// Since PDU is malformed we need to close socket since we cannot trust data from now on.
|
|
||||||
returnObj.closeSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Processing ' + cmdLength + ' bytes of data');
|
|
||||||
|
|
||||||
// If there is at least enough bytes in the dataQueue to fill this PDU, do it!
|
|
||||||
if (cmdLength <= returnObj.dataQueue.length) {
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'Full PDU found in dataQueue, processing ' + cmdLength + ' bytes of queue total ' + returnObj.dataQueue.length + ' bytes');
|
|
||||||
|
|
||||||
// Slice up the dataQueue buffer to this commands length
|
|
||||||
pdu = returnObj.dataQueue.slice(0, cmdLength);
|
|
||||||
|
|
||||||
// Slice off the command from the dataQueue
|
|
||||||
returnObj.dataQueue = returnObj.dataQueue.slice(cmdLength, returnObj.dataQueue.length);
|
|
||||||
|
|
||||||
returnObj.emit('incomingPdu', pdu);
|
|
||||||
} else {
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'Tried to process ' + cmdLength + ' bytes, but only ' + returnObj.dataQueue.length + ' bytes found. Awaiting more data. Current data in queue: ' + returnObj.dataQueue.toString('hex'));
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (returnObj.dataQueue.length === 0) {
|
|
||||||
returnObj.log.silly(thisLogPrefix + 'All queue handled, breaking while loop.');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the command length is larger than the queue, we need to wait for more data. Stop processing!
|
|
||||||
if (cmdLength > returnObj.dataQueue) {
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'Incomplete PDU found in dataQueue, waiting for more data to continue. Current cmdLength: ' + cmdLength + ' current queue: ' + returnObj.dataQueue.toString('hex'));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle incoming Pdu Buffers
|
|
||||||
returnObj.on('incomingPdu', function (pdu) {
|
|
||||||
const thisLogPrefix = logPrefix + 'sock.on(incomingPdu) - ';
|
|
||||||
|
|
||||||
utils.pduToObj(pdu, function (err, pduObj) {
|
|
||||||
if (err) {
|
|
||||||
returnObj.log.warn(thisLogPrefix + 'Invalid PDU, closing socket.');
|
|
||||||
|
|
||||||
returnObj.closeSocket();
|
|
||||||
} else {
|
|
||||||
returnObj.log.verbose(thisLogPrefix + 'Incoming PDU parsed. Seqnr: ' + pduObj.seqNr + ' cmd: ' + pduObj.cmdName + ' cmdStatus: ' + pduObj.cmdStatus + ' hex: ' + pdu.toString('hex'));
|
|
||||||
|
|
||||||
if (pduObj.isResp()) {
|
|
||||||
// We do this so we can remove the dynamic event listeners to not have a memory leak
|
|
||||||
returnObj.emit('incomingPduObj' + pduObj.seqNr, pduObj);
|
|
||||||
|
|
||||||
// Clean up by removing this listener or else it will lurk along forever
|
|
||||||
returnObj.removeAllListeners('incomingPduObj' + pduObj.seqNr);
|
|
||||||
} else {
|
|
||||||
returnObj.emit('incomingPduObj', pduObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a 'close' event handler to this instance of socket
|
|
||||||
options.sock.on('close', function () {
|
|
||||||
const thisLogPrefix = logPrefix + 'sock.on(close) - ';
|
|
||||||
|
|
||||||
returnObj.emit('close');
|
|
||||||
if (returnObj.enqLinkTimer) {
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'enqLinkTimer found, clearing.');
|
|
||||||
clearTimeout(returnObj.enqLinkTimer);
|
|
||||||
}
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'socket closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
options.sock.on('error', function () {
|
|
||||||
const thisLogPrefix = logPrefix + 'sock.on(error) - ';
|
|
||||||
|
|
||||||
returnObj.log.warn(thisLogPrefix + 'Socket error detected!');
|
|
||||||
if (returnObj.enqLinkTimer) {
|
|
||||||
returnObj.log.debug(thisLogPrefix + 'enqLinkTimer found, clearing.');
|
|
||||||
clearTimeout(returnObj.enqLinkTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return returnObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose some functions
|
|
||||||
exports = module.exports = session;
|
|
||||||
-737
@@ -1,737 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const topLogPrefix = 'larvitsmpp: lib/utils.js: ',
|
|
||||||
LUtils = require('larvitutils'),
|
|
||||||
lUtils = new LUtils(),
|
|
||||||
defs = require(__dirname + '/defs.js');
|
|
||||||
|
|
||||||
let bundleMsgId = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate cmdLength from object
|
|
||||||
*
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {function} cb - cb(err, cmdLength)
|
|
||||||
*/
|
|
||||||
function calcCmdLength(obj, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'calcCmdLength() - ';
|
|
||||||
|
|
||||||
let cmdLength = 16; // All commands are at least 16 octets long
|
|
||||||
|
|
||||||
// Handle params - All command params should always exists, even if they do not contain data.
|
|
||||||
for (const param in defs.cmds[obj.cmdName].params) {
|
|
||||||
// Get the parameter type, int, string, cstring etc.
|
|
||||||
// This is needed so we can calculate length etc
|
|
||||||
const paramType = defs.cmds[obj.cmdName].params[param].type;
|
|
||||||
|
|
||||||
if (obj.params[param] === undefined) {
|
|
||||||
obj.params[param] = paramType.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(paramType.size(obj.params[param]))) {
|
|
||||||
const err = new Error('Invalid param value "' + obj.params[param] + '" for param "' + param + '" and command "' + obj.cmdName + '". Is it of the right type?');
|
|
||||||
exports.log.error(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdLength += paramType.size(obj.params[param]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLV params - optional parameters
|
|
||||||
for (const tlvName in obj.tlvs) {
|
|
||||||
const tlvValue = obj.tlvs[tlvName].tagValue;
|
|
||||||
|
|
||||||
let tlvDef = defs.tlvsById[obj.tlvs[tlvName].tagId];
|
|
||||||
|
|
||||||
if (tlvDef === undefined) {
|
|
||||||
tlvDef = defs.tlvs.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
cmdLength += tlvDef.type.size(tlvValue) + 4;
|
|
||||||
} catch (err) {
|
|
||||||
const manErr = new Error('Could not get size of TLV parameter "' + tlvName + '" with value "' + tlvValue + '", err: ' + err.message);
|
|
||||||
exports.log.error(logPrefix + manErr.message);
|
|
||||||
return cb(manErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(null, cmdLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write PDU to buffer
|
|
||||||
*
|
|
||||||
* @param {object} obj - the PDU object to be written to buffer
|
|
||||||
* @param {number} cmdLength - The length of the pdu buffer
|
|
||||||
* @param {function} cb - cb(err, buff)
|
|
||||||
*/
|
|
||||||
function writeBuffer(obj, cmdLength, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'writeBuffer() - ';
|
|
||||||
|
|
||||||
let offset = 16, // Start the offset on the body
|
|
||||||
buff;
|
|
||||||
|
|
||||||
if (isNaN(cmdLength)) {
|
|
||||||
const err = new Error('cmdLength is NaN');
|
|
||||||
exports.log.error(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmdLength < 16) {
|
|
||||||
const err = new Error('cmdLength is less than 16 (' + cmdLength + ')');
|
|
||||||
exports.log.error(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
buff = new Buffer(cmdLength);
|
|
||||||
|
|
||||||
// Write PDU header
|
|
||||||
try {
|
|
||||||
buff.writeUInt32BE(cmdLength, 0); // Command length for the first 4 octets
|
|
||||||
buff.writeUInt32BE(defs.cmds[obj.cmdName].id, 4); // Command id for the second 4 octets
|
|
||||||
buff.writeUInt32BE(defs.errors[obj.cmdStatus], 8); // Command status for the third 4 octets
|
|
||||||
buff.writeUInt32BE(obj.seqNr, 12); // Sequence number as the fourth 4 octets
|
|
||||||
} catch (err) {
|
|
||||||
const manErr = new Error('Could not write PDU header, catched err: ' + err.message, obj);
|
|
||||||
exports.log.error(logPrefix + manErr.message);
|
|
||||||
return cb(manErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cycle through the defs list to make sure the params are in the right order
|
|
||||||
for (const param in defs.cmds[obj.cmdName].params) {
|
|
||||||
const paramType = defs.cmds[obj.cmdName].params[param].type,
|
|
||||||
paramSize = paramType.size(obj.params[param]);
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(obj.params[param])) {
|
|
||||||
exports.log.silly(logPrefix + 'Writing param "' + param + '" with content "' + obj.params[param].toString('hex') + '" and size "' + paramSize + '"');
|
|
||||||
} else {
|
|
||||||
if (param === 'sm_length') {
|
|
||||||
exports.log.silly(logPrefix + 'sm_length is calculated by short_message: "' + obj.params.short_message.toString('hex') + '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'Writing param "' + param + '" with content "' + obj.params[param] + '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write parameter value to buffer using the types method write()
|
|
||||||
paramType.write(obj.params[param], buff, offset);
|
|
||||||
|
|
||||||
// Increase the offset for the next param
|
|
||||||
offset += paramSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cycle through the tlvs
|
|
||||||
for (const tlvName in obj.tlvs) {
|
|
||||||
const tlvValue = obj.tlvs[tlvName].tagValue,
|
|
||||||
tlvId = obj.tlvs[tlvName].tagId;
|
|
||||||
|
|
||||||
let tlvDef = defs.tlvsById[tlvId],
|
|
||||||
tlvSize;
|
|
||||||
|
|
||||||
if (tlvDef === undefined) {
|
|
||||||
tlvDef = defs.tlvs.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
tlvSize = tlvDef.type.size(tlvValue);
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'Writing TLV "' + tlvName + '" offset: ' + offset + ' value: "' + tlvValue + '"');
|
|
||||||
|
|
||||||
buff.writeUInt16BE(tlvId, offset);
|
|
||||||
buff.writeUInt16BE(tlvSize, offset + 2);
|
|
||||||
tlvDef.type.write(tlvValue, buff, offset + 4);
|
|
||||||
|
|
||||||
offset += tlvDef.type.size(tlvValue) + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'Complete PDU: "' + buff.toString('hex') + '"');
|
|
||||||
|
|
||||||
cb(null, buff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a short_message
|
|
||||||
*
|
|
||||||
* @param {buffer} buffer
|
|
||||||
* @param {string} encoding 'ASCII', 'LATIN1' or 'UCS2' or hex values
|
|
||||||
* @param {number} offset - defaults to 0
|
|
||||||
* @return {string} in utf8 format
|
|
||||||
*/
|
|
||||||
function decodeMsg(buffer, encoding, offset) {
|
|
||||||
const logPrefix = topLogPrefix + 'decodeMsg() - ';
|
|
||||||
|
|
||||||
if (offset === undefined) {
|
|
||||||
offset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const checkEnc in defs.consts.ENCODING) {
|
|
||||||
if (parseInt(encoding) === defs.consts.ENCODING[checkEnc] || encoding === checkEnc) {
|
|
||||||
encoding = checkEnc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defs.encodings[encoding] === undefined) {
|
|
||||||
exports.log.info(logPrefix + 'Invalid encoding "' + encoding + '" given. Falling back to ASCII (0x01).');
|
|
||||||
encoding = 'ASCII';
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.debug(logPrefix + 'Decoding msg. Encoding: "' + encoding + '" offset: "' + offset + '" buffer: "' + buffer.toString('hex') + '"');
|
|
||||||
|
|
||||||
return defs.encodings[encoding].decode(buffer.slice(offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeMsg(str) {
|
|
||||||
const encoding = defs.encodings.detect(str);
|
|
||||||
|
|
||||||
return defs.encodings[encoding].encode(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms a PDU to an object
|
|
||||||
*
|
|
||||||
* @param {buffer} pdu
|
|
||||||
* @param {boolean} stupidNullByte - Define if the short_message should be followed by a stupid NULL byte - will be auto resolved if left undefined
|
|
||||||
* @param {function} cb - cb(err, obj)
|
|
||||||
*/
|
|
||||||
function pduToObj(pdu, stupidNullByte, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'pduToObj() - ',
|
|
||||||
retObj = {'params': {}, 'tlvs': {}};
|
|
||||||
|
|
||||||
let offset = 16, // 0-15 is the header, so the body starts at 16
|
|
||||||
command;
|
|
||||||
|
|
||||||
if (typeof stupidNullByte === 'function') {
|
|
||||||
cb = stupidNullByte;
|
|
||||||
stupidNullByte = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if this PDU is a response to another PDU
|
|
||||||
retObj.isResp = function () {
|
|
||||||
return ! ! (this.cmdId & 0x80000000);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'Decoding PDU to Obj. PDU buff in hex: ' + pdu.toString('hex'));
|
|
||||||
|
|
||||||
if (pdu.length < 16) {
|
|
||||||
const err = new Error('PDU is to small, minimum size is 16, given size is ' + pdu.length);
|
|
||||||
exports.log.warn(logPrefix + '' + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the PDU Header
|
|
||||||
retObj.cmdLength = parseInt(pdu.readUInt32BE(0));
|
|
||||||
retObj.cmdId = parseInt(pdu.readUInt32BE(4));
|
|
||||||
retObj.cmdStatus = defs.errorsById[parseInt(pdu.readUInt32BE(8))];
|
|
||||||
retObj.seqNr = parseInt(pdu.readUInt32BE(12));
|
|
||||||
|
|
||||||
// Lookup the command id in the definitions
|
|
||||||
if (defs.cmdsById[retObj.cmdId] === undefined) {
|
|
||||||
const err = new Error('Unknown PDU command id: ' + retObj.cmdId + ' PDU buff in hex: ' + pdu.toString('hex'));
|
|
||||||
exports.log.warn(logPrefix + '' + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(retObj.seqNr)) {
|
|
||||||
const err = new Error('Invalid seqNr, is not an interger: "' + retObj.seqNr + '"');
|
|
||||||
exports.log.warn(logPrefix + '' + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retObj.seqNr > 2147483646) {
|
|
||||||
const err = new Error('Invalid seqNr, maximum size of 2147483646 (0x7fffffff) exceeded.');
|
|
||||||
exports.log.warn(logPrefix + '' + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
command = defs.cmdsById[retObj.cmdId];
|
|
||||||
retObj.cmdName = command.command;
|
|
||||||
|
|
||||||
// Get all parameters from the body that should exists with this command
|
|
||||||
for (const param in command.params) {
|
|
||||||
|
|
||||||
// Get the parameter value by using the definition type read() function
|
|
||||||
try {
|
|
||||||
let paramSize;
|
|
||||||
|
|
||||||
retObj.params[param] = command.params[param].type.read(pdu, offset, retObj.params.sm_length);
|
|
||||||
paramSize = command.params[param].type.size(retObj.params[param]);
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'Reading param "' + param + '" at offset ' + offset + ' with calculated size: ' + paramSize + ' content in hex: ' + pdu.slice(offset, offset + paramSize).toString('hex'));
|
|
||||||
if (param === 'short_message') {
|
|
||||||
// Check if we have a trailing NULL octet after the short_message. Some idiot thought that would be a good idea
|
|
||||||
// in some implementations, so we need to account for that.
|
|
||||||
if (stupidNullByte === true) {
|
|
||||||
exports.log.silly(logPrefix + 'stupidNullByte is set, so short_message is followed by a NULL octet, increase paramSize one extra to account for that');
|
|
||||||
paramSize ++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increase the offset by the current params length
|
|
||||||
offset += paramSize;
|
|
||||||
} catch (err) {
|
|
||||||
const manErr = new Error('Failed to read param "' + param + '", err: ' + err.message);
|
|
||||||
exports.log.error(logPrefix + '' + manErr.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the length is greater than the current offset, there must be TLVs - resolve them!
|
|
||||||
// The minimal size for a TLV is its head, 4 octets
|
|
||||||
while ((offset + 4) < retObj.cmdLength) {
|
|
||||||
let tlvLength,
|
|
||||||
tlvCmdId,
|
|
||||||
tlvValue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
tlvCmdId = pdu.readInt16BE(offset);
|
|
||||||
tlvLength = pdu.readInt16BE(offset + 2);
|
|
||||||
} catch (err) {
|
|
||||||
const manErr = new Error('Unable to read TLV at offset "' + offset + '", given cmdLength: "' + retObj.cmdLength + '" pdu: ' + pdu.toString('hex') + ', err: ' + err.message);
|
|
||||||
exports.log.error(logPrefix + '' + manErr.message);
|
|
||||||
return cb(manErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defs.tlvsById[tlvCmdId] === undefined) {
|
|
||||||
tlvValue = pdu.slice(offset + 4, offset + 4 + tlvLength).toString('hex');
|
|
||||||
|
|
||||||
retObj.tlvs[tlvCmdId] = {
|
|
||||||
'tagId': tlvCmdId,
|
|
||||||
'tagName': undefined,
|
|
||||||
'tagValue': tlvValue
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.log.verbose(logPrefix + 'Unknown TLV found. Hex ID: ' + tlvCmdId.toString(16) + ' length: ' + tlvLength + ' hex value: ' + tlvValue);
|
|
||||||
} else {
|
|
||||||
tlvValue = defs.tlvsById[tlvCmdId].type.read(pdu, offset + 4, tlvLength);
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(tlvValue)) {
|
|
||||||
tlvValue = tlvValue.toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
retObj.tlvs[defs.tlvsById[tlvCmdId].tag] = {
|
|
||||||
'tagId': tlvCmdId,
|
|
||||||
'tagName': defs.tlvsById[tlvCmdId].tag,
|
|
||||||
'tagValue': tlvValue
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'TLV found: "' + defs.tlvsById[tlvCmdId].tag + '" ID: "' + tlvCmdId + '" value: "' + tlvValue + '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
offset = offset + 4 + tlvLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset !== retObj.cmdLength && stupidNullByte === undefined) {
|
|
||||||
exports.log.verbose(logPrefix + 'Offset (' + offset + ') !== cmdLength (' + retObj.cmdLength + ') for seqNr: ' + retObj.seqNr + ' - retry with the stupid NULL byte for short_message');
|
|
||||||
|
|
||||||
return pduToObj(pdu, true, cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset !== retObj.cmdLength) {
|
|
||||||
exports.log.warn(logPrefix + 'Offset (' + offset + ') !== cmdLength (' + retObj.cmdLength + ') for seqNr: ' + retObj.seqNr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the short message if it is set and esm_class is 0
|
|
||||||
// The esm_class 0x40 (64 int) means the short_message have a UDH
|
|
||||||
// Thats why we return the short_message as a buffer
|
|
||||||
if (retObj.params.short_message !== undefined && (retObj.params.esm_class & 0x40) !== 0x40) {
|
|
||||||
retObj.params.short_message = decodeMsg(retObj.params.short_message, retObj.params.data_coding);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.debug(logPrefix + 'Complete decoded PDU: ' + JSON.stringify(retObj));
|
|
||||||
|
|
||||||
cb(null, retObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform an object to a PDU
|
|
||||||
*
|
|
||||||
* @param {object} obj - example {'cmdName': 'bind_transceiver_resp', 'cmdStatus': 'ESME_ROK', 'seqNr': 2} - to add parameters add a key 'params' as object
|
|
||||||
* @param {function} cb - cb(err, pdu)
|
|
||||||
*/
|
|
||||||
function objToPdu(obj, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'objToPdu() - ',
|
|
||||||
seqNr = parseInt(obj.seqNr);
|
|
||||||
|
|
||||||
// Check so the command is ok
|
|
||||||
if (defs.cmds[obj.cmdName] === undefined) {
|
|
||||||
const err = new Error('Invalid cmdName: "' + obj.cmdName + '"');
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check so the command status is ok
|
|
||||||
if (obj.cmdStatus === undefined) {
|
|
||||||
obj.cmdStatus = 'ESME_ROK'; // Default to OK
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defs.errors[obj.cmdStatus] === undefined) {
|
|
||||||
const err = new Error('Invalid cmdStatus: "' + obj.cmdStatus + '"');
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check so seqNr is ok
|
|
||||||
if (isNaN(seqNr)) {
|
|
||||||
const err = new Error('Invalid seqNr, is not an interger: "' + obj.seqNr + '"');
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seqNr > 2147483646) {
|
|
||||||
const err = new Error('Invalid seqNr, maximum size of 2147483646 (0x7fffffff) exceeded.');
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params must be an object
|
|
||||||
if (obj.params === undefined) {
|
|
||||||
obj.params = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If param "short_message" exists, encode it and set parameter "data_coding" accordingly
|
|
||||||
if (obj.params.short_message !== undefined && ! Buffer.isBuffer(obj.params.short_message)) {
|
|
||||||
let shortMsg;
|
|
||||||
|
|
||||||
// Detect encoding if is not set already
|
|
||||||
if (obj.params.data_coding === undefined) {
|
|
||||||
obj.params.data_coding = defs.encodings.detect(obj.params.short_message);
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'data_coding "' + obj.params.data_coding + '" detected');
|
|
||||||
|
|
||||||
// Now set the hex value
|
|
||||||
obj.params.data_coding = defs.consts.ENCODING[obj.params.data_coding];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acutally encode the string
|
|
||||||
shortMsg = obj.params.short_message;
|
|
||||||
obj.params.short_message = encodeMsg(obj.params.short_message);
|
|
||||||
obj.params.sm_length = obj.params.short_message.length;
|
|
||||||
exports.log.silly(logPrefix + 'Encoding message "' + shortMsg + '" to "' + obj.params.short_message.toString('hex') + '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.debug(logPrefix + 'Complete object to encode: ' + JSON.stringify(obj));
|
|
||||||
|
|
||||||
calcCmdLength(obj, function (err, cmdLength) {
|
|
||||||
if (err) return cb(err);
|
|
||||||
|
|
||||||
writeBuffer(obj, cmdLength, cb);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a PDU as a return to another PDU
|
|
||||||
*
|
|
||||||
* @param {object|buffer} pdu
|
|
||||||
* @param {string} status - see list at defs.errors - defaults to 'ESME_ROK' - no error (OPTIONAL)
|
|
||||||
* @param {object} [params]
|
|
||||||
* @param {object} [tlvs]
|
|
||||||
* @param {function} [cb(err, pduBuffer)]
|
|
||||||
*/
|
|
||||||
function pduReturn(pdu, status, params, tlvs, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'pduReturn() - ',
|
|
||||||
retPdu = {};
|
|
||||||
|
|
||||||
let err = null;
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(pdu)) {
|
|
||||||
exports.log.silly(logPrefix + 'Ran with pdu as buffer, run pduToObj() and retry');
|
|
||||||
|
|
||||||
pduToObj(pdu, function (err, pduObj) {
|
|
||||||
if (err) return cb(err);
|
|
||||||
|
|
||||||
pduReturn(pduObj, status, params, tlvs, cb);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'ran');
|
|
||||||
|
|
||||||
if (typeof tlvs === 'function') {
|
|
||||||
cb = tlvs;
|
|
||||||
tlvs = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof params === 'function') {
|
|
||||||
cb = params;
|
|
||||||
params = {};
|
|
||||||
tlvs = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof status === 'function') {
|
|
||||||
cb = status;
|
|
||||||
status = 'ESME_ROK';
|
|
||||||
params = {};
|
|
||||||
tlvs = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === undefined) {
|
|
||||||
status = 'ESME_ROK';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cb === undefined) {
|
|
||||||
cb = function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params === undefined) {
|
|
||||||
params = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pdu === undefined) err = new Error('PDU is undefined, cannot create response PDU');
|
|
||||||
if (pdu.cmdName === undefined) err = new Error('pdu.cmdName is undefined, cannot create response PDU');
|
|
||||||
if (pdu.seqNr === undefined) err = new Error('pdu.seqNr is undefined, cannot create response PDU');
|
|
||||||
if (err === null && defs.errors[status] === undefined) err = new Error('Invalid status: "' + status + '"');
|
|
||||||
if (err === null && defs.cmds[pdu.cmdName + '_resp'] === undefined) err = new Error('This command does not have a response listed. Given command: "' + pdu.cmdName + '"');
|
|
||||||
|
|
||||||
if (err !== null) {
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
retPdu.cmdName = pdu.cmdName + '_resp';
|
|
||||||
retPdu.cmdStatus = status;
|
|
||||||
retPdu.seqNr = pdu.seqNr;
|
|
||||||
retPdu.params = params;
|
|
||||||
retPdu.tlvs = tlvs;
|
|
||||||
|
|
||||||
// Populate parameters that should exist in the response
|
|
||||||
for (const param in defs.cmds[pdu.cmdName + '_resp'].params) {
|
|
||||||
|
|
||||||
// Do not override the manually supplied parameters
|
|
||||||
if (retPdu.params[param] === undefined) {
|
|
||||||
retPdu.params[param] = pdu.params[param];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
objToPdu(retPdu, cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a js date object as ugly SMPP date format
|
|
||||||
*
|
|
||||||
* @param {object} jsDateObj
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
function smppDate(jsDateObj) {
|
|
||||||
let uglyStr = '';
|
|
||||||
|
|
||||||
uglyStr += jsDateObj.getFullYear().toString().substring(2);
|
|
||||||
|
|
||||||
if (jsDateObj.getMonth() < 10) {
|
|
||||||
uglyStr += '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
uglyStr += jsDateObj.getMonth();
|
|
||||||
|
|
||||||
if (jsDateObj.getDate() < 10) {
|
|
||||||
uglyStr += '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
uglyStr += jsDateObj.getDate();
|
|
||||||
|
|
||||||
if (jsDateObj.getHours() < 10) {
|
|
||||||
uglyStr += '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
uglyStr += jsDateObj.getHours();
|
|
||||||
|
|
||||||
if (jsDateObj.getMinutes() < 10) {
|
|
||||||
uglyStr += '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
uglyStr += jsDateObj.getMinutes();
|
|
||||||
|
|
||||||
return uglyStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate bitCount of short_message
|
|
||||||
*
|
|
||||||
* @param {string} msg
|
|
||||||
* @param {string} encoding - Force encoding ASCII or UCS2 (OPTIONAL)
|
|
||||||
* @return integer
|
|
||||||
*/
|
|
||||||
function bitCount(msg, encoding) {
|
|
||||||
if (defs.encodings[encoding] === undefined) {
|
|
||||||
encoding = defs.encodings.detect(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encoding === 'ASCII') {
|
|
||||||
return defs.encodings.ASCII.encode(msg).length * 7; // * 7 since each character takes up 7 bits
|
|
||||||
} else {
|
|
||||||
return defs.encodings.UCS2.encode(msg).length * 8; // * 8 since its encoded as 16-bits.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a message into multiple messages
|
|
||||||
*
|
|
||||||
* @param {string} msg
|
|
||||||
* @param {string} encoding - Force encoding ASCII or UCS2 (OPTIONAL)
|
|
||||||
* @return array of buffers
|
|
||||||
*/
|
|
||||||
function splitMsg(msg, encoding) {
|
|
||||||
const resolvedEncoding = encoding || defs.encodings.detect(msg),
|
|
||||||
totBitCount = bitCount(msg, resolvedEncoding),
|
|
||||||
logPrefix = topLogPrefix + 'splitMsg() - ',
|
|
||||||
msgs = [];
|
|
||||||
|
|
||||||
let msgPart = '',
|
|
||||||
partCharLimit,
|
|
||||||
i2;
|
|
||||||
|
|
||||||
// A single message could contain up to 1120 bits
|
|
||||||
// Return directly if the message fits into that
|
|
||||||
if (totBitCount < 1121) {
|
|
||||||
exports.log.silly(logPrefix + 'bitCount below 1121 (' + totBitCount + ') return only one part');
|
|
||||||
return [defs.encodings[resolvedEncoding].encode(msg)];
|
|
||||||
}
|
|
||||||
|
|
||||||
bundleMsgId ++; // This will identify this message "bundle"
|
|
||||||
|
|
||||||
if (bundleMsgId === 256) {
|
|
||||||
bundleMsgId = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.silly(logPrefix + 'bundleMsgId set to ' + bundleMsgId);
|
|
||||||
|
|
||||||
if (resolvedEncoding === 'ASCII') {
|
|
||||||
partCharLimit = 153;
|
|
||||||
} else {
|
|
||||||
partCharLimit = 67;
|
|
||||||
}
|
|
||||||
|
|
||||||
i2 = 0;
|
|
||||||
for (let i = 0; msg[i] !== undefined; i ++) {
|
|
||||||
msgPart += msg[i];
|
|
||||||
|
|
||||||
i2 ++;
|
|
||||||
|
|
||||||
if (i2 === partCharLimit) {
|
|
||||||
// We've reached the message limit
|
|
||||||
|
|
||||||
// Reset the local counter
|
|
||||||
i2 = 0;
|
|
||||||
|
|
||||||
// Add this msgPart minus the last character to the msgs array as an encoded buffer
|
|
||||||
msgs.push(defs.encodings[resolvedEncoding].encode(msgPart.slice(0, - 1)));
|
|
||||||
|
|
||||||
// Reset msgPart
|
|
||||||
msgPart = '';
|
|
||||||
|
|
||||||
// Put i back one to account for the last character we removed from the msgPart
|
|
||||||
i --;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the last msgPart to the msgs array
|
|
||||||
msgs.push(defs.encodings[resolvedEncoding].encode(msgPart));
|
|
||||||
|
|
||||||
// Add the UDH (http://en.wikipedia.org/wiki/Concatenated_SMS)
|
|
||||||
for (let i = 0; msgs[i] !== undefined; i ++) {
|
|
||||||
// Create the UDH buffer
|
|
||||||
const udh = new Buffer([
|
|
||||||
0x05, // Length of User Data Header, in this case 05.
|
|
||||||
0x00, // Information Element Identifier, equal to 00 (Concatenated short messages, 8-bit reference number)
|
|
||||||
0x03, // Length of the header, excluding the first two fields; equal to 03
|
|
||||||
bundleMsgId, // CSMS reference number, must be same for all the SMS parts in the CSMS
|
|
||||||
msgs.length, // Total number of parts. The value shall remain constant for every short message which makes up the concatenated short message. If the value is zero then the receiving entity shall ignore the whole information element
|
|
||||||
i + 1 // This part's number in the sequence. The value shall start at 1 and increment for every short message which makes up the concatenated short message.
|
|
||||||
]);
|
|
||||||
|
|
||||||
msgs[i] = Buffer.concat([udh, msgs[i]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a dlr to an sms
|
|
||||||
* This must be called from an sms object context
|
|
||||||
*
|
|
||||||
* @param {string} status - see list at defs.consts.MESSAGE_STATE - defaults to 'DELIVERED'
|
|
||||||
* @param {function} cb - cb(err, retPdu, dlrPduObj)
|
|
||||||
*/
|
|
||||||
function smsDlr(status, cb) {
|
|
||||||
const logPrefix = topLogPrefix + 'smsDlr() - ',
|
|
||||||
dlrPduObj = {},
|
|
||||||
sms = this;
|
|
||||||
|
|
||||||
let shortMessage = 'id:' + sms.smsId + ' sub:001 ';
|
|
||||||
|
|
||||||
if (typeof status === 'function') {
|
|
||||||
cb = status;
|
|
||||||
status = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === undefined || status === true || status === 2 || status === 'true') {
|
|
||||||
status = 2;
|
|
||||||
} else if (defs.consts.MESSAGE_STATE[status] !== undefined) {
|
|
||||||
status = defs.consts.MESSAGE_STATE[status];
|
|
||||||
} else if (defs.constsById.MESSAGE_STATE[status]) {
|
|
||||||
status = parseInt(status);
|
|
||||||
} else {
|
|
||||||
status = 5; // UNDELIVERABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof cb !== 'function') {
|
|
||||||
cb = function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sms.smsId === undefined) {
|
|
||||||
const err = new Error('Trying to send DLR with no smsId.');
|
|
||||||
exports.log.warn(logPrefix + err.message);
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 2) {
|
|
||||||
shortMessage += 'dlvrd:1 ';
|
|
||||||
} else {
|
|
||||||
shortMessage += 'dlvrd:0 ';
|
|
||||||
}
|
|
||||||
|
|
||||||
shortMessage += 'submit date:' + smppDate(sms.submitTime);
|
|
||||||
shortMessage += ' done date:' + smppDate(new Date());
|
|
||||||
|
|
||||||
if (status === 2) {
|
|
||||||
shortMessage += ' stat:DELIVRD err:0 text:xxx';
|
|
||||||
} else {
|
|
||||||
shortMessage += ' stat:UNDELIVERABLE err:1 text:xxx';
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.log.verbose(logPrefix + 'Sending DLR message: "' + shortMessage + '"');
|
|
||||||
|
|
||||||
dlrPduObj.cmdName = 'deliver_sm';
|
|
||||||
dlrPduObj.params = {
|
|
||||||
'source_addr': sms.from,
|
|
||||||
'destination_addr': sms.to,
|
|
||||||
'esm_class': 4,
|
|
||||||
'short_message': shortMessage
|
|
||||||
};
|
|
||||||
dlrPduObj.tlvs = {
|
|
||||||
'receipted_message_id': {
|
|
||||||
'tagId': 0x001E,
|
|
||||||
'tagName': 'receipted_message_id',
|
|
||||||
'tagValue': sms.smsId
|
|
||||||
},
|
|
||||||
'message_state': {
|
|
||||||
'tagId': 0x0427,
|
|
||||||
'tagName': 'message_state',
|
|
||||||
'tagValue': status
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sms.session.send(dlrPduObj, false, function (err, retPdu) {
|
|
||||||
cb(err, retPdu, dlrPduObj);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose some functions
|
|
||||||
exports.decodeMsg = decodeMsg;
|
|
||||||
exports.encodeMsg = encodeMsg;
|
|
||||||
exports.pduToObj = pduToObj;
|
|
||||||
exports.objToPdu = objToPdu;
|
|
||||||
exports.pduReturn = pduReturn;
|
|
||||||
exports.smppDate = smppDate;
|
|
||||||
exports.bitCount = bitCount;
|
|
||||||
exports.splitMsg = splitMsg;
|
|
||||||
exports.smsDlr = smsDlr;
|
|
||||||
exports.log = new lUtils.Log();
|
|
||||||
Generated
+1340
File diff suppressed because it is too large
Load Diff
+56
-46
@@ -1,48 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "larvitsmpp",
|
"name": "@larvit/smpp",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"author": {
|
"description": "SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send window, long messages and delivery receipts",
|
||||||
"name": "Mikael 'Lilleman' Göransson",
|
"keywords": [
|
||||||
"email": "lilleman@larvit.se",
|
"esm",
|
||||||
"url": "http://github.com/larvit/larvitsmpp"
|
"pdu",
|
||||||
},
|
"sms",
|
||||||
"private": false,
|
"smpp",
|
||||||
"contributors": [],
|
"typescript"
|
||||||
"dependencies": {
|
],
|
||||||
"async": "^2.4.1",
|
"homepage": "https://gitea.larvit.se/larvit/smpp-js",
|
||||||
"iconv-lite": "^0.4.18",
|
"bugs": {
|
||||||
"larvitutils": "^2.1.0",
|
"url": "https://github.com/larvit/smpp-js/issues"
|
||||||
"moment": "^2.18.1",
|
},
|
||||||
"utils-merge": "^1.0.0",
|
"repository": {
|
||||||
"uuid": "^3.2.1"
|
"type": "git",
|
||||||
},
|
"url": "git+https://gitea.larvit.se/larvit/smpp-js.git"
|
||||||
"description": "Simplified SMPP implementation",
|
},
|
||||||
"devDependencies": {
|
"license": "MIT",
|
||||||
"coveralls": "3.0.2",
|
"author": {
|
||||||
"eslint": "5.7.0",
|
"name": "Mikael 'Lilleman' Göransson",
|
||||||
"istanbul": "0.4.5",
|
"email": "lilleman@larvit.se",
|
||||||
"mocha": "6.0.0",
|
"url": "https://gitea.larvit.se/larvit/smpp-js"
|
||||||
"mocha-eslint": "5.0.0",
|
},
|
||||||
"portfinder": "1.0.20"
|
"type": "module",
|
||||||
},
|
"exports": {
|
||||||
"keywords": [
|
".": {
|
||||||
"smpp",
|
"types": "./dist/index.d.ts",
|
||||||
"pdu",
|
"default": "./dist/index.js"
|
||||||
"sms"
|
}
|
||||||
],
|
},
|
||||||
"main": "index.js",
|
"files": [
|
||||||
"repository": {
|
"dist"
|
||||||
"url": "https://github.com/larvit/larvitsmpp",
|
],
|
||||||
"type": "git"
|
"engines": {
|
||||||
},
|
"node": ">=18.0.0"
|
||||||
"readmeFilename": "README.md",
|
},
|
||||||
"bugs": {
|
"publishConfig": {
|
||||||
"url": "https://github.com/larvit/larvitsmpp/issues"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/larvit/larvitsmpp",
|
"scripts": {
|
||||||
"scripts": {
|
"build": "tsc --project tsconfig.build.json",
|
||||||
"cover": "istanbul cover _mocha",
|
"lint": "eslint . && tsc --noEmit",
|
||||||
"test": "mocha"
|
"prepack": "npm run build",
|
||||||
},
|
"test": "npm run lint && node --test test/*.test.ts",
|
||||||
"license": "MIT"
|
"test:compiled": "tsc --project tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"@larvit/log": "2.3.0",
|
||||||
|
"@types/node": "22.20.1",
|
||||||
|
"eslint": "10.9.1",
|
||||||
|
"smpp": "0.6.0-rc.4",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"typescript-eslint": "8.68.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": [
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"config:base"
|
"extends": ["config:recommended"],
|
||||||
]
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "typescript-eslint peer-requires TypeScript <6.1, so TS 7 has to wait for it.",
|
||||||
|
"allowedVersions": "<6.1.0",
|
||||||
|
"matchPackageNames": ["typescript"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
import type { ConnectionOptions } from 'node:tls';
|
||||||
|
import type { Result, VoidResult } from './result.ts';
|
||||||
|
import type { BindType, ReconnectOptions } from './session-options.ts';
|
||||||
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
|
import type { Socket } from 'node:net';
|
||||||
|
export type { BindType };
|
||||||
|
|
||||||
|
import { ReconnectLoop } from './reconnect-loop.ts';
|
||||||
|
import { Session } from './session.ts';
|
||||||
|
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
||||||
|
import { connect as netConnect } from 'node:net';
|
||||||
|
import { connect as tlsConnect } from 'node:tls';
|
||||||
|
import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||||
|
import { guardedLog } from './log.ts';
|
||||||
|
|
||||||
|
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
|
||||||
|
type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
|
||||||
|
|
||||||
|
export type ClientOptions = {
|
||||||
|
addressRange?: string;
|
||||||
|
addrNpi?: number;
|
||||||
|
addrTon?: number;
|
||||||
|
bindType?: BindType;
|
||||||
|
enquireLinkInterval?: number;
|
||||||
|
host?: string;
|
||||||
|
idleTimeout?: number;
|
||||||
|
interfaceVersion?: number;
|
||||||
|
log?: SmppLog;
|
||||||
|
maxOutstanding?: number;
|
||||||
|
password?: string;
|
||||||
|
port?: number;
|
||||||
|
reconnect?: ReconnectTuning | false;
|
||||||
|
responseTimeout?: number;
|
||||||
|
shutdownTimeout?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
smsIdFormat?: SmsIdFormat;
|
||||||
|
systemType?: string;
|
||||||
|
tls?: ConnectionOptions | boolean;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
bindType: 'transceiver',
|
||||||
|
enquireLinkInterval: 20_000,
|
||||||
|
host: 'localhost',
|
||||||
|
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||||||
|
idleTimeoutFactor: 2,
|
||||||
|
interfaceVersion: defaultInterfaceVersion,
|
||||||
|
password: 'pass',
|
||||||
|
port: 2775,
|
||||||
|
username: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||||
|
const host = options.host ?? defaults.host;
|
||||||
|
const port = options.port ?? defaults.port;
|
||||||
|
const secure = options.tls !== undefined && options.tls !== false;
|
||||||
|
const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined;
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const signal = options.signal;
|
||||||
|
|
||||||
|
if (signal?.aborted === true) {
|
||||||
|
resolve({ err: new Error('Aborted before connecting') });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sock: Socket;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
|
||||||
|
} catch (thrown: unknown) {
|
||||||
|
resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = (result: Result<{ sock: Socket }>): void => {
|
||||||
|
sock.removeListener('error', onError);
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
function onError(err: Error): void {
|
||||||
|
settle({ err });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAbort(): void {
|
||||||
|
sock.destroy();
|
||||||
|
settle({ err: new Error('Aborted while connecting') });
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.once('error', onError);
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
sock.once(secure ? 'secureConnect' : 'connect', () => {
|
||||||
|
settle({ sock });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every connect this client makes goes through here, so a failed one is named the same way once. */
|
||||||
|
async function connectSocket(options: ClientOptions, log: SmppLog): Promise<Result<{ sock: Socket }>> {
|
||||||
|
const opened = await openSocket(options);
|
||||||
|
|
||||||
|
if (opened.err) {
|
||||||
|
log.warn('client - could not connect', {
|
||||||
|
host: options.host ?? defaults.host,
|
||||||
|
message: opened.err.message,
|
||||||
|
port: options.port ?? defaults.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindParams(options: ClientOptions, systemId: string) {
|
||||||
|
return {
|
||||||
|
address_range: options.addressRange ?? '',
|
||||||
|
addr_npi: options.addrNpi ?? 0,
|
||||||
|
addr_ton: options.addrTon ?? 0,
|
||||||
|
interface_version: options.interfaceVersion ?? defaults.interfaceVersion,
|
||||||
|
password: options.password ?? defaults.password,
|
||||||
|
system_id: systemId,
|
||||||
|
system_type: options.systemType ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> {
|
||||||
|
const bindType = options.bindType ?? defaults.bindType;
|
||||||
|
const systemId = options.username ?? defaults.username;
|
||||||
|
const sent = await session.send(
|
||||||
|
{ cmdName: `bind_${bindType}`, params: bindParams(options, systemId) },
|
||||||
|
options.signal ? { signal: options.signal } : {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sent.err) return { err: sent.err };
|
||||||
|
|
||||||
|
if (sent.pduObj.cmdStatus !== 'ESME_ROK') {
|
||||||
|
session.log.info('client - bind refused', {
|
||||||
|
cmdStatus: sent.pduObj.cmdStatus ?? sent.pduObj.cmdStatusId,
|
||||||
|
systemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
|
||||||
|
|
||||||
|
session.boundAs = bindType;
|
||||||
|
session.loggedIn = true;
|
||||||
|
session.peerInterfaceVersion = typeof declared === 'number'
|
||||||
|
? declared
|
||||||
|
: undeclaredInterfaceVersion;
|
||||||
|
session.log.info('client - bound', { bindType, systemId });
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | undefined {
|
||||||
|
if (options.reconnect === false) return undefined;
|
||||||
|
|
||||||
|
const tuning = options.reconnect ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect: () => connectSocket(options, log),
|
||||||
|
maxDelay: tuning.maxDelay,
|
||||||
|
minDelay: tuning.minDelay,
|
||||||
|
onConnected: reconnected => bind(reconnected, options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session {
|
||||||
|
const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval;
|
||||||
|
|
||||||
|
return new Session({
|
||||||
|
enquireLinkInterval,
|
||||||
|
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
|
||||||
|
log,
|
||||||
|
maxOutstanding: options.maxOutstanding,
|
||||||
|
reconnect: reconnectFor(options, log),
|
||||||
|
responseTimeout: options.responseTimeout,
|
||||||
|
shutdownTimeout: options.shutdownTimeout,
|
||||||
|
smsIdFormat: options.smsIdFormat,
|
||||||
|
sock,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAndBind(
|
||||||
|
options: ClientOptions,
|
||||||
|
log: SmppLog,
|
||||||
|
): Promise<Result<{ session: Session }>> {
|
||||||
|
const opened = await connectSocket(options, log);
|
||||||
|
|
||||||
|
if (opened.err) return { err: opened.err };
|
||||||
|
|
||||||
|
return bindOn(createSession(options, log, opened.sock), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binds a session the caller has not seen yet, so a failure takes it down instead of surfacing. */
|
||||||
|
async function bindOn(
|
||||||
|
session: Session,
|
||||||
|
options: ClientOptions,
|
||||||
|
): Promise<Result<{ session: Session }>> {
|
||||||
|
const signal = options.signal;
|
||||||
|
|
||||||
|
if (signal?.aborted === true) {
|
||||||
|
void session.close({ signal });
|
||||||
|
|
||||||
|
return { err: new Error('Aborted before binding') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = (): void => { void session.close({ signal }); };
|
||||||
|
|
||||||
|
// Registered before the bind: an abort landing while it is in flight has to close the session.
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
const bound = await bind(session, options);
|
||||||
|
|
||||||
|
if (bound.err) {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
// close() must reach the loop's stop() before its first await, or this session retries too.
|
||||||
|
void session.close({ signal });
|
||||||
|
|
||||||
|
return { err: bound.err };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session };
|
||||||
|
}
|
||||||
|
|
||||||
|
function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning {
|
||||||
|
return reconnect !== undefined && reconnect !== false && reconnect.fromStart === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh session per attempt, and the failure to answer an abort with when none of them binds. */
|
||||||
|
function initialAttempts(options: ClientOptions, log: SmppLog, failed: Error) {
|
||||||
|
let lastErr = failed;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bind: async (sock: Socket): Promise<Result<{ session: Session }>> => {
|
||||||
|
const bound = await bindOn(createSession(options, log, sock), options);
|
||||||
|
|
||||||
|
if (bound.err) lastErr = bound.err;
|
||||||
|
|
||||||
|
return bound;
|
||||||
|
},
|
||||||
|
connect: async (): Promise<Result<{ sock: Socket }>> => {
|
||||||
|
const opened = await connectSocket(options, log);
|
||||||
|
|
||||||
|
if (opened.err) lastErr = opened.err;
|
||||||
|
|
||||||
|
return opened;
|
||||||
|
},
|
||||||
|
lastErr: (): Error => lastErr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */
|
||||||
|
function keepTrying(
|
||||||
|
options: ClientOptions,
|
||||||
|
log: SmppLog,
|
||||||
|
tuning: ReconnectTuning,
|
||||||
|
failed: Error,
|
||||||
|
): Promise<Result<{ session: Session }>> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const attempts = initialAttempts(options, log, failed);
|
||||||
|
const signal = options.signal;
|
||||||
|
let settled = false;
|
||||||
|
const loop = new ReconnectLoop({
|
||||||
|
connect: attempts.connect,
|
||||||
|
log,
|
||||||
|
maxDelay: tuning.maxDelay,
|
||||||
|
minDelay: tuning.minDelay,
|
||||||
|
onConnected: async sock => {
|
||||||
|
const bound = await attempts.bind(sock);
|
||||||
|
|
||||||
|
if (bound.err) return { err: bound.err };
|
||||||
|
|
||||||
|
settle({ session: bound.session });
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
// Awaited with no other handle, so an unref()'d wait would exit the process unbound.
|
||||||
|
unref: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function settle(result: Result<{ session: Session }>): void {
|
||||||
|
if (settled) return;
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
loop.stop();
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAbort(): void {
|
||||||
|
settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
loop.schedule();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects to an SMSC and binds. */
|
||||||
|
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
|
||||||
|
const log = guardedLog(options.log);
|
||||||
|
const checked = checkSessionOptions(options);
|
||||||
|
|
||||||
|
if (checked.err) {
|
||||||
|
log.warn('client - unusable option', { message: checked.err.message });
|
||||||
|
|
||||||
|
return { err: checked.err };
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await connectAndBind(options, log);
|
||||||
|
const reconnect = options.reconnect;
|
||||||
|
|
||||||
|
if (!first.err || options.signal?.aborted === true || !retriesFromStart(reconnect)) return first;
|
||||||
|
|
||||||
|
return keepTrying(options, log, reconnect, first.err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ConcatInfo } from './udh.ts';
|
||||||
|
import type { PduObject } from './pdu.ts';
|
||||||
|
import { concatInfo } from './udh.ts';
|
||||||
|
import { hasUdh } from './defs/constants.ts';
|
||||||
|
import { messageOctets } from './message-body.ts';
|
||||||
|
import { paramNumber } from './defs/types.ts';
|
||||||
|
|
||||||
|
/** Where a segment sits in its message, and what ties it to the rest of that message. */
|
||||||
|
export type Concat = ConcatInfo & {
|
||||||
|
/** Which of the two carried the numbering: their references are counters of their own. */
|
||||||
|
spelling: 'sar' | 'udh';
|
||||||
|
};
|
||||||
|
|
||||||
|
function sarConcat(pduObj: PduObject): Concat | undefined {
|
||||||
|
const reference = pduObj.tlvs.sar_msg_ref_num?.tagValue;
|
||||||
|
const part = pduObj.tlvs.sar_segment_seqnum?.tagValue;
|
||||||
|
const total = pduObj.tlvs.sar_total_segments?.tagValue;
|
||||||
|
|
||||||
|
if (typeof reference !== 'number' || typeof part !== 'number' || typeof total !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { part, reference, spelling: 'sar', total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a PDU says it is one segment of a longer message: the UDH its body starts with, or the
|
||||||
|
* sar_* TLVs SMPP 3.4 5.3.2.31-5.3.2.33 define in its place. A UDH that names the concatenation
|
||||||
|
* wins; one carrying only a port or a language indicator leaves the TLVs to say.
|
||||||
|
*/
|
||||||
|
export function concatOf(pduObj: PduObject): Concat | undefined {
|
||||||
|
const body = messageOctets(pduObj);
|
||||||
|
const udh = body !== undefined && hasUdh(paramNumber(pduObj.params.esm_class, 0))
|
||||||
|
? concatInfo(body)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (udh) return { ...udh, spelling: 'udh' };
|
||||||
|
|
||||||
|
return sarConcat(pduObj);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import type { WireType } from './types.ts';
|
||||||
|
import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from './types.ts';
|
||||||
|
|
||||||
|
type CommandSpec = {
|
||||||
|
id: number;
|
||||||
|
params?: Record<string, WireType>;
|
||||||
|
tlvMap?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindParams = {
|
||||||
|
system_id: cstring,
|
||||||
|
password: cstring,
|
||||||
|
system_type: cstring,
|
||||||
|
interface_version: int8,
|
||||||
|
addr_ton: int8,
|
||||||
|
addr_npi: int8,
|
||||||
|
address_range: cstring,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key order inside each `params` object is the order the fields appear on the wire. Reordering
|
||||||
|
* them corrupts every PDU of that command, and moving a field after `short_message` also stops the
|
||||||
|
* codec skipping the NULL octet some peers append to it.
|
||||||
|
*/
|
||||||
|
const specs = {
|
||||||
|
alert_notification: {
|
||||||
|
id: 0x00000102,
|
||||||
|
params: {
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
esme_addr_ton: int8,
|
||||||
|
esme_addr_npi: int8,
|
||||||
|
esme_addr: cstring,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bind_receiver: { id: 0x00000001, params: bindParams },
|
||||||
|
bind_receiver_resp: { id: 0x80000001, params: { system_id: cstring } },
|
||||||
|
bind_transmitter: { id: 0x00000002, params: bindParams },
|
||||||
|
bind_transmitter_resp: { id: 0x80000002, params: { system_id: cstring } },
|
||||||
|
bind_transceiver: { id: 0x00000009, params: bindParams },
|
||||||
|
bind_transceiver_resp: { id: 0x80000009, params: { system_id: cstring } },
|
||||||
|
broadcast_sm: {
|
||||||
|
id: 0x00000111,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
message_id: cstring,
|
||||||
|
priority_flag: int8,
|
||||||
|
schedule_delivery_time: cstring,
|
||||||
|
validity_period: cstring,
|
||||||
|
replace_if_present_flag: int8,
|
||||||
|
data_coding: int8,
|
||||||
|
sm_default_msg_id: int8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
broadcast_sm_resp: {
|
||||||
|
id: 0x80000111,
|
||||||
|
params: { message_id: cstring },
|
||||||
|
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
|
||||||
|
},
|
||||||
|
cancel_broadcast_sm: {
|
||||||
|
id: 0x00000113,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
message_id: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel_broadcast_sm_resp: { id: 0x80000113 },
|
||||||
|
cancel_sm: {
|
||||||
|
id: 0x00000008,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
message_id: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
dest_addr_ton: int8,
|
||||||
|
dest_addr_npi: int8,
|
||||||
|
destination_addr: cstring,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel_sm_resp: { id: 0x80000008 },
|
||||||
|
data_sm: {
|
||||||
|
id: 0x00000103,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
dest_addr_ton: int8,
|
||||||
|
dest_addr_npi: int8,
|
||||||
|
destination_addr: cstring,
|
||||||
|
esm_class: int8,
|
||||||
|
registered_delivery: int8,
|
||||||
|
data_coding: int8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data_sm_resp: { id: 0x80000103, params: { message_id: cstring } },
|
||||||
|
deliver_sm: {
|
||||||
|
id: 0x00000005,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
dest_addr_ton: int8,
|
||||||
|
dest_addr_npi: int8,
|
||||||
|
destination_addr: cstring,
|
||||||
|
esm_class: int8,
|
||||||
|
protocol_id: int8,
|
||||||
|
priority_flag: int8,
|
||||||
|
schedule_delivery_time: cstring,
|
||||||
|
validity_period: cstring,
|
||||||
|
registered_delivery: int8,
|
||||||
|
replace_if_present_flag: int8,
|
||||||
|
data_coding: int8,
|
||||||
|
sm_default_msg_id: int8,
|
||||||
|
sm_length: int8,
|
||||||
|
short_message: buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deliver_sm_resp: { id: 0x80000005, params: { message_id: cstring } },
|
||||||
|
enquire_link: { id: 0x00000015 },
|
||||||
|
enquire_link_resp: { id: 0x80000015 },
|
||||||
|
generic_nack: { id: 0x80000000 },
|
||||||
|
outbind: { id: 0x0000000B, params: { system_id: cstring, password: cstring } },
|
||||||
|
query_broadcast_sm: {
|
||||||
|
id: 0x00000112,
|
||||||
|
params: {
|
||||||
|
message_id: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query_broadcast_sm_resp: { id: 0x80000112, params: { message_id: cstring } },
|
||||||
|
query_sm: {
|
||||||
|
id: 0x00000003,
|
||||||
|
params: {
|
||||||
|
message_id: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query_sm_resp: {
|
||||||
|
id: 0x80000003,
|
||||||
|
params: {
|
||||||
|
message_id: cstring,
|
||||||
|
final_date: cstring,
|
||||||
|
message_state: int8,
|
||||||
|
error_code: int8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replace_sm: {
|
||||||
|
id: 0x00000007,
|
||||||
|
params: {
|
||||||
|
message_id: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
schedule_delivery_time: cstring,
|
||||||
|
validity_period: cstring,
|
||||||
|
registered_delivery: int8,
|
||||||
|
sm_default_msg_id: int8,
|
||||||
|
sm_length: int8,
|
||||||
|
short_message: buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replace_sm_resp: { id: 0x80000007 },
|
||||||
|
submit_multi: {
|
||||||
|
id: 0x00000021,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
dest_address: dest_address_array,
|
||||||
|
esm_class: int8,
|
||||||
|
protocol_id: int8,
|
||||||
|
priority_flag: int8,
|
||||||
|
schedule_delivery_time: cstring,
|
||||||
|
validity_period: cstring,
|
||||||
|
registered_delivery: int8,
|
||||||
|
replace_if_present_flag: int8,
|
||||||
|
data_coding: int8,
|
||||||
|
sm_default_msg_id: int8,
|
||||||
|
sm_length: int8,
|
||||||
|
short_message: buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit_multi_resp: {
|
||||||
|
id: 0x80000021,
|
||||||
|
params: { message_id: cstring, unsuccess_sme: unsuccess_sme_array },
|
||||||
|
},
|
||||||
|
submit_sm: {
|
||||||
|
id: 0x00000004,
|
||||||
|
params: {
|
||||||
|
service_type: cstring,
|
||||||
|
source_addr_ton: int8,
|
||||||
|
source_addr_npi: int8,
|
||||||
|
source_addr: cstring,
|
||||||
|
dest_addr_ton: int8,
|
||||||
|
dest_addr_npi: int8,
|
||||||
|
destination_addr: cstring,
|
||||||
|
esm_class: int8,
|
||||||
|
protocol_id: int8,
|
||||||
|
priority_flag: int8,
|
||||||
|
schedule_delivery_time: cstring,
|
||||||
|
validity_period: cstring,
|
||||||
|
registered_delivery: int8,
|
||||||
|
replace_if_present_flag: int8,
|
||||||
|
data_coding: int8,
|
||||||
|
sm_default_msg_id: int8,
|
||||||
|
sm_length: int8,
|
||||||
|
short_message: buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit_sm_resp: { id: 0x80000004, params: { message_id: cstring } },
|
||||||
|
unbind: { id: 0x00000006 },
|
||||||
|
unbind_resp: { id: 0x80000006 },
|
||||||
|
} satisfies Record<string, CommandSpec>;
|
||||||
|
|
||||||
|
export type CommandName = keyof typeof specs;
|
||||||
|
|
||||||
|
type ParamsSpecOf<C extends CommandName> = (typeof specs)[C] extends { params: infer P } ? P : Record<never, never>;
|
||||||
|
|
||||||
|
/** Parameters as they come off the wire: every field the command defines, always present. */
|
||||||
|
export type PduParams<C extends CommandName = CommandName> = {
|
||||||
|
[K in keyof ParamsSpecOf<C>]: ParamsSpecOf<C>[K] extends WireType<infer V> ? V : never;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parameters callers supply: all optional, and numbers are accepted for the string fields. */
|
||||||
|
export type PduParamsInput<C extends CommandName = CommandName> = {
|
||||||
|
[K in keyof ParamsSpecOf<C>]?: ParamsSpecOf<C>[K] extends WireType<infer V>
|
||||||
|
? V extends string ? number | string
|
||||||
|
: V extends Buffer ? Buffer | string
|
||||||
|
: V
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandDefinition = CommandSpec & { command: string };
|
||||||
|
|
||||||
|
export const cmds: Record<string, CommandDefinition> = {};
|
||||||
|
export const cmdsById: Record<number, CommandDefinition> = {};
|
||||||
|
|
||||||
|
for (const [command, spec] of Object.entries<CommandSpec>(specs)) {
|
||||||
|
const definition: CommandDefinition = { ...spec, command };
|
||||||
|
|
||||||
|
cmds[command] = definition;
|
||||||
|
cmdsById[spec.id] = definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommandName(value: unknown): value is CommandName {
|
||||||
|
return typeof value === 'string' && Object.hasOwn(specs, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandNameById(id: number): CommandName | undefined {
|
||||||
|
const command = cmdsById[id]?.command;
|
||||||
|
|
||||||
|
return isCommandName(command) ? command : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The response SMPP pairs with a request command, where it has one. */
|
||||||
|
export function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
|
||||||
|
if (cmdName === undefined) return undefined;
|
||||||
|
|
||||||
|
const respName = `${cmdName}_resp`;
|
||||||
|
|
||||||
|
return isCommandName(respName) ? respName : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/** The version declared on the wire. The tables below cover 5.0, which is a superset of it. */
|
||||||
|
export const defaultInterfaceVersion = 0x34;
|
||||||
|
|
||||||
|
/** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */
|
||||||
|
export const optionalParamsMinVersion = 0x34;
|
||||||
|
|
||||||
|
export const consts = {
|
||||||
|
BROADCAST_AREA_FORMAT: {
|
||||||
|
ALIAS: 0x00,
|
||||||
|
ELLIPSOID_ARC: 0x01,
|
||||||
|
NAME: 0x00,
|
||||||
|
POLYGON: 0x02,
|
||||||
|
},
|
||||||
|
BROADCAST_FREQUENCY_INTERVAL: {
|
||||||
|
DAYS: 0x0B,
|
||||||
|
HOURS: 0x0A,
|
||||||
|
MAX_POSSIBLE: 0x00,
|
||||||
|
MINUTES: 0x09,
|
||||||
|
MONTHS: 0x0D,
|
||||||
|
SECONDS: 0x08,
|
||||||
|
WEEKS: 0x0C,
|
||||||
|
YEARS: 0x0E,
|
||||||
|
},
|
||||||
|
ENCODING: {
|
||||||
|
BINARY: 0x04,
|
||||||
|
CYRILLIC: 0x06,
|
||||||
|
EXTENDED_KANJI_JIS: 0x0D,
|
||||||
|
FLASH: 0x10,
|
||||||
|
HEBREW: 0x07,
|
||||||
|
IA5: 0x01,
|
||||||
|
ISO_2022_JP: 0x0A,
|
||||||
|
ISO_8859_1: 0x03,
|
||||||
|
ISO_8859_5: 0x06,
|
||||||
|
ISO_8859_8: 0x07,
|
||||||
|
JIS: 0x05,
|
||||||
|
KS_C_5601: 0x0E,
|
||||||
|
LATIN1: 0x03,
|
||||||
|
PICTOGRAM: 0x09,
|
||||||
|
UCS2: 0x08,
|
||||||
|
X_0208_1990: 0x05,
|
||||||
|
X_0212_1990: 0x0D,
|
||||||
|
},
|
||||||
|
ESM_CLASS: {
|
||||||
|
CONVERSATION_ABORT: 0x18,
|
||||||
|
DELIVERY_ACKNOWLEDGEMENT: 0x08,
|
||||||
|
INTERMEDIATE_DELIVERY: 0x20,
|
||||||
|
MC_DELIVERY_RECEIPT: 0x04,
|
||||||
|
SET_REPLY_PATH: 0x80,
|
||||||
|
UDH_INDICATOR: 0x40,
|
||||||
|
USER_ACKNOWLEDGEMENT: 0x10,
|
||||||
|
},
|
||||||
|
MESSAGE_STATE: {
|
||||||
|
ACCEPTED: 6,
|
||||||
|
DELETED: 4,
|
||||||
|
DELIVERED: 2,
|
||||||
|
ENROUTE: 1,
|
||||||
|
EXPIRED: 3,
|
||||||
|
REJECTED: 8,
|
||||||
|
SCHEDULED: 0,
|
||||||
|
SKIPPED: 9,
|
||||||
|
UNDELIVERABLE: 5,
|
||||||
|
UNKNOWN: 7,
|
||||||
|
},
|
||||||
|
/** SMPP 3.4 5.2.12 bits 1-0 of esm_class, the field the rest of that octet is OR-ed into. */
|
||||||
|
MESSAGING_MODE: {
|
||||||
|
DATAGRAM: 0x01,
|
||||||
|
FORWARD: 0x02,
|
||||||
|
SMSC_DEFAULT: 0x00,
|
||||||
|
STORE_FORWARD: 0x03,
|
||||||
|
},
|
||||||
|
NETWORK: {
|
||||||
|
CDMA: 0x03,
|
||||||
|
GENERIC: 0x00,
|
||||||
|
GSM: 0x01,
|
||||||
|
TDMA: 0x02,
|
||||||
|
},
|
||||||
|
NPI: {
|
||||||
|
DATA: 0x03,
|
||||||
|
ERMES: 0x0A,
|
||||||
|
INTERNET: 0x0E,
|
||||||
|
IP: 0x0E,
|
||||||
|
ISDN: 0x01,
|
||||||
|
LAND_MOBILE: 0x06,
|
||||||
|
NATIONAL: 0x08,
|
||||||
|
PRIVATE: 0x09,
|
||||||
|
TELEX: 0x04,
|
||||||
|
UNKNOWN: 0x00,
|
||||||
|
WAP: 0x12,
|
||||||
|
},
|
||||||
|
REGISTERED_DELIVERY: {
|
||||||
|
DELIVERY_ACKNOWLEDGEMENT: 0x04,
|
||||||
|
FAILURE: 0x02,
|
||||||
|
FINAL: 0x01,
|
||||||
|
INTERMEDIATE: 0x10,
|
||||||
|
SUCCESS: 0x03,
|
||||||
|
USER_ACKNOWLEDGEMENT: 0x08,
|
||||||
|
},
|
||||||
|
TON: {
|
||||||
|
ABBREVIATED: 0x06,
|
||||||
|
ALPHANUMERIC: 0x05,
|
||||||
|
INTERNATIONAL: 0x01,
|
||||||
|
NATIONAL: 0x02,
|
||||||
|
NETWORK_SPECIFIC: 0x03,
|
||||||
|
SUBSCRIBER_NUMBER: 0x04,
|
||||||
|
UNKNOWN: 0x00,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function hasUdh(esmClass: number): boolean {
|
||||||
|
return (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bits 5-2 of esm_class; the rest carry the messaging mode and the GSM features. */
|
||||||
|
export function messageTypeOf(esmClass: number): number {
|
||||||
|
return esmClass & 0x3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConstGroup = keyof typeof consts;
|
||||||
|
export type MessageState = keyof typeof consts.MESSAGE_STATE;
|
||||||
|
export type MessagingMode = keyof typeof consts.MESSAGING_MODE;
|
||||||
|
|
||||||
|
/** SMPP 3.4 2.10.3 carries transaction mode on `data_sm` alone, so a `submit_sm` never asks for it. */
|
||||||
|
const transactionMode = 'FORWARD' satisfies MessagingMode;
|
||||||
|
|
||||||
|
export const defaultMessagingMode = 'SMSC_DEFAULT' satisfies MessagingMode;
|
||||||
|
|
||||||
|
export type SubmitMessagingMode = Exclude<MessagingMode, typeof transactionMode>;
|
||||||
|
|
||||||
|
export const submitMessagingModes: readonly string[] = Object.keys(consts.MESSAGING_MODE)
|
||||||
|
.filter(mode => mode !== transactionMode);
|
||||||
|
|
||||||
|
export function isMessagingMode(value: unknown): value is MessagingMode {
|
||||||
|
return typeof value === 'string' && Object.hasOwn(consts.MESSAGING_MODE, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubmitMessagingMode(value: unknown): value is SubmitMessagingMode {
|
||||||
|
return isMessagingMode(value) && value !== transactionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aliased values (NPI.IP === NPI.INTERNET === 0x0E) resolve to whichever name sorts last.
|
||||||
|
export const constsById: Record<string, Record<number, string>> = {};
|
||||||
|
|
||||||
|
for (const [groupName, group] of Object.entries(consts)) {
|
||||||
|
const byId: Record<number, string> = {};
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(group)) {
|
||||||
|
byId[value] = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
constsById[groupName] = byId;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user