diff --git a/.gitattributes b/.gitattributes
index 1372660..38e255e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,6 +2,10 @@
# binary disables any text transformation git or editors might otherwise apply.
*.raw binary
+# Pre-generated test keystore — keep as opaque binary bytes.
+*.p12 binary
+*.jks binary
+
# Java sources, properties, markdown — normal LF normalisation in repo.
*.java text eol=lf
*.xml text eol=lf
diff --git a/sip-transport-nio/src/main/java/com/sip/transport/nio/NioTlsTransport.java b/sip-transport-nio/src/main/java/com/sip/transport/nio/NioTlsTransport.java
new file mode 100644
index 0000000..4267140
--- /dev/null
+++ b/sip-transport-nio/src/main/java/com/sip/transport/nio/NioTlsTransport.java
@@ -0,0 +1,347 @@
+package com.sip.transport.nio;
+
+import com.sip.codec.SipCodecException;
+import com.sip.codec.SipEncoder;
+import com.sip.codec.SipParser;
+import com.sip.message.SipMessage;
+import com.sip.transport.Endpoint;
+import com.sip.transport.InboundMessage;
+import com.sip.transport.MessageListener;
+import com.sip.transport.Transport;
+import com.sip.transport.TransportType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLSocket;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * TLS transport — SIPS / SIP over TLS (RFC 3261 §26.2, RFC 5630).
+ *
+ *
Architecture mirrors {@link NioTcpTransport}: per-connection virtual
+ * thread + Content-Length framing + connection reuse (RFC 5923). The
+ * difference is the socket factory comes from a caller-supplied
+ * {@link SSLContext}, so server-cert provisioning, mTLS, cipher suite
+ * selection, and protocol version pinning all live on the application
+ * side, exactly as they should.
+ *
+ * Hostname verification (RFC 5922)
+ * When dialing as a client, {@link SSLSocket}'s default does NOT verify
+ * the peer hostname against the certificate. We enable HTTPS-style
+ * endpoint identification, which RFC 5922 explicitly requires for SIPS.
+ */
+public final class NioTlsTransport implements Transport {
+
+ private static final Logger log = LoggerFactory.getLogger(NioTlsTransport.class);
+
+ private final SSLContext sslContext;
+ private final InetSocketAddress bindAddress;
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicLong rxMessages = new AtomicLong();
+ private final AtomicLong txMessages = new AtomicLong();
+ private final AtomicLong parseErrors = new AtomicLong();
+ private final ConcurrentHashMap connections =
+ new ConcurrentHashMap<>();
+
+ private volatile MessageListener listener;
+ private volatile SSLServerSocket serverSocket;
+ private volatile Endpoint local;
+ private volatile java.util.concurrent.ExecutorService executor;
+
+ public NioTlsTransport(SSLContext sslContext, InetSocketAddress bindAddress) {
+ this.sslContext = Objects.requireNonNull(sslContext, "sslContext");
+ this.bindAddress = Objects.requireNonNull(bindAddress, "bindAddress");
+ }
+
+ @Override
+ public TransportType type() {
+ return TransportType.TLS;
+ }
+
+ @Override
+ public Endpoint local() {
+ return local;
+ }
+
+ @Override
+ public CompletableFuture start() {
+ if (!running.compareAndSet(false, true)) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("transport already started"));
+ }
+ try {
+ SSLServerSocket ss = (SSLServerSocket) sslContext.getServerSocketFactory()
+ .createServerSocket();
+ ss.setReuseAddress(true);
+ ss.bind(bindAddress);
+ this.serverSocket = ss;
+ this.local = new Endpoint(TransportType.TLS,
+ (InetSocketAddress) ss.getLocalSocketAddress());
+ this.executor = Executors.newThreadPerTaskExecutor(
+ Thread.ofVirtual().name("sip-tls-", 0).factory());
+ executor.submit(this::acceptLoop);
+ log.info("TLS transport bound to {}", local.address());
+ return CompletableFuture.completedFuture(null);
+ } catch (IOException e) {
+ running.set(false);
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public CompletableFuture send(SipMessage message, Endpoint peer) {
+ if (!running.get()) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("transport not started"));
+ }
+ if (peer.transport() != TransportType.TLS) {
+ return CompletableFuture.failedFuture(
+ new IllegalArgumentException(
+ "TLS transport cannot send to " + peer.transport() + " endpoint"));
+ }
+ try {
+ byte[] bytes = SipEncoder.encode(message);
+ Connection conn = connectionFor(peer.address());
+ conn.send(bytes);
+ txMessages.incrementAndGet();
+ return CompletableFuture.completedFuture(null);
+ } catch (IOException | SipCodecException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public void listener(MessageListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!running.compareAndSet(true, false)) {
+ return;
+ }
+ SSLServerSocket ss = this.serverSocket;
+ if (ss != null) {
+ ss.close();
+ }
+ for (Connection conn : connections.values()) {
+ conn.close();
+ }
+ connections.clear();
+ java.util.concurrent.ExecutorService exec = this.executor;
+ if (exec != null) {
+ exec.shutdownNow();
+ }
+ log.info("TLS transport closed; rx={} tx={} parseErrors={}",
+ rxMessages.get(), txMessages.get(), parseErrors.get());
+ }
+
+ public long receivedMessages() { return rxMessages.get(); }
+ public long sentMessages() { return txMessages.get(); }
+ public long parseErrors() { return parseErrors.get(); }
+
+ /* -------- internals -------- */
+
+ private void acceptLoop() {
+ while (running.get()) {
+ SSLSocket socket;
+ try {
+ socket = (SSLSocket) serverSocket.accept();
+ } catch (java.net.SocketException closed) {
+ return;
+ } catch (IOException e) {
+ if (running.get()) {
+ log.warn("TLS accept error: {}", e.toString());
+ }
+ continue;
+ }
+ InetSocketAddress peerAddr = (InetSocketAddress) socket.getRemoteSocketAddress();
+ Connection conn = new Connection(socket, peerAddr);
+ connections.put(peerAddr, conn);
+ executor.submit(conn::readLoop);
+ }
+ }
+
+ private Connection connectionFor(InetSocketAddress peer) throws IOException {
+ Connection existing = connections.get(peer);
+ if (existing != null && existing.alive()) {
+ return existing;
+ }
+ SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket();
+ // RFC 5922 — SIPS requires hostname verification.
+ var params = socket.getSSLParameters();
+ params.setEndpointIdentificationAlgorithm("HTTPS");
+ socket.setSSLParameters(params);
+
+ socket.connect(peer, 5_000);
+ socket.startHandshake();
+
+ Connection conn = new Connection(socket, peer);
+ connections.put(peer, conn);
+ executor.submit(conn::readLoop);
+ return conn;
+ }
+
+ final class Connection implements AutoCloseable {
+ private final Socket socket;
+ private final InetSocketAddress peerAddr;
+ private final InputStream in;
+ private final OutputStream out;
+ private final ReentrantLock writeLock = new ReentrantLock();
+ private volatile boolean closed;
+
+ Connection(Socket socket, InetSocketAddress peerAddr) {
+ this.socket = socket;
+ this.peerAddr = peerAddr;
+ try {
+ this.in = socket.getInputStream();
+ this.out = socket.getOutputStream();
+ } catch (IOException e) {
+ throw new RuntimeException("failed to open streams for " + peerAddr, e);
+ }
+ }
+
+ boolean alive() {
+ return !closed && !socket.isClosed();
+ }
+
+ void send(byte[] bytes) throws IOException {
+ writeLock.lock();
+ try {
+ out.write(bytes);
+ out.flush();
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ void readLoop() {
+ try {
+ while (running.get() && !closed) {
+ byte[] msgBytes = readOneMessage();
+ if (msgBytes == null) {
+ return;
+ }
+ rxMessages.incrementAndGet();
+ dispatch(msgBytes);
+ }
+ } catch (IOException e) {
+ if (running.get()) {
+ log.debug("TLS read error on {}: {}", peerAddr, e.toString());
+ }
+ } finally {
+ close();
+ }
+ }
+
+ private byte[] readOneMessage() throws IOException {
+ java.io.ByteArrayOutputStream header = new java.io.ByteArrayOutputStream(512);
+ int crlfState = 0;
+ while (crlfState != 4) {
+ int b = in.read();
+ if (b < 0) {
+ if (header.size() == 0) {
+ return null;
+ }
+ throw new IOException("EOF mid-header (read " + header.size() + " bytes)");
+ }
+ header.write(b);
+ crlfState = switch (b) {
+ case '\r' -> (crlfState == 2) ? 3 : 1;
+ case '\n' -> (crlfState == 1) ? 2 : (crlfState == 3) ? 4 : 0;
+ default -> 0;
+ };
+ if (header.size() > 64 * 1024) {
+ throw new IOException("header block exceeds 64 KiB");
+ }
+ }
+ byte[] headerBytes = header.toByteArray();
+ int contentLength = scanContentLength(headerBytes);
+ if (contentLength == 0) {
+ return headerBytes;
+ }
+ byte[] full = new byte[headerBytes.length + contentLength];
+ System.arraycopy(headerBytes, 0, full, 0, headerBytes.length);
+ int read = 0;
+ while (read < contentLength) {
+ int n = in.read(full, headerBytes.length + read, contentLength - read);
+ if (n < 0) {
+ throw new IOException("EOF mid-body after "
+ + read + " of " + contentLength + " bytes");
+ }
+ read += n;
+ }
+ return full;
+ }
+
+ private int scanContentLength(byte[] hdr) throws IOException {
+ String text = new String(hdr, StandardCharsets.US_ASCII);
+ int idx = 0;
+ while (idx < text.length()) {
+ int eol = text.indexOf("\r\n", idx);
+ if (eol < 0) break;
+ String line = text.substring(idx, eol);
+ idx = eol + 2;
+ int colon = line.indexOf(':');
+ if (colon <= 0) continue;
+ String name = line.substring(0, colon).trim();
+ if (name.equalsIgnoreCase("Content-Length") || name.equalsIgnoreCase("l")) {
+ String value = line.substring(colon + 1).trim();
+ try {
+ int v = Integer.parseInt(value);
+ if (v < 0) {
+ throw new IOException("Content-Length is negative on TLS: " + v);
+ }
+ return v;
+ } catch (NumberFormatException e) {
+ throw new IOException("Content-Length not numeric: '" + value + "'");
+ }
+ }
+ }
+ return 0;
+ }
+
+ private void dispatch(byte[] bytes) {
+ MessageListener l = listener;
+ Endpoint peer = new Endpoint(TransportType.TLS, peerAddr);
+ try {
+ SipMessage msg = SipParser.parse(bytes);
+ if (l != null) {
+ l.onMessage(new InboundMessage(msg, peer, local, Instant.now()));
+ }
+ } catch (SipCodecException e) {
+ parseErrors.incrementAndGet();
+ if (l != null) {
+ try { l.onError(peer, e); } catch (Throwable ignore) { }
+ }
+ } catch (Throwable t) {
+ if (l != null) {
+ try { l.onError(peer, t); } catch (Throwable ignore) { }
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ if (closed) return;
+ closed = true;
+ try { socket.close(); } catch (IOException ignore) { }
+ connections.remove(peerAddr, this);
+ }
+ }
+}
diff --git a/sip-transport-nio/src/main/java/com/sip/transport/nio/NioWebSocketTransport.java b/sip-transport-nio/src/main/java/com/sip/transport/nio/NioWebSocketTransport.java
new file mode 100644
index 0000000..08c861a
--- /dev/null
+++ b/sip-transport-nio/src/main/java/com/sip/transport/nio/NioWebSocketTransport.java
@@ -0,0 +1,348 @@
+package com.sip.transport.nio;
+
+import com.sip.codec.SipCodecException;
+import com.sip.codec.SipEncoder;
+import com.sip.codec.SipParser;
+import com.sip.message.SipMessage;
+import com.sip.transport.Endpoint;
+import com.sip.transport.InboundMessage;
+import com.sip.transport.MessageListener;
+import com.sip.transport.Transport;
+import com.sip.transport.TransportType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.time.Instant;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * WebSocket transport — SIP over WS / WSS (RFC 7118).
+ *
+ * One TEXT WebSocket frame carries exactly one SIP message: this lets
+ * the receiver use the frame boundary as message boundary, so the
+ * {@code Content-Length}-driven framing logic from TCP is not needed.
+ *
+ * Threading
+ *
+ * - Server: a virtual-thread accept loop; each connection then runs
+ * handshake + frame read loop on its own virtual thread.
+ * - Client: outbound dial opens a TCP socket, runs the client-side
+ * handshake, then reuses the same virtual-thread read loop.
+ * - Sends serialise per-connection with a {@link ReentrantLock} so
+ * interleaved frames are well-formed.
+ *
+ *
+ * Path / host
+ * The server accepts upgrades on path {@code /}. The client transport
+ * uses path {@code /} and {@code Host: host:port} by default. SIP-aware
+ * intermediaries that key on the request URI can override later.
+ */
+public final class NioWebSocketTransport implements Transport {
+
+ private static final Logger log = LoggerFactory.getLogger(NioWebSocketTransport.class);
+
+ private final InetSocketAddress bindAddress;
+ private final boolean isClientRole;
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicLong rxMessages = new AtomicLong();
+ private final AtomicLong txMessages = new AtomicLong();
+ private final AtomicLong parseErrors = new AtomicLong();
+ private final ConcurrentHashMap connections =
+ new ConcurrentHashMap<>();
+
+ private volatile MessageListener listener;
+ private volatile ServerSocket serverSocket;
+ private volatile Endpoint local;
+ private volatile java.util.concurrent.ExecutorService executor;
+
+ /**
+ * @param bindAddress local socket address. For server role pass the
+ * listening port; for pure client role pass 0
+ * (an ephemeral port is allocated but never accepted on).
+ * @param clientRole {@code true} to disable the accept loop and only
+ * use the transport for outbound dials (which still
+ * receive responses on the dialed connection).
+ */
+ public NioWebSocketTransport(InetSocketAddress bindAddress, boolean clientRole) {
+ this.bindAddress = bindAddress;
+ this.isClientRole = clientRole;
+ }
+
+ public NioWebSocketTransport(InetSocketAddress bindAddress) {
+ this(bindAddress, false);
+ }
+
+ @Override
+ public TransportType type() {
+ return TransportType.WS;
+ }
+
+ @Override
+ public Endpoint local() {
+ return local;
+ }
+
+ @Override
+ public CompletableFuture start() {
+ if (!running.compareAndSet(false, true)) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("transport already started"));
+ }
+ try {
+ this.executor = Executors.newThreadPerTaskExecutor(
+ Thread.ofVirtual().name("sip-ws-", 0).factory());
+ ServerSocket ss = new ServerSocket();
+ ss.setReuseAddress(true);
+ ss.bind(bindAddress);
+ this.serverSocket = ss;
+ this.local = new Endpoint(TransportType.WS,
+ (InetSocketAddress) ss.getLocalSocketAddress());
+ if (!isClientRole) {
+ executor.submit(this::acceptLoop);
+ }
+ log.info("WebSocket transport bound to {} (role={})",
+ local.address(), isClientRole ? "client" : "server");
+ return CompletableFuture.completedFuture(null);
+ } catch (IOException e) {
+ running.set(false);
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public CompletableFuture send(SipMessage message, Endpoint peer) {
+ if (!running.get()) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("transport not started"));
+ }
+ if (peer.transport() != TransportType.WS) {
+ return CompletableFuture.failedFuture(
+ new IllegalArgumentException(
+ "WS transport cannot send to " + peer.transport() + " endpoint"));
+ }
+ try {
+ byte[] bytes = SipEncoder.encode(message);
+ Connection conn = connectionFor(peer.address());
+ conn.send(bytes);
+ txMessages.incrementAndGet();
+ return CompletableFuture.completedFuture(null);
+ } catch (IOException | SipCodecException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public void listener(MessageListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!running.compareAndSet(true, false)) {
+ return;
+ }
+ ServerSocket ss = this.serverSocket;
+ if (ss != null) {
+ ss.close();
+ }
+ for (Connection conn : connections.values()) {
+ conn.close();
+ }
+ connections.clear();
+ java.util.concurrent.ExecutorService exec = this.executor;
+ if (exec != null) {
+ exec.shutdownNow();
+ }
+ log.info("WebSocket transport closed; rx={} tx={} parseErrors={}",
+ rxMessages.get(), txMessages.get(), parseErrors.get());
+ }
+
+ public long receivedMessages() { return rxMessages.get(); }
+ public long sentMessages() { return txMessages.get(); }
+ public long parseErrors() { return parseErrors.get(); }
+
+ /* -------- internals -------- */
+
+ private void acceptLoop() {
+ while (running.get()) {
+ Socket socket;
+ try {
+ socket = serverSocket.accept();
+ } catch (java.net.SocketException closed) {
+ return;
+ } catch (IOException e) {
+ if (running.get()) {
+ log.warn("WS accept error: {}", e.toString());
+ }
+ continue;
+ }
+ InetSocketAddress peerAddr = (InetSocketAddress) socket.getRemoteSocketAddress();
+ executor.submit(() -> handleServerSide(socket, peerAddr));
+ }
+ }
+
+ private void handleServerSide(Socket socket, InetSocketAddress peerAddr) {
+ try {
+ WebSocketHandshake.acceptServerHandshake(
+ socket.getInputStream(), socket.getOutputStream());
+ } catch (IOException e) {
+ log.debug("WS handshake failed from {}: {}", peerAddr, e.toString());
+ try { socket.close(); } catch (IOException ignore) { }
+ return;
+ }
+ Connection conn = new Connection(socket, peerAddr, /* maskOutbound */ false);
+ connections.put(peerAddr, conn);
+ conn.readLoop();
+ }
+
+ private Connection connectionFor(InetSocketAddress peer) throws IOException {
+ Connection existing = connections.get(peer);
+ if (existing != null && existing.alive()) {
+ return existing;
+ }
+ Socket socket = new Socket();
+ socket.connect(peer, 5_000);
+ WebSocketHandshake.initiateClientHandshake(
+ socket.getInputStream(), socket.getOutputStream(),
+ peer.getHostString() + ":" + peer.getPort(), "/");
+ Connection conn = new Connection(socket, peer, /* maskOutbound */ true);
+ connections.put(peer, conn);
+ executor.submit(conn::readLoop);
+ return conn;
+ }
+
+ final class Connection implements AutoCloseable {
+ private final Socket socket;
+ private final InetSocketAddress peerAddr;
+ private final InputStream in;
+ private final OutputStream out;
+ private final boolean maskOutbound;
+ private final ReentrantLock writeLock = new ReentrantLock();
+ private volatile boolean closed;
+
+ Connection(Socket socket, InetSocketAddress peerAddr, boolean maskOutbound) {
+ this.socket = socket;
+ this.peerAddr = peerAddr;
+ this.maskOutbound = maskOutbound;
+ try {
+ this.in = socket.getInputStream();
+ this.out = socket.getOutputStream();
+ } catch (IOException e) {
+ throw new RuntimeException("failed to open streams for " + peerAddr, e);
+ }
+ }
+
+ boolean alive() {
+ return !closed && !socket.isClosed();
+ }
+
+ void send(byte[] bytes) throws IOException {
+ writeLock.lock();
+ try {
+ WebSocketFrame.write(out, WebSocketFrame.OPCODE_TEXT, bytes, maskOutbound);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ void readLoop() {
+ try {
+ while (running.get() && !closed) {
+ WebSocketFrame.Frame frame;
+ try {
+ frame = WebSocketFrame.read(in);
+ } catch (java.io.EOFException eof) {
+ return;
+ }
+ if (!handleFrame(frame)) {
+ return;
+ }
+ }
+ } catch (IOException e) {
+ if (running.get()) {
+ log.debug("WS read error on {}: {}", peerAddr, e.toString());
+ }
+ } finally {
+ close();
+ }
+ }
+
+ /** Returns {@code false} if the connection should be torn down. */
+ private boolean handleFrame(WebSocketFrame.Frame frame) throws IOException {
+ switch (frame.opcode()) {
+ case WebSocketFrame.OPCODE_TEXT, WebSocketFrame.OPCODE_BINARY -> {
+ dispatch(frame.payload());
+ return true;
+ }
+ case WebSocketFrame.OPCODE_PING -> {
+ writeLock.lock();
+ try {
+ WebSocketFrame.write(out, WebSocketFrame.OPCODE_PONG,
+ frame.payload(), maskOutbound);
+ } finally {
+ writeLock.unlock();
+ }
+ return true;
+ }
+ case WebSocketFrame.OPCODE_PONG -> {
+ return true;
+ }
+ case WebSocketFrame.OPCODE_CLOSE -> {
+ writeLock.lock();
+ try {
+ WebSocketFrame.write(out, WebSocketFrame.OPCODE_CLOSE,
+ new byte[0], maskOutbound);
+ } finally {
+ writeLock.unlock();
+ }
+ return false;
+ }
+ default -> {
+ log.debug("ignoring unknown WS opcode 0x{} from {}",
+ Integer.toHexString(frame.opcode()), peerAddr);
+ return true;
+ }
+ }
+ }
+
+ private void dispatch(byte[] bytes) {
+ MessageListener l = listener;
+ rxMessages.incrementAndGet();
+ Endpoint peer = new Endpoint(TransportType.WS, peerAddr);
+ try {
+ SipMessage msg = SipParser.parse(bytes);
+ if (l != null) {
+ l.onMessage(new InboundMessage(msg, peer, local, Instant.now()));
+ }
+ } catch (SipCodecException e) {
+ parseErrors.incrementAndGet();
+ if (l != null) {
+ try { l.onError(peer, e); } catch (Throwable ignore) { }
+ }
+ } catch (Throwable t) {
+ if (l != null) {
+ try { l.onError(peer, t); } catch (Throwable ignore) { }
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ if (closed) return;
+ closed = true;
+ try { socket.close(); } catch (IOException ignore) { }
+ connections.remove(peerAddr, this);
+ }
+ }
+}
diff --git a/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketFrame.java b/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketFrame.java
new file mode 100644
index 0000000..cea4593
--- /dev/null
+++ b/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketFrame.java
@@ -0,0 +1,117 @@
+package com.sip.transport.nio;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.SecureRandom;
+
+/**
+ * RFC 6455 §5.2 WebSocket frame codec — TEXT / BINARY / CLOSE / PING / PONG.
+ *
+ * Tailored to RFC 7118 (SIP-over-WebSocket): SIP messages are transported
+ * as a single TEXT frame each, and the SIP framing is taken from the frame
+ * boundary rather than from {@code Content-Length}.
+ *
+ * RFC 6455 mandates that client-originated frames be masked with a 32-bit
+ * key. The {@link #write(OutputStream, int, byte[], boolean) write} method
+ * generates a fresh random mask per frame when {@code maskOutbound = true}.
+ */
+public final class WebSocketFrame {
+
+ public static final int OPCODE_CONTINUATION = 0x0;
+ public static final int OPCODE_TEXT = 0x1;
+ public static final int OPCODE_BINARY = 0x2;
+ public static final int OPCODE_CLOSE = 0x8;
+ public static final int OPCODE_PING = 0x9;
+ public static final int OPCODE_PONG = 0xA;
+
+ private static final SecureRandom RNG = new SecureRandom();
+ /** RFC 6455 §5.2 — frames bigger than this are likely abuse. */
+ static final int MAX_PAYLOAD = 4 * 1024 * 1024;
+
+ private WebSocketFrame() { }
+
+ public record Frame(boolean fin, int opcode, byte[] payload) {
+ public Frame {
+ payload = payload.clone();
+ }
+ @Override
+ public byte[] payload() {
+ return payload.clone();
+ }
+ }
+
+ public static Frame read(InputStream in) throws IOException {
+ DataInputStream dis = new DataInputStream(in);
+ int b1 = dis.read();
+ if (b1 < 0) {
+ throw new java.io.EOFException("connection closed mid-frame");
+ }
+ boolean fin = (b1 & 0x80) != 0;
+ int opcode = b1 & 0x0F;
+ int b2 = dis.readUnsignedByte();
+ boolean masked = (b2 & 0x80) != 0;
+ long length = b2 & 0x7F;
+ if (length == 126) {
+ length = dis.readUnsignedShort();
+ } else if (length == 127) {
+ length = dis.readLong();
+ if (length < 0 || length > MAX_PAYLOAD) {
+ throw new IOException("payload too large: " + length);
+ }
+ }
+ if (length > MAX_PAYLOAD) {
+ throw new IOException("payload too large: " + length);
+ }
+ byte[] mask = null;
+ if (masked) {
+ mask = new byte[4];
+ dis.readFully(mask);
+ }
+ byte[] payload = new byte[(int) length];
+ dis.readFully(payload);
+ if (masked) {
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (payload[i] ^ mask[i & 3]);
+ }
+ }
+ return new Frame(fin, opcode, payload);
+ }
+
+ /**
+ * Write a single frame. {@code maskOutbound} should be {@code true} for
+ * client-originated frames, {@code false} for server-originated frames
+ * (RFC 6455 §5.1).
+ */
+ public static void write(OutputStream out, int opcode, byte[] payload,
+ boolean maskOutbound) throws IOException {
+ DataOutputStream dos = new DataOutputStream(out);
+ dos.writeByte(0x80 | (opcode & 0x0F)); // FIN=1
+ int len = payload.length;
+ int maskBit = maskOutbound ? 0x80 : 0;
+ if (len <= 125) {
+ dos.writeByte(maskBit | len);
+ } else if (len <= 0xFFFF) {
+ dos.writeByte(maskBit | 126);
+ dos.writeShort(len);
+ } else {
+ dos.writeByte(maskBit | 127);
+ dos.writeLong(len);
+ }
+ if (maskOutbound) {
+ byte[] mask = new byte[4];
+ RNG.nextBytes(mask);
+ dos.write(mask);
+ byte[] masked = new byte[len];
+ for (int i = 0; i < len; i++) {
+ masked[i] = (byte) (payload[i] ^ mask[i & 3]);
+ }
+ dos.write(masked);
+ } else {
+ dos.write(payload);
+ }
+ dos.flush();
+ }
+}
diff --git a/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketHandshake.java b/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketHandshake.java
new file mode 100644
index 0000000..9930786
--- /dev/null
+++ b/sip-transport-nio/src/main/java/com/sip/transport/nio/WebSocketHandshake.java
@@ -0,0 +1,191 @@
+package com.sip.transport.nio;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * RFC 6455 §4 / RFC 7118 §5 WebSocket upgrade handshake.
+ *
+ * The server reads the HTTP upgrade request and writes the matching
+ * 101 response with the {@code Sec-WebSocket-Accept} hash. RFC 7118
+ * additionally requires negotiation of the {@code sip} sub-protocol.
+ *
+ * Notes:
+ *
+ * - HTTP request line + headers are parsed; the body is ignored.
+ * - Header names are case-insensitive (HTTP §4.2); we lowercase on
+ * insert.
+ * - We don't bother with extensions ({@code Sec-WebSocket-Extensions})
+ * — none are needed for SIP-over-WS.
+ *
+ */
+public final class WebSocketHandshake {
+
+ public static final String SIP_SUBPROTOCOL = "sip";
+
+ /** Magic GUID per RFC 6455 §4.2.2 step 5. */
+ static final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ private WebSocketHandshake() { }
+
+ /** Reads a client upgrade request off the wire and writes the 101 response. */
+ public static void acceptServerHandshake(InputStream in, OutputStream out)
+ throws IOException {
+ Map headers = readHttpHeaders(in);
+
+ String key = headers.get("sec-websocket-key");
+ if (key == null) {
+ sendError(out, 400, "Bad Request", "missing Sec-WebSocket-Key");
+ throw new IOException("missing Sec-WebSocket-Key");
+ }
+ String upgrade = headers.getOrDefault("upgrade", "");
+ if (!upgrade.equalsIgnoreCase("websocket")) {
+ sendError(out, 400, "Bad Request", "Upgrade header is not websocket");
+ throw new IOException("not a WebSocket upgrade: '" + upgrade + "'");
+ }
+ String version = headers.getOrDefault("sec-websocket-version", "");
+ if (!"13".equals(version)) {
+ sendError(out, 426, "Upgrade Required", "Sec-WebSocket-Version 13 required");
+ throw new IOException("unsupported version: '" + version + "'");
+ }
+ String subprotocols = headers.getOrDefault("sec-websocket-protocol", "");
+ boolean hasSip = false;
+ for (String p : subprotocols.split(",")) {
+ if (p.trim().equalsIgnoreCase(SIP_SUBPROTOCOL)) {
+ hasSip = true;
+ break;
+ }
+ }
+
+ String accept = encodeAccept(key);
+ StringBuilder response = new StringBuilder(256);
+ response.append("HTTP/1.1 101 Switching Protocols\r\n");
+ response.append("Upgrade: websocket\r\n");
+ response.append("Connection: Upgrade\r\n");
+ response.append("Sec-WebSocket-Accept: ").append(accept).append("\r\n");
+ if (hasSip) {
+ response.append("Sec-WebSocket-Protocol: ").append(SIP_SUBPROTOCOL).append("\r\n");
+ }
+ response.append("\r\n");
+ out.write(response.toString().getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ }
+
+ /** Writes a client upgrade request and validates the 101 response. */
+ public static void initiateClientHandshake(InputStream in, OutputStream out,
+ String host, String resource)
+ throws IOException {
+ String key = generateClientKey();
+ StringBuilder request = new StringBuilder(256);
+ request.append("GET ").append(resource).append(" HTTP/1.1\r\n");
+ request.append("Host: ").append(host).append("\r\n");
+ request.append("Upgrade: websocket\r\n");
+ request.append("Connection: Upgrade\r\n");
+ request.append("Sec-WebSocket-Key: ").append(key).append("\r\n");
+ request.append("Sec-WebSocket-Version: 13\r\n");
+ request.append("Sec-WebSocket-Protocol: ").append(SIP_SUBPROTOCOL).append("\r\n");
+ request.append("\r\n");
+ out.write(request.toString().getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+
+ // Read status line + headers.
+ BufferedReader reader = new BufferedReader(
+ new InputStreamReader(in, StandardCharsets.US_ASCII));
+ String statusLine = reader.readLine();
+ if (statusLine == null || !statusLine.contains("101")) {
+ throw new IOException("server did not switch protocols: '" + statusLine + "'");
+ }
+ Map headers = new HashMap<>();
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ int colon = line.indexOf(':');
+ if (colon > 0) {
+ headers.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
+ line.substring(colon + 1).trim());
+ }
+ }
+ String expected = encodeAccept(key);
+ String actual = headers.get("sec-websocket-accept");
+ if (!expected.equals(actual)) {
+ throw new IOException(
+ "Sec-WebSocket-Accept mismatch (expected " + expected + ", got " + actual + ")");
+ }
+ }
+
+ static String encodeAccept(String clientKey) {
+ try {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ return Base64.getEncoder().encodeToString(
+ sha1.digest((clientKey + GUID).getBytes(StandardCharsets.US_ASCII)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-1 unavailable", e);
+ }
+ }
+
+ static String generateClientKey() {
+ byte[] random = new byte[16];
+ new java.security.SecureRandom().nextBytes(random);
+ return Base64.getEncoder().encodeToString(random);
+ }
+
+ private static Map readHttpHeaders(InputStream in) throws IOException {
+ // Lightweight HTTP request line + headers reader.
+ StringBuilder buf = new StringBuilder(512);
+ int crlfState = 0;
+ while (crlfState != 4) {
+ int b = in.read();
+ if (b < 0) {
+ throw new IOException("EOF reading WebSocket upgrade request");
+ }
+ buf.append((char) b);
+ crlfState = switch (b) {
+ case '\r' -> (crlfState == 2) ? 3 : 1;
+ case '\n' -> (crlfState == 1) ? 2 : (crlfState == 3) ? 4 : 0;
+ default -> 0;
+ };
+ if (buf.length() > 64 * 1024) {
+ throw new IOException("HTTP request too large");
+ }
+ }
+ String text = buf.toString();
+ Map out = new HashMap<>();
+ int idx = text.indexOf("\r\n");
+ idx = text.indexOf("\r\n", idx + 2); // skip request line
+ int start = text.indexOf("\r\n") + 2;
+ while (start < text.length() - 2) {
+ int eol = text.indexOf("\r\n", start);
+ if (eol < 0) break;
+ String line = text.substring(start, eol);
+ int colon = line.indexOf(':');
+ if (colon > 0) {
+ out.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
+ line.substring(colon + 1).trim());
+ }
+ start = eol + 2;
+ }
+ return out;
+ }
+
+ private static void sendError(OutputStream out, int code, String reason, String body)
+ throws IOException {
+ String payload = body + "\r\n";
+ StringBuilder resp = new StringBuilder();
+ resp.append("HTTP/1.1 ").append(code).append(' ').append(reason).append("\r\n");
+ resp.append("Content-Type: text/plain\r\n");
+ resp.append("Content-Length: ").append(payload.length()).append("\r\n");
+ resp.append("Connection: close\r\n\r\n");
+ resp.append(payload);
+ out.write(resp.toString().getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ }
+}
diff --git a/sip-transport-nio/src/test/java/com/sip/transport/nio/NioTlsTransportTest.java b/sip-transport-nio/src/test/java/com/sip/transport/nio/NioTlsTransportTest.java
new file mode 100644
index 0000000..56a71f8
--- /dev/null
+++ b/sip-transport-nio/src/test/java/com/sip/transport/nio/NioTlsTransportTest.java
@@ -0,0 +1,143 @@
+package com.sip.transport.nio;
+
+import com.sip.message.SipMethod;
+import com.sip.message.SipRequest;
+import com.sip.message.SipResponse;
+import com.sip.message.SipVersion;
+import com.sip.message.header.Headers;
+import com.sip.message.uri.SipUri;
+import com.sip.transport.InboundMessage;
+import com.sip.transport.TransportType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import javax.net.ssl.SSLContext;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NioTlsTransportTest {
+
+ private final List open = new ArrayList<>();
+
+ @AfterEach
+ void closeAll() throws Exception {
+ for (int i = open.size() - 1; i >= 0; i--) {
+ open.get(i).close();
+ }
+ }
+
+ private NioTlsTransport bindServer() throws Exception {
+ SSLContext server = TestSslContext.serverContext();
+ InetSocketAddress addr = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
+ NioTlsTransport t = new NioTlsTransport(server, addr);
+ t.start().get(5, TimeUnit.SECONDS);
+ open.add(t);
+ return t;
+ }
+
+ private NioTlsTransport bindClient() throws Exception {
+ SSLContext client = TestSslContext.clientContext();
+ InetSocketAddress addr = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
+ NioTlsTransport t = new NioTlsTransport(client, addr);
+ t.start().get(5, TimeUnit.SECONDS);
+ open.add(t);
+ return t;
+ }
+
+ @Test
+ @Timeout(value = 15, unit = TimeUnit.SECONDS)
+ void clientCanSendInviteToServerOverTls() throws Exception {
+ NioTlsTransport server = bindServer();
+ NioTlsTransport client = bindClient();
+
+ CountDownLatch latch = new CountDownLatch(1);
+ List received = new ArrayList<>();
+ server.listener(in -> {
+ received.add(in);
+ latch.countDown();
+ });
+
+ byte[] body = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n".getBytes();
+ SipRequest req = new SipRequest(
+ SipMethod.INVITE,
+ SipUri.builder().secure(true).host("server").build(),
+ SipVersion.SIP_2_0,
+ Headers.builder()
+ .add("Via", "SIP/2.0/TLS host;branch=z9hG4bK1")
+ .add("From", ";tag=1")
+ .add("To", "")
+ .add("Call-ID", "tls@e")
+ .add("CSeq", "1 INVITE")
+ .add("Content-Type", "application/sdp")
+ .add("Max-Forwards", "70")
+ .build(),
+ body);
+
+ client.send(req, server.local()).get(5, TimeUnit.SECONDS);
+
+ assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(received).hasSize(1);
+ InboundMessage in = received.get(0);
+ assertThat(in.peer().transport()).isEqualTo(TransportType.TLS);
+ SipRequest got = (SipRequest) in.message();
+ assertThat(got.method()).isEqualTo(SipMethod.INVITE);
+ assertThat(got.body()).isEqualTo(body);
+ }
+
+ @Test
+ @Timeout(value = 15, unit = TimeUnit.SECONDS)
+ void serverCanReplyOverSameTlsConnection() throws Exception {
+ NioTlsTransport server = bindServer();
+ NioTlsTransport client = bindClient();
+
+ CountDownLatch repliedLatch = new CountDownLatch(1);
+ List clientInbox = new ArrayList<>();
+ client.listener(in -> {
+ clientInbox.add(in);
+ repliedLatch.countDown();
+ });
+
+ server.listener(in -> {
+ SipResponse rsp = SipResponse.builder()
+ .status(200).reason("OK")
+ .headers(Headers.builder()
+ .add("Via", in.message().headers().first(
+ com.sip.message.header.HeaderName.VIA)
+ .orElseThrow().value())
+ .add("From", ";tag=1")
+ .add("To", ";tag=2")
+ .add("Call-ID", "tls-reply@e")
+ .add("CSeq", "1 OPTIONS")
+ .build())
+ .build();
+ try { server.send(rsp, in.peer()).get(); }
+ catch (Exception ex) { throw new RuntimeException(ex); }
+ });
+
+ SipRequest req = new SipRequest(
+ SipMethod.OPTIONS,
+ SipUri.builder().secure(true).host("server").build(),
+ SipVersion.SIP_2_0,
+ Headers.builder()
+ .add("Via", "SIP/2.0/TLS host;branch=z9hG4bK1")
+ .add("From", ";tag=1")
+ .add("To", "")
+ .add("Call-ID", "tls-reply@e")
+ .add("CSeq", "1 OPTIONS")
+ .add("Max-Forwards", "70")
+ .build(),
+ new byte[0]);
+ client.send(req, server.local()).get(5, TimeUnit.SECONDS);
+
+ assertThat(repliedLatch.await(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(clientInbox).hasSize(1);
+ assertThat(((SipResponse) clientInbox.get(0).message()).status()).isEqualTo(200);
+ }
+}
diff --git a/sip-transport-nio/src/test/java/com/sip/transport/nio/NioWebSocketTransportTest.java b/sip-transport-nio/src/test/java/com/sip/transport/nio/NioWebSocketTransportTest.java
new file mode 100644
index 0000000..50094da
--- /dev/null
+++ b/sip-transport-nio/src/test/java/com/sip/transport/nio/NioWebSocketTransportTest.java
@@ -0,0 +1,167 @@
+package com.sip.transport.nio;
+
+import com.sip.message.SipMethod;
+import com.sip.message.SipRequest;
+import com.sip.message.SipResponse;
+import com.sip.message.SipVersion;
+import com.sip.message.header.Headers;
+import com.sip.message.uri.SipUri;
+import com.sip.transport.InboundMessage;
+import com.sip.transport.TransportType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NioWebSocketTransportTest {
+
+ private final List open = new ArrayList<>();
+
+ @AfterEach
+ void closeAll() throws Exception {
+ for (int i = open.size() - 1; i >= 0; i--) {
+ open.get(i).close();
+ }
+ }
+
+ private NioWebSocketTransport bindServer() throws Exception {
+ InetSocketAddress addr = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
+ NioWebSocketTransport t = new NioWebSocketTransport(addr, false);
+ t.start().get(5, TimeUnit.SECONDS);
+ open.add(t);
+ return t;
+ }
+
+ private NioWebSocketTransport bindClient() throws Exception {
+ InetSocketAddress addr = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
+ NioWebSocketTransport t = new NioWebSocketTransport(addr, true);
+ t.start().get(5, TimeUnit.SECONDS);
+ open.add(t);
+ return t;
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ void clientCanSendSipOverWebSocket() throws Exception {
+ NioWebSocketTransport server = bindServer();
+ NioWebSocketTransport client = bindClient();
+
+ CountDownLatch latch = new CountDownLatch(1);
+ List received = new ArrayList<>();
+ server.listener(in -> {
+ received.add(in);
+ latch.countDown();
+ });
+
+ SipRequest req = new SipRequest(
+ SipMethod.MESSAGE,
+ SipUri.builder().host("server").build(),
+ SipVersion.SIP_2_0,
+ Headers.builder()
+ .add("Via", "SIP/2.0/WS host;branch=z9hG4bK1")
+ .add("From", ";tag=1")
+ .add("To", "")
+ .add("Call-ID", "ws@e")
+ .add("CSeq", "1 MESSAGE")
+ .add("Content-Type", "text/plain")
+ .add("Max-Forwards", "70")
+ .build(),
+ "hello".getBytes());
+
+ client.send(req, server.local()).get(5, TimeUnit.SECONDS);
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ InboundMessage in = received.get(0);
+ assertThat(in.peer().transport()).isEqualTo(TransportType.WS);
+ SipRequest got = (SipRequest) in.message();
+ assertThat(got.method()).isEqualTo(SipMethod.MESSAGE);
+ assertThat(new String(got.body())).isEqualTo("hello");
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ void multipleMessagesPerConnectionReuseHandshake() throws Exception {
+ NioWebSocketTransport server = bindServer();
+ NioWebSocketTransport client = bindClient();
+
+ CountDownLatch latch = new CountDownLatch(3);
+ server.listener(in -> latch.countDown());
+
+ for (int i = 0; i < 3; i++) {
+ SipRequest req = new SipRequest(
+ SipMethod.OPTIONS,
+ SipUri.builder().host("server").build(),
+ SipVersion.SIP_2_0,
+ Headers.builder()
+ .add("Via", "SIP/2.0/WS host;branch=z9hG4bK" + i)
+ .add("From", ";tag=1")
+ .add("To", "")
+ .add("Call-ID", "ws-reuse@e")
+ .add("CSeq", (i + 1) + " OPTIONS")
+ .add("Max-Forwards", "70")
+ .build(),
+ new byte[0]);
+ client.send(req, server.local()).get(5, TimeUnit.SECONDS);
+ }
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(server.receivedMessages()).isEqualTo(3);
+ }
+
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ void serverCanReplyOnSameWebSocket() throws Exception {
+ NioWebSocketTransport server = bindServer();
+ NioWebSocketTransport client = bindClient();
+
+ CountDownLatch replied = new CountDownLatch(1);
+ List clientInbox = new ArrayList<>();
+ client.listener(in -> {
+ clientInbox.add(in);
+ replied.countDown();
+ });
+
+ server.listener(in -> {
+ SipResponse rsp = SipResponse.builder()
+ .status(200).reason("OK")
+ .headers(Headers.builder()
+ .add("Via", in.message().headers().first(
+ com.sip.message.header.HeaderName.VIA)
+ .orElseThrow().value())
+ .add("From", ";tag=1")
+ .add("To", ";tag=2")
+ .add("Call-ID", "ws-reply@e")
+ .add("CSeq", "1 OPTIONS")
+ .build())
+ .build();
+ try { server.send(rsp, in.peer()).get(); }
+ catch (Exception ex) { throw new RuntimeException(ex); }
+ });
+
+ SipRequest req = new SipRequest(
+ SipMethod.OPTIONS,
+ SipUri.builder().host("server").build(),
+ SipVersion.SIP_2_0,
+ Headers.builder()
+ .add("Via", "SIP/2.0/WS host;branch=z9hG4bK1")
+ .add("From", ";tag=1")
+ .add("To", "")
+ .add("Call-ID", "ws-reply@e")
+ .add("CSeq", "1 OPTIONS")
+ .add("Max-Forwards", "70")
+ .build(),
+ new byte[0]);
+ client.send(req, server.local()).get(5, TimeUnit.SECONDS);
+
+ assertThat(replied.await(5, TimeUnit.SECONDS)).isTrue();
+ SipResponse rsp = (SipResponse) clientInbox.get(0).message();
+ assertThat(rsp.status()).isEqualTo(200);
+ }
+}
diff --git a/sip-transport-nio/src/test/java/com/sip/transport/nio/TestSslContext.java b/sip-transport-nio/src/test/java/com/sip/transport/nio/TestSslContext.java
new file mode 100644
index 0000000..f3d4235
--- /dev/null
+++ b/sip-transport-nio/src/test/java/com/sip/transport/nio/TestSslContext.java
@@ -0,0 +1,71 @@
+package com.sip.transport.nio;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509ExtendedTrustManager;
+import java.io.InputStream;
+import java.net.Socket;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLEngine;
+
+/**
+ * Helper that builds SSLContexts for integration tests from a pre-generated
+ * PKCS12 keystore shipped in {@code src/test/resources/test-keystore.p12}.
+ *
+ * The keystore is created once via {@code keytool}; the test setup is
+ * therefore dependency-free at runtime. The certificate subject is
+ * {@code CN=localhost} with SANs for {@code localhost} and {@code 127.0.0.1}
+ * so SIPS hostname verification (RFC 5922) passes when peers dial the
+ * loopback interface.
+ */
+final class TestSslContext {
+
+ private static final char[] PASSWORD = "changeit".toCharArray();
+ private static final String KEYSTORE_RESOURCE = "/test-keystore.p12";
+
+ private TestSslContext() { }
+
+ static SSLContext serverContext() throws Exception {
+ KeyStore keyStore = loadKeystore();
+ KeyManagerFactory kmf = KeyManagerFactory.getInstance(
+ KeyManagerFactory.getDefaultAlgorithm());
+ kmf.init(keyStore, PASSWORD);
+ SSLContext ctx = SSLContext.getInstance("TLSv1.3");
+ ctx.init(kmf.getKeyManagers(), trustAll(), new SecureRandom());
+ return ctx;
+ }
+
+ static SSLContext clientContext() throws Exception {
+ SSLContext ctx = SSLContext.getInstance("TLSv1.3");
+ ctx.init(null, trustAll(), new SecureRandom());
+ return ctx;
+ }
+
+ private static KeyStore loadKeystore() throws Exception {
+ try (InputStream in = TestSslContext.class.getResourceAsStream(KEYSTORE_RESOURCE)) {
+ if (in == null) {
+ throw new IllegalStateException(
+ "test keystore not found on classpath: " + KEYSTORE_RESOURCE);
+ }
+ KeyStore ks = KeyStore.getInstance("PKCS12");
+ ks.load(in, PASSWORD);
+ return ks;
+ }
+ }
+
+ /** Trust-everything manager. Strictly test-only — NEVER ship to production. */
+ private static TrustManager[] trustAll() {
+ return new TrustManager[]{new X509ExtendedTrustManager() {
+ @Override public void checkClientTrusted(X509Certificate[] c, String t) { }
+ @Override public void checkClientTrusted(X509Certificate[] c, String t, Socket s) { }
+ @Override public void checkClientTrusted(X509Certificate[] c, String t, SSLEngine e) { }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t) { }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t, Socket s) { }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t, SSLEngine e) { }
+ @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }};
+ }
+}
diff --git a/sip-transport-nio/src/test/resources/test-keystore.p12 b/sip-transport-nio/src/test/resources/test-keystore.p12
new file mode 100644
index 0000000..fa96edd
Binary files /dev/null and b/sip-transport-nio/src/test/resources/test-keystore.p12 differ