Run Jasmin in both directions: message_payload, data_sm and a multipart deadlock confirmed against a production gateway
This commit is contained in:
@@ -194,7 +194,7 @@ document, library changes, and the AGENTS.md decision record each one needs.
|
|||||||
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
| 2 | done | [02-smppsim.md](findings/02-smppsim.md) |
|
| 2 | done | [02-smppsim.md](findings/02-smppsim.md) |
|
||||||
| 3 | not started | — |
|
| 3 | done | [03-jasmin.md](findings/03-jasmin.md) |
|
||||||
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
||||||
| 5–10 | not started | — |
|
| 5–10 | not started | — |
|
||||||
|
|
||||||
|
|||||||
@@ -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,212 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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 reassembling before answering),
|
||||||
|
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).
|
||||||
|
- **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 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,705 @@
|
|||||||
|
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 { 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: {
|
||||||
|
message_payload: { tagValue: Buffer.from(opts.message, 'latin1') },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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 });
|
||||||
|
|
||||||
|
// Recorded either way, per the task: one whole `sms` (Jasmin reassembled the SAR segments
|
||||||
|
// before forwarding) or several fragments (it relayed them, and our SAR-blind reassembler -
|
||||||
|
// keyed on UDH only - never groups them; see findings for which happened and the reproducer).
|
||||||
|
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 SAR fragments to arrive');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 === ''), 5000);
|
||||||
|
|
||||||
|
await session.close({ signal: AbortSignal.abort() });
|
||||||
|
|
||||||
|
// Confirmed (target 2): Jasmin relays message_payload faithfully (sm_length 0, the real text
|
||||||
|
// in the TLV) - our own incoming-requests.ts reads short_message only, so the sms that arrives
|
||||||
|
// here has the right envelope (from/to) but an empty message, never the text carried in the
|
||||||
|
// TLV. See findings/03-jasmin.md for the reproducer.
|
||||||
|
assert.ok(arrived, 'expected an sms to arrive (with an empty message, per target 2) for the message_payload push');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
|
||||||
|
test('a receipt thrown as data_sm is not read as a dlr; the raw PDU still arrives', 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 arrived = await waitFor(() => incomingDataSm[0], 15_000);
|
||||||
|
|
||||||
|
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
|
||||||
|
|
||||||
|
await delay(2000);
|
||||||
|
assert.deepEqual(dlrs, [], 'data_sm is answered ESME_RINVCMDID and never reaches the dlr event (target 4)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,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
|
||||||
@@ -13,6 +13,7 @@ EXPERT_SEVERITY_ERROR = "8388608"
|
|||||||
|
|
||||||
# The SMPP port each peer's compose overlay exposes its SMSC on.
|
# The SMPP port each peer's compose overlay exposes its SMSC on.
|
||||||
PORT_BY_PEER = {
|
PORT_BY_PEER = {
|
||||||
|
"jasmin": 2775,
|
||||||
"kannel": 2775,
|
"kannel": 2775,
|
||||||
"smppsim": 2775,
|
"smppsim": 2775,
|
||||||
"smscsim": 2775,
|
"smscsim": 2775,
|
||||||
|
|||||||
Reference in New Issue
Block a user