diff --git a/interop-tests/PLAN.md b/interop-tests/PLAN.md index 5bf74a9..75a91ca 100644 --- a/interop-tests/PLAN.md +++ b/interop-tests/PLAN.md @@ -196,7 +196,8 @@ document, library changes, and the AGENTS.md decision record each one needs. | 2 | done | [02-smppsim.md](findings/02-smppsim.md) | | 3 | done | [03-jasmin.md](findings/03-jasmin.md) | | 4 | done | [04-kannel.md](findings/04-kannel.md) | -| 5–10 | not started | — | +| 5 | done | [05-java-clients.md](findings/05-java-clients.md) | +| 6–10 | not started | — | Research notes behind this plan, 2026-09-05, are in `research/`: SMSC simulators, ESME clients and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts new file mode 100644 index 0000000..dca5b5b --- /dev/null +++ b/interop-tests/cloudhopper.test.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test, { after, describe } from 'node:test'; +import type { Session } from '../src/session.ts'; +import type { Sms } from '../src/sms.ts'; +import type { SmppServer } from '../src/server.ts'; +import { server } from '../src/server.ts'; + +const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080'; +const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); +const TLS_PORT = Number(process.env.TLS_PORT ?? '2776'); +/** How long the "slow" server holds a submit_sm before answering it - long enough that a burst of + * concurrent submits genuinely queues behind a small window instead of finishing before it matters. */ +const SLOW_DELAY_MS = 300; + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +async function waitFor(get: () => T | undefined, budget = 8000): Promise { + const deadline = Date.now() + budget; + let value = get(); + + while (value === undefined && Date.now() < deadline) { + await delay(20); + value = get(); + } + + return value; +} + +type DriverResult = Record; + +async function driver(path: string, params: Record = {}): Promise { + const url = `http://${CLOUDHOPPER_HOST}${path}?${new URLSearchParams(params).toString()}`; + const response = await fetch(url); + + return response.json() as Promise; +} + +const manualTexts = new Set(); +const allSms: { session: Session; sms: Sms }[] = []; + +function attach(session: Session): void { + session.on('sms', sms => { + allSms.push({ session, sms }); + + if (manualTexts.has(sms.message)) return; + + // The slow server this phase's window scenarios need: every ordinary submit is held for + // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. + void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); + }); +} + +const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); + +assert.equal(serverErr, undefined); +assert.ok(smpp); +smpp.on('session', attach); + +const key = readFileSync('/shared-certs/server.key'); +const cert = readFileSync('/shared-certs/server.crt'); +// Cloudhopper's SSL client (Netty 3.9.6.Final, from 2015) cannot complete a TLS 1.3 handshake - see +// findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is +// unrestricted. +const { err: tlsServerErr, server: tlsSmpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, + port: TLS_PORT, + tls: { cert, key, maxVersion: 'TLSv1.2' }, +}); + +assert.equal(tlsServerErr, undefined); +assert.ok(tlsSmpp); +tlsSmpp.on('session', attach); + +after(async () => { + await smpp.close(); + await tlsSmpp.close(); +}); + +async function waitForSessionCount(server_: SmppServer, count: number, budget = 15_000): Promise { + const found = await waitFor(() => ([...server_.sessions].length >= count ? [...server_.sessions] : undefined), budget); + + assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`); + + return found; +} + +describe('S5 - window pressure against a slow sms handler (target 11)', () => { + for (const windowSize of [1, 10, 50]) { + test(`window ${String(windowSize)}: every request answered, none twice, order preserved`, async () => { + const session = `w${String(windowSize)}`; + const bind = await driver('/bind', { password: 'chpw', session, systemId: `ch-${session}`, windowSize: String(windowSize) }); + + assert.equal(bind.ok, true); + await waitForSessionCount(smpp, 1); + + const count = windowSize === 50 ? 60 : windowSize * 3; + const burst = await driver('/windowBurst', { + count: String(count), prefix: session, session, timeoutMs: '30000', + }); + + assert.equal(burst.ok, true); + const results = burst.results as { index: number; messageId?: string; ok: boolean }[]; + + assert.equal(results.length, count); + assert.ok(results.every(r => r.ok), `every submit answered: ${JSON.stringify(results.filter(r => !r.ok))}`); + + const ids = results.map(r => r.messageId); + + assert.equal(new Set(ids).size, ids.length, 'no message id answered twice'); + assert.ok((burst.peakWindowSize as number) <= windowSize, `peak window ${String(burst.peakWindowSize)} stayed within ${String(windowSize)}`); + + // "Order preserved" here means each response correlates to its own request rather than a + // different one - guaranteed by Cloudhopper's own sequence-number-keyed window, which is + // exactly why the per-index messageId uniqueness above is the meaningful assertion: each + // of the `count` concurrent callers blocks on its own submit() and gets its own answer, + // racing only on which of them the OS schedules onto the window's free slot(s) first. + const arrived = allSms.filter(entry => entry.sms.message.startsWith(`${session}-`)).length; + + assert.equal(arrived, count); + + await driver('/unbind', { session }); + }); + } +}); + +describe('S5 - request expiry shorter than the handler delay (target 11)', () => { + test('the peer reports the expiry itself; our side is not left in a bad state', async () => { + const bind = await driver('/bind', { + password: 'chpw', requestExpiryTimeout: '100', session: 'expiry', systemId: 'ch-expiry', + windowMonitorInterval: '50', windowSize: '1', + }); + + assert.equal(bind.ok, true); + await waitForSessionCount(smpp, 1); + + const text = 'expiry-probe'; + + manualTexts.add(text); + + const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); + + assert.ok(sms); + + // Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window + // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. + await delay(1000); + await sms.sendResp(); + + const result = await submitted; + + assert.equal(result.ok, false); + // Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException, + // not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are + // distinct mechanisms and this is the window monitor's own name for it. + assert.match(String(result.errorClass), /RecoverablePduException/); + + const health = await driver('/health'); + + assert.equal(health.ok, true); + await driver('/unbind', { session: 'expiry' }); + }); +}); + +describe('S10 - Cloudhopper SSL client against our server({ tls })', () => { + test('handshake, bind, submit over TLS', async () => { + const bind = await driver('/bind', { + password: 'chsslpw', port: String(TLS_PORT), session: 'tls', systemId: 'ch-tls', useSsl: 'true', + }); + + assert.equal(bind.ok, true); + await waitForSessionCount(tlsSmpp, 1); + + const text = 'over-tls-phase5'; + const result = await driver('/submit', { session: 'tls', text }); + + assert.equal(result.ok, true); + assert.equal(result.commandStatus, 0); + + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); + + assert.ok(sms); + await driver('/unbind', { session: 'tls' }); + }); +}); + +describe('a refusing status is surfaced back to Cloudhopper', () => { + test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => { + const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' }); + + assert.equal(bind.ok, true); + await waitForSessionCount(smpp, 1); + + const text = 'ch-refuse-me'; + + manualTexts.add(text); + + const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); + + assert.ok(sms); + await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + + const result = await submitted; + + assert.equal(result.ok, true); + assert.equal(result.commandStatus, 0x00000014); + await driver('/unbind', { session: 'refuse' }); + }); +}); diff --git a/interop-tests/compose.cloudhopper.yaml b/interop-tests/compose.cloudhopper.yaml new file mode 100644 index 0000000..f0c0280 --- /dev/null +++ b/interop-tests/compose.cloudhopper.yaml @@ -0,0 +1,50 @@ +x-log-limits: &log-limits + logging: + driver: json-file + options: + max-file: "3" + max-size: 20m + +volumes: + # The build-time self-signed cert cloudhopper's image trusts (S10) - copied out at container + # start so node's test file can load the same key/cert into server({ tls }). Never committed. + cloudhopper-tls: + +services: + cloudhopper: + build: ./interop-tests/peers/cloudhopper + image: interop-cloudhopper:5.0.10-ae6485a + command: ["node", "2775"] + <<: *log-limits + volumes: + - cloudhopper-tls:/shared-certs + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"] + interval: 1s + retries: 30 + timeout: 2s + + # cloudhopper only ever dials node on this container's own network namespace, so this sees exactly + # its side of every scenario below (same pattern as compose.kannel.yaml). Both the plain (2775) and + # TLS (2776) listeners node opens are on this one veth. + capture: + image: nicolaka/netshoot:v0.16 + network_mode: "service:cloudhopper" + cap_add: + - NET_ADMIN + - NET_RAW + depends_on: + cloudhopper: + condition: service_started + command: ["dumpcap", "-i", "any", "-f", "tcp port 2775 or tcp port 2776", "-w", "/captures/cloudhopper.pcapng"] + volumes: + - ./interop-tests/captures:/captures + + node: + volumes: + - cloudhopper-tls:/shared-certs + depends_on: + capture: + condition: service_started + cloudhopper: + condition: service_healthy diff --git a/interop-tests/compose.jsmpp.yaml b/interop-tests/compose.jsmpp.yaml new file mode 100644 index 0000000..9d43a89 --- /dev/null +++ b/interop-tests/compose.jsmpp.yaml @@ -0,0 +1,40 @@ +x-log-limits: &log-limits + logging: + driver: json-file + options: + max-file: "3" + max-size: 20m + +services: + jsmpp: + build: ./interop-tests/peers/jsmpp + image: interop-jsmpp:3.0.3-a24db96 + command: ["node", "2775"] + <<: *log-limits + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"] + interval: 1s + retries: 30 + timeout: 2s + + # jsmpp only ever dials node:2775 on this container's own network namespace, so this sees exactly + # its side of every scenario below (same pattern as compose.kannel.yaml). + capture: + image: nicolaka/netshoot:v0.16 + network_mode: "service:jsmpp" + cap_add: + - NET_ADMIN + - NET_RAW + depends_on: + jsmpp: + condition: service_started + command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jsmpp.pcapng"] + volumes: + - ./interop-tests/captures:/captures + + node: + depends_on: + capture: + condition: service_started + jsmpp: + condition: service_healthy diff --git a/interop-tests/findings/05-java-clients.md b/interop-tests/findings/05-java-clients.md new file mode 100644 index 0000000..be2dd59 --- /dev/null +++ b/interop-tests/findings/05-java-clients.md @@ -0,0 +1,196 @@ +# 05 java clients + +Date: 2026-09-06. Repo commit: `4194816` (working tree, phase 5 changes uncommitted on top). Host +Docker: 29.6.2. Images: `interop-jsmpp:3.0.3-a24db96` (jsmpp cloned at commit `a24db96`, built with +`maven:3.9.11-eclipse-temurin-21`, run on `eclipse-temurin:21.0.8_9-jre-jammy`), +`interop-cloudhopper:5.0.10-ae6485a` (fizzed/cloudhopper-smpp cloned at commit `ae6485a`, same +build/runtime image pair, plus a build-time self-signed cert for S10), `nicolaka/netshoot:v0.16` +(capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner, from the root +`compose.yaml`). + +## Setup + +Each peer is a small Java driver (its own Maven project, `interop-tests/peers//`) exposing an +HTTP command channel on 8080 - the node test file drives scenarios by calling it, the same shape as +`kannel.test.ts`'s `sendsms()` HTTP calls, and the same port doubles as the compose healthcheck +target. jsmpp's driver binds one or more named `SMPPSession`s and answers with the real client's own +exceptions and return values; Cloudhopper's binds one or more named `SmppSession`s the same way. +Long-message wire shapes (UDH 8/16-bit, `sar_*`, `message_payload`) are all built through jsmpp's own +typed `submitShortMessage(..., OptionalParameter...)` API, never a raw socket - jsmpp exposes exactly +the fields needed. Only three deliberately-malformed PDUs (an unknown command id, a truncated TLV +stream, a body shorter than `sm_length` declares) cannot be expressed through any typed SMPP client, +jsmpp included, so those go out over a second, plain `Socket` the driver opens alongside its jsmpp +session - noted here so "jsmpp accepts our answer" claims below are read as applying to the typed-API +scenarios only, where jsmpp's own reaction is what is being tested. + +Build snags, both fixed in the Dockerfile, not in `src/`: + +- Cloudhopper's parent pom (`fizzed-maven-parent:1.15`) hardcodes `1.7` / + `1.7` directly in its compiler-plugin config, which modern `javac` refuses + ("Source option 7 is no longer supported") and which `-Dmaven.compiler.source` cannot override + since it is not read from a property. Fixed by `sed`-injecting a `` block into + `ch-smpp`'s own pom (which declares none) with `source`/`target` `8`, the narrowest override that + changes nothing else. +- Cloudhopper's test sources reference `javax.annotation.PreDestroy` (JSR-250), removed from the JDK + this builds with. `-DskipTests` only skips running them; Maven's `install` lifecycle still + test-*compiles* them first. `-Dmaven.test.skip=true` skips compiling them too. +- Cloudhopper's `SslContextFactory` only takes the "no validation" branch when *neither* a keystore + nor a truststore is configured; a client with only a trust store falls through to + `loadKeyStore()` with a null path and fails ("SSL doesn't have a valid keystore"). Fixed by also + building a PKCS12 keystore from the same build-time self-signed cert and pointing + `SslConfiguration.setKeyStorePath()` at it - functionally unused (our server never requests a + client certificate) but required for Cloudhopper's own SSL setup to get past its own null check. +- The shared `cloudhopper-tls` volume Cloudhopper's entrypoint copies `server.key`/`server.crt` + into (so node's test file can load the same pair into `server({ tls })`) came out root-owned, + 0600 - unreadable by `node`'s container, which runs as uid 1000. Fixed with a `chmod 644` in the + same entrypoint step. +- Cloudhopper's SSL client (Netty 3.9.6.Final, 2015-era) cannot complete a handshake against our + server's default TLS 1.3 - see Peer quirks. `server({ tls: { ..., maxVersion: 'TLSv1.2' } })` on + the S10 listener only; the plain listener the window scenarios use is unrestricted, and a Python + `ssl` client confirmed TLS 1.3 itself works before this was traced to the peer. +- One GSM7 encoding footgun in the jsmpp driver's own text fixtures, not in `@larvit/smpp`: the + driver's "gsm7" mode sends plain ASCII bytes for `short_message`, and GSM 03.38's default + alphabet maps ASCII `_` (0x5F) to `§`, not underscore - a message_payload fixture containing `_` + round-tripped as `§` until the character was dropped from the fixture text. + +Two runs each of `./interop-tests/run.py jsmpp` and `./interop-tests/run.py cloudhopper`, all four +stable: jsmpp 12/12 both times, Cloudhopper 6/6 both times. + +``` +jsmpp: frames 37, submit_sm 8/8, query_sm 1/1, cancel_sm 1/1, replace_sm 1/1, + deliver_sm 2/2, enquire_link 2/2, generic_nack 1, malformed 2, expert errors 2 +cloudhopper: frames 90-92 (varies slightly run to run - see Peer quirks), bind_transceiver 5/5, + submit_sm/submit_sm_resp present, unbind 4/4, malformed 0, expert errors 0 +``` + +jsmpp's `malformed: 2` / `expert errors: 2` are not a stability problem: they are tshark +independently flagging the same two deliberately-malformed PDUs the "S3" scenarios send on purpose +(the truncated-TLV and short-body reproducers below) - both are genuinely malformed by the spec, so +an independent dissector agreeing is the expected outcome, not a surprise. + +## Scenarios (PLAN.md) + +| Id | Result | Evidence | +| --- | --- | --- | +| S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `-1`/`-2` | +| S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read | +| S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" | +| S2 `sar_*` (target 3) | defect confirmed, second peer | `jsmpp.test.ts` "sar_*: defect (target 3)": two independent `sms` events, not one | +| S3 known-but-unhandled (targets 1, 6) | pass | `jsmpp.test.ts` "query_sm, cancel_sm, replace_sm": `ESME_RINVCMDID`, link survives, jsmpp raises `NegativeResponseException` and keeps going | +| S3 unknown command id (target 1) | pass | `jsmpp.test.ts` "an unknown command id gets generic_nack..." | +| S3 truncated TLV stream (target 1) | pass | `jsmpp.test.ts` "a deliver_sm with a truncated TLV stream..." | +| S3 short body (target 1) | pass | `jsmpp.test.ts` "a deliver_sm whose body is shorter than sm_length declares..." | +| Bind strictness (target 8) | pass, quirk noted | `jsmpp.test.ts` "bind version negotiation": 0x34 gets `sc_interface_version` back, 0x33 gets none; see Peer quirks for jsmpp's own negotiated-version report | +| Refusing status (jsmpp) | pass | `jsmpp.test.ts` "a refusing status is surfaced back to jsmpp" | +| S5 window 1/10/50 (target 11) | pass | `cloudhopper.test.ts` "S5 - window pressure...": all answered, no id answered twice, peak window never exceeds the configured size | +| S5 request expiry (target 11) | pass | `cloudhopper.test.ts` "the peer reports the expiry itself...": Cloudhopper's own window monitor reports it, our side does nothing unusual | +| S10 TLS | pass, quirk noted | `cloudhopper.test.ts` "handshake, bind, submit over TLS" | +| Refusing status (Cloudhopper) | pass | `cloudhopper.test.ts` "a refusing status is surfaced back to Cloudhopper" | + +## Defects in @larvit/smpp + +### `sar_*` segmentation confirmed unread, from a second independent peer (target 3) + +What happened: jsmpp splits a message into two `sar_*`-tagged `submit_sm`s (no UDH, `esm_class` +carries no UDHI bit); each arrives at our server as its own, independent `sms` event carrying only +its own ~half of the text, with its own unrelated generated id - never merged into one message. +Confirms Jasmin's finding (`findings/03-jasmin.md`) from a second, independently-written client. + +Spec: SMPP 3.4 5.3.2.16-5.3.2.18 defines `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` +as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may +ignore it. + +Reproducer: `jsmpp.test.ts`, "sar_\*: defect (target 3)" - two `submit_sm`s to the same +`source_addr`/`destination_addr`, `esm_class` 0x00, one `sar_msg_ref_num` (0x77) across both, `1/2` +then `2/2` in `sar_total_segments`/`sar_segment_seqnum`. Severity: as already scoped in PLAN.md +target 3 / phase 10 - a known, tracked limitation, not new. + +### A 4-octet truncated TLV tail is silently accepted rather than refused (target 1) + +What happened: a `deliver_sm` whose mandatory fields are complete, followed by exactly one bare TLV +header (tag, 2-octet declared length) and *no* value octets at all, is answered `ESME_ROK` and its +mandatory-field text delivered as an ordinary `sms` - the TLV is silently dropped rather than the PDU +being refused with `ESME_RINVTLVSTREAM`, which is what the *same* codec path does correctly when a +few value octets (but still short of the declared length) follow the header instead of none. + +Why: `pdu.ts`'s `pduToObj()` tries two parses of every PDU - "plain", and "padded" (some peers add a +trailing NUL after `short_message` for non-UDH text). Here "plain" parsing hits the TLV loop, reads a +length that overruns `command_length`, and correctly errors. But "padded" parsing shifts the TLV +region by one octet (treating the first of the four trailing octets as that padding NUL), leaving +only 3 octets - one short of what `parseTlvs()`'s loop needs even to read a tag+length pair +(`offset + 4 <= cmdLength` is false) - so the loop exits with no error and 3 octets unconsumed +(`aligned` false). `pduToObj()`'s fallback chain then reaches `if (!padded.err) return +{ pduObj: padded.pduObj }`, which accepts a misaligned parse whenever it produced no error, even +though 3 octets of the peer's PDU were never read. The fix is scoped to that fallback, not touched +here per the read-only rule. + +Spec: SMPP 3.4 4.3 - a TLV field this codec cannot parse should be refused with +`ESME_RINVTLVSTREAM` (5.0's name; 3.4 spells it `ESME_RINVOPTPARSTREAM`), the same as the sibling +case with a few value octets present. + +Reproducer (raw hex, sent after an ordinary `bind_transceiver`; independently reproduced with a +plain Python socket, no Java involved): + +``` +000000460000000500000000000000630000007261772d66726f6d0000007261772d746f00000000000000000000137472756e636174656420746c762070726f6265001d00c8 +``` + +This is a `deliver_sm` (`source_addr` `raw-from`, `destination_addr` `raw-to`, body "truncated tlv +probe silent") followed by `00 1d 00 c8` - tag `0x001D`, declared length 200, zero value octets. +Our server answers `command_status 0x00000000` (`ESME_ROK`) and delivers the text as `sms`. +Appending 4 more arbitrary octets to the same tail (8 total, still declaring length 200) correctly +triggers `ESME_RINVTLVSTREAM` instead - `jsmpp.test.ts`'s "a deliver_sm with a truncated TLV stream" +test uses that 8-octet form deliberately, to test the *documented* refusal path rather than this +adjacent bug. Reproduced with jsmpp's own driver too: +`jsmpp.test.ts`, "defect: a 4-octet truncated TLV tail is silently accepted rather than refused" - +same bytes, over a `net.Socket` opened directly against `server()` (not through jsmpp's typed API, +which cannot build this shape at all). Severity: low - a narrow boundary condition (exactly 4 +trailing octets, no value) rather than a general TLV-validation gap, but it is a hole in the fix +target 1 otherwise closed, silently dropping a TLV the peer meant to send instead of losing (and +counting) the one malformed PDU. + +## Peer quirks + +- **jsmpp's `session.getInterfaceVersion()` echoes what the driver declared, not what the earlier + research pass expected.** `research/esme-clients-and-validators.md` A2 quotes jsmpp's own source + (`scVersion != null ? IF_50.min(valueOf(scVersion)) : IF_34`) as defaulting to 3.4 whenever the + server's `bind_resp` omits `sc_interface_version`. Binding at 0x33 against our server (which + omits the TLV for a pre-3.4 peer) and reading `session.getInterfaceVersion()` back gives `0x33`, + not `0x34` - this getter does not visibly take that fallback branch here. Recorded as observed; + whether jsmpp's *internal* negotiated-version state (used, per its source, to decide whether to + attach optional parameters to requests it sends) differs from what this getter reports is not + established either way. +- **jsmpp accepts every one of our target-1/6 answers without closing the link.** `query_sm`, + `cancel_sm` and `replace_sm` each raise a catchable `NegativeResponseException` carrying + `ESME_RINVCMDID` (0x00000003); the session stays `BOUND_TRX` afterward (confirmed with a + follow-up `enquire_link`). A strict, actively-maintained Java client tolerates the exact answers + the interop plan's fixes promise. +- **Cloudhopper's SSL client (Netty 3.9.6.Final, last touched 2018) cannot complete a TLS 1.3 + handshake.** `setUseSsl(true)` against our server's default listener fails immediately with + `org.jboss.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record`, on the very first + record. A plain Python `ssl.SSLContext` client handshakes the same listener at TLS 1.3 without + issue, isolating the incompatibility to Cloudhopper's decade-old SSL stack rather than our + server. Capping the S10 listener at `maxVersion: 'TLSv1.2'` resolves it completely - bind, + submit and response all succeed. Not attempted: whether an older JRE for the *driver* (rather + than capping the server) would let Cloudhopper negotiate TLS 1.3 on its own terms. +- **Cloudhopper's window-monitor expiry and its own per-call timeout are different exceptions.** + Setting `requestExpiryTimeout` shorter than how long our (deliberately slow) handler holds a + message completes the blocking `session.submit(pdu, timeoutMs)` call early with + `RecoverablePduException`, distinct from the `SmppTimeoutException` a plain `timeoutMs` expiry + raises - worth telling apart in anything scripting around Cloudhopper's timeouts. Our side does + nothing unusual: the held message is answered on its own schedule, over the still-open socket, + once our slow handler gets to it; nothing server-side errors or is left in a half-finished state. +- **tshark's per-frame JSON export undercounts `submit_sm` frames under a tight concurrent + Cloudhopper burst on one TCP connection**, e.g. 90-92 total frames decoded across two otherwise + identical runs of the same test file (`dumpcap` itself reports zero drops: "Packets + received/dropped on interface 'any': 200/0"). Not chased further - `malformed`/`expert errors` + are unaffected (both 0 on every run), and the S5 assertions rely on the driver's own structured + per-request results, not the tshark histogram, for exactly this reason. + +## Open questions + +- Whether jsmpp's own negotiated-version fallback (the `IF_34` branch in its source) is reachable + through any observable other than `getInterfaceVersion()` - not established this phase. +- Whether the tshark frame undercount under a Cloudhopper burst is specific to `-T json` batch + export, or would also show up reading the same capture interactively - not investigated, time- + boxed. diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts new file mode 100644 index 0000000..063a025 --- /dev/null +++ b/interop-tests/jsmpp.test.ts @@ -0,0 +1,345 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import test, { after, describe } from 'node:test'; +import type { Session } from '../src/session.ts'; +import type { Sms } from '../src/sms.ts'; +import { PduRefusedError } from '../src/index.ts'; +import { server } from '../src/server.ts'; + +const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080'; +const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +async function waitFor(get: () => T | undefined, budget = 8000): Promise { + const deadline = Date.now() + budget; + let value = get(); + + while (value === undefined && Date.now() < deadline) { + await delay(20); + value = get(); + } + + return value; +} + +type DriverResult = Record; + +/** The jsmpp driver's own HTTP command channel - one bound session or raw socket per `session`/`raw` name. */ +async function driver(path: string, params: Record = {}): Promise { + const url = `http://${JSMPP_HOST}${path}?${new URLSearchParams(params).toString()}`; + const response = await fetch(url); + + return response.json() as Promise; +} + +const allSms: { session: Session; sms: Sms }[] = []; +const allSessionErrors: { err: Error; session: Session }[] = []; +const bindPdus: Record[] = []; +/** Messages a test answers itself (a refusing status, or asserting on the response) - populate + * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ +const manualTexts = new Set(); + +const { err: serverErr, server: smpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, + port: SMPP_PORT, +}); + +assert.equal(serverErr, undefined); +assert.ok(smpp); + +const smppServer = smpp; + +smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params); + }); + session.on('sms', sms => { + allSms.push({ session, sms }); + if (!manualTexts.has(sms.message)) void sms.sendResp(); + }); + session.on('sessionError', err => { allSessionErrors.push({ err, session }); }); +}); + +after(async () => { + await smppServer.close(); +}); + +async function waitForSms(message: string, budget = 8000): Promise { + const found = await waitFor(() => allSms.find(entry => entry.sms.message === message)?.sms, budget); + + assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived (seen: ${JSON.stringify(allSms.map(e => e.sms.message))})`); + + return found; +} + +async function waitForSessions(count: number, budget = 15_000): Promise { + const found = await waitFor(() => ([...smppServer.sessions].length >= count ? [...smppServer.sessions] : undefined), budget); + + assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`); + + return found; +} + +describe('bind version negotiation (target 8)', () => { + test('0x34: our bind_resp carries sc_interface_version, jsmpp negotiates 3.4', async () => { + const result = await driver('/bind', { interfaceVersion: '52', password: 'jsmpppw', session: 'v34', systemId: 'jsmpp-v34' }); + + assert.equal(result.ok, true); + assert.equal(result.negotiatedInterfaceVersion, 52); + + await waitForSessions(1); + const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v34')); + + assert.ok(bind); + assert.equal(bind.interface_version, 0x34); + }); + + test('0x33: our bind_resp carries no TLVs, and jsmpp reports back what it asked for', async () => { + const result = await driver('/bind', { interfaceVersion: '51', password: 'jsmpppw', session: 'v33', systemId: 'jsmpp-v33' }); + + assert.equal(result.ok, true); + // jsmpp's session.getInterfaceVersion() simply echoes what the driver declared - it does not + // visibly fall back to 3.4 here despite the absent sc_interface_version TLV, which is the + // opposite of what its own source (a scVersion-null branch defaulting to IF_34) suggests. + // Recorded as observed rather than as a confirmation of that code path - see Peer quirks. + assert.equal(result.negotiatedInterfaceVersion, 51); + + const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v33')); + + assert.ok(bind); + assert.equal(bind.interface_version, 0x33); + + const session = [...smppServer.sessions].find(s => s.peerInterfaceVersion === 0x33); + + assert.ok(session); + assert.equal(session.acceptsOptionalParams(), false); + }); +}); + +describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + test('UDH, 8-bit reference: one reassembled sms, each segment answered -', async () => { + await waitForSessions(1); + + const text = 'u8-'.padEnd(200, 'a'); + const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh8', session: 'v34', text, to: '2001' }); + + assert.equal(result.ok, true); + const segments = result.segments as { messageId: string; part: number; total: number }[]; + + assert.equal(segments.length, 2); + + const sms = await waitForSms(text, 15_000); + + assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); + + test('UDH, 16-bit reference: also reassembled - our library reads both widths', async () => { + await waitForSessions(1); + + const text = 'u16-'.padEnd(200, 'b'); + const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh16', session: 'v34', text, to: '2001' }); + + assert.equal(result.ok, true); + const segments = result.segments as { messageId: string }[]; + + const sms = await waitForSms(text, 15_000); + + assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); + + test('message_payload: one sms, the full text', async () => { + await waitForSessions(1); + + // No underscore: GSM 03.38's default alphabet maps ASCII 0x5F to section-sign, not "_" - + // the driver's "gsm7" mode sends plain ASCII bytes, so a real underscore round-trips wrong + // on purpose (a GSM7 encoder bug in this fixture, not in @larvit/smpp). + const text = 'payload carries the whole body in one submit sm'; + const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'payload', session: 'v34', text, to: '2001' }); + + assert.equal(result.ok, true); + + const sms = await waitForSms(text); + + assert.equal(sms.message, text); + assert.equal(sms.answeredOnArrival, false); + }); + + test('sar_*: defect (target 3) - each segment reaches the application as its own sms, not one', async () => { + await waitForSessions(1); + + const text = 'sar-'.padEnd(200, 'c'); + const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'sar', session: 'v34', text, to: '2001' }); + + assert.equal(result.ok, true); + const segments = result.segments as { messageId: string; part: number }[]; + + assert.equal(segments.length, 2); + + // Neither segment ever arrives as the whole 200-char text: each is its own ordinary sms + // carrying only its own ~130-char slice, with its own unrelated (non--) message id. + const first = await waitForSms(text.slice(0, 130), 15_000); + const second = await waitForSms(text.slice(130), 15_000); + + assert.equal(first.answeredOnArrival, false); + assert.equal(second.answeredOnArrival, false); + assert.notEqual(first.smsId, second.smsId); + assert.equal(allSms.some(entry => entry.sms.message === text), false); + void first; + void second; + }); +}); + +describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => { + test('query_sm, cancel_sm, replace_sm: ESME_RINVCMDID, link survives, jsmpp accepts the answer', async () => { + await waitForSessions(1); + + const errorsBefore = allSessionErrors.length; + + for (const path of ['/querySm', '/cancelSm', '/replaceSm']) { + const result = await driver(path, { messageId: '1', session: 'v34' }); + + assert.equal(result.ok, true); + assert.equal(result.refused, true); + assert.equal(result.commandStatus, 0x0003); + } + + // jsmpp itself did not throw or close the link over any of the three refusals. + const link = await driver('/enquireLink', { session: 'v34' }); + + assert.equal(link.ok, true); + assert.equal(link.sessionState, 'BOUND_TRX'); + assert.equal(allSessionErrors.length, errorsBefore); + }); + + test('an unknown command id gets generic_nack ESME_RINVCMDID, and the link survives', async () => { + await driver('/rawBind', { interfaceVersion: '52', password: 'rawpw', raw: 'malformed', systemId: 'jsmpp-raw' }); + + const result = await driver('/rawUnknownCommand', { raw: 'malformed' }); + const response = result.response as Record; + + assert.equal(response.cmdIdHex, '0x80000000'); + assert.equal(response.cmdStatusHex, '0x3'); + + const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError + && e.err.reason === 'command')); + + assert.ok(refused); + + const link = await driver('/rawEnquireLink', { raw: 'malformed' }); + const linkResponse = link.response as Record; + + assert.equal(linkResponse.cmdStatusHex, '0x0'); + }); + + test('a deliver_sm with a truncated TLV stream gets ESME_RINVTLVSTREAM, link survives', async () => { + const result = await driver('/rawTruncatedTlv', { raw: 'malformed' }); + const response = result.response as Record; + + assert.equal(response.cmdIdHex, '0x80000005'); + assert.equal(response.cmdStatusHex, '0xc0'); + + const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError && e.err.reason === 'tlvs')); + + assert.ok(refused); + }); + + test('a deliver_sm whose body is shorter than sm_length declares gets ESME_RINVCMDLEN', async () => { + const result = await driver('/rawShortBody', { raw: 'malformed' }); + const response = result.response as Record; + + assert.equal(response.cmdIdHex, '0x80000005'); + assert.equal(response.cmdStatusHex, '0x2'); + + const link = await driver('/rawEnquireLink', { raw: 'malformed' }); + const linkResponse = link.response as Record; + + assert.equal(linkResponse.cmdStatusHex, '0x0'); + }); + + // Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage) - this is + // a raw fixture opened directly against our server(), discovered while building the reproducers + // above. Kept here rather than in test/ because it was found during, and belongs beside, this + // phase's malformed-PDU work. + test('defect: a 4-octet truncated TLV tail is silently accepted rather than refused', async () => { + await waitForSessions(1); + + function cstring(value: string): Buffer { + return Buffer.concat([Buffer.from(value, 'latin1'), Buffer.from([0])]); + } + + const msg = Buffer.from('truncated tlv probe silent', 'latin1'); + const body = Buffer.concat([ + cstring(''), Buffer.from([0, 0]), cstring('raw2-from'), + Buffer.from([0, 0]), cstring('raw2-to'), + Buffer.from([0, 0, 0]), cstring(''), cstring(''), + Buffer.from([0, 0, 0, 0, msg.length]), msg, + // A bare 4-octet TLV header (tag 0x001D, declared length 200) with zero value octets. + Buffer.from([0x00, 0x1D, 0x00, 0xC8]), + ]); + const header = Buffer.alloc(16); + + header.writeUInt32BE(16 + body.length, 0); + header.writeUInt32BE(0x00000005, 4); + header.writeUInt32BE(0, 8); + header.writeUInt32BE(777, 12); + + const sock = net.connect(SMPP_PORT, '127.0.0.1'); + + await new Promise(resolve => { sock.once('connect', () => { resolve(); }); }); + + const bindBody = Buffer.concat([cstring('rawverify'), cstring('pw'), cstring(''), Buffer.from([0x34, 0, 0]), cstring('')]); + const bindHeader = Buffer.alloc(16); + + bindHeader.writeUInt32BE(16 + bindBody.length, 0); + bindHeader.writeUInt32BE(0x00000009, 4); + bindHeader.writeUInt32BE(0, 8); + bindHeader.writeUInt32BE(1, 12); + sock.write(Buffer.concat([bindHeader, bindBody])); + await new Promise(resolve => { sock.once('data', () => { resolve(); }); }); + + const responsePromise = new Promise(resolve => { sock.once('data', data => { resolve(data); }); }); + + sock.write(Buffer.concat([header, body])); + + const response = await responsePromise; + const cmdStatus = response.readUInt32BE(8); + + // Defect: this should be ESME_RINVTLVSTREAM (0xC0); the codec's trailing-NUL retry instead + // treats the 4 leftover octets as unparsed slack and accepts the PDU as ESME_ROK. + assert.equal(cmdStatus, 0x00000000); + + const arrived = await waitForSms('truncated tlv probe silent'); + + assert.equal(arrived.from, 'raw2-from'); + sock.destroy(); + }); +}); + +describe('a refusing status is surfaced back to jsmpp', () => { + test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches jsmpp as a NegativeResponseException', async () => { + await waitForSessions(1); + + const text = 'refuse-me'; + + manualTexts.add(text); + + const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); + const sms = await waitForSms(text); + + await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + + const result = await submitted; + + assert.equal(result.ok, true); + assert.equal(result.refused, true); + assert.equal(result.commandStatusHex, '0x' + (0x00000014).toString(16)); + }); +}); diff --git a/interop-tests/peers/cloudhopper/Dockerfile b/interop-tests/peers/cloudhopper/Dockerfile new file mode 100644 index 0000000..c9f70d9 --- /dev/null +++ b/interop-tests/peers/cloudhopper/Dockerfile @@ -0,0 +1,55 @@ +# cloudhopper-smpp (the fizzed fork) has no published artifact; cloned at a fixed commit and +# installed into the local Maven repo, which the driver build below then depends on. +FROM maven:3.9.11-eclipse-temurin-21 AS cloudhopper-source + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +ARG CLOUDHOPPER_COMMIT=ae6485a86c968d344bebf0fa180928905b4a8ad1 + +# The parent pom (fizzed-maven-parent:1.15) hardcodes source/target 1.7 in its own compiler-plugin +# config, which -Dmaven.compiler.source cannot override; ch-smpp's own pom declares no of +# its own, so injecting one here (source/target 8, otherwise unmodified) is the narrowest override. +RUN git clone https://github.com/fizzed/cloudhopper-smpp.git /src \ + && cd /src \ + && git checkout "${CLOUDHOPPER_COMMIT}" \ + && sed -i 's##org.apache.maven.pluginsmaven-compiler-plugin88#' pom.xml \ + && mvn -q install -Dmaven.test.skip=true -Dgpg.skip=true -Dmaven.javadoc.skip=true + +# A self-signed cert this image trusts, generated at build time - never committed. server.key/crt +# are PEM (for our server()'s own tls option); truststore.jks is what the driver's SSL client trusts. +FROM eclipse-temurin:21.0.8_9-jre-jammy AS certs + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /certs \ + && openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout /certs/server.key -out /certs/server.crt -subj "/CN=interop-cloudhopper" \ + && keytool -importcert -noprompt -alias interop -file /certs/server.crt \ + -keystore /certs/truststore.jks -storepass changeit \ + && openssl pkcs12 -export -in /certs/server.crt -inkey /certs/server.key \ + -out /certs/keystore.p12 -name interop -password pass:changeit + +FROM maven:3.9.11-eclipse-temurin-21 AS build + +COPY --from=cloudhopper-source /root/.m2 /root/.m2 + +WORKDIR /driver +COPY pom.xml . +COPY src ./src +RUN mvn -q package -DskipTests + +FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime + +WORKDIR /app +COPY --from=build /driver/target/driver.jar ./driver.jar +COPY --from=certs /certs /certs + +EXPOSE 8080 +# node's test file needs the same server.key/server.crt to configure its own tls option; the +# S10 compose overlay mounts a shared volume at /shared-certs on both this service and node. +ENTRYPOINT ["sh", "-c", "cp /certs/server.key /certs/server.crt /shared-certs/ 2>/dev/null && chmod 644 /shared-certs/server.key /shared-certs/server.crt || true; exec java -jar driver.jar \"$@\"", "--"] +CMD ["node", "2775"] diff --git a/interop-tests/peers/cloudhopper/pom.xml b/interop-tests/peers/cloudhopper/pom.xml new file mode 100644 index 0000000..a3ca0d4 --- /dev/null +++ b/interop-tests/peers/cloudhopper/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + se.larvit.interop + cloudhopper-driver + 1.0.0 + jar + + + 17 + UTF-8 + + + + + com.fizzed + ch-smpp + 5.0.10-SNAPSHOT + + + org.slf4j + slf4j-simple + 1.7.36 + + + + + driver + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + smpp.interop.cloudhopper.Driver + + + + + + + + + diff --git a/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Driver.java b/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Driver.java new file mode 100644 index 0000000..41e95e3 --- /dev/null +++ b/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Driver.java @@ -0,0 +1,315 @@ +package smpp.interop.cloudhopper; + +import com.cloudhopper.smpp.SmppBindType; +import com.cloudhopper.smpp.SmppSession; +import com.cloudhopper.smpp.SmppSessionConfiguration; +import com.cloudhopper.smpp.impl.DefaultSmppClient; +import com.cloudhopper.smpp.impl.DefaultSmppSessionHandler; +import com.cloudhopper.smpp.pdu.PduRequest; +import com.cloudhopper.smpp.pdu.SubmitSm; +import com.cloudhopper.smpp.pdu.SubmitSmResp; +import com.cloudhopper.smpp.ssl.SslConfiguration; +import com.cloudhopper.smpp.type.Address; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An HTTP-driven Cloudhopper ESME: bind with a chosen window configuration, then fire concurrent + * submits against our (possibly deliberately slow) server and report per-request outcomes and the + * observed send-window occupancy, so the node test file can assert none lost, none duplicated. + */ +public final class Driver { + private static final Logger log = LoggerFactory.getLogger(Driver.class); + private static final Map sessions = new ConcurrentHashMap<>(); + private static final AtomicInteger expiredCount = new AtomicInteger(0); + private static DefaultSmppClient client; + private static ScheduledThreadPoolExecutor monitorExecutor; + private static ExecutorService ioExecutor; + private static String host; + private static int port; + + private Driver() { } + + public static void main(String[] args) throws IOException { + host = args.length > 0 ? args[0] : "node"; + port = args.length > 1 ? Integer.parseInt(args[1]) : 2775; + + ioExecutor = Executors.newCachedThreadPool(); + monitorExecutor = new ScheduledThreadPoolExecutor(2); + client = new DefaultSmppClient(Executors.newCachedThreadPool(), 50, monitorExecutor); + + HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); + server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}")); + server.createContext("/bind", Driver::handleBind); + server.createContext("/unbind", Driver::handleUnbind); + server.createContext("/submit", Driver::handleSubmit); + server.createContext("/windowBurst", Driver::handleWindowBurst); + server.createContext("/sendWindowSize", Driver::handleSendWindowSize); + server.setExecutor(null); + server.start(); + System.out.println("cloudhopper driver listening on 8080, target " + host + ":" + port); + } + + // --- HTTP plumbing (same shape as the jsmpp driver's, kept independent on purpose) --- + + private static Map queryParams(HttpExchange exchange) { + Map params = new LinkedHashMap<>(); + String query = exchange.getRequestURI().getRawQuery(); + + if (query == null) return params; + + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = eq < 0 ? pair : pair.substring(0, eq); + String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + params.put(key, value); + } + + return params; + } + + private static void respond(HttpExchange exchange, int status, String body) { + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } catch (IOException e) { + // The client gave up reading; nothing left to answer. + } + } + + private static void respondOk(HttpExchange exchange, Map result) { + respond(exchange, 200, Json.write(result)); + } + + private static void respondErr(HttpExchange exchange, Exception e) { + Map result = new LinkedHashMap<>(); + result.put("ok", false); + result.put("errorClass", e.getClass().getName()); + result.put("error", String.valueOf(e.getMessage())); + respond(exchange, 200, Json.write(result)); + } + + // --- handlers --- + + private static void handleBind(HttpExchange exchange) { + Map p = queryParams(exchange); + String name = p.getOrDefault("session", "default"); + + try { + SmppSessionConfiguration config = new SmppSessionConfiguration(); + config.setName(name); + config.setType(SmppBindType.TRANSCEIVER); + config.setHost(p.getOrDefault("host", host)); + config.setPort(Integer.parseInt(p.getOrDefault("port", String.valueOf(port)))); + config.setConnectTimeout(10_000); + config.setSystemId(p.getOrDefault("systemId", "cloudhopper")); + config.setPassword(p.getOrDefault("password", "chpw")); + config.setWindowSize(Integer.parseInt(p.getOrDefault("windowSize", "1"))); + config.setRequestExpiryTimeout(Long.parseLong(p.getOrDefault("requestExpiryTimeout", "30000"))); + config.setWindowMonitorInterval(Long.parseLong(p.getOrDefault("windowMonitorInterval", "15000"))); + config.setCountersEnabled(true); + + if (Boolean.parseBoolean(p.getOrDefault("useSsl", "false"))) { + // Cloudhopper's SslContextFactory only skips keystore loading when *neither* store is + // configured - a trust-store-only client falls through to loadKeyStore() with a null + // path and fails, so the build-time self-signed cert also gets used as the (otherwise + // unneeded) client keystore. + SslConfiguration ssl = new SslConfiguration(); + ssl.setTrustStorePath("/certs/truststore.jks"); + ssl.setTrustStorePassword("changeit"); + ssl.setKeyStorePath("/certs/keystore.p12"); + ssl.setKeyStorePassword("changeit"); + ssl.setKeyStoreType("PKCS12"); + config.setUseSsl(true); + config.setSslConfiguration(ssl); + } + + DefaultSmppSessionHandler handler = new DefaultSmppSessionHandler(log) { + @Override + public void firePduRequestExpired(PduRequest pduRequest) { + expiredCount.incrementAndGet(); + log.warn("PDU request expired in window monitor: {}", pduRequest); + } + }; + + SmppSession session = client.bind(config, handler); + sessions.put(name, session); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static void handleUnbind(HttpExchange exchange) { + Map p = queryParams(exchange); + SmppSession session = sessions.remove(p.getOrDefault("session", "default")); + + try { + if (session != null) { + session.unbind(5000); + session.destroy(); + } + + respondOk(exchange, Map.of("ok", true)); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static SubmitSm buildSubmit(String from, String to, String text) + throws com.cloudhopper.smpp.type.SmppInvalidArgumentException { + SubmitSm submit = new SubmitSm(); + submit.setSourceAddress(new Address((byte) 0x01, (byte) 0x01, from)); + submit.setDestAddress(new Address((byte) 0x01, (byte) 0x01, to)); + submit.setShortMessage(text.getBytes(StandardCharsets.US_ASCII)); + + return submit; + } + + private static void handleSubmit(HttpExchange exchange) { + Map p = queryParams(exchange); + SmppSession session = sessions.get(p.getOrDefault("session", "default")); + + if (session == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session"))); + + return; + } + + try { + long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "10000")); + long start = System.currentTimeMillis(); + SubmitSmResp resp = session.submit( + buildSubmit(p.getOrDefault("from", "1000"), p.getOrDefault("to", "2000"), p.getOrDefault("text", "hi")), + timeoutMs); + long elapsed = System.currentTimeMillis() - start; + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("messageId", resp.getMessageId()); + result.put("commandStatus", resp.getCommandStatus()); + result.put("elapsedMs", (int) elapsed); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + /** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */ + private static void handleWindowBurst(HttpExchange exchange) { + Map p = queryParams(exchange); + SmppSession session = sessions.get(p.getOrDefault("session", "default")); + + if (session == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session"))); + + return; + } + + int count = Integer.parseInt(p.getOrDefault("count", "10")); + long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000")); + String from = p.getOrDefault("from", "1000"); + String to = p.getOrDefault("to", "2000"); + String prefix = p.getOrDefault("prefix", "burst"); + + AtomicInteger peakWindow = new AtomicInteger(0); + Thread sampler = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + int size = session.getSendWindow().getSize(); + peakWindow.updateAndGet(prev -> Math.max(prev, size)); + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + sampler.setDaemon(true); + sampler.start(); + + List>> futures = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + int index = i; + futures.add(ioExecutor.submit((Callable>) () -> { + Map entry = new LinkedHashMap<>(); + entry.put("index", index); + + try { + long start = System.currentTimeMillis(); + SubmitSmResp resp = session.submit(buildSubmit(from, to, prefix + "-" + index), timeoutMs); + entry.put("ok", true); + entry.put("messageId", resp.getMessageId()); + entry.put("elapsedMs", (int) (System.currentTimeMillis() - start)); + } catch (Exception e) { + entry.put("ok", false); + entry.put("errorClass", e.getClass().getSimpleName()); + entry.put("error", String.valueOf(e.getMessage())); + } + + return entry; + })); + } + + List results = new ArrayList<>(); + + for (Future> f : futures) { + try { + results.add(f.get()); + } catch (Exception e) { + results.add(Map.of("ok", false, "error", String.valueOf(e.getMessage()))); + } + } + + sampler.interrupt(); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("results", results); + result.put("peakWindowSize", peakWindow.get()); + result.put("expiredCount", expiredCount.get()); + respondOk(exchange, result); + } + + private static void handleSendWindowSize(HttpExchange exchange) { + Map p = queryParams(exchange); + SmppSession session = sessions.get(p.getOrDefault("session", "default")); + + if (session == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session"))); + + return; + } + + respondOk(exchange, Map.of("ok", true, "size", session.getSendWindow().getSize())); + } +} diff --git a/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Json.java b/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Json.java new file mode 100644 index 0000000..33378d4 --- /dev/null +++ b/interop-tests/peers/cloudhopper/src/main/java/smpp/interop/cloudhopper/Json.java @@ -0,0 +1,75 @@ +package smpp.interop.cloudhopper; + +import java.util.List; +import java.util.Map; + +/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */ +final class Json { + private Json() { } + + static String write(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) { + sb.append(value); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) sb.append(','); + first = false; + writeString(sb, String.valueOf(entry.getKey())); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) sb.append(','); + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else if (value instanceof byte[] bytes) { + writeString(sb, hex(bytes)); + } else { + writeString(sb, String.valueOf(value)); + } + } + + private static void writeString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + } + sb.append('"'); + } + + static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } +} diff --git a/interop-tests/peers/jsmpp/Dockerfile b/interop-tests/peers/jsmpp/Dockerfile new file mode 100644 index 0000000..2253702 --- /dev/null +++ b/interop-tests/peers/jsmpp/Dockerfile @@ -0,0 +1,32 @@ +# jsmpp has no published artifact for this pin; cloned at a fixed commit and installed into the +# local Maven repo, which the driver build below then depends on. +FROM maven:3.9.11-eclipse-temurin-21 AS jsmpp-source + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +ARG JSMPP_COMMIT=a24db96ad7014cdc84a3eebfa64a75c362daef2a + +RUN git clone https://github.com/opentelecoms-org/jsmpp.git /src \ + && cd /src \ + && git checkout "${JSMPP_COMMIT}" \ + && mvn -q -pl jsmpp -am install -DskipTests -Dgpg.skip=true + +FROM maven:3.9.11-eclipse-temurin-21 AS build + +COPY --from=jsmpp-source /root/.m2 /root/.m2 + +WORKDIR /driver +COPY pom.xml . +COPY src ./src +RUN mvn -q package -DskipTests + +FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime + +WORKDIR /app +COPY --from=build /driver/target/driver.jar ./driver.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "driver.jar"] +CMD ["node", "2775"] diff --git a/interop-tests/peers/jsmpp/pom.xml b/interop-tests/peers/jsmpp/pom.xml new file mode 100644 index 0000000..44cdb24 --- /dev/null +++ b/interop-tests/peers/jsmpp/pom.xml @@ -0,0 +1,47 @@ + + 4.0.0 + + se.larvit.interop + jsmpp-driver + 1.0.0 + jar + + + 21 + UTF-8 + + + + + org.jsmpp + jsmpp + 3.0.3-SNAPSHOT + + + + + driver + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + smpp.interop.jsmpp.Driver + + + + + + + + + diff --git a/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Driver.java b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Driver.java new file mode 100644 index 0000000..ad7b95f --- /dev/null +++ b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Driver.java @@ -0,0 +1,471 @@ +package smpp.interop.jsmpp; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.jsmpp.InvalidResponseException; +import org.jsmpp.PDUException; +import org.jsmpp.bean.Alphabet; +import org.jsmpp.bean.AlertNotification; +import org.jsmpp.bean.BindType; +import org.jsmpp.bean.DataSm; +import org.jsmpp.bean.DeliverSm; +import org.jsmpp.bean.ESMClass; +import org.jsmpp.bean.GeneralDataCoding; +import org.jsmpp.bean.NumberingPlanIndicator; +import org.jsmpp.bean.OptionalParameter; +import org.jsmpp.bean.RegisteredDelivery; +import org.jsmpp.bean.SMSCDeliveryReceipt; +import org.jsmpp.bean.TypeOfNumber; +import org.jsmpp.bean.InterfaceVersion; +import org.jsmpp.extra.NegativeResponseException; +import org.jsmpp.extra.ProcessRequestException; +import org.jsmpp.extra.ResponseTimeoutException; +import org.jsmpp.session.BindParameter; +import org.jsmpp.session.MessageReceiverListener; +import org.jsmpp.session.QuerySmResult; +import org.jsmpp.session.SMPPSession; +import org.jsmpp.session.Session; +import org.jsmpp.session.SubmitSmResult; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against + * the SMPP server named by host/port args, and answers with the result as JSON. Kept alive as one + * process so a session can be reused across several requests, the way a real ESME would. + */ +public final class Driver { + private static final Map sessions = new ConcurrentHashMap<>(); + private static final Map requestedVersions = new ConcurrentHashMap<>(); + private static final Map rawSockets = new ConcurrentHashMap<>(); + private static final Map rawSeqNr = new ConcurrentHashMap<>(); + private static String host; + private static int port; + + private Driver() { } + + public static void main(String[] args) throws IOException { + host = args.length > 0 ? args[0] : "node"; + port = args.length > 1 ? Integer.parseInt(args[1]) : 2775; + + HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); + server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}")); + server.createContext("/bind", Driver::handleBind); + server.createContext("/unbind", Driver::handleUnbind); + server.createContext("/enquireLink", Driver::handleEnquireLink); + server.createContext("/submit", Driver::handleSubmit); + server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query")); + server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel")); + server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace")); + server.createContext("/rawBind", Driver::handleRawBind); + server.createContext("/rawUnknownCommand", exchange -> handleRawAction(exchange, RawSmpp::unknownCommandPdu)); + server.createContext("/rawTruncatedTlv", exchange -> handleRawAction(exchange, RawSmpp::truncatedTlvDeliverSmPdu)); + server.createContext("/rawShortBody", exchange -> handleRawAction(exchange, RawSmpp::shortBodyDeliverSmPdu)); + server.createContext("/rawEnquireLink", exchange -> handleRawAction(exchange, RawSmpp::enquireLinkPdu)); + server.setExecutor(null); + server.start(); + System.out.println("jsmpp driver listening on 8080, target " + host + ":" + port); + } + + // --- HTTP plumbing --- + + private static Map queryParams(HttpExchange exchange) { + Map params = new LinkedHashMap<>(); + String query = exchange.getRequestURI().getRawQuery(); + + if (query == null) return params; + + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = eq < 0 ? pair : pair.substring(0, eq); + String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + params.put(key, value); + } + + return params; + } + + private static void respond(HttpExchange exchange, int status, String body) { + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } catch (IOException e) { + // The client gave up reading; nothing left to answer. + } + } + + private static void respondOk(HttpExchange exchange, Map result) { + respond(exchange, 200, Json.write(result)); + } + + private static void respondErr(HttpExchange exchange, Exception e) { + Map result = new LinkedHashMap<>(); + result.put("ok", false); + result.put("errorClass", e.getClass().getName()); + result.put("error", String.valueOf(e.getMessage())); + + if (e instanceof NegativeResponseException nre) { + result.put("commandStatus", nre.getCommandStatus()); + result.put("commandStatusHex", "0x" + Integer.toHexString(nre.getCommandStatus())); + } + + respond(exchange, 200, Json.write(result)); + } + + // --- jsmpp-backed handlers --- + + private static void handleBind(HttpExchange exchange) { + Map p = queryParams(exchange); + String name = p.getOrDefault("session", "default"); + + try { + SMPPSession session = new SMPPSession(); + session.setMessageReceiverListener(new NoopListener()); + + String type = p.getOrDefault("type", "transceiver"); + BindType bindType = switch (type) { + case "receiver" -> BindType.BIND_RX; + case "transmitter" -> BindType.BIND_TX; + default -> BindType.BIND_TRX; + }; + + int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52")); + InterfaceVersion interfaceVersion = InterfaceVersion.valueOf((byte) ifVersion); + + String systemId = p.getOrDefault("systemId", "jsmpp"); + String password = p.getOrDefault("password", "jsmpppw"); + + BindParameter bindParameter = new BindParameter(bindType, systemId, password, "interop", + TypeOfNumber.UNKNOWN, NumberingPlanIndicator.UNKNOWN, null, interfaceVersion); + + String scSystemId = session.connectAndBind(host, port, bindParameter); + + sessions.put(name, session); + requestedVersions.put(name, ifVersion); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("scSystemId", scSystemId); + result.put("requestedInterfaceVersion", ifVersion); + result.put("negotiatedInterfaceVersion", session.getInterfaceVersion().value() & 0xFF); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static void handleUnbind(HttpExchange exchange) { + Map p = queryParams(exchange); + SMPPSession session = sessions.remove(p.getOrDefault("session", "default")); + + try { + if (session != null) session.unbindAndClose(); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static void handleEnquireLink(HttpExchange exchange) { + Map p = queryParams(exchange); + SMPPSession session = sessions.get(p.getOrDefault("session", "default")); + + Map result = new LinkedHashMap<>(); + + if (session == null) { + result.put("ok", false); + result.put("error", "no such session"); + respondOk(exchange, result); + + return; + } + + result.put("ok", true); + result.put("sessionState", session.getSessionState().name()); + respondOk(exchange, result); + } + + private static byte[] encode(String text, String encoding) { + return switch (encoding) { + case "ucs2" -> text.getBytes(StandardCharsets.UTF_16BE); + case "latin1" -> text.getBytes(StandardCharsets.ISO_8859_1); + default -> text.getBytes(StandardCharsets.US_ASCII); + }; + } + + private static GeneralDataCoding dataCoding(String encoding) { + Alphabet alphabet = switch (encoding) { + case "ucs2" -> Alphabet.ALPHA_UCS2; + case "latin1" -> Alphabet.ALPHA_LATIN1; + default -> Alphabet.ALPHA_DEFAULT; + }; + + return new GeneralDataCoding(alphabet, null, false); + } + + /** Chunk size chosen well under any segment limit for every encoding this driver sends. */ + private static final int CHUNK_CHARS = 130; + + private static void handleSubmit(HttpExchange exchange) { + Map p = queryParams(exchange); + SMPPSession session = sessions.get(p.getOrDefault("session", "default")); + + if (session == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session"))); + + return; + } + + String from = p.getOrDefault("from", "12345"); + String to = p.getOrDefault("to", "67890"); + String text = p.getOrDefault("text", "hello"); + String mode = p.getOrDefault("mode", "plain"); + String encoding = p.getOrDefault("encoding", "gsm7"); + + try { + java.util.List> segments = new java.util.ArrayList<>(); + + switch (mode) { + case "udh8" -> submitUdh(session, from, to, text, encoding, false, segments); + case "udh16" -> submitUdh(session, from, to, text, encoding, true, segments); + case "sar" -> submitSar(session, from, to, text, encoding, segments); + case "payload" -> submitPayload(session, from, to, text, encoding, segments); + default -> submitPlain(session, from, to, text, encoding, segments); + } + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("segments", segments); + respondOk(exchange, result); + } catch (NegativeResponseException e) { + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("refused", true); + result.put("commandStatus", e.getCommandStatus()); + result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus())); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding, + java.util.List> segments) throws Exception { + SubmitSmResult r = session.submitShortMessage("CMT", + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from, + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to, + new ESMClass(), (byte) 0, (byte) 1, null, null, + new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0, + encode(text, encoding)); + segments.add(Map.of("messageId", r.getMessageId())); + } + + private static java.util.List chunk(String text, int size) { + java.util.List out = new java.util.ArrayList<>(); + + for (int i = 0; i < text.length(); i += size) { + out.add(text.substring(i, Math.min(text.length(), i + size))); + } + + return out; + } + + private static void submitUdh(SMPPSession session, String from, String to, String text, String encoding, + boolean sixteenBit, java.util.List> segments) throws Exception { + java.util.List chunks = chunk(text, CHUNK_CHARS); + int reference = sixteenBit ? 0x1234 : 0x42; + int total = chunks.size(); + + for (int i = 0; i < chunks.size(); i++) { + byte[] chunkBytes = encode(chunks.get(i), encoding); + byte[] udh = sixteenBit + ? new byte[] { 0x06, 0x08, 0x04, (byte) ((reference >> 8) & 0xFF), (byte) (reference & 0xFF), (byte) total, (byte) (i + 1) } + : new byte[] { 0x05, 0x00, 0x03, (byte) reference, (byte) total, (byte) (i + 1) }; + byte[] shortMessage = new byte[udh.length + chunkBytes.length]; + System.arraycopy(udh, 0, shortMessage, 0, udh.length); + System.arraycopy(chunkBytes, 0, shortMessage, udh.length, chunkBytes.length); + + SubmitSmResult r = session.submitShortMessage("CMT", + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from, + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to, + new ESMClass(0x40), (byte) 0, (byte) 1, null, null, + new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0, + shortMessage); + segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total)); + } + } + + private static void submitSar(SMPPSession session, String from, String to, String text, String encoding, + java.util.List> segments) throws Exception { + java.util.List chunks = chunk(text, CHUNK_CHARS); + int reference = 0x77; + int total = chunks.size(); + + for (int i = 0; i < chunks.size(); i++) { + byte[] chunkBytes = encode(chunks.get(i), encoding); + OptionalParameter refNum = new OptionalParameter.Sar_msg_ref_num((short) reference); + OptionalParameter totalSegments = new OptionalParameter.Sar_total_segments((byte) total); + OptionalParameter seqNum = new OptionalParameter.Sar_segment_seqnum((byte) (i + 1)); + + SubmitSmResult r = session.submitShortMessage("CMT", + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from, + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to, + new ESMClass(), (byte) 0, (byte) 1, null, null, + new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0, + chunkBytes, refNum, totalSegments, seqNum); + segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total)); + } + } + + private static void submitPayload(SMPPSession session, String from, String to, String text, String encoding, + java.util.List> segments) throws Exception { + byte[] payload = encode(text, encoding); + OptionalParameter messagePayload = new OptionalParameter.Message_payload(payload); + + SubmitSmResult r = session.submitShortMessage("CMT", + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from, + TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to, + new ESMClass(), (byte) 0, (byte) 1, null, null, + new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0, + new byte[0], messagePayload); + segments.add(Map.of("messageId", r.getMessageId())); + } + + private static void handleUnhandledCommand(HttpExchange exchange, String which) { + Map p = queryParams(exchange); + SMPPSession session = sessions.get(p.getOrDefault("session", "default")); + String messageId = p.getOrDefault("messageId", "0"); + + if (session == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session"))); + + return; + } + + try { + switch (which) { + case "query" -> { + QuerySmResult r = session.queryShortMessage(messageId, TypeOfNumber.INTERNATIONAL, + NumberingPlanIndicator.UNKNOWN, "12345"); + respondOk(exchange, Map.of("ok", true, "refused", false, "finalDate", String.valueOf(r.getFinalDate()))); + } + case "cancel" -> { + session.cancelShortMessage("CMT", messageId, TypeOfNumber.INTERNATIONAL, + NumberingPlanIndicator.UNKNOWN, "12345", TypeOfNumber.INTERNATIONAL, + NumberingPlanIndicator.UNKNOWN, "67890"); + respondOk(exchange, Map.of("ok", true, "refused", false)); + } + case "replace" -> { + session.replaceShortMessage(messageId, TypeOfNumber.INTERNATIONAL, + NumberingPlanIndicator.UNKNOWN, "12345", null, null, + new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, "replacement".getBytes(StandardCharsets.US_ASCII)); + respondOk(exchange, Map.of("ok", true, "refused", false)); + } + default -> respond(exchange, 400, "{}"); + } + } catch (NegativeResponseException e) { + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("refused", true); + result.put("commandStatus", e.getCommandStatus()); + result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus())); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + // --- raw-socket handlers, for PDUs jsmpp's typed API cannot construct --- + + private static void handleRawBind(HttpExchange exchange) { + Map p = queryParams(exchange); + String name = p.getOrDefault("raw", "default"); + + try { + Socket sock = new Socket(); + sock.connect(new InetSocketAddress(host, port), 5000); + + int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52")); + int seqNr = 1; + RawSmpp.write(sock, RawSmpp.bindTransceiverPdu( + p.getOrDefault("systemId", "rawjsmpp"), p.getOrDefault("password", "rawpw"), ifVersion, seqNr)); + + Map resp = RawSmpp.readOne(sock, 5000); + rawSockets.put(name, sock); + rawSeqNr.put(name, seqNr + 1); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("bindResp", resp); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private interface PduBuilder { + byte[] build(int seqNr); + } + + private static void handleRawAction(HttpExchange exchange, PduBuilder builder) { + Map p = queryParams(exchange); + String name = p.getOrDefault("raw", "default"); + Socket sock = rawSockets.get(name); + + if (sock == null) { + respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such raw socket - call rawBind first"))); + + return; + } + + try { + int seqNr = rawSeqNr.getOrDefault(name, 1); + RawSmpp.write(sock, builder.build(seqNr)); + rawSeqNr.put(name, seqNr + 1); + + Map resp = RawSmpp.readOne(sock, 5000); + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("response", resp); + respondOk(exchange, result); + } catch (Exception e) { + respondErr(exchange, e); + } + } + + private static final class NoopListener implements MessageReceiverListener { + @Override + public void onAcceptDeliverSm(DeliverSm deliverSm) throws ProcessRequestException { + // This phase's scenarios never have the SMSC push a deliver_sm to jsmpp. + } + + @Override + public void onAcceptAlertNotification(AlertNotification alertNotification) { + // Nothing to do: no scenario here sends one. + } + + @Override + public org.jsmpp.session.DataSmResult onAcceptDataSm(DataSm dataSm, Session source) + throws ProcessRequestException { + throw new ProcessRequestException("data_sm not supported by this driver", 3); + } + } +} diff --git a/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Json.java b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Json.java new file mode 100644 index 0000000..6445b59 --- /dev/null +++ b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/Json.java @@ -0,0 +1,75 @@ +package smpp.interop.jsmpp; + +import java.util.List; +import java.util.Map; + +/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */ +final class Json { + private Json() { } + + static String write(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) { + sb.append(value); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) sb.append(','); + first = false; + writeString(sb, String.valueOf(entry.getKey())); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) sb.append(','); + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else if (value instanceof byte[] bytes) { + writeString(sb, hex(bytes)); + } else { + writeString(sb, String.valueOf(value)); + } + } + + private static void writeString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + } + sb.append('"'); + } + + static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } +} diff --git a/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/RawSmpp.java b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/RawSmpp.java new file mode 100644 index 0000000..1fcf12f --- /dev/null +++ b/interop-tests/peers/jsmpp/src/main/java/smpp/interop/jsmpp/RawSmpp.java @@ -0,0 +1,176 @@ +package smpp.interop.jsmpp; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A hand-rolled SMPP encoder over a plain socket, for the malformed PDUs jsmpp's own typed API + * cannot construct (target 1: unknown command id, a truncated TLV stream, a body shorter than it + * declares). Only what these scenarios need - a bind and the malformed shapes - not a real client. + */ +final class RawSmpp { + private RawSmpp() { } + + private static void u8(ByteArrayOutputStream out, int v) { + out.write(v & 0xFF); + } + + private static void u32(ByteArrayOutputStream out, long v) { + out.write((int) ((v >> 24) & 0xFF)); + out.write((int) ((v >> 16) & 0xFF)); + out.write((int) ((v >> 8) & 0xFF)); + out.write((int) (v & 0xFF)); + } + + private static void cstring(ByteArrayOutputStream out, String s) { + if (s != null && !s.isEmpty()) out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1)); + out.write(0); + } + + private static byte[] pdu(int cmdId, int status, int seqNr, byte[] body) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + u32(out, 16L + body.length); + u32(out, cmdId); + u32(out, status); + u32(out, seqNr); + out.writeBytes(body); + return out.toByteArray(); + } + + static byte[] bindTransceiverPdu(String systemId, String password, int interfaceVersion, int seqNr) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + cstring(body, systemId); + cstring(body, password); + cstring(body, ""); + u8(body, interfaceVersion); + u8(body, 0); + u8(body, 0); + cstring(body, ""); + return pdu(0x00000009, 0, seqNr, body.toByteArray()); + } + + /** command_id 0x00050001 names no SMPP 3.4 command - an empty body is a well-framed PDU. */ + static byte[] unknownCommandPdu(int seqNr) { + return pdu(0x00050001, 0, seqNr, new byte[0]); + } + + /** + * A deliver_sm whose mandatory fields are all present and correct, followed by one TLV header + * whose declared length (200) runs past cmd_length - the codec's parseTlvs() refuses this with + * reason 'tlvs'. + */ + static byte[] truncatedTlvDeliverSmPdu(int seqNr) { + ByteArrayOutputStream body = deliverSmMandatory("raw-from", "raw-to", "truncated tlv probe"); + // Tag 0x001D (any tag id serves), declared length 200, only 4 value octets actually follow. + // A full 8-byte tail (not a bare 4-byte header) so the codec's trailing-NUL retry - which + // shifts the TLV region by one octet looking for a padded short_message - still finds an + // overrunning length rather than silently swallowing an unparsed remainder as alignment slack. + body.write(0x00); + body.write(0x1D); + body.write(0x00); + body.write(0xC8); + body.write(0x41); + body.write(0x41); + body.write(0x41); + body.write(0x41); + return pdu(0x00000005, 0, seqNr, body.toByteArray()); + } + + /** A deliver_sm whose sm_length declares 200 octets while only 5 are actually present. */ + static byte[] shortBodyDeliverSmPdu(int seqNr) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + cstring(body, ""); + u8(body, 0); + u8(body, 0); + cstring(body, "raw-from"); + u8(body, 0); + u8(body, 0); + cstring(body, "raw-to"); + u8(body, 0); + u8(body, 0); + u8(body, 0); + cstring(body, ""); + cstring(body, ""); + u8(body, 0); + u8(body, 0); + u8(body, 0); + u8(body, 0); + u8(body, 200); // sm_length declares 200 octets + body.writeBytes("short".getBytes(StandardCharsets.ISO_8859_1)); // only 5 actually follow + return pdu(0x00000005, 0, seqNr, body.toByteArray()); + } + + static byte[] enquireLinkPdu(int seqNr) { + return pdu(0x00000015, 0, seqNr, new byte[0]); + } + + private static ByteArrayOutputStream deliverSmMandatory(String from, String to, String text) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + cstring(body, ""); + u8(body, 0); + u8(body, 0); + cstring(body, from); + u8(body, 0); + u8(body, 0); + cstring(body, to); + u8(body, 0); + u8(body, 0); + u8(body, 0); + cstring(body, ""); + cstring(body, ""); + u8(body, 0); + u8(body, 0); + u8(body, 0); + u8(body, 0); + byte[] textBytes = text.getBytes(StandardCharsets.ISO_8859_1); + u8(body, textBytes.length); + body.writeBytes(textBytes); + return body; + } + + static void write(Socket sock, byte[] pdu) throws IOException { + OutputStream out = sock.getOutputStream(); + out.write(pdu); + out.flush(); + } + + /** Reads exactly one PDU (command_length-framed) and parses its 16-octet header. */ + static Map readOne(Socket sock, int timeoutMs) throws IOException { + sock.setSoTimeout(timeoutMs); + DataInputStream in = new DataInputStream(sock.getInputStream()); + byte[] lenBytes = new byte[4]; + in.readFully(lenBytes); + long cmdLength = ((long) (lenBytes[0] & 0xFF) << 24) | ((lenBytes[1] & 0xFF) << 16) + | ((lenBytes[2] & 0xFF) << 8) | (lenBytes[3] & 0xFF); + byte[] rest = new byte[(int) cmdLength - 4]; + in.readFully(rest); + + int cmdId = b32(rest, 0); + int status = b32(rest, 4); + int seqNr = b32(rest, 8); + + Map result = new LinkedHashMap<>(); + result.put("cmdId", cmdId); + result.put("cmdIdHex", "0x" + Integer.toHexString(cmdId)); + result.put("cmdStatus", status); + result.put("cmdStatusHex", "0x" + Integer.toHexString(status)); + result.put("seqNr", seqNr); + byte[] full = new byte[4 + rest.length]; + System.arraycopy(lenBytes, 0, full, 0, 4); + System.arraycopy(rest, 0, full, 4, rest.length); + result.put("hex", Json.hex(full)); + + return result; + } + + private static int b32(byte[] b, int offset) { + return ((b[offset] & 0xFF) << 24) | ((b[offset + 1] & 0xFF) << 16) + | ((b[offset + 2] & 0xFF) << 8) | (b[offset + 3] & 0xFF); + } +} diff --git a/interop-tests/run.py b/interop-tests/run.py index f922816..ad836a9 100755 --- a/interop-tests/run.py +++ b/interop-tests/run.py @@ -13,7 +13,9 @@ EXPERT_SEVERITY_ERROR = "8388608" # The SMPP port each peer's compose overlay exposes its SMSC on. PORT_BY_PEER = { + "cloudhopper": 2775, "jasmin": 2775, + "jsmpp": 2775, "kannel": 2775, "smppsim": 2775, "smscsim": 2775,