Java SMPP helper

6 minute read

One of my projects involves communicating over SMS with cellular-connected IoT devices. The company already has a working infastructure for sending and recieving SMS with Twilio, which worked well for most of the time. The API was convenient (HTTP to send, HTTP webhooks to recieve), pricing was reasonable, and reliability was OK. However, there was a small problem.

All SMS messages to T-Mobile numbers in the US would fail with 30005 The destination number you are trying to reach is unknown and may no longer exist.
SMS messages from our presonal phones to those numbers would work just fine. Even weirder, messages from Twilio to random T-Mobile numbers that are not part of our plan also worked.
When looking for a solution, I found this excerpt on Twilio’s support page:

T-Mobile has a feature that allows recipients on their network to enable a setting which blocks ANY SMS sent to their mobile number from business-type sender number. SMS sent to T-Mobile recipients with this feature enabled often returns error 30005 in the SMS logs, indicating the block. This can only be removed by having the recipient contact T-Mobile Support directly to request that setting be turned off.

I reached out to the comapny providing us with those phone lines, and they replied with this quote from T-Mobile:

As a courtesy, T-Mobile is notifying our IoT/M2M customers about potential issues related to using SMS aggregators. For SMS aggregators to send messages to T-Mobile IoT customers, the SMS aggregator must register their lines and associated use cases with T-Mobile as an IoT/M2M service. If they do not complete this registration, then messages will fail. If you are encountering any issues with receiving messages from your SMS aggregator, please reach out to your SMS aggregator to complete the required IoT service registration.

So it seems that T-Mobile wants us to pre-register our outgoing numbers as IoT specific (and not marketing-related, I guess) which is something Twilio would need to do, and Twilio tells us to go ask T-Mobile to unblock us, which seems like a deadlock.
After telling our line provider that this makes us sad, they suggested connecting us to their SMPP relay, and having us send messages through that instead of Twilio.
All good and well, except that the team has no idea bout SMPP. My goal was not only making the SMPP connection work, but also to leave the team with an understandable interface they can modify without my help later.

Prototyping

I couldn’t easily find a Java-based SMPP CLI. I did find smpp-cli, which works really nicely:

npx smpp-cli send MySender +34000000000 "Hello World!" -h smpp.example.com -p 2675 -L loginUser -P myPassword

Since SMPP is not encrypted, we implemented a TLS wrapper around it (nginx on the server side, I believe). This CLI doesn’t support TLS though, so I did something like this:

socat tcp-listen:2675,fork,reuseaddr openssl:smpp.example.com:3675

This setup had sane-enough defaults to work out of the box, and allow me to wireshark-inspect the traffic to fine-tune my Java counterpart.

Now in Java

jSMPP is great and works. It’s a bit unintuitive to get started with, and has a little bit of Factory-Pattern-Interface-Factory. While I managed to scrounge a working MVP, using the library directly in our business logic would prove unmaintainable later on.
I created the following collection of Java wrappers:

Small helper class for all of the “connection secrets” - user, password, and number the messages are sent from (imagine a FROM header in SMTP):

public class CredSet {

	public final String user;
	public final String password;
	public final String originatingNumber;

	public CredSet(String user, String password, String originatingNumber) {
		this.user = user;
		this.password = password;
		this.originatingNumber = originatingNumber;
	}
}

Another one for holding server connection details - host, port, and whether to use SSL (no / yes but don’t check the server’s certificate / yes):

public class HostPort {
	public final String host;
	public final int port;
	public final SSL_MODE sslMode;

	public enum SSL_MODE {
		NONE,
		USE,
		USE_IGNORE_CERTIFICATE;
	}

	public HostPort(String host, int port, SSL_MODE sslMode) {
		this.host = host;
		this.port = port;
		this.sslMode = sslMode;
	}
}

A message struct:

public static class Message {
	public final String number;
	public final String body;

	public Message(String number, String body) {
		this.number = number;
		this.body = body;
	}
}

And now getting a bit SMPP-ish, a class that abstract jSMPP’s incoming message event handling:

class IncomingMessageHandler implements MessageReceiverListener {

	private final Consumer<Message> processor;
	public IncomingMessageHandler(Consumer<Message> processor) {
		this.processor = processor;
	}

	@Override
	public void onAcceptDeliverSm(DeliverSm deliverSm) {
		if (!MessageType.SMSC_DEL_RECEIPT.containedIn(deliverSm.getEsmClass())) {
			processor.accept(new Message(deliverSm.getSourceAddr(), new String(deliverSm.getShortMessage())));
		}
	}

