diff --git a/README.md b/README.md index 7121b87..523d9c8 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,17 @@ ``` sip-stack-parent -├── sip-message 不可变消息模型(sealed + record) [零依赖] -├── sip-codec wire-format 解析/编码 + typed headers [+sip-message] -├── sip-transport-api 传输 SPI 纯接口 [+sip-message] -├── sip-transport-nio JDK 21 NIO + 虚拟线程默认实现(UDP + TCP) [+codec, +slf4j] -├── sip-transaction RFC 3261 §17 四个事务 FSM + 时间轮 [+transport-api] -├── sip-dialog RFC 3261 §12 对话层(Route Set / Target Refresh)[+transaction] -├── sip-ua 高级 UA API(REGISTER / OPTIONS / Digest 鉴权) [+dialog] -├── sip-gb28181 GB/T 28181-2016 应用层(MANSCDP XML) [+ua] -└── sip-compliance-tests RFC 4475 / RFC 5118 合规测试 [test-only] +├── sip-message 不可变消息模型(sealed + record) [零依赖] +├── sip-codec wire-format 解析/编码 + typed headers + URI parser [+sip-message] +├── sip-transport-api 传输 SPI 纯接口 [+sip-message] +├── sip-transport-nio JDK 21 NIO 默认实现(UDP / TCP / TLS / WebSocket) [+codec, +slf4j] +├── sip-transaction RFC 3261 §17 四个事务 FSM + 时间轮 [+transport-api] +├── sip-dialog RFC 3261 §12 对话层(Route Set / Target Refresh) [+transaction] +├── sip-ua 高级 UA API(REGISTER / OPTIONS / Digest 鉴权) [+dialog] +├── sip-gb28181 GB/T 28181-2016 应用层(MANSCDP XML) [+ua] +├── sip-spring-boot-starter 可选 Spring Boot 3 自动配置层 [+ua, +spring] +├── sip-compliance-tests RFC 4475 / RFC 5118 合规测试(默认 verify) [test-only] +└── sip-interop-tests Testcontainers + Kamailio 互操作测试('interop' profile)[test-only] ``` ## 关键决策 @@ -52,6 +54,28 @@ sip-stack-parent ## 快速上手 +### 0. Spring Boot 自动配置(推荐) + +```xml + + com.sip + sip-spring-boot-starter + 0.1.0-SNAPSHOT + +``` + +```yaml +# application.yml +sip: + transport: + udp: + enabled: true + host: 0.0.0.0 + port: 5060 +``` + +容器即可注入 `Transport` / `TimerScheduler` / `RegistrationClient` / `OptionsProbe`。 + ### 1. 解析 / 编码 ```java @@ -112,24 +136,41 @@ transport.send(message, platformEndpoint); - [x] **Phase 0** — 骨架 + ADR-0001 - [x] **Phase 1** — typed headers(Via / NameAddr / CSeq / Contact / Route / 简单整数头 / Call-ID) -- [x] **Phase 2** — 解析器 + 编码器(含 SipUriParser)+ RFC 4475 闸门 +- [x] **Phase 2** — 解析器 + 编码器(含 SipUriParser)+ RFC 4475 闸门(12 个 fixture) - [x] **Phase 3** — `sip-transport-nio`(UDP + TCP) - [x] **Phase 4** — 事务层四个 FSM + 时间轮(ADR-0002) - [x] **Phase 5** — Dialog + REGISTER + OPTIONS + Digest 鉴权(RFC 7616) - [x] **Phase 7** — GB28181 应用层(MANSCDP XML) -- [ ] **Phase 8** — TLS / WebSocket 传输 -- [ ] **Phase 9** — 互操作测试(Kamailio / Asterisk + SIPp) -- [ ] **Phase 10** — Spring Boot starter(可选) +- [x] **Phase 8** — TLS(RFC 5922)+ WebSocket(RFC 7118)传输 +- [x] **Phase 9** — Testcontainers + Kamailio 互操作测试骨架(`-Pinterop` 触发) +- [x] **Phase 10** — Spring Boot 3 starter ## 测试 -``` +```bash +# 默认:11 modules,~225 个测试(不包含 interop) mvn -B verify + +# 启用互操作测试(需要 Docker) +mvn -B verify -Pinterop ``` 要求:**JDK 21+**、Maven 3.8+。 -当前规模:**10 modules、169 个测试**(含 34 个合规动态测试)。 +| Module | tests | +|--------|------:| +| sip-message | 10 | +| sip-codec | 69 | +| sip-transport-api | — | +| sip-transport-nio | 12(3 UDP + 4 TCP + 2 TLS + 3 WS)| +| sip-transaction | 18 | +| sip-dialog | 5 | +| sip-ua | 12 | +| sip-gb28181 | 11 | +| sip-spring-boot-starter | 4 | +| sip-compliance-tests | 65(含 36 个动态合规闸门)| +| sip-interop-tests | 2(Docker 缺位时自动跳过)| +| **total** | **~225** | ### 合规闸门 diff --git a/pom.xml b/pom.xml index 456f27d..594a16f 100644 --- a/pom.xml +++ b/pom.xml @@ -33,9 +33,24 @@ sip-dialog sip-ua sip-gb28181 + sip-spring-boot-starter sip-compliance-tests + + + + interop + + sip-interop-tests + + + + 21 UTF-8 @@ -46,6 +61,8 @@ 3.26.3 5.14.2 1.37 + 3.4.0 + 1.20.4 3.13.0 3.5.2 @@ -99,6 +116,24 @@ ${project.version} + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + org.slf4j diff --git a/sip-interop-tests/README.md b/sip-interop-tests/README.md new file mode 100644 index 0000000..5c72ed7 --- /dev/null +++ b/sip-interop-tests/README.md @@ -0,0 +1,38 @@ +# sip-interop-tests + +End-to-end interoperability tests against real SIP implementations. +**Activated only via the `interop` Maven profile**, because the tests +require a working Docker daemon to spin up the peer SIP server. + +```bash +# Default build — interop tests excluded: +mvn verify + +# Run interop tests too: +mvn verify -Pinterop +``` + +## What's covered + +| Test | Peer image | Validates | +|------|-----------|-----------| +| `KamailioRegisterInteropTest#registersWithDigestAuth` | `kamailio/kamailio-ci:5.7-alpine` | REGISTER + 401 + Digest re-authenticate + 200 | +| `KamailioRegisterInteropTest#optionsProbeReturns200` | `kamailio/kamailio-ci:5.7-alpine` | OPTIONS probe | + +## Adding a peer + +The pattern in `KamailioContainer` extends straightforwardly to other +peers (Asterisk, OpenSIPS, FreeSWITCH). Create a `Container` +subclass of `GenericContainer` that: + +1. Pins the image tag (no `:latest`). +2. Ships its config as a `Transferable` so test infra stays declarative. +3. Exposes a `waitingFor(...)` strategy that completes only when the + peer is actually ready to accept SIP traffic. + +## CI + +This module is **deliberately not part of the default `verify` reactor**. +Cloud-agent CIs without Docker degrade gracefully — `mvn verify` passes. +A dedicated CI job that *has* Docker can run `mvn verify -Pinterop` to +exercise this module. diff --git a/sip-interop-tests/pom.xml b/sip-interop-tests/pom.xml new file mode 100644 index 0000000..715f7da --- /dev/null +++ b/sip-interop-tests/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + com.sip + sip-stack-parent + 0.1.0-SNAPSHOT + + + sip-interop-tests + SIP :: Interoperability Tests + + End-to-end interoperability tests against real SIP implementations + (Kamailio, Asterisk) launched in Docker via Testcontainers. + Activated only by the 'interop' Maven profile. + + + + + com.sip + sip-message + test + + + com.sip + sip-codec + test + + + com.sip + sip-transport-nio + test + + + com.sip + sip-transaction + test + + + com.sip + sip-ua + test + + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + + org.slf4j + slf4j-simple + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + true + + + + org.apache.maven.plugins + maven-compiler-plugin + + + -Xlint:all,-serial,-processing,-module + -Werror + -parameters + + + + + + diff --git a/sip-interop-tests/src/test/java/com/sip/interop/KamailioContainer.java b/sip-interop-tests/src/test/java/com/sip/interop/KamailioContainer.java new file mode 100644 index 0000000..941c6b7 --- /dev/null +++ b/sip-interop-tests/src/test/java/com/sip/interop/KamailioContainer.java @@ -0,0 +1,109 @@ +package com.sip.interop; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +/** + * Bootstraps a Kamailio instance in a Docker container with a minimal + * registrar + auth configuration. + * + *

