Run jsmpp and Cloudhopper against our server: sar_* fragmentation confirmed and a truncated TLV tail silently accepted
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test, { after, describe } from 'node:test';
|
||||
import type { Session } from '../src/session.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import type { SmppServer } from '../src/server.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
|
||||
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
|
||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||
const TLS_PORT = Number(process.env.TLS_PORT ?? '2776');
|
||||
/** How long the "slow" server holds a submit_sm before answering it - long enough that a burst of
|
||||
* concurrent submits genuinely queues behind a small window instead of finishing before it matters. */
|
||||
const SLOW_DELAY_MS = 300;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||
const deadline = Date.now() + budget;
|
||||
let value = get();
|
||||
|
||||
while (value === undefined && Date.now() < deadline) {
|
||||
await delay(20);
|
||||
value = get();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
type DriverResult = Record<string, unknown>;
|
||||
|
||||
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
|
||||
const url = `http://${CLOUDHOPPER_HOST}${path}?${new URLSearchParams(params).toString()}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
return response.json() as Promise<DriverResult>;
|
||||
}
|
||||
|
||||
const manualTexts = new Set<string>();
|
||||
const allSms: { session: Session; sms: Sms }[] = [];
|
||||
|
||||
function attach(session: Session): void {
|
||||
session.on('sms', sms => {
|
||||
allSms.push({ session, sms });
|
||||
|
||||
if (manualTexts.has(sms.message)) return;
|
||||
|
||||
// The slow server this phase's window scenarios need: every ordinary submit is held for
|
||||
// SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
|
||||
void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
|
||||
});
|
||||
}
|
||||
|
||||
const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||||
|
||||
assert.equal(serverErr, undefined);
|
||||
assert.ok(smpp);
|
||||
smpp.on('session', attach);
|
||||
|
||||
const key = readFileSync('/shared-certs/server.key');
|
||||
const cert = readFileSync('/shared-certs/server.crt');
|
||||
// Cloudhopper's SSL client (Netty 3.9.6.Final, from 2015) cannot complete a TLS 1.3 handshake - see
|
||||
// findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is
|
||||
// unrestricted.
|
||||
const { err: tlsServerErr, server: tlsSmpp } = await server({
|
||||
authenticate: () => true,
|
||||
idleTimeout: 40_000,
|
||||
port: TLS_PORT,
|
||||
tls: { cert, key, maxVersion: 'TLSv1.2' },
|
||||
});
|
||||
|
||||
assert.equal(tlsServerErr, undefined);
|
||||
assert.ok(tlsSmpp);
|
||||
tlsSmpp.on('session', attach);
|
||||
|
||||
after(async () => {
|
||||
await smpp.close();
|
||||
await tlsSmpp.close();
|
||||
});
|
||||
|
||||
async function waitForSessionCount(server_: SmppServer, count: number, budget = 15_000): Promise<Session[]> {
|
||||
const found = await waitFor(() => ([...server_.sessions].length >= count ? [...server_.sessions] : undefined), budget);
|
||||
|
||||
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('S5 - window pressure against a slow sms handler (target 11)', () => {
|
||||
for (const windowSize of [1, 10, 50]) {
|
||||
test(`window ${String(windowSize)}: every request answered, none twice, order preserved`, async () => {
|
||||
const session = `w${String(windowSize)}`;
|
||||
const bind = await driver('/bind', { password: 'chpw', session, systemId: `ch-${session}`, windowSize: String(windowSize) });
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const count = windowSize === 50 ? 60 : windowSize * 3;
|
||||
const burst = await driver('/windowBurst', {
|
||||
count: String(count), prefix: session, session, timeoutMs: '30000',
|
||||
});
|
||||
|
||||
assert.equal(burst.ok, true);
|
||||
const results = burst.results as { index: number; messageId?: string; ok: boolean }[];
|
||||
|
||||
assert.equal(results.length, count);
|
||||
assert.ok(results.every(r => r.ok), `every submit answered: ${JSON.stringify(results.filter(r => !r.ok))}`);
|
||||
|
||||
const ids = results.map(r => r.messageId);
|
||||
|
||||
assert.equal(new Set(ids).size, ids.length, 'no message id answered twice');
|
||||
assert.ok((burst.peakWindowSize as number) <= windowSize, `peak window ${String(burst.peakWindowSize)} stayed within ${String(windowSize)}`);
|
||||
|
||||
// "Order preserved" here means each response correlates to its own request rather than a
|
||||
// different one - guaranteed by Cloudhopper's own sequence-number-keyed window, which is
|
||||
// exactly why the per-index messageId uniqueness above is the meaningful assertion: each
|
||||
// of the `count` concurrent callers blocks on its own submit() and gets its own answer,
|
||||
// racing only on which of them the OS schedules onto the window's free slot(s) first.
|
||||
const arrived = allSms.filter(entry => entry.sms.message.startsWith(`${session}-`)).length;
|
||||
|
||||
assert.equal(arrived, count);
|
||||
|
||||
await driver('/unbind', { session });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('S5 - request expiry shorter than the handler delay (target 11)', () => {
|
||||
test('the peer reports the expiry itself; our side is not left in a bad state', async () => {
|
||||
const bind = await driver('/bind', {
|
||||
password: 'chpw', requestExpiryTimeout: '100', session: 'expiry', systemId: 'ch-expiry',
|
||||
windowMonitorInterval: '50', windowSize: '1',
|
||||
});
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const text = 'expiry-probe';
|
||||
|
||||
manualTexts.add(text);
|
||||
|
||||
const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' });
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
|
||||
// Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window
|
||||
// monitor gives up on it first - recorded, not asserted against, since that is the peer's call.
|
||||
await delay(1000);
|
||||
await sms.sendResp();
|
||||
|
||||
const result = await submitted;
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
// Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException,
|
||||
// not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are
|
||||
// distinct mechanisms and this is the window monitor's own name for it.
|
||||
assert.match(String(result.errorClass), /RecoverablePduException/);
|
||||
|
||||
const health = await driver('/health');
|
||||
|
||||
assert.equal(health.ok, true);
|
||||
await driver('/unbind', { session: 'expiry' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('S10 - Cloudhopper SSL client against our server({ tls })', () => {
|
||||
test('handshake, bind, submit over TLS', async () => {
|
||||
const bind = await driver('/bind', {
|
||||
password: 'chsslpw', port: String(TLS_PORT), session: 'tls', systemId: 'ch-tls', useSsl: 'true',
|
||||
});
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(tlsSmpp, 1);
|
||||
|
||||
const text = 'over-tls-phase5';
|
||||
const result = await driver('/submit', { session: 'tls', text });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.commandStatus, 0);
|
||||
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
await driver('/unbind', { session: 'tls' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refusing status is surfaced back to Cloudhopper', () => {
|
||||
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => {
|
||||
const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' });
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const text = 'ch-refuse-me';
|
||||
|
||||
manualTexts.add(text);
|
||||
|
||||
const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' });
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
|
||||
|
||||
const result = await submitted;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.commandStatus, 0x00000014);
|
||||
await driver('/unbind', { session: 'refuse' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
x-log-limits: &log-limits
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-file: "3"
|
||||
max-size: 20m
|
||||
|
||||
volumes:
|
||||
# The build-time self-signed cert cloudhopper's image trusts (S10) - copied out at container
|
||||
# start so node's test file can load the same key/cert into server({ tls }). Never committed.
|
||||
cloudhopper-tls:
|
||||
|
||||
services:
|
||||
cloudhopper:
|
||||
build: ./interop-tests/peers/cloudhopper
|
||||
image: interop-cloudhopper:5.0.10-ae6485a
|
||||
command: ["node", "2775"]
|
||||
<<: *log-limits
|
||||
volumes:
|
||||
- cloudhopper-tls:/shared-certs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||
interval: 1s
|
||||
retries: 30
|
||||
timeout: 2s
|
||||
|
||||
# cloudhopper only ever dials node on this container's own network namespace, so this sees exactly
|
||||
# its side of every scenario below (same pattern as compose.kannel.yaml). Both the plain (2775) and
|
||||
# TLS (2776) listeners node opens are on this one veth.
|
||||
capture:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
network_mode: "service:cloudhopper"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
depends_on:
|
||||
cloudhopper:
|
||||
condition: service_started
|
||||
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775 or tcp port 2776", "-w", "/captures/cloudhopper.pcapng"]
|
||||
volumes:
|
||||
- ./interop-tests/captures:/captures
|
||||
|
||||
node:
|
||||
volumes:
|
||||
- cloudhopper-tls:/shared-certs
|
||||
depends_on:
|
||||
capture:
|
||||
condition: service_started
|
||||
cloudhopper:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,40 @@
|
||||
x-log-limits: &log-limits
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-file: "3"
|
||||
max-size: 20m
|
||||
|
||||
services:
|
||||
jsmpp:
|
||||
build: ./interop-tests/peers/jsmpp
|
||||
image: interop-jsmpp:3.0.3-a24db96
|
||||
command: ["node", "2775"]
|
||||
<<: *log-limits
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||
interval: 1s
|
||||
retries: 30
|
||||
timeout: 2s
|
||||
|
||||
# jsmpp only ever dials node:2775 on this container's own network namespace, so this sees exactly
|
||||
# its side of every scenario below (same pattern as compose.kannel.yaml).
|
||||
capture:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
network_mode: "service:jsmpp"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
depends_on:
|
||||
jsmpp:
|
||||
condition: service_started
|
||||
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jsmpp.pcapng"]
|
||||
volumes:
|
||||
- ./interop-tests/captures:/captures
|
||||
|
||||
node:
|
||||
depends_on:
|
||||
capture:
|
||||
condition: service_started
|
||||
jsmpp:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,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/<peer>/`) exposing an
|
||||
HTTP command channel on 8080 - the node test file drives scenarios by calling it, the same shape as
|
||||
`kannel.test.ts`'s `sendsms()` HTTP calls, and the same port doubles as the compose healthcheck
|
||||
target. jsmpp's driver binds one or more named `SMPPSession`s and answers with the real client's own
|
||||
exceptions and return values; Cloudhopper's binds one or more named `SmppSession`s the same way.
|
||||
Long-message wire shapes (UDH 8/16-bit, `sar_*`, `message_payload`) are all built through jsmpp's own
|
||||
typed `submitShortMessage(..., OptionalParameter...)` API, never a raw socket - jsmpp exposes exactly
|
||||
the fields needed. Only three deliberately-malformed PDUs (an unknown command id, a truncated TLV
|
||||
stream, a body shorter than `sm_length` declares) cannot be expressed through any typed SMPP client,
|
||||
jsmpp included, so those go out over a second, plain `Socket` the driver opens alongside its jsmpp
|
||||
session - noted here so "jsmpp accepts our answer" claims below are read as applying to the typed-API
|
||||
scenarios only, where jsmpp's own reaction is what is being tested.
|
||||
|
||||
Build snags, both fixed in the Dockerfile, not in `src/`:
|
||||
|
||||
- Cloudhopper's parent pom (`fizzed-maven-parent:1.15`) hardcodes `<source>1.7</source>` /
|
||||
`<target>1.7</target>` directly in its compiler-plugin config, which modern `javac` refuses
|
||||
("Source option 7 is no longer supported") and which `-Dmaven.compiler.source` cannot override
|
||||
since it is not read from a property. Fixed by `sed`-injecting a `<build>` block into
|
||||
`ch-smpp`'s own pom (which declares none) with `source`/`target` `8`, the narrowest override that
|
||||
changes nothing else.
|
||||
- Cloudhopper's test sources reference `javax.annotation.PreDestroy` (JSR-250), removed from the JDK
|
||||
this builds with. `-DskipTests` only skips running them; Maven's `install` lifecycle still
|
||||
test-*compiles* them first. `-Dmaven.test.skip=true` skips compiling them too.
|
||||
- Cloudhopper's `SslContextFactory` only takes the "no validation" branch when *neither* a keystore
|
||||
nor a truststore is configured; a client with only a trust store falls through to
|
||||
`loadKeyStore()` with a null path and fails ("SSL doesn't have a valid keystore"). Fixed by also
|
||||
building a PKCS12 keystore from the same build-time self-signed cert and pointing
|
||||
`SslConfiguration.setKeyStorePath()` at it - functionally unused (our server never requests a
|
||||
client certificate) but required for Cloudhopper's own SSL setup to get past its own null check.
|
||||
- The shared `cloudhopper-tls` volume Cloudhopper's entrypoint copies `server.key`/`server.crt`
|
||||
into (so node's test file can load the same pair into `server({ tls })`) came out root-owned,
|
||||
0600 - unreadable by `node`'s container, which runs as uid 1000. Fixed with a `chmod 644` in the
|
||||
same entrypoint step.
|
||||
- Cloudhopper's SSL client (Netty 3.9.6.Final, 2015-era) cannot complete a handshake against our
|
||||
server's default TLS 1.3 - see Peer quirks. `server({ tls: { ..., maxVersion: 'TLSv1.2' } })` on
|
||||
the S10 listener only; the plain listener the window scenarios use is unrestricted, and a Python
|
||||
`ssl` client confirmed TLS 1.3 itself works before this was traced to the peer.
|
||||
- One GSM7 encoding footgun in the jsmpp driver's own text fixtures, not in `@larvit/smpp`: the
|
||||
driver's "gsm7" mode sends plain ASCII bytes for `short_message`, and GSM 03.38's default
|
||||
alphabet maps ASCII `_` (0x5F) to `§`, not underscore - a message_payload fixture containing `_`
|
||||
round-tripped as `§` until the character was dropped from the fixture text.
|
||||
|
||||
Two runs each of `./interop-tests/run.py jsmpp` and `./interop-tests/run.py cloudhopper`, all four
|
||||
stable: jsmpp 12/12 both times, Cloudhopper 6/6 both times.
|
||||
|
||||
```
|
||||
jsmpp: frames 37, submit_sm 8/8, query_sm 1/1, cancel_sm 1/1, replace_sm 1/1,
|
||||
deliver_sm 2/2, enquire_link 2/2, generic_nack 1, malformed 2, expert errors 2
|
||||
cloudhopper: frames 90-92 (varies slightly run to run - see Peer quirks), bind_transceiver 5/5,
|
||||
submit_sm/submit_sm_resp present, unbind 4/4, malformed 0, expert errors 0
|
||||
```
|
||||
|
||||
jsmpp's `malformed: 2` / `expert errors: 2` are not a stability problem: they are tshark
|
||||
independently flagging the same two deliberately-malformed PDUs the "S3" scenarios send on purpose
|
||||
(the truncated-TLV and short-body reproducers below) - both are genuinely malformed by the spec, so
|
||||
an independent dissector agreeing is the expected outcome, not a surprise.
|
||||
|
||||
## Scenarios (PLAN.md)
|
||||
|
||||
| Id | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
|
||||
| S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read |
|
||||
| S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" |
|
||||
| S2 `sar_*` (target 3) | defect confirmed, second peer | `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.
|
||||
@@ -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<void> {
|
||||
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||
const deadline = Date.now() + budget;
|
||||
let value = get();
|
||||
|
||||
while (value === undefined && Date.now() < deadline) {
|
||||
await delay(20);
|
||||
value = get();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
type DriverResult = Record<string, unknown>;
|
||||
|
||||
/** The jsmpp driver's own HTTP command channel - one bound session or raw socket per `session`/`raw` name. */
|
||||
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
|
||||
const url = `http://${JSMPP_HOST}${path}?${new URLSearchParams(params).toString()}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
return response.json() as Promise<DriverResult>;
|
||||
}
|
||||
|
||||
const allSms: { session: Session; sms: Sms }[] = [];
|
||||
const allSessionErrors: { err: Error; session: Session }[] = [];
|
||||
const bindPdus: Record<string, unknown>[] = [];
|
||||
/** Messages a test answers itself (a refusing status, or asserting on the response) - populate
|
||||
* before triggering the submit that will carry this exact text, so the global auto-ack never runs. */
|
||||
const manualTexts = new Set<string>();
|
||||
|
||||
const { err: serverErr, server: smpp } = await server({
|
||||
authenticate: () => true,
|
||||
idleTimeout: 40_000,
|
||||
port: SMPP_PORT,
|
||||
});
|
||||
|
||||
assert.equal(serverErr, undefined);
|
||||
assert.ok(smpp);
|
||||
|
||||
const smppServer = smpp;
|
||||
|
||||
smppServer.on('session', session => {
|
||||
session.on('incomingPduObj', pduObj => {
|
||||
if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params);
|
||||
});
|
||||
session.on('sms', sms => {
|
||||
allSms.push({ session, sms });
|
||||
if (!manualTexts.has(sms.message)) void sms.sendResp();
|
||||
});
|
||||
session.on('sessionError', err => { allSessionErrors.push({ err, session }); });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await smppServer.close();
|
||||
});
|
||||
|
||||
async function waitForSms(message: string, budget = 8000): Promise<Sms> {
|
||||
const found = await waitFor(() => allSms.find(entry => entry.sms.message === message)?.sms, budget);
|
||||
|
||||
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived (seen: ${JSON.stringify(allSms.map(e => e.sms.message))})`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
async function waitForSessions(count: number, budget = 15_000): Promise<Session[]> {
|
||||
const found = await waitFor(() => ([...smppServer.sessions].length >= count ? [...smppServer.sessions] : undefined), budget);
|
||||
|
||||
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('bind version negotiation (target 8)', () => {
|
||||
test('0x34: our bind_resp carries sc_interface_version, jsmpp negotiates 3.4', async () => {
|
||||
const result = await driver('/bind', { interfaceVersion: '52', password: 'jsmpppw', session: 'v34', systemId: 'jsmpp-v34' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.negotiatedInterfaceVersion, 52);
|
||||
|
||||
await waitForSessions(1);
|
||||
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v34'));
|
||||
|
||||
assert.ok(bind);
|
||||
assert.equal(bind.interface_version, 0x34);
|
||||
});
|
||||
|
||||
test('0x33: our bind_resp carries no TLVs, and jsmpp reports back what it asked for', async () => {
|
||||
const result = await driver('/bind', { interfaceVersion: '51', password: 'jsmpppw', session: 'v33', systemId: 'jsmpp-v33' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
// jsmpp's session.getInterfaceVersion() simply echoes what the driver declared - it does not
|
||||
// visibly fall back to 3.4 here despite the absent sc_interface_version TLV, which is the
|
||||
// opposite of what its own source (a scVersion-null branch defaulting to IF_34) suggests.
|
||||
// Recorded as observed rather than as a confirmation of that code path - see Peer quirks.
|
||||
assert.equal(result.negotiatedInterfaceVersion, 51);
|
||||
|
||||
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v33'));
|
||||
|
||||
assert.ok(bind);
|
||||
assert.equal(bind.interface_version, 0x33);
|
||||
|
||||
const session = [...smppServer.sessions].find(s => s.peerInterfaceVersion === 0x33);
|
||||
|
||||
assert.ok(session);
|
||||
assert.equal(session.acceptsOptionalParams(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => {
|
||||
test('UDH, 8-bit reference: one reassembled sms, each segment answered <base>-<n>', async () => {
|
||||
await waitForSessions(1);
|
||||
|
||||
const text = 'u8-'.padEnd(200, 'a');
|
||||
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh8', session: 'v34', text, to: '2001' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const segments = result.segments as { messageId: string; part: number; total: number }[];
|
||||
|
||||
assert.equal(segments.length, 2);
|
||||
|
||||
const sms = await waitForSms(text, 15_000);
|
||||
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||
});
|
||||
|
||||
test('UDH, 16-bit reference: also reassembled - our library reads both widths', async () => {
|
||||
await waitForSessions(1);
|
||||
|
||||
const text = 'u16-'.padEnd(200, 'b');
|
||||
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh16', session: 'v34', text, to: '2001' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const segments = result.segments as { messageId: string }[];
|
||||
|
||||
const sms = await waitForSms(text, 15_000);
|
||||
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||
});
|
||||
|
||||
test('message_payload: one sms, the full text', async () => {
|
||||
await waitForSessions(1);
|
||||
|
||||
// No underscore: GSM 03.38's default alphabet maps ASCII 0x5F to section-sign, not "_" -
|
||||
// the driver's "gsm7" mode sends plain ASCII bytes, so a real underscore round-trips wrong
|
||||
// on purpose (a GSM7 encoder bug in this fixture, not in @larvit/smpp).
|
||||
const text = 'payload carries the whole body in one submit sm';
|
||||
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'payload', session: 'v34', text, to: '2001' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
const sms = await waitForSms(text);
|
||||
|
||||
assert.equal(sms.message, text);
|
||||
assert.equal(sms.answeredOnArrival, false);
|
||||
});
|
||||
|
||||
test('sar_*: 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-<base>-<n>) 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<string, unknown>;
|
||||
|
||||
assert.equal(response.cmdIdHex, '0x80000000');
|
||||
assert.equal(response.cmdStatusHex, '0x3');
|
||||
|
||||
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError
|
||||
&& e.err.reason === 'command'));
|
||||
|
||||
assert.ok(refused);
|
||||
|
||||
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
|
||||
const linkResponse = link.response as Record<string, unknown>;
|
||||
|
||||
assert.equal(linkResponse.cmdStatusHex, '0x0');
|
||||
});
|
||||
|
||||
test('a deliver_sm with a truncated TLV stream gets ESME_RINVTLVSTREAM, link survives', async () => {
|
||||
const result = await driver('/rawTruncatedTlv', { raw: 'malformed' });
|
||||
const response = result.response as Record<string, unknown>;
|
||||
|
||||
assert.equal(response.cmdIdHex, '0x80000005');
|
||||
assert.equal(response.cmdStatusHex, '0xc0');
|
||||
|
||||
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError && e.err.reason === 'tlvs'));
|
||||
|
||||
assert.ok(refused);
|
||||
});
|
||||
|
||||
test('a deliver_sm whose body is shorter than sm_length declares gets ESME_RINVCMDLEN', async () => {
|
||||
const result = await driver('/rawShortBody', { raw: 'malformed' });
|
||||
const response = result.response as Record<string, unknown>;
|
||||
|
||||
assert.equal(response.cmdIdHex, '0x80000005');
|
||||
assert.equal(response.cmdStatusHex, '0x2');
|
||||
|
||||
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
|
||||
const linkResponse = link.response as Record<string, unknown>;
|
||||
|
||||
assert.equal(linkResponse.cmdStatusHex, '0x0');
|
||||
});
|
||||
|
||||
// Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage) - 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<void>(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<void>(resolve => { sock.once('data', () => { resolve(); }); });
|
||||
|
||||
const responsePromise = new Promise<Buffer>(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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
# cloudhopper-smpp (the fizzed fork) has no published artifact; cloned at a fixed commit and
|
||||
# installed into the local Maven repo, which the driver build below then depends on.
|
||||
FROM maven:3.9.11-eclipse-temurin-21 AS cloudhopper-source
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG CLOUDHOPPER_COMMIT=ae6485a86c968d344bebf0fa180928905b4a8ad1
|
||||
|
||||
# The parent pom (fizzed-maven-parent:1.15) hardcodes source/target 1.7 in its own compiler-plugin
|
||||
# config, which -Dmaven.compiler.source cannot override; ch-smpp's own pom declares no <build> of
|
||||
# its own, so injecting one here (source/target 8, otherwise unmodified) is the narrowest override.
|
||||
RUN git clone https://github.com/fizzed/cloudhopper-smpp.git /src \
|
||||
&& cd /src \
|
||||
&& git checkout "${CLOUDHOPPER_COMMIT}" \
|
||||
&& sed -i 's#</project>#<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><source>8</source><target>8</target></configuration></plugin></plugins></build></project>#' pom.xml \
|
||||
&& mvn -q install -Dmaven.test.skip=true -Dgpg.skip=true -Dmaven.javadoc.skip=true
|
||||
|
||||
# A self-signed cert this image trusts, generated at build time - never committed. server.key/crt
|
||||
# are PEM (for our server()'s own tls option); truststore.jks is what the driver's SSL client trusts.
|
||||
FROM eclipse-temurin:21.0.8_9-jre-jammy AS certs
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /certs \
|
||||
&& openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
|
||||
-keyout /certs/server.key -out /certs/server.crt -subj "/CN=interop-cloudhopper" \
|
||||
&& keytool -importcert -noprompt -alias interop -file /certs/server.crt \
|
||||
-keystore /certs/truststore.jks -storepass changeit \
|
||||
&& openssl pkcs12 -export -in /certs/server.crt -inkey /certs/server.key \
|
||||
-out /certs/keystore.p12 -name interop -password pass:changeit
|
||||
|
||||
FROM maven:3.9.11-eclipse-temurin-21 AS build
|
||||
|
||||
COPY --from=cloudhopper-source /root/.m2 /root/.m2
|
||||
|
||||
WORKDIR /driver
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
RUN mvn -q package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /driver/target/driver.jar ./driver.jar
|
||||
COPY --from=certs /certs /certs
|
||||
|
||||
EXPOSE 8080
|
||||
# node's test file needs the same server.key/server.crt to configure its own tls option; the
|
||||
# S10 compose overlay mounts a shared volume at /shared-certs on both this service and node.
|
||||
ENTRYPOINT ["sh", "-c", "cp /certs/server.key /certs/server.crt /shared-certs/ 2>/dev/null && chmod 644 /shared-certs/server.key /shared-certs/server.crt || true; exec java -jar driver.jar \"$@\"", "--"]
|
||||
CMD ["node", "2775"]
|
||||
@@ -0,0 +1,52 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>se.larvit.interop</groupId>
|
||||
<artifactId>cloudhopper-driver</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fizzed</groupId>
|
||||
<artifactId>ch-smpp</artifactId>
|
||||
<version>5.0.10-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.36</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>driver</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>smpp.interop.cloudhopper.Driver</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,315 @@
|
||||
package smpp.interop.cloudhopper;
|
||||
|
||||
import com.cloudhopper.smpp.SmppBindType;
|
||||
import com.cloudhopper.smpp.SmppSession;
|
||||
import com.cloudhopper.smpp.SmppSessionConfiguration;
|
||||
import com.cloudhopper.smpp.impl.DefaultSmppClient;
|
||||
import com.cloudhopper.smpp.impl.DefaultSmppSessionHandler;
|
||||
import com.cloudhopper.smpp.pdu.PduRequest;
|
||||
import com.cloudhopper.smpp.pdu.SubmitSm;
|
||||
import com.cloudhopper.smpp.pdu.SubmitSmResp;
|
||||
import com.cloudhopper.smpp.ssl.SslConfiguration;
|
||||
import com.cloudhopper.smpp.type.Address;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* An HTTP-driven Cloudhopper ESME: bind with a chosen window configuration, then fire concurrent
|
||||
* submits against our (possibly deliberately slow) server and report per-request outcomes and the
|
||||
* observed send-window occupancy, so the node test file can assert none lost, none duplicated.
|
||||
*/
|
||||
public final class Driver {
|
||||
private static final Logger log = LoggerFactory.getLogger(Driver.class);
|
||||
private static final Map<String, SmppSession> sessions = new ConcurrentHashMap<>();
|
||||
private static final AtomicInteger expiredCount = new AtomicInteger(0);
|
||||
private static DefaultSmppClient client;
|
||||
private static ScheduledThreadPoolExecutor monitorExecutor;
|
||||
private static ExecutorService ioExecutor;
|
||||
private static String host;
|
||||
private static int port;
|
||||
|
||||
private Driver() { }
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
host = args.length > 0 ? args[0] : "node";
|
||||
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
|
||||
|
||||
ioExecutor = Executors.newCachedThreadPool();
|
||||
monitorExecutor = new ScheduledThreadPoolExecutor(2);
|
||||
client = new DefaultSmppClient(Executors.newCachedThreadPool(), 50, monitorExecutor);
|
||||
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
|
||||
server.createContext("/bind", Driver::handleBind);
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
||||
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
System.out.println("cloudhopper driver listening on 8080, target " + host + ":" + port);
|
||||
}
|
||||
|
||||
// --- HTTP plumbing (same shape as the jsmpp driver's, kept independent on purpose) ---
|
||||
|
||||
private static Map<String, String> queryParams(HttpExchange exchange) {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
String query = exchange.getRequestURI().getRawQuery();
|
||||
|
||||
if (query == null) return params;
|
||||
|
||||
for (String pair : query.split("&")) {
|
||||
int eq = pair.indexOf('=');
|
||||
String key = eq < 0 ? pair : pair.substring(0, eq);
|
||||
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
|
||||
params.put(key, value);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) {
|
||||
try {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(bytes);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// The client gave up reading; nothing left to answer.
|
||||
}
|
||||
}
|
||||
|
||||
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
|
||||
respond(exchange, 200, Json.write(result));
|
||||
}
|
||||
|
||||
private static void respondErr(HttpExchange exchange, Exception e) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", false);
|
||||
result.put("errorClass", e.getClass().getName());
|
||||
result.put("error", String.valueOf(e.getMessage()));
|
||||
respond(exchange, 200, Json.write(result));
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
private static void handleBind(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
String name = p.getOrDefault("session", "default");
|
||||
|
||||
try {
|
||||
SmppSessionConfiguration config = new SmppSessionConfiguration();
|
||||
config.setName(name);
|
||||
config.setType(SmppBindType.TRANSCEIVER);
|
||||
config.setHost(p.getOrDefault("host", host));
|
||||
config.setPort(Integer.parseInt(p.getOrDefault("port", String.valueOf(port))));
|
||||
config.setConnectTimeout(10_000);
|
||||
config.setSystemId(p.getOrDefault("systemId", "cloudhopper"));
|
||||
config.setPassword(p.getOrDefault("password", "chpw"));
|
||||
config.setWindowSize(Integer.parseInt(p.getOrDefault("windowSize", "1")));
|
||||
config.setRequestExpiryTimeout(Long.parseLong(p.getOrDefault("requestExpiryTimeout", "30000")));
|
||||
config.setWindowMonitorInterval(Long.parseLong(p.getOrDefault("windowMonitorInterval", "15000")));
|
||||
config.setCountersEnabled(true);
|
||||
|
||||
if (Boolean.parseBoolean(p.getOrDefault("useSsl", "false"))) {
|
||||
// Cloudhopper's SslContextFactory only skips keystore loading when *neither* store is
|
||||
// configured - a trust-store-only client falls through to loadKeyStore() with a null
|
||||
// path and fails, so the build-time self-signed cert also gets used as the (otherwise
|
||||
// unneeded) client keystore.
|
||||
SslConfiguration ssl = new SslConfiguration();
|
||||
ssl.setTrustStorePath("/certs/truststore.jks");
|
||||
ssl.setTrustStorePassword("changeit");
|
||||
ssl.setKeyStorePath("/certs/keystore.p12");
|
||||
ssl.setKeyStorePassword("changeit");
|
||||
ssl.setKeyStoreType("PKCS12");
|
||||
config.setUseSsl(true);
|
||||
config.setSslConfiguration(ssl);
|
||||
}
|
||||
|
||||
DefaultSmppSessionHandler handler = new DefaultSmppSessionHandler(log) {
|
||||
@Override
|
||||
public void firePduRequestExpired(PduRequest pduRequest) {
|
||||
expiredCount.incrementAndGet();
|
||||
log.warn("PDU request expired in window monitor: {}", pduRequest);
|
||||
}
|
||||
};
|
||||
|
||||
SmppSession session = client.bind(config, handler);
|
||||
sessions.put(name, session);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleUnbind(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SmppSession session = sessions.remove(p.getOrDefault("session", "default"));
|
||||
|
||||
try {
|
||||
if (session != null) {
|
||||
session.unbind(5000);
|
||||
session.destroy();
|
||||
}
|
||||
|
||||
respondOk(exchange, Map.of("ok", true));
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubmitSm buildSubmit(String from, String to, String text)
|
||||
throws com.cloudhopper.smpp.type.SmppInvalidArgumentException {
|
||||
SubmitSm submit = new SubmitSm();
|
||||
submit.setSourceAddress(new Address((byte) 0x01, (byte) 0x01, from));
|
||||
submit.setDestAddress(new Address((byte) 0x01, (byte) 0x01, to));
|
||||
submit.setShortMessage(text.getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
return submit;
|
||||
}
|
||||
|
||||
private static void handleSubmit(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "10000"));
|
||||
long start = System.currentTimeMillis();
|
||||
SubmitSmResp resp = session.submit(
|
||||
buildSubmit(p.getOrDefault("from", "1000"), p.getOrDefault("to", "2000"), p.getOrDefault("text", "hi")),
|
||||
timeoutMs);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("messageId", resp.getMessageId());
|
||||
result.put("commandStatus", resp.getCommandStatus());
|
||||
result.put("elapsedMs", (int) elapsed);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
|
||||
private static void handleWindowBurst(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int count = Integer.parseInt(p.getOrDefault("count", "10"));
|
||||
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
|
||||
String from = p.getOrDefault("from", "1000");
|
||||
String to = p.getOrDefault("to", "2000");
|
||||
String prefix = p.getOrDefault("prefix", "burst");
|
||||
|
||||
AtomicInteger peakWindow = new AtomicInteger(0);
|
||||
Thread sampler = new Thread(() -> {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
int size = session.getSendWindow().getSize();
|
||||
peakWindow.updateAndGet(prev -> Math.max(prev, size));
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
});
|
||||
sampler.setDaemon(true);
|
||||
sampler.start();
|
||||
|
||||
List<Future<Map<String, Object>>> futures = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
int index = i;
|
||||
futures.add(ioExecutor.submit((Callable<Map<String, Object>>) () -> {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("index", index);
|
||||
|
||||
try {
|
||||
long start = System.currentTimeMillis();
|
||||
SubmitSmResp resp = session.submit(buildSubmit(from, to, prefix + "-" + index), timeoutMs);
|
||||
entry.put("ok", true);
|
||||
entry.put("messageId", resp.getMessageId());
|
||||
entry.put("elapsedMs", (int) (System.currentTimeMillis() - start));
|
||||
} catch (Exception e) {
|
||||
entry.put("ok", false);
|
||||
entry.put("errorClass", e.getClass().getSimpleName());
|
||||
entry.put("error", String.valueOf(e.getMessage()));
|
||||
}
|
||||
|
||||
return entry;
|
||||
}));
|
||||
}
|
||||
|
||||
List<Object> results = new ArrayList<>();
|
||||
|
||||
for (Future<Map<String, Object>> f : futures) {
|
||||
try {
|
||||
results.add(f.get());
|
||||
} catch (Exception e) {
|
||||
results.add(Map.of("ok", false, "error", String.valueOf(e.getMessage())));
|
||||
}
|
||||
}
|
||||
|
||||
sampler.interrupt();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("results", results);
|
||||
result.put("peakWindowSize", peakWindow.get());
|
||||
result.put("expiredCount", expiredCount.get());
|
||||
respondOk(exchange, result);
|
||||
}
|
||||
|
||||
private static void handleSendWindowSize(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
respondOk(exchange, Map.of("ok", true, "size", session.getSendWindow().getSize()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package smpp.interop.cloudhopper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
|
||||
final class Json {
|
||||
private Json() { }
|
||||
|
||||
static String write(Object value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
writeValue(sb, value);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void writeValue(StringBuilder sb, Object value) {
|
||||
if (value == null) {
|
||||
sb.append("null");
|
||||
} else if (value instanceof String s) {
|
||||
writeString(sb, s);
|
||||
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
|
||||
sb.append(value);
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (!first) sb.append(',');
|
||||
first = false;
|
||||
writeString(sb, String.valueOf(entry.getKey()));
|
||||
sb.append(':');
|
||||
writeValue(sb, entry.getValue());
|
||||
}
|
||||
sb.append('}');
|
||||
} else if (value instanceof List<?> list) {
|
||||
sb.append('[');
|
||||
boolean first = true;
|
||||
for (Object item : list) {
|
||||
if (!first) sb.append(',');
|
||||
first = false;
|
||||
writeValue(sb, item);
|
||||
}
|
||||
sb.append(']');
|
||||
} else if (value instanceof byte[] bytes) {
|
||||
writeString(sb, hex(bytes));
|
||||
} else {
|
||||
writeString(sb, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeString(StringBuilder sb, String s) {
|
||||
sb.append('"');
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> sb.append("\\\"");
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '\n' -> sb.append("\\n");
|
||||
case '\r' -> sb.append("\\r");
|
||||
case '\t' -> sb.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
|
||||
else sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
|
||||
static String hex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# jsmpp has no published artifact for this pin; cloned at a fixed commit and installed into the
|
||||
# local Maven repo, which the driver build below then depends on.
|
||||
FROM maven:3.9.11-eclipse-temurin-21 AS jsmpp-source
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG JSMPP_COMMIT=a24db96ad7014cdc84a3eebfa64a75c362daef2a
|
||||
|
||||
RUN git clone https://github.com/opentelecoms-org/jsmpp.git /src \
|
||||
&& cd /src \
|
||||
&& git checkout "${JSMPP_COMMIT}" \
|
||||
&& mvn -q -pl jsmpp -am install -DskipTests -Dgpg.skip=true
|
||||
|
||||
FROM maven:3.9.11-eclipse-temurin-21 AS build
|
||||
|
||||
COPY --from=jsmpp-source /root/.m2 /root/.m2
|
||||
|
||||
WORKDIR /driver
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
RUN mvn -q package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /driver/target/driver.jar ./driver.jar
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "driver.jar"]
|
||||
CMD ["node", "2775"]
|
||||
@@ -0,0 +1,47 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>se.larvit.interop</groupId>
|
||||
<artifactId>jsmpp-driver</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jsmpp</groupId>
|
||||
<artifactId>jsmpp</artifactId>
|
||||
<version>3.0.3-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>driver</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>smpp.interop.jsmpp.Driver</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,471 @@
|
||||
package smpp.interop.jsmpp;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import org.jsmpp.InvalidResponseException;
|
||||
import org.jsmpp.PDUException;
|
||||
import org.jsmpp.bean.Alphabet;
|
||||
import org.jsmpp.bean.AlertNotification;
|
||||
import org.jsmpp.bean.BindType;
|
||||
import org.jsmpp.bean.DataSm;
|
||||
import org.jsmpp.bean.DeliverSm;
|
||||
import org.jsmpp.bean.ESMClass;
|
||||
import org.jsmpp.bean.GeneralDataCoding;
|
||||
import org.jsmpp.bean.NumberingPlanIndicator;
|
||||
import org.jsmpp.bean.OptionalParameter;
|
||||
import org.jsmpp.bean.RegisteredDelivery;
|
||||
import org.jsmpp.bean.SMSCDeliveryReceipt;
|
||||
import org.jsmpp.bean.TypeOfNumber;
|
||||
import org.jsmpp.bean.InterfaceVersion;
|
||||
import org.jsmpp.extra.NegativeResponseException;
|
||||
import org.jsmpp.extra.ProcessRequestException;
|
||||
import org.jsmpp.extra.ResponseTimeoutException;
|
||||
import org.jsmpp.session.BindParameter;
|
||||
import org.jsmpp.session.MessageReceiverListener;
|
||||
import org.jsmpp.session.QuerySmResult;
|
||||
import org.jsmpp.session.SMPPSession;
|
||||
import org.jsmpp.session.Session;
|
||||
import org.jsmpp.session.SubmitSmResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
|
||||
* the SMPP server named by host/port args, and answers with the result as JSON. Kept alive as one
|
||||
* process so a session can be reused across several requests, the way a real ESME would.
|
||||
*/
|
||||
public final class Driver {
|
||||
private static final Map<String, SMPPSession> sessions = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Integer> requestedVersions = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Socket> rawSockets = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Integer> rawSeqNr = new ConcurrentHashMap<>();
|
||||
private static String host;
|
||||
private static int port;
|
||||
|
||||
private Driver() { }
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
host = args.length > 0 ? args[0] : "node";
|
||||
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
|
||||
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
|
||||
server.createContext("/bind", Driver::handleBind);
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
||||
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
||||
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
|
||||
server.createContext("/rawBind", Driver::handleRawBind);
|
||||
server.createContext("/rawUnknownCommand", exchange -> handleRawAction(exchange, RawSmpp::unknownCommandPdu));
|
||||
server.createContext("/rawTruncatedTlv", exchange -> handleRawAction(exchange, RawSmpp::truncatedTlvDeliverSmPdu));
|
||||
server.createContext("/rawShortBody", exchange -> handleRawAction(exchange, RawSmpp::shortBodyDeliverSmPdu));
|
||||
server.createContext("/rawEnquireLink", exchange -> handleRawAction(exchange, RawSmpp::enquireLinkPdu));
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
System.out.println("jsmpp driver listening on 8080, target " + host + ":" + port);
|
||||
}
|
||||
|
||||
// --- HTTP plumbing ---
|
||||
|
||||
private static Map<String, String> queryParams(HttpExchange exchange) {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
String query = exchange.getRequestURI().getRawQuery();
|
||||
|
||||
if (query == null) return params;
|
||||
|
||||
for (String pair : query.split("&")) {
|
||||
int eq = pair.indexOf('=');
|
||||
String key = eq < 0 ? pair : pair.substring(0, eq);
|
||||
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
|
||||
params.put(key, value);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) {
|
||||
try {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(bytes);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// The client gave up reading; nothing left to answer.
|
||||
}
|
||||
}
|
||||
|
||||
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
|
||||
respond(exchange, 200, Json.write(result));
|
||||
}
|
||||
|
||||
private static void respondErr(HttpExchange exchange, Exception e) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", false);
|
||||
result.put("errorClass", e.getClass().getName());
|
||||
result.put("error", String.valueOf(e.getMessage()));
|
||||
|
||||
if (e instanceof NegativeResponseException nre) {
|
||||
result.put("commandStatus", nre.getCommandStatus());
|
||||
result.put("commandStatusHex", "0x" + Integer.toHexString(nre.getCommandStatus()));
|
||||
}
|
||||
|
||||
respond(exchange, 200, Json.write(result));
|
||||
}
|
||||
|
||||
// --- jsmpp-backed handlers ---
|
||||
|
||||
private static void handleBind(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
String name = p.getOrDefault("session", "default");
|
||||
|
||||
try {
|
||||
SMPPSession session = new SMPPSession();
|
||||
session.setMessageReceiverListener(new NoopListener());
|
||||
|
||||
String type = p.getOrDefault("type", "transceiver");
|
||||
BindType bindType = switch (type) {
|
||||
case "receiver" -> BindType.BIND_RX;
|
||||
case "transmitter" -> BindType.BIND_TX;
|
||||
default -> BindType.BIND_TRX;
|
||||
};
|
||||
|
||||
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
|
||||
InterfaceVersion interfaceVersion = InterfaceVersion.valueOf((byte) ifVersion);
|
||||
|
||||
String systemId = p.getOrDefault("systemId", "jsmpp");
|
||||
String password = p.getOrDefault("password", "jsmpppw");
|
||||
|
||||
BindParameter bindParameter = new BindParameter(bindType, systemId, password, "interop",
|
||||
TypeOfNumber.UNKNOWN, NumberingPlanIndicator.UNKNOWN, null, interfaceVersion);
|
||||
|
||||
String scSystemId = session.connectAndBind(host, port, bindParameter);
|
||||
|
||||
sessions.put(name, session);
|
||||
requestedVersions.put(name, ifVersion);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("scSystemId", scSystemId);
|
||||
result.put("requestedInterfaceVersion", ifVersion);
|
||||
result.put("negotiatedInterfaceVersion", session.getInterfaceVersion().value() & 0xFF);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleUnbind(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SMPPSession session = sessions.remove(p.getOrDefault("session", "default"));
|
||||
|
||||
try {
|
||||
if (session != null) session.unbindAndClose();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleEnquireLink(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
if (session == null) {
|
||||
result.put("ok", false);
|
||||
result.put("error", "no such session");
|
||||
respondOk(exchange, result);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
result.put("ok", true);
|
||||
result.put("sessionState", session.getSessionState().name());
|
||||
respondOk(exchange, result);
|
||||
}
|
||||
|
||||
private static byte[] encode(String text, String encoding) {
|
||||
return switch (encoding) {
|
||||
case "ucs2" -> text.getBytes(StandardCharsets.UTF_16BE);
|
||||
case "latin1" -> text.getBytes(StandardCharsets.ISO_8859_1);
|
||||
default -> text.getBytes(StandardCharsets.US_ASCII);
|
||||
};
|
||||
}
|
||||
|
||||
private static GeneralDataCoding dataCoding(String encoding) {
|
||||
Alphabet alphabet = switch (encoding) {
|
||||
case "ucs2" -> Alphabet.ALPHA_UCS2;
|
||||
case "latin1" -> Alphabet.ALPHA_LATIN1;
|
||||
default -> Alphabet.ALPHA_DEFAULT;
|
||||
};
|
||||
|
||||
return new GeneralDataCoding(alphabet, null, false);
|
||||
}
|
||||
|
||||
/** Chunk size chosen well under any segment limit for every encoding this driver sends. */
|
||||
private static final int CHUNK_CHARS = 130;
|
||||
|
||||
private static void handleSubmit(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
String from = p.getOrDefault("from", "12345");
|
||||
String to = p.getOrDefault("to", "67890");
|
||||
String text = p.getOrDefault("text", "hello");
|
||||
String mode = p.getOrDefault("mode", "plain");
|
||||
String encoding = p.getOrDefault("encoding", "gsm7");
|
||||
|
||||
try {
|
||||
java.util.List<Map<String, Object>> segments = new java.util.ArrayList<>();
|
||||
|
||||
switch (mode) {
|
||||
case "udh8" -> submitUdh(session, from, to, text, encoding, false, segments);
|
||||
case "udh16" -> submitUdh(session, from, to, text, encoding, true, segments);
|
||||
case "sar" -> submitSar(session, from, to, text, encoding, segments);
|
||||
case "payload" -> submitPayload(session, from, to, text, encoding, segments);
|
||||
default -> submitPlain(session, from, to, text, encoding, segments);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("segments", segments);
|
||||
respondOk(exchange, result);
|
||||
} catch (NegativeResponseException e) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("refused", true);
|
||||
result.put("commandStatus", e.getCommandStatus());
|
||||
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
|
||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||
encode(text, encoding));
|
||||
segments.add(Map.of("messageId", r.getMessageId()));
|
||||
}
|
||||
|
||||
private static java.util.List<String> chunk(String text, int size) {
|
||||
java.util.List<String> out = new java.util.ArrayList<>();
|
||||
|
||||
for (int i = 0; i < text.length(); i += size) {
|
||||
out.add(text.substring(i, Math.min(text.length(), i + size)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void submitUdh(SMPPSession session, String from, String to, String text, String encoding,
|
||||
boolean sixteenBit, java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
|
||||
int reference = sixteenBit ? 0x1234 : 0x42;
|
||||
int total = chunks.size();
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
byte[] chunkBytes = encode(chunks.get(i), encoding);
|
||||
byte[] udh = sixteenBit
|
||||
? new byte[] { 0x06, 0x08, 0x04, (byte) ((reference >> 8) & 0xFF), (byte) (reference & 0xFF), (byte) total, (byte) (i + 1) }
|
||||
: new byte[] { 0x05, 0x00, 0x03, (byte) reference, (byte) total, (byte) (i + 1) };
|
||||
byte[] shortMessage = new byte[udh.length + chunkBytes.length];
|
||||
System.arraycopy(udh, 0, shortMessage, 0, udh.length);
|
||||
System.arraycopy(chunkBytes, 0, shortMessage, udh.length, chunkBytes.length);
|
||||
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||
new ESMClass(0x40), (byte) 0, (byte) 1, null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||
shortMessage);
|
||||
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
|
||||
}
|
||||
}
|
||||
|
||||
private static void submitSar(SMPPSession session, String from, String to, String text, String encoding,
|
||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
|
||||
int reference = 0x77;
|
||||
int total = chunks.size();
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
byte[] chunkBytes = encode(chunks.get(i), encoding);
|
||||
OptionalParameter refNum = new OptionalParameter.Sar_msg_ref_num((short) reference);
|
||||
OptionalParameter totalSegments = new OptionalParameter.Sar_total_segments((byte) total);
|
||||
OptionalParameter seqNum = new OptionalParameter.Sar_segment_seqnum((byte) (i + 1));
|
||||
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||
chunkBytes, refNum, totalSegments, seqNum);
|
||||
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
|
||||
}
|
||||
}
|
||||
|
||||
private static void submitPayload(SMPPSession session, String from, String to, String text, String encoding,
|
||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
byte[] payload = encode(text, encoding);
|
||||
OptionalParameter messagePayload = new OptionalParameter.Message_payload(payload);
|
||||
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
|
||||
new byte[0], messagePayload);
|
||||
segments.add(Map.of("messageId", r.getMessageId()));
|
||||
}
|
||||
|
||||
private static void handleUnhandledCommand(HttpExchange exchange, String which) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
String messageId = p.getOrDefault("messageId", "0");
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (which) {
|
||||
case "query" -> {
|
||||
QuerySmResult r = session.queryShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
|
||||
NumberingPlanIndicator.UNKNOWN, "12345");
|
||||
respondOk(exchange, Map.of("ok", true, "refused", false, "finalDate", String.valueOf(r.getFinalDate())));
|
||||
}
|
||||
case "cancel" -> {
|
||||
session.cancelShortMessage("CMT", messageId, TypeOfNumber.INTERNATIONAL,
|
||||
NumberingPlanIndicator.UNKNOWN, "12345", TypeOfNumber.INTERNATIONAL,
|
||||
NumberingPlanIndicator.UNKNOWN, "67890");
|
||||
respondOk(exchange, Map.of("ok", true, "refused", false));
|
||||
}
|
||||
case "replace" -> {
|
||||
session.replaceShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
|
||||
NumberingPlanIndicator.UNKNOWN, "12345", null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, "replacement".getBytes(StandardCharsets.US_ASCII));
|
||||
respondOk(exchange, Map.of("ok", true, "refused", false));
|
||||
}
|
||||
default -> respond(exchange, 400, "{}");
|
||||
}
|
||||
} catch (NegativeResponseException e) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("refused", true);
|
||||
result.put("commandStatus", e.getCommandStatus());
|
||||
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- raw-socket handlers, for PDUs jsmpp's typed API cannot construct ---
|
||||
|
||||
private static void handleRawBind(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
String name = p.getOrDefault("raw", "default");
|
||||
|
||||
try {
|
||||
Socket sock = new Socket();
|
||||
sock.connect(new InetSocketAddress(host, port), 5000);
|
||||
|
||||
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
|
||||
int seqNr = 1;
|
||||
RawSmpp.write(sock, RawSmpp.bindTransceiverPdu(
|
||||
p.getOrDefault("systemId", "rawjsmpp"), p.getOrDefault("password", "rawpw"), ifVersion, seqNr));
|
||||
|
||||
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
|
||||
rawSockets.put(name, sock);
|
||||
rawSeqNr.put(name, seqNr + 1);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("bindResp", resp);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private interface PduBuilder {
|
||||
byte[] build(int seqNr);
|
||||
}
|
||||
|
||||
private static void handleRawAction(HttpExchange exchange, PduBuilder builder) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
String name = p.getOrDefault("raw", "default");
|
||||
Socket sock = rawSockets.get(name);
|
||||
|
||||
if (sock == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such raw socket - call rawBind first")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int seqNr = rawSeqNr.getOrDefault(name, 1);
|
||||
RawSmpp.write(sock, builder.build(seqNr));
|
||||
rawSeqNr.put(name, seqNr + 1);
|
||||
|
||||
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", true);
|
||||
result.put("response", resp);
|
||||
respondOk(exchange, result);
|
||||
} catch (Exception e) {
|
||||
respondErr(exchange, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoopListener implements MessageReceiverListener {
|
||||
@Override
|
||||
public void onAcceptDeliverSm(DeliverSm deliverSm) throws ProcessRequestException {
|
||||
// This phase's scenarios never have the SMSC push a deliver_sm to jsmpp.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAcceptAlertNotification(AlertNotification alertNotification) {
|
||||
// Nothing to do: no scenario here sends one.
|
||||
}
|
||||
|
||||
@Override
|
||||
public org.jsmpp.session.DataSmResult onAcceptDataSm(DataSm dataSm, Session source)
|
||||
throws ProcessRequestException {
|
||||
throw new ProcessRequestException("data_sm not supported by this driver", 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package smpp.interop.jsmpp;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
|
||||
final class Json {
|
||||
private Json() { }
|
||||
|
||||
static String write(Object value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
writeValue(sb, value);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void writeValue(StringBuilder sb, Object value) {
|
||||
if (value == null) {
|
||||
sb.append("null");
|
||||
} else if (value instanceof String s) {
|
||||
writeString(sb, s);
|
||||
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
|
||||
sb.append(value);
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (!first) sb.append(',');
|
||||
first = false;
|
||||
writeString(sb, String.valueOf(entry.getKey()));
|
||||
sb.append(':');
|
||||
writeValue(sb, entry.getValue());
|
||||
}
|
||||
sb.append('}');
|
||||
} else if (value instanceof List<?> list) {
|
||||
sb.append('[');
|
||||
boolean first = true;
|
||||
for (Object item : list) {
|
||||
if (!first) sb.append(',');
|
||||
first = false;
|
||||
writeValue(sb, item);
|
||||
}
|
||||
sb.append(']');
|
||||
} else if (value instanceof byte[] bytes) {
|
||||
writeString(sb, hex(bytes));
|
||||
} else {
|
||||
writeString(sb, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeString(StringBuilder sb, String s) {
|
||||
sb.append('"');
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> sb.append("\\\"");
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '\n' -> sb.append("\\n");
|
||||
case '\r' -> sb.append("\\r");
|
||||
case '\t' -> sb.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
|
||||
else sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
|
||||
static String hex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package smpp.interop.jsmpp;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A hand-rolled SMPP encoder over a plain socket, for the malformed PDUs jsmpp's own typed API
|
||||
* cannot construct (target 1: unknown command id, a truncated TLV stream, a body shorter than it
|
||||
* declares). Only what these scenarios need - a bind and the malformed shapes - not a real client.
|
||||
*/
|
||||
final class RawSmpp {
|
||||
private RawSmpp() { }
|
||||
|
||||
private static void u8(ByteArrayOutputStream out, int v) {
|
||||
out.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void u32(ByteArrayOutputStream out, long v) {
|
||||
out.write((int) ((v >> 24) & 0xFF));
|
||||
out.write((int) ((v >> 16) & 0xFF));
|
||||
out.write((int) ((v >> 8) & 0xFF));
|
||||
out.write((int) (v & 0xFF));
|
||||
}
|
||||
|
||||
private static void cstring(ByteArrayOutputStream out, String s) {
|
||||
if (s != null && !s.isEmpty()) out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1));
|
||||
out.write(0);
|
||||
}
|
||||
|
||||
private static byte[] pdu(int cmdId, int status, int seqNr, byte[] body) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
u32(out, 16L + body.length);
|
||||
u32(out, cmdId);
|
||||
u32(out, status);
|
||||
u32(out, seqNr);
|
||||
out.writeBytes(body);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] bindTransceiverPdu(String systemId, String password, int interfaceVersion, int seqNr) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
cstring(body, systemId);
|
||||
cstring(body, password);
|
||||
cstring(body, "");
|
||||
u8(body, interfaceVersion);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, "");
|
||||
return pdu(0x00000009, 0, seqNr, body.toByteArray());
|
||||
}
|
||||
|
||||
/** command_id 0x00050001 names no SMPP 3.4 command - an empty body is a well-framed PDU. */
|
||||
static byte[] unknownCommandPdu(int seqNr) {
|
||||
return pdu(0x00050001, 0, seqNr, new byte[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliver_sm whose mandatory fields are all present and correct, followed by one TLV header
|
||||
* whose declared length (200) runs past cmd_length - the codec's parseTlvs() refuses this with
|
||||
* reason 'tlvs'.
|
||||
*/
|
||||
static byte[] truncatedTlvDeliverSmPdu(int seqNr) {
|
||||
ByteArrayOutputStream body = deliverSmMandatory("raw-from", "raw-to", "truncated tlv probe");
|
||||
// Tag 0x001D (any tag id serves), declared length 200, only 4 value octets actually follow.
|
||||
// A full 8-byte tail (not a bare 4-byte header) so the codec's trailing-NUL retry - which
|
||||
// shifts the TLV region by one octet looking for a padded short_message - still finds an
|
||||
// overrunning length rather than silently swallowing an unparsed remainder as alignment slack.
|
||||
body.write(0x00);
|
||||
body.write(0x1D);
|
||||
body.write(0x00);
|
||||
body.write(0xC8);
|
||||
body.write(0x41);
|
||||
body.write(0x41);
|
||||
body.write(0x41);
|
||||
body.write(0x41);
|
||||
return pdu(0x00000005, 0, seqNr, body.toByteArray());
|
||||
}
|
||||
|
||||
/** A deliver_sm whose sm_length declares 200 octets while only 5 are actually present. */
|
||||
static byte[] shortBodyDeliverSmPdu(int seqNr) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
cstring(body, "");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, "raw-from");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, "raw-to");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, "");
|
||||
cstring(body, "");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 200); // sm_length declares 200 octets
|
||||
body.writeBytes("short".getBytes(StandardCharsets.ISO_8859_1)); // only 5 actually follow
|
||||
return pdu(0x00000005, 0, seqNr, body.toByteArray());
|
||||
}
|
||||
|
||||
static byte[] enquireLinkPdu(int seqNr) {
|
||||
return pdu(0x00000015, 0, seqNr, new byte[0]);
|
||||
}
|
||||
|
||||
private static ByteArrayOutputStream deliverSmMandatory(String from, String to, String text) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
cstring(body, "");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, from);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, to);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
cstring(body, "");
|
||||
cstring(body, "");
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
u8(body, 0);
|
||||
byte[] textBytes = text.getBytes(StandardCharsets.ISO_8859_1);
|
||||
u8(body, textBytes.length);
|
||||
body.writeBytes(textBytes);
|
||||
return body;
|
||||
}
|
||||
|
||||
static void write(Socket sock, byte[] pdu) throws IOException {
|
||||
OutputStream out = sock.getOutputStream();
|
||||
out.write(pdu);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
/** Reads exactly one PDU (command_length-framed) and parses its 16-octet header. */
|
||||
static Map<String, Object> readOne(Socket sock, int timeoutMs) throws IOException {
|
||||
sock.setSoTimeout(timeoutMs);
|
||||
DataInputStream in = new DataInputStream(sock.getInputStream());
|
||||
byte[] lenBytes = new byte[4];
|
||||
in.readFully(lenBytes);
|
||||
long cmdLength = ((long) (lenBytes[0] & 0xFF) << 24) | ((lenBytes[1] & 0xFF) << 16)
|
||||
| ((lenBytes[2] & 0xFF) << 8) | (lenBytes[3] & 0xFF);
|
||||
byte[] rest = new byte[(int) cmdLength - 4];
|
||||
in.readFully(rest);
|
||||
|
||||
int cmdId = b32(rest, 0);
|
||||
int status = b32(rest, 4);
|
||||
int seqNr = b32(rest, 8);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("cmdId", cmdId);
|
||||
result.put("cmdIdHex", "0x" + Integer.toHexString(cmdId));
|
||||
result.put("cmdStatus", status);
|
||||
result.put("cmdStatusHex", "0x" + Integer.toHexString(status));
|
||||
result.put("seqNr", seqNr);
|
||||
byte[] full = new byte[4 + rest.length];
|
||||
System.arraycopy(lenBytes, 0, full, 0, 4);
|
||||
System.arraycopy(rest, 0, full, 4, rest.length);
|
||||
result.put("hex", Json.hex(full));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int b32(byte[] b, int offset) {
|
||||
return ((b[offset] & 0xFF) << 24) | ((b[offset + 1] & 0xFF) << 16)
|
||||
| ((b[offset + 2] & 0xFF) << 8) | (b[offset + 3] & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user