Run jsmpp and Cloudhopper against our server: sar_* fragmentation confirmed and a truncated TLV tail silently accepted

This commit is contained in:
2026-09-06 07:00:45 +02:00
parent 4194816d6b
commit 0d1da1dc87
16 changed files with 2147 additions and 1 deletions
@@ -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"]
+52
View File
@@ -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();
}
}
+32
View File
@@ -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"]
+47
View File
@@ -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);
}
}