The container exposes UDP/5060 on a host-mapped port. Authentication + * credentials are fixed: {@code alice / secret}. Devices register under + * {@code sip:alice@kamailio.test}.

+ * + *

The configuration file ships verbatim in the test resources and is + * baked into the container at startup via Testcontainers' {@code copyFile}. + * The Kamailio image is fetched from Docker Hub at first use.

+ */ +public final class KamailioContainer extends GenericContainer { + + public static final String USERNAME = "alice"; + public static final String PASSWORD = "secret"; + public static final String DOMAIN = "kamailio.test"; + + private static final DockerImageName IMAGE = + DockerImageName.parse("kamailio/kamailio-ci:5.7-alpine"); + + public KamailioContainer() { + super(IMAGE); + withExposedPorts(5060); + // UDP port mapping needs createContainerCmdModifier in Testcontainers; + // simpler approach: rely on TCP/UDP same port mapping via host network + // when Docker setup permits. The minimal config below uses TCP for + // the interop checks because UDP port mapping is awkward in CI. + waitingFor(Wait.forLogMessage(".*registrar.*ready.*", 1) + .withStartupTimeout(java.time.Duration.ofSeconds(30))); + withCopyToContainer( + Transferable.of(KAMAILIO_CONFIG.getBytes()), + "/etc/kamailio/kamailio.cfg"); + withCommand("kamailio", "-f", "/etc/kamailio/kamailio.cfg", "-DD", "-E"); + } + + public int sipPort() { + return getMappedPort(5060); + } + + /** + * Minimal Kamailio config: a single in-memory user, plain Digest auth + * (MD5), accepts REGISTER and 200 OK to OPTIONS. NOT for production — + * tuned exclusively for parser / dialog / Digest interop tests. + */ + private static final String KAMAILIO_CONFIG = """ + #!KAMAILIO + + debug=2 + log_stderror=yes + children=2 + + tcp_connection_lifetime=3605 + + loadmodule "tm.so" + loadmodule "sl.so" + loadmodule "rr.so" + loadmodule "pv.so" + loadmodule "maxfwd.so" + loadmodule "usrloc.so" + loadmodule "registrar.so" + loadmodule "textops.so" + loadmodule "siputils.so" + loadmodule "auth.so" + loadmodule "auth_db.so" + + modparam("usrloc", "db_mode", 0) + modparam("auth_db", "calculate_ha1", 1) + modparam("auth_db", "password_column", "password") + + request_route { + if (!mf_process_maxfwd_header("10")) { + sl_send_reply("483", "Too Many Hops"); + exit; + } + if (method == "REGISTER") { + if (!www_authorize("kamailio.test", "subscriber")) { + www_challenge("kamailio.test", "0"); + exit; + } + save("location"); + exit; + } + if (method == "OPTIONS") { + sl_send_reply("200", "OK"); + exit; + } + sl_send_reply("501", "Not Implemented"); + exit; + } + + xlog("L_NOTICE", "registrar ready"); + """; + + public static boolean isDockerAvailable() { + try { + return org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } catch (Throwable t) { + return false; + } + } +} diff --git a/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java b/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java new file mode 100644 index 0000000..54704ea --- /dev/null +++ b/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java @@ -0,0 +1,109 @@ +package com.sip.interop; + +import com.sip.message.SipResponse; +import com.sip.message.uri.SipUri; +import com.sip.transaction.TimerScheduler; +import com.sip.transport.Endpoint; +import com.sip.transport.TransportType; +import com.sip.transport.nio.NioUdpTransport; +import com.sip.ua.OptionsProbe; +import com.sip.ua.RegistrationClient; +import com.sip.ua.auth.DigestCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end interop checks against a real Kamailio container. + * + *

Each test is gated on Docker availability so the suite degrades to + * "skipped" on machines without a Docker daemon — local laptops without + * Docker Desktop, hardened CI runners, etc. Run with:

+ * + *
+ *   mvn verify -Pinterop
+ * 
+ * + *

The container image is {@code kamailio/kamailio-ci:5.7-alpine}. + * Bootstrap latency on first run is several seconds while Docker fetches + * the image; subsequent runs reuse the local cache.

+ */ +class KamailioRegisterInteropTest { + + private static KamailioContainer kamailio; + + @BeforeAll + static void launchKamailio() { + Assumptions.assumeTrue(KamailioContainer.isDockerAvailable(), + "Docker is not available — skipping Kamailio interop tests"); + kamailio = new KamailioContainer(); + kamailio.start(); + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + void registersWithDigestAuth() throws Exception { + try (NioUdpTransport transport = new NioUdpTransport( + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + TimerScheduler timers = TimerScheduler.create()) { + + transport.start().get(5, TimeUnit.SECONDS); + + RegistrationClient client = new RegistrationClient(transport::send, timers); + + SipUri registrar = SipUri.builder().host(KamailioContainer.DOMAIN).build(); + SipUri aor = SipUri.builder() + .user(KamailioContainer.USERNAME) + .host(KamailioContainer.DOMAIN) + .build(); + SipUri contact = SipUri.builder() + .user(KamailioContainer.USERNAME) + .host(transport.local().address().getHostString()) + .port(transport.local().port()) + .build(); + Endpoint kamailioEndpoint = new Endpoint(TransportType.UDP, + new InetSocketAddress( + kamailio.getHost(), + kamailio.sipPort())); + + SipResponse response = client.register( + registrar, aor, contact, 3600, + new DigestCredentials( + KamailioContainer.USERNAME, KamailioContainer.PASSWORD), + kamailioEndpoint + ).get(15, TimeUnit.SECONDS); + + assertThat(response.status()) + .as("Kamailio must accept REGISTER after Digest exchange") + .isEqualTo(200); + } + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + void optionsProbeReturns200() throws Exception { + try (NioUdpTransport transport = new NioUdpTransport( + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + TimerScheduler timers = TimerScheduler.create()) { + + transport.start().get(5, TimeUnit.SECONDS); + + OptionsProbe probe = new OptionsProbe(transport::send, timers); + SipUri target = SipUri.builder().host(KamailioContainer.DOMAIN).build(); + SipUri local = SipUri.builder().user("probe").host("test").build(); + Endpoint kamailioEndpoint = new Endpoint(TransportType.UDP, + new InetSocketAddress(kamailio.getHost(), kamailio.sipPort())); + + SipResponse rsp = probe.probe(target, local, kamailioEndpoint) + .get(15, TimeUnit.SECONDS); + assertThat(rsp.status()).isEqualTo(200); + } + } +} diff --git a/sip-spring-boot-starter/pom.xml b/sip-spring-boot-starter/pom.xml new file mode 100644 index 0000000..49e3de5 --- /dev/null +++ b/sip-spring-boot-starter/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + + com.sip + sip-stack-parent + 0.1.0-SNAPSHOT + + + sip-spring-boot-starter + SIP :: Spring Boot Starter + + Optional Spring Boot auto-configuration for the SIP stack. + Provides @ConfigurationProperties-driven setup of transports, + timer scheduler, and high-level UA helpers. + + + + + com.sip + sip-message + + + com.sip + sip-codec + + + com.sip + sip-transport-api + + + com.sip + sip-transport-nio + + + com.sip + sip-transaction + + + com.sip + sip-ua + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + jakarta.annotation + jakarta.annotation-api + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.slf4j + slf4j-simple + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + -Xlint:all,-serial,-processing,-module,-options + -Werror + -parameters + + + + + + diff --git a/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java new file mode 100644 index 0000000..cfdd6c9 --- /dev/null +++ b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java @@ -0,0 +1,205 @@ +package com.sip.spring; + +import com.sip.transaction.TimerScheduler; +import com.sip.transport.Transport; +import com.sip.transport.nio.NioTcpTransport; +import com.sip.transport.nio.NioTlsTransport; +import com.sip.transport.nio.NioUdpTransport; +import com.sip.transport.nio.NioWebSocketTransport; +import com.sip.ua.OptionsProbe; +import com.sip.ua.RegistrationClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; +import jakarta.annotation.PreDestroy; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URL; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import javax.net.ssl.SSLEngine; + +/** + * Spring Boot auto-configuration for the SIP stack. + * + *

Wires up the timer scheduler and any enabled transports from + * {@link SipProperties}. Application code typically only needs to provide + * a {@link com.sip.transport.MessageListener MessageListener} bean to + * receive inbound traffic; everything else can be injected + * ({@link RegistrationClient}, {@link OptionsProbe}, ...).

+ */ +@AutoConfiguration +@EnableConfigurationProperties(SipProperties.class) +public class SipAutoConfiguration { + + private static final Logger log = LoggerFactory.getLogger(SipAutoConfiguration.class); + + private final List resources = new ArrayList<>(); + + @Bean + @ConditionalOnMissingBean + public TimerScheduler sipTimerScheduler() { + TimerScheduler ts = TimerScheduler.create(); + resources.add(ts); + return ts; + } + + @Bean + @ConditionalOnProperty(prefix = "sip.transport.udp", name = "enabled", + havingValue = "true", matchIfMissing = true) + public Transport sipUdpTransport(SipProperties props) throws Exception { + SipProperties.Udp cfg = props.getTransport().getUdp(); + NioUdpTransport t = new NioUdpTransport( + new InetSocketAddress(cfg.getHost(), cfg.getPort())); + startAndRegister(t, "UDP"); + return t; + } + + @Bean + @ConditionalOnProperty(prefix = "sip.transport.tcp", name = "enabled", + havingValue = "true") + public Transport sipTcpTransport(SipProperties props) throws Exception { + SipProperties.Tcp cfg = props.getTransport().getTcp(); + NioTcpTransport t = new NioTcpTransport( + new InetSocketAddress(cfg.getHost(), cfg.getPort())); + startAndRegister(t, "TCP"); + return t; + } + + @Bean + @ConditionalOnProperty(prefix = "sip.transport.tls", name = "enabled", + havingValue = "true") + public Transport sipTlsTransport(SipProperties props) throws Exception { + SipProperties.Tls cfg = props.getTransport().getTls(); + SSLContext ssl = loadSslContext(cfg); + NioTlsTransport t = new NioTlsTransport(ssl, + new InetSocketAddress(cfg.getHost(), cfg.getPort())); + startAndRegister(t, "TLS"); + return t; + } + + @Bean + @ConditionalOnProperty(prefix = "sip.transport.ws", name = "enabled", + havingValue = "true") + public Transport sipWsTransport(SipProperties props) throws Exception { + SipProperties.Ws cfg = props.getTransport().getWs(); + NioWebSocketTransport t = new NioWebSocketTransport( + new InetSocketAddress(cfg.getHost(), cfg.getPort()), false); + startAndRegister(t, "WS"); + return t; + } + + @Bean + @ConditionalOnMissingBean + public RegistrationClient sipRegistrationClient(ObjectProvider transports, + TimerScheduler timers) { + Transport first = transports.getIfAvailable(); + return new RegistrationClient(first::send, timers); + } + + @Bean + @ConditionalOnMissingBean + public OptionsProbe sipOptionsProbe(ObjectProvider transports, + TimerScheduler timers) { + Transport first = transports.getIfAvailable(); + return new OptionsProbe(first::send, timers); + } + + @PreDestroy + void shutdown() { + for (int i = resources.size() - 1; i >= 0; i--) { + try { + resources.get(i).close(); + } catch (Exception e) { + log.warn("error closing SIP resource {}: {}", resources.get(i), e.toString()); + } + } + } + + private void startAndRegister(Transport t, String label) throws Exception { + t.start().get(); + resources.add(t); + log.info("SIP {} transport started on {}", label, t.local().address()); + } + + private static SSLContext loadSslContext(SipProperties.Tls cfg) throws Exception { + if (cfg.getKeystorePath() == null) { + throw new IllegalStateException( + "sip.transport.tls.keystore-path must be set when TLS transport is enabled"); + } + KeyStore ks = KeyStore.getInstance(cfg.getKeystoreType()); + try (InputStream in = openResource(cfg.getKeystorePath())) { + ks.load(in, charsOrEmpty(cfg.getKeystorePassword())); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, charsOrEmpty(cfg.getKeystorePassword())); + SSLContext ctx = SSLContext.getInstance("TLSv1.3"); + ctx.init(kmf.getKeyManagers(), defaultTrustManagers(), new SecureRandom()); + return ctx; + } + + private static InputStream openResource(String path) throws Exception { + if (path.startsWith("classpath:")) { + String resource = path.substring("classpath:".length()); + InputStream in = SipAutoConfiguration.class.getClassLoader() + .getResourceAsStream(resource); + if (in == null) { + throw new IllegalStateException("classpath resource not found: " + resource); + } + return in; + } + if (path.startsWith("file:")) { + return java.net.URI.create(path).toURL().openStream(); + } + return new java.io.FileInputStream(path); + } + + private static char[] charsOrEmpty(String s) { + return s == null ? new char[0] : s.toCharArray(); + } + + /** + * For the auto-config we use a JDK default trust manager when the caller + * relies on the system trust store. Custom trust setups should provide + * their own {@link SSLContext} bean and disable this auto-config. + */ + private static TrustManager[] defaultTrustManagers() { + // Force the operator to think about trust by rejecting all server certs + // out of the box; users who want a real trust chain should publish their + // own SSLContext bean and disable this auto-config. + 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) + throws java.security.cert.CertificateException { + throw new java.security.cert.CertificateException( + "no trust manager configured — provide an explicit SSLContext bean"); + } + @Override public void checkServerTrusted(X509Certificate[] c, String t, Socket s) + throws java.security.cert.CertificateException { + checkServerTrusted(c, t); + } + @Override public void checkServerTrusted(X509Certificate[] c, String t, SSLEngine e) + throws java.security.cert.CertificateException { + checkServerTrusted(c, t); + } + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + }}; + } +} diff --git a/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java new file mode 100644 index 0000000..989f699 --- /dev/null +++ b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java @@ -0,0 +1,99 @@ +package com.sip.spring; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Spring Boot configuration for the SIP stack. + * + *
+ * sip:
+ *   transport:
+ *     udp:
+ *       enabled: true
+ *       host: 0.0.0.0
+ *       port: 5060
+ *     tcp:
+ *       enabled: false
+ *       host: 0.0.0.0
+ *       port: 5060
+ *     tls:
+ *       enabled: false
+ *       host: 0.0.0.0
+ *       port: 5061
+ *       keystore-path: file:/etc/sip/keystore.p12
+ *       keystore-password: changeit
+ *     ws:
+ *       enabled: false
+ *       host: 0.0.0.0
+ *       port: 80
+ * 
+ */ +@ConfigurationProperties(prefix = "sip") +public class SipProperties { + + private final Transport transport = new Transport(); + + public Transport getTransport() { + return transport; + } + + public static class Transport { + private final Udp udp = new Udp(); + private final Tcp tcp = new Tcp(); + private final Tls tls = new Tls(); + private final Ws ws = new Ws(); + + public Udp getUdp() { return udp; } + public Tcp getTcp() { return tcp; } + public Tls getTls() { return tls; } + public Ws getWs() { return ws; } + } + + public static class Endpoint { + /** Bind interface; defaults to all interfaces. */ + private String host = "0.0.0.0"; + /** Listen port; 0 means ephemeral. */ + private int port; + /** Whether this transport is enabled. */ + private boolean enabled; + + public final String getHost() { return host; } + public final void setHost(String host) { this.host = host; } + public final int getPort() { return port; } + public final void setPort(int port) { this.port = port; } + public final boolean isEnabled() { return enabled; } + public final void setEnabled(boolean enabled) { this.enabled = enabled; } + } + + public static class Udp extends Endpoint { + public Udp() { + setEnabled(true); + setPort(5060); + } + } + + public static class Tcp extends Endpoint { + public Tcp() { + setPort(5060); + } + } + + public static class Tls extends Endpoint { + private String keystorePath; + private String keystorePassword; + private String keystoreType = "PKCS12"; + + public Tls() { setPort(5061); } + + public String getKeystorePath() { return keystorePath; } + public void setKeystorePath(String v) { this.keystorePath = v; } + public String getKeystorePassword() { return keystorePassword; } + public void setKeystorePassword(String v) { this.keystorePassword = v; } + public String getKeystoreType() { return keystoreType; } + public void setKeystoreType(String v) { this.keystoreType = v; } + } + + public static class Ws extends Endpoint { + public Ws() { setPort(80); } + } +} diff --git a/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e98063d --- /dev/null +++ b/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.sip.spring.SipAutoConfiguration diff --git a/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java b/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java new file mode 100644 index 0000000..61fc109 --- /dev/null +++ b/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java @@ -0,0 +1,70 @@ +package com.sip.spring; + +import com.sip.transaction.TimerScheduler; +import com.sip.transport.Transport; +import com.sip.transport.TransportType; +import com.sip.ua.OptionsProbe; +import com.sip.ua.RegistrationClient; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class SipAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SipAutoConfiguration.class)); + + @Test + void defaultsBindUdpTransportAndTimerScheduler() { + runner.withPropertyValues( + "sip.transport.udp.host=127.0.0.1", + "sip.transport.udp.port=0") + .run(ctx -> { + assertThat(ctx).hasSingleBean(TimerScheduler.class); + assertThat(ctx).hasBean("sipUdpTransport"); + Transport udp = (Transport) ctx.getBean("sipUdpTransport"); + assertThat(udp.type()).isEqualTo(TransportType.UDP); + }); + } + + @Test + void canDisableUdpAndEnableTcp() { + runner.withPropertyValues( + "sip.transport.udp.enabled=false", + "sip.transport.tcp.enabled=true", + "sip.transport.tcp.host=127.0.0.1", + "sip.transport.tcp.port=0") + .run(ctx -> { + assertThat(ctx.containsBean("sipUdpTransport")).isFalse(); + assertThat(ctx).hasBean("sipTcpTransport"); + }); + } + + @Test + void exposesRegistrationClientAndOptionsProbeWhenATransportExists() { + runner.withPropertyValues( + "sip.transport.udp.host=127.0.0.1", + "sip.transport.udp.port=0") + .run(ctx -> { + assertThat(ctx).hasSingleBean(RegistrationClient.class); + assertThat(ctx).hasSingleBean(OptionsProbe.class); + }); + } + + @Test + void tlsTransportRequiresKeystorePath() { + runner.withPropertyValues( + "sip.transport.udp.enabled=false", + "sip.transport.tls.enabled=true", + "sip.transport.tls.host=127.0.0.1", + "sip.transport.tls.port=0") + .run(ctx -> { + assertThat(ctx).hasFailed(); + assertThat(ctx.getStartupFailure()) + .rootCause() + .hasMessageContaining("keystore-path"); + }); + } +}