	@Override
	public void onAcceptAlertNotification(AlertNotification alertNotification) {

	}

	@Override
	public DataSmResult onAcceptDataSm(DataSm dataSm, Session source) {
		return null;
	}
}

Note that we’re accepting a Lambda-friendly Consumer<Mesasge> (a function that gets a message and returns nothing) as a parameter, which will be called if this is a truly incoming message (and not a delivery report or something). We’re also implementing the other mandatory methods for the interface, even though we don’t need them.

Now for the big class:

public class SmppHandler {
    private final String originatingNumber;
    private final SMPPSession session;

    public SmppHandler(CredSet credSet, HostPort hostPort, Consumer<Message> incomingMessage) {
        this.originatingNumber = credSet.originatingNumber;

        switch (hostPort.sslMode) {
            case NONE:
                session = new SMPPSession();
                break;
            case USE:
                session = new SMPPSession(new TrustStoreSSLSocketConnectionFactory(false));
                break;
            case USE_IGNORE_CERTIFICATE:
                session = new SMPPSession(new TrustStoreSSLSocketConnectionFactory(true));
                break;
            default:
                throw new RuntimeException(String.format("Unhandled enum value %s", hostPort.sslMode));
        }

        boolean bindIncoming = incomingMessage != null;
        
        try {
            session.connectAndBind(hostPort.host,
                    hostPort.port,
                    new BindParameter(
                            bindIncoming ? BindType.BIND_TRX : BindType.BIND_TX,
                            credSet.user,
                            credSet.password,
                            "cp",
                            TypeOfNumber.UNKNOWN,
                            NumberingPlanIndicator.UNKNOWN,
                            null));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        if (bindIncoming) {
            session.setMessageReceiverListener(new IncomingMessageHandler(incomingMessage));
        }
    }

    public void sendMessage(Message message) {
        try {
            session.submitShortMessage("CMT",
                    TypeOfNumber.UNKNOWN,
                    NumberingPlanIndicator.UNKNOWN,
                    originatingNumber,
                    TypeOfNumber.UNKNOWN,
                    NumberingPlanIndicator.UNKNOWN,
                    message.number,
                    new ESMClass(0),
                    (byte) 0,
                    (byte) 0,
                    null,
                    null,
                    new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT),
                    (byte) 0,
                    new GeneralDataCoding(Alphabet.ALPHA_IA5, null, false),
                    (byte) 0,
                    message.body.getBytes());
        } catch (PDUException | InvalidResponseException | NegativeResponseException | IOException |
                 ResponseTimeoutException e) {
            throw new RuntimeException(e);
        }
    }
}

We can see some mysterious parameters in sendMessage, but I copied them off smpp-cli and it works, so I’m happy.

Things I like about my design

All of the outside interaction with this class has no SMPP-ish artifacts - you send and recieve Message objects, no need to consider “EMS classees” or “NPI”, or sessions or events.
We might need to when we modify our SMPP connections, but for pure business logic, you can put all of the SMPP stuff out of your mind.
Another nice trickery is that we’re only binding for sending and receiving messages when provided with an incomingMessage handler. This allows us to add clients that send messages, without having them “steal” the incoming messages and do nothing with them.

SSL in Java remains annoying

Although there was some documentation in the jSMPP repo on how to connect via TLS, one part was unclear to me - how do I load the trust store, where all of the Internet’s PKI certificates are configured?
In more user-friendly languages, like Python or NodeJS or even Perl, as long as you use a proper, boring Linux distro, this trust store is autoconfigured for you. You can just do:

use strict;
use warnings;
use LWP::UserAgent;

my $B = new LWP::UserAgent (agent => 'Mozilla/5.0', cookie_jar =>{});

my $GET = $B->get('https://moz.com')->content;
print $GET;

or

import urllib.request
page = urllib.request.urlopen('https://google.com')
print(page.read())

But not Java. Java has its own format of certificate store, and even if it’s installed by your distro, you still need to tell Java where it is, as it won’t try looking for the file itself. After not finding an elegant solution, I settled on this:

private File getCertificateStorePath() {
    List<String> candidates = List.of("/etc/ssl/certs/java/cacerts", "/etc/pki/java/cacerts");
    return candidates.stream()
            .map(File::new)
            .filter(File::exists)
            .findFirst()
            .orElseThrow(() -> new RuntimeException("No certificate store found"));
}

Then using it like this:

KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(new FileInputStream(getCertificateStorePath()), null);

Not my finest work - I just noted every place those certs can be located on our servers, and going through them in order.

Tags: ,

Updated: