Browse Source

Merge pull request #15301 from AndriiLandiak/ssl-real-time-certificate-update

Added automatic SSL/TLS certificate reload for transports without service restart
pull/15538/head
Viacheslav Klimov 1 month ago
committed by GitHub
parent
commit
4cf953ca0f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      application/src/main/resources/thingsboard.yml
  2. 149
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
  3. 6
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  4. 349
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
  5. 246
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
  6. 8
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
  7. 20
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
  8. 40
      common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
  9. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java
  10. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java
  11. 60
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
  12. 29
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java
  13. 62
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  14. 106
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java
  15. 198
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java
  16. 106
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java
  17. 192
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java
  18. 41
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java
  19. 3
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java
  20. 276
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java
  21. 197
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java
  22. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java
  23. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java
  24. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java
  25. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  26. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java
  27. 92
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java
  28. 14
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java
  29. 43
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java
  30. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java
  31. 30
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java
  32. 120
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  33. 299
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
  34. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  35. 8
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java
  36. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java
  37. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java
  38. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java
  39. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java
  40. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java
  41. 1
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java
  42. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java
  43. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java
  44. 185
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java
  45. 277
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java
  46. 355
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
  47. 9
      transport/coap/src/main/resources/tb-coap-transport.yml
  48. 9
      transport/http/src/main/resources/tb-http-transport.yml
  49. 9
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  50. 9
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml

9
application/src/main/resources/thingsboard.yml

@ -1406,6 +1406,15 @@ transport:
branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}"
# Fetch frequency in hours for gateways dashboard repository
fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Interval in seconds for certificate reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
coap:

149
common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java

@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S
@Slf4j
@Component
@TbCoapServerComponent
public class DefaultCoapServerService implements CoapServerService {
public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton {
@Autowired
private CoapServerContext coapServerContext;
private CoapServer server;
private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
private ScheduledExecutorService dtlsSessionsExecutor;
private volatile DTLSConnector dtlsConnector;
private volatile CoapEndpoint dtlsCoapEndpoint;
@PostConstruct
public void init() throws UnknownHostException {
createCoapServer();
}
@Override
public void afterSingletonsInstantiated() {
if (isDtlsEnabled()) {
coapServerContext.getDtlsSettings().registerReloadCallback(() -> {
try {
log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint...");
recreateDtlsEndpoint();
log.info("CoAP DTLS endpoint recreated successfully with new certificates.");
} catch (Exception e) {
log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e);
}
});
}
}
@PreDestroy
public void shutdown() {
if (dtlsSessionsExecutor != null) {
@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService {
}
private CoapServer createCoapServer() throws UnknownHostException {
Configuration networkConfig = new Configuration();
networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
Configuration networkConfig = createNetworkConfiguration();
server = new CoapServer(networkConfig);
CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService {
CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
server.addEndpoint(noSecCoapEndpoint);
if (isDtlsEnabled()) {
CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig);
dtlsCoapEndpointBuilder.setConnector(connector);
CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build();
server.addEndpoint(dtlsCoapEndpoint);
tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
createDtlsEndpoint(networkConfig);
dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
}
@ -137,4 +140,106 @@ public class DefaultCoapServerService implements CoapServerService {
return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout();
}
private Configuration createNetworkConfiguration() {
Configuration networkConfig = new Configuration();
networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
return networkConfig;
}
// Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig.
private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException {
TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
return dtlsConnectorConfig;
}
private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) {
CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
dtlsCoapEndpointBuilder.setConnector(connector);
return dtlsCoapEndpointBuilder.build();
}
private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException {
DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
server.addEndpoint(newEndpoint);
dtlsConnector = newConnector;
dtlsCoapEndpoint = newEndpoint;
tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
}
private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) {
return new DTLSConnector(config);
}
private synchronized void recreateDtlsEndpoint() throws IOException {
CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint;
DTLSConnector oldDtlsConnector = dtlsConnector;
Configuration networkConfig = createNetworkConfiguration();
log.info("Creating new DTLS endpoint with updated certificates...");
DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
// We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port.
// This creates a brief window where the port is unbound;
// if the new endpoint fails to start, we attempt to restore the old one (see rollback below).
if (oldDtlsEndpoint != null) {
log.info("Stopping old DTLS endpoint to release the port...");
server.getEndpoints().remove(oldDtlsEndpoint);
oldDtlsEndpoint.stop();
}
server.addEndpoint(newEndpoint);
try {
newEndpoint.start();
} catch (IOException e) {
log.error("Failed to start new DTLS endpoint, restoring old endpoint", e);
server.getEndpoints().remove(newEndpoint);
newEndpoint.destroy();
newConnector.destroy();
// Attempt to restore the old endpoint
if (oldDtlsEndpoint != null) {
try {
server.addEndpoint(oldDtlsEndpoint);
oldDtlsEndpoint.start();
log.info("Old DTLS endpoint restored successfully.");
} catch (IOException restoreEx) {
log.error("Failed to restore old DTLS endpoint", restoreEx);
}
}
throw e;
}
log.info("New DTLS endpoint started successfully.");
// Only swap instance fields after a successful start
dtlsConnector = newConnector;
dtlsCoapEndpoint = newEndpoint;
tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
// Destroy old resources after a successful swap
if (oldDtlsEndpoint != null) {
if (oldDtlsConnector != null) {
oldDtlsConnector.destroy();
}
oldDtlsEndpoint.destroy();
log.info("Old DTLS endpoint destroyed.");
}
}
}

6
common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java

@ -100,6 +100,10 @@ public class TbCoapDtlsSettings {
@Autowired(required = false)
private TbServiceInfoProvider serviceInfoProvider;
public void registerReloadCallback(Runnable callback) {
coapDtlsCredentialsConfig.registerReloadCallback(callback);
}
public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException {
DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration);
configBuilder.setAddress(getInetSocketAddress());
@ -154,5 +158,5 @@ public class TbCoapDtlsSettings {
}
return null;
}
}
}

349
common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java

@ -0,0 +1,349 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.coapserver;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.eclipse.californium.core.CoapClient;
import org.eclipse.californium.core.CoapResource;
import org.eclipse.californium.core.CoapResponse;
import org.eclipse.californium.core.CoapServer;
import org.eclipse.californium.core.coap.CoAP;
import org.eclipse.californium.core.config.CoapConfig;
import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.core.server.resources.CoapExchange;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.elements.util.SslContextUtil;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConfig;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.eclipse.californium.scandium.dtls.CertificateType;
import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider;
import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType;
import java.io.OutputStreamWriter;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE;
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT;
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE;
import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY;
public class CoapDtlsCertificateReloadIntegrationTest {
private static final String TEST_RESOURCE_PATH = "test";
private static final String TEST_PAYLOAD = "hello-dtls";
@TempDir
Path tempDir;
private CoapServer coapServer;
@AfterEach
public void teardown() {
if (coapServer != null) {
coapServer.destroy();
}
}
@Test
public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception {
KeyPair keyPairA = generateKeyPair();
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
KeyPair keyPairB = generateKeyPair();
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, certA);
writeKeyPem(keyFile, keyPairA);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
Configuration config = createServerConfig();
coapServer = new CoapServer(config);
coapServer.add(new TestResource());
int dtlsPort = findAvailablePort();
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpointA);
coapServer.start();
CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
assertThat(responseA).isNotNull();
assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD);
writeCertPem(certFile, certB);
writeKeyPem(keyFile, keyPairB);
credentialsConfig.onCertificateFileChanged();
coapServer.getEndpoints().remove(endpointA);
endpointA.stop();
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpointB);
endpointB.start();
endpointA.destroy();
CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
assertThat(responseB).isNotNull();
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD);
}
@Test
public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception {
KeyPair keyPairA = generateKeyPair();
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
KeyPair keyPairB = generateKeyPair();
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, certA);
writeKeyPem(keyFile, keyPairA);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
Configuration config = createServerConfig();
coapServer = new CoapServer(config);
coapServer.add(new TestResource());
int dtlsPort = findAvailablePort();
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpointA);
coapServer.start();
CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
assertThat(responseA).isNotNull();
writeCertPem(certFile, certB);
writeKeyPem(keyFile, keyPairB);
credentialsConfig.onCertificateFileChanged();
coapServer.getEndpoints().remove(endpointA);
endpointA.stop();
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpointB);
endpointB.start();
endpointA.destroy();
CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA);
assertThat(failedResponse).isNull();
CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
assertThat(responseB).isNotNull();
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
}
@Test
public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception {
KeyPair keyPair = generateKeyPair();
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, cert);
writeKeyPem(keyFile, keyPair);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
Configuration config = createServerConfig();
coapServer = new CoapServer(config);
coapServer.add(new TestResource());
int dtlsPort = findAvailablePort();
CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpoint1);
coapServer.start();
CoapResponse response1 = doDtlsRequest(dtlsPort, cert);
assertThat(response1).isNotNull();
assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
credentialsConfig.onCertificateFileChanged();
coapServer.getEndpoints().remove(endpoint1);
endpoint1.stop();
CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
coapServer.addEndpoint(endpoint2);
endpoint2.start();
endpoint1.destroy();
CoapResponse response2 = doDtlsRequest(dtlsPort, cert);
assertThat(response2).isNotNull();
assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
}
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) {
PemSslCredentials pem = new PemSslCredentials();
pem.setCertFile(certFile.toAbsolutePath().toString());
pem.setKeyFile(keyFile.toAbsolutePath().toString());
SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false);
config.setEnabled(true);
config.setType(SslCredentialsType.PEM);
config.setPem(pem);
config.setKeystore(new KeystoreSslCredentials());
config.init();
return config;
}
private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) {
DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config);
dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port));
dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY);
dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS);
dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE,
org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED);
SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials(
credentials.getPrivateKey(), null, credentials.getCertificateChain());
dtlsBuilder.setCertificateIdentityProvider(
new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(),
Collections.singletonList(CertificateType.X_509)));
dtlsBuilder.setAdvancedCertificateVerifier(
StaticNewAdvancedCertificateVerifier.builder()
.setTrustAllCertificates()
.build());
DTLSConnector connector = new DTLSConnector(dtlsBuilder.build());
CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder();
endpointBuilder.setConfiguration(config);
endpointBuilder.setConnector(connector);
return endpointBuilder.build();
}
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(256);
return kpg.generateKeyPair();
}
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception {
X500Name subject = new X500Name(subjectDn);
Date now = new Date();
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1));
return new JcaX509CertificateConverter().getCertificate(
new JcaX509v3CertificateBuilder(
subject, BigInteger.valueOf(System.nanoTime()), now, expiry,
subject, kp.getPublic())
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate())));
}
private void writeCertPem(Path path, X509Certificate cert) throws Exception {
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded()));
}
}
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception {
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded()));
}
}
private Configuration createServerConfig() {
Configuration config = new Configuration();
config.set(CoapConfig.MAX_RETRANSMIT, 2);
config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
return config;
}
private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) {
try {
Configuration clientConfig = new Configuration();
clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1);
clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY);
clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS);
clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false);
clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false);
DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig);
clientDtls.setAdvancedCertificateVerifier(
StaticNewAdvancedCertificateVerifier.builder()
.setTrustedCertificates(trustedCert)
.build());
DTLSConnector clientConnector = new DTLSConnector(clientDtls.build());
CoapEndpoint clientEndpoint = new CoapEndpoint.Builder()
.setConfiguration(clientConfig)
.setConnector(clientConnector)
.build();
CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH);
client.setEndpoint(clientEndpoint);
client.setTimeout((long) 5000);
try {
clientEndpoint.start();
return client.get();
} finally {
client.shutdown();
clientEndpoint.destroy();
}
} catch (Exception e) {
return null;
}
}
private int findAvailablePort() throws Exception {
try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) {
return socket.getLocalPort();
}
}
private static class TestResource extends CoapResource {
TestResource() {
super(TEST_RESOURCE_PATH);
}
@Override
public void handleGET(CoapExchange exchange) {
exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD);
}
}
}

246
common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java

@ -0,0 +1,246 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.coapserver;
import org.eclipse.californium.core.CoapServer;
import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.core.network.Endpoint;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class CoapDtlsCertificateReloadTest {
@Mock
private CoapServerContext mockCoapServerContext;
@Mock
private TbCoapDtlsSettings mockDtlsSettings;
@Mock
private CoapServer mockCoapServer;
@Mock
private CoapEndpoint mockDtlsEndpoint;
@Mock
private DTLSConnector mockDtlsConnector;
private DefaultCoapServerService coapServerService;
@BeforeEach
public void setup() {
coapServerService = new DefaultCoapServerService();
ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext);
when(mockCoapServerContext.getHost()).thenReturn("localhost");
when(mockCoapServerContext.getPort()).thenReturn(5683);
doAnswer(invocation -> {
invocation.getArgument(0);
return null;
}).when(mockDtlsSettings).registerReloadCallback(any());
}
@Test
public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
assertThat(callbackCaptor.getValue()).isNotNull();
}
@Test
public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
verify(mockDtlsSettings, never()).registerReloadCallback(any());
}
@Test
public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
// dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw.
// The old endpoint should not be stopped/destroyed when creation of the new one fails.
reloadCallback.run();
verify(mockDtlsEndpoint, never()).stop();
verify(mockDtlsConnector, never()).destroy();
}
@Test
public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
when(mockCoapServerContext.getHost()).thenReturn("localhost");
when(mockCoapServerContext.getPort()).thenReturn(5683);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class));
}
@Test
public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
}
@Test
public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception {
// GIVEN
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class);
when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier);
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>();
endpointsList.add(mockDtlsEndpoint);
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class);
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class,
(builder, context) -> {
when(builder.build()).thenReturn(mockNewEndpoint);
when(builder.setConfiguration(any())).thenReturn(builder);
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
})) {
// WHEN
ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint");
// THEN
assertThat(endpointsList).doesNotContain(mockDtlsEndpoint);
verify(mockDtlsEndpoint).stop();
verify(mockDtlsEndpoint).destroy();
verify(mockDtlsConnector).destroy();
verify(mockCoapServer).addEndpoint(mockNewEndpoint);
verify(mockNewEndpoint).start();
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint);
}
}
@Test
public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception {
// GIVEN
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>();
endpointsList.add(mockDtlsEndpoint);
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
doThrow(new IOException("start failed")).when(mockNewEndpoint).start();
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class);
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class,
(builder, context) -> {
when(builder.build()).thenReturn(mockNewEndpoint);
when(builder.setConfiguration(any())).thenReturn(builder);
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
})) {
// WHEN
coapServerService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
reloadCallback.run();
// THEN - new resources cleaned up
DTLSConnector constructedConnector = dtlsMock.constructed().get(0);
verify(mockNewEndpoint).destroy();
verify(constructedConnector).destroy();
assertThat(endpointsList).doesNotContain(mockNewEndpoint);
// Old endpoint was stopped to release port, then restored after new one failed
verify(mockDtlsEndpoint).stop();
verify(mockDtlsEndpoint).start();
// Old fields preserved
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint);
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector);
}
}
}

8
common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java

@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest {
@Autowired
TbCoapDtlsSettings coapDtlsSettings;
@MockBean
@MockitoBean
SslCredentialsConfig sslCredentialsConfig;
@MockBean
@MockitoBean
private TransportService transportService;
@MockBean
@MockitoBean
private TbServiceInfoProvider serviceInfoProvider;
@Test

20
common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java

@ -51,11 +51,9 @@ public class ResourceUtils {
return true;
} else {
try {
URL url = Resources.getResource(path);
if (url != null) {
return true;
}
} catch (IllegalArgumentException e) {}
Resources.getResource(path);
return true;
} catch (IllegalArgumentException ignored) {}
}
return false;
}
@ -93,9 +91,9 @@ public class ResourceUtils {
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
log.warn("Unable to find resource: " + filePath);
log.warn("Unable to find resource: {}", filePath);
} else {
log.warn("Unable to find resource: " + filePath, e);
log.warn("Unable to find resource: {}", filePath, e);
}
}
throw new RuntimeException("Unable to find resource: " + filePath);
@ -113,15 +111,19 @@ public class ResourceUtils {
return resourceFile.getAbsolutePath();
} else {
URL url = classLoader.getResource(filePath);
if (url == null) {
throw new RuntimeException("Unable to find resource: " + filePath);
}
return url.toURI().toString();
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
log.warn("Unable to find resource: " + filePath);
log.warn("Unable to find resource: {}", filePath);
} else {
log.warn("Unable to find resource: " + filePath, e);
log.warn("Unable to find resource: {}", filePath, e);
}
throw new RuntimeException("Unable to find resource: " + filePath);
}
}
}

40
common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java

@ -0,0 +1,40 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ResourceUtilsTest {
@Test
public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() {
assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Unable to find resource");
}
@Test
public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() {
String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class");
assertThat(result).isNotNull();
assertThat(result).contains("ResourceUtilsTest");
}
}

4
common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java

@ -76,10 +76,6 @@ import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
/**
* @author Andrew Shvayka
*/
@RestController
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')")
@RequestMapping("/api/v1")

4
common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java

@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.transport.TransportContext;
/**
* Created by ashvayka on 04.10.18.
*/
@Slf4j
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')")
@Component
@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext {
}
};
}
}

60
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java

@ -29,6 +29,7 @@ import org.eclipse.leshan.server.californium.bootstrap.LwM2mBootstrapPskStore;
import org.eclipse.leshan.server.californium.bootstrap.endpoint.CaliforniumBootstrapServerEndpointsProvider;
import org.eclipse.leshan.server.californium.bootstrap.endpoint.coap.CoapBootstrapServerProtocolProvider;
import org.eclipse.leshan.server.californium.bootstrap.endpoint.coaps.CoapsBootstrapServerProtocolProvider;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
@ -55,7 +56,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se
@Component
@TbLwM2mBootstrapTransportComponent
@RequiredArgsConstructor
public class LwM2MTransportBootstrapService {
public class LwM2MTransportBootstrapService implements SmartInitializingSingleton {
private final LwM2MTransportServerConfig serverConfig;
private final LwM2MTransportBootstrapConfig bootstrapConfig;
@ -63,7 +64,20 @@ public class LwM2MTransportBootstrapService {
private final LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore;
private final TransportService transportService;
private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier;
private LeshanBootstrapServer server;
private volatile LeshanBootstrapServer server;
@Override
public void afterSingletonsInstantiated() {
bootstrapConfig.registerServerReloadCallback(() -> {
try {
log.info("LwM2M Bootstrap certificates reloaded. Recreating bootstrap server...");
recreateBootstrapServer();
log.info("LwM2M Bootstrap server recreated successfully with new certificates.");
} catch (Exception e) {
log.error("Failed to recreate LwM2M Bootstrap server after certificate reload", e);
}
});
}
@PostConstruct
public void init() {
@ -110,7 +124,7 @@ public class LwM2MTransportBootstrapService {
// Create Californium Configuration
Configuration serverCoapConfig = endpointsBuilder.createDefaultConfiguration();
getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(),serverConfig);
getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(), serverConfig);
serverCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY);
serverCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, serverConfig.isRecommendedCiphers());
serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH);
@ -119,7 +133,7 @@ public class LwM2MTransportBootstrapService {
serverCoapConfig.set(DTLS_RETRANSMISSION_TIMEOUT, serverConfig.getDtlsRetransmissionTimeout(), MILLISECONDS);
if (serverConfig.getDtlsCidLength() != null) {
setDtlsConnectorConfigCidLength( serverCoapConfig, serverConfig.getDtlsCidLength());
setDtlsConnectorConfigCidLength(serverCoapConfig, serverConfig.getDtlsCidLength());
}
/* Create DTLS Config */
@ -164,4 +178,42 @@ public class LwM2MTransportBootstrapService {
builder.setTrustedCertificates(new X509Certificate[0]);
}
}
private synchronized void recreateBootstrapServer() {
LeshanBootstrapServer oldServer = this.server;
log.info("Creating new LwM2M Bootstrap server with updated certificates...");
LeshanBootstrapServer newServer = getLhBootstrapServer();
// Stop (not destroy) the old server to release ports but keep it restartable for rollback
if (oldServer != null) {
log.info("Stopping old LwM2M Bootstrap server to release ports...");
oldServer.stop();
}
try {
newServer.start();
} catch (Exception e) {
log.error("Failed to start new LwM2M Bootstrap server", e);
newServer.destroy();
// Attempt to restart the old server (only stopped, not destroyed)
if (oldServer != null) {
try {
oldServer.start();
log.info("Restored old LwM2M Bootstrap server successfully.");
} catch (Exception restoreEx) {
log.error("Failed to restore old LwM2M Bootstrap server", restoreEx);
}
}
throw e;
}
this.server = newServer;
log.info("New LwM2M Bootstrap server started successfully.");
// Destroy the old server only after a successful swap
if (oldServer != null) {
oldServer.destroy();
}
}
}

29
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.transport.lwm2m.config;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -27,6 +28,9 @@ import org.springframework.stereotype.Component;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
@Component
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'")
@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig {
@Qualifier("lwm2mBootstrapCredentials")
private SslCredentialsConfig credentialsConfig;
private final List<Runnable> serverReloadCallbacks = new CopyOnWriteArrayList<>();
@PostConstruct
public void init() {
credentialsConfig.registerReloadCallback(() -> {
log.info("LwM2M Bootstrap DTLS certificates reloaded. Triggering bootstrap server reload...");
notifyServerReload();
});
}
public void registerServerReloadCallback(Runnable callback) {
serverReloadCallbacks.add(callback);
}
private void notifyServerReload() {
for (Runnable callback : serverReloadCallbacks) {
try {
callback.run();
} catch (Exception e) {
log.error("Error executing LwM2M bootstrap server reload callback", e);
}
}
}
@Override
public SslCredentials getSslCredentials() {
return this.credentialsConfig.getCredentials();
}
}

62
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.server.transport.lwm2m.config;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
@ -26,11 +28,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.TbProperty;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@ -38,6 +46,13 @@ import java.util.List;
@ConfigurationProperties(prefix = "transport.lwm2m")
public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
private static final long RELOAD_DEBOUNCE_SECONDS = 2;
private final List<Runnable> serverReloadCallbacks = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService reloadDebouncer = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("lwm2m-reload-debouncer"));
private volatile ScheduledFuture<?> pendingReload;
@Getter
@Value("${transport.lwm2m.dtls.retransmission_timeout:9000}")
private int dtlsRetransmissionTimeout;
@ -134,6 +149,52 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
@Qualifier("lwm2mTrustCredentials")
private SslCredentialsConfig trustCredentialsConfig;
@PostConstruct
public void init() {
credentialsConfig.registerReloadCallback(() -> {
log.info("LwM2M Server DTLS certificates reloaded. Scheduling debounced server reload...");
scheduleServerReload();
});
trustCredentialsConfig.registerReloadCallback(() -> {
log.info("LwM2M Trust certificates reloaded. Scheduling debounced server reload...");
scheduleServerReload();
});
}
@PreDestroy
public void destroy() {
reloadDebouncer.shutdownNow();
}
public void registerServerReloadCallback(Runnable callback) {
serverReloadCallbacks.add(callback);
}
/**
* Debounces server reload so that if both server and trust credentials change in the same
* poll cycle, only the 'single server recreation' is triggered after both are reloaded.
*/
private synchronized void scheduleServerReload() {
if (pendingReload != null) {
pendingReload.cancel(false);
}
pendingReload = reloadDebouncer.schedule(() -> {
log.info("Debounce window elapsed. Triggering LwM2M server reload...");
notifyServerReload();
}, RELOAD_DEBOUNCE_SECONDS, TimeUnit.SECONDS);
}
private void notifyServerReload() {
for (Runnable callback : serverReloadCallbacks) {
try {
callback.run();
} catch (Exception e) {
log.error("Error executing LwM2M server reload callback", e);
}
}
}
@Override
public SslCredentials getSslCredentials() {
return this.credentialsConfig.getCredentials();
@ -142,4 +203,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
public SslCredentials getTrustSslCredentials() {
return this.trustCredentialsConfig.getCredentials();
}
}

106
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java

@ -32,7 +32,9 @@ import org.eclipse.leshan.server.californium.LwM2mPskStore;
import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpointsProvider;
import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider;
import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider;
import org.eclipse.leshan.server.endpoint.LwM2mServerEndpointsProvider;
import org.eclipse.leshan.server.registration.RegistrationStore;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import org.thingsboard.server.cache.ota.OtaPackageDataCache;
@ -68,7 +70,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se
@DependsOn({"lwM2mDownlinkMsgHandler", "lwM2mUplinkMsgHandler"})
@TbLwM2mTransportComponent
@RequiredArgsConstructor
public class DefaultLwM2mTransportService implements LwM2MTransportService {
public class DefaultLwM2mTransportService implements LwM2MTransportService, SmartInitializingSingleton {
public static final CipherSuite[] RPK_OR_X509_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256};
public static final CipherSuite[] PSK_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256};
@ -83,7 +85,21 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService {
private final TbLwM2MAuthorizer authorizer;
private final LwM2mVersionedModelProvider modelProvider;
private LeshanServer server;
private volatile LeshanServer server;
private volatile LwM2mServerListener serverListener;
@Override
public void afterSingletonsInstantiated() {
config.registerServerReloadCallback(() -> {
try {
log.info("LwM2M certificates reloaded. Recreating LwM2M server...");
recreateLwM2mServer();
log.info("LwM2M server recreated successfully with new certificates.");
} catch (Exception e) {
log.error("Failed to recreate LwM2M server after certificate reload", e);
}
});
}
@AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE)
public void init() {
@ -95,11 +111,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService {
private void startLhServer() {
log.info("Starting LwM2M transport server...");
this.server.start();
LwM2mServerListener lhServerCertListener = new LwM2mServerListener(handler);
this.server.getRegistrationService().addListener(lhServerCertListener.registrationListener);
this.server.getPresenceService().addListener(lhServerCertListener.presenceListener);
this.server.getObservationService().addListener(lhServerCertListener.observationListener);
this.server.getSendService().addListener(lhServerCertListener.sendListener);
serverListener = new LwM2mServerListener(handler);
this.server.getRegistrationService().addListener(serverListener.registrationListener);
this.server.getPresenceService().addListener(serverListener.presenceListener);
this.server.getObservationService().addListener(serverListener.observationListener);
this.server.getSendService().addListener(serverListener.sendListener);
log.info("Started LwM2M transport server.");
}
@ -214,6 +230,82 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService {
}
}
private synchronized void recreateLwM2mServer() {
LeshanServer oldServer = this.server;
LwM2mServerListener oldListener = this.serverListener;
log.info("Creating new LwM2M server with updated certificates...");
LeshanServer newServer = getLhServer();
// Only cycle the endpoint providers (CoAP/DTLS). The RegistrationStore and SecurityStore are
// Spring singletons shared with newServer — calling oldServer.stop()/destroy() would propagate
// to them (LeshanServer.stop/destroy propagate to Stoppable/Destroyable stores), which would
// shut down the shared schedulers (TbInMemoryRegistrationStore.destroy calls schedExecutor.shutdownNow),
// killing newServer's cleaner tasks. Leaving the stores running preserves existing device
// registrations across the swap — clients only need to re-establish DTLS on next uplink.
if (oldServer != null) {
log.info("Stopping old LwM2M endpoints to release ports...");
if (oldListener != null) {
oldServer.getRegistrationService().removeListener(oldListener.registrationListener);
oldServer.getPresenceService().removeListener(oldListener.presenceListener);
oldServer.getObservationService().removeListener(oldListener.observationListener);
oldServer.getSendService().removeListener(oldListener.sendListener);
}
stopEndpoints(oldServer);
}
try {
newServer.start();
} catch (Exception e) {
log.error("Failed to start new LwM2M server", e);
destroyEndpoints(newServer);
// Attempt to restart the old endpoints (shared stores are still running).
if (oldServer != null) {
try {
startEndpoints(oldServer);
if (oldListener != null) {
oldServer.getRegistrationService().addListener(oldListener.registrationListener);
oldServer.getPresenceService().addListener(oldListener.presenceListener);
oldServer.getObservationService().addListener(oldListener.observationListener);
oldServer.getSendService().addListener(oldListener.sendListener);
}
log.info("Restored old LwM2M endpoints successfully.");
} catch (Exception restoreEx) {
log.error("Failed to restore old LwM2M endpoints", restoreEx);
}
}
throw e;
}
LwM2mServerListener newListener = new LwM2mServerListener(handler);
newServer.getRegistrationService().addListener(newListener.registrationListener);
newServer.getPresenceService().addListener(newListener.presenceListener);
newServer.getObservationService().addListener(newListener.observationListener);
newServer.getSendService().addListener(newListener.sendListener);
this.server = newServer;
this.context.setServer(newServer);
this.serverListener = newListener;
log.info("New LwM2M server started with refreshed certificates. Existing device registrations preserved; clients will re-establish DTLS on next uplink.");
// Destroy old endpoints only — leave the shared stores alone.
if (oldServer != null) {
destroyEndpoints(oldServer);
}
}
private void stopEndpoints(LeshanServer server) {
server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::stop);
}
private void startEndpoints(LeshanServer server) {
server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::start);
}
private void destroyEndpoints(LeshanServer server) {
server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::destroy);
}
@Override
public String getName() {
return DataConstants.LWM2M_TRANSPORT_NAME;

198
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java

@ -0,0 +1,198 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.bootstrap;
import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier;
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore;
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore;
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig;
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class LwM2mBootstrapCertificateReloadTest {
@Mock
private LwM2MTransportServerConfig mockServerConfig;
@Mock
private LwM2MTransportBootstrapConfig mockBootstrapConfig;
@Mock
private LwM2MBootstrapSecurityStore mockSecurityStore;
@Mock
private LwM2MInMemoryBootstrapConfigStore mockConfigStore;
@Mock
private TransportService mockTransportService;
@Mock
private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier;
@Mock
private LeshanBootstrapServer mockBootstrapServer;
@Mock
private SslCredentials mockSslCredentials;
private LwM2MTransportBootstrapService bootstrapService;
@BeforeEach
public void setup() {
bootstrapService = new LwM2MTransportBootstrapService(
mockServerConfig,
mockBootstrapConfig,
mockSecurityStore,
mockConfigStore,
mockTransportService,
mockCertificateVerifier
);
when(mockBootstrapConfig.getHost()).thenReturn("localhost");
when(mockBootstrapConfig.getPort()).thenReturn(5687);
when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost");
when(mockBootstrapConfig.getSecurePort()).thenReturn(5688);
when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials);
when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000);
}
@Test
public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() {
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer);
bootstrapService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
assertThat(callbackCaptor.getValue()).isNotNull();
}
@Test
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() {
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer);
// Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw)
when(mockBootstrapConfig.getHost()).thenReturn(null);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
// getLhBootstrapServer() will fail due to null host before old server is stopped.
// The old server should NOT be destroyed since the new server was never created.
reloadCallback.run();
verify(mockBootstrapServer, never()).stop();
verify(mockBootstrapServer, never()).destroy();
assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer);
}
@Test
public void givenNullServer_whenRecreate_thenShouldNotThrow() {
ReflectionTestUtils.setField(bootstrapService, "server", null);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
// Should not throw — callback catches exceptions internally
reloadCallback.run();
}
@Test
public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() {
SslCredentials oldCredentials = mockSslCredentials;
SslCredentials newCredentials = mock(SslCredentials.class);
when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials);
SslCredentials firstCall = mockBootstrapConfig.getSslCredentials();
assertThat(firstCall).isEqualTo(oldCredentials);
SslCredentials secondCall = mockBootstrapConfig.getSslCredentials();
assertThat(secondCall).isEqualTo(newCredentials);
verify(mockBootstrapConfig, times(2)).getSslCredentials();
}
@Test
public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() {
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any());
}
@Test
public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() {
// GIVEN
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer);
LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class);
doThrow(new RuntimeException("start failed")).when(mockNewServer).start();
LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService);
doReturn(mockNewServer).when(spyService).getLhBootstrapServer();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
spyService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
// WHEN
reloadCallback.run();
// THEN
// Old server is stopped (not destroyed) to release ports
verify(mockBootstrapServer).stop();
verify(mockBootstrapServer, never()).destroy();
// The new server fails to start and is destroyed
verify(mockNewServer).destroy();
// Old server is restarted (not rebuilt from potentially stale credentials)
verify(mockBootstrapServer).start();
assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer);
}
}

106
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java

@ -0,0 +1,106 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.config;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import java.util.concurrent.atomic.AtomicInteger;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@ExtendWith(MockitoExtension.class)
public class LwM2MTransportServerConfigDebounceTest {
private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS");
@Mock
private SslCredentialsConfig credentialsConfig;
@Mock
private SslCredentialsConfig trustCredentialsConfig;
private LwM2MTransportServerConfig config;
@BeforeEach
public void setup() {
config = new LwM2MTransportServerConfig();
ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig);
ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig);
}
@AfterEach
public void teardown() {
config.destroy();
}
@Test
public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() {
AtomicInteger callCount = new AtomicInteger(0);
config.registerServerReloadCallback(callCount::incrementAndGet);
invokeScheduleServerReload();
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS)
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1));
}
@Test
public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() {
AtomicInteger callCount = new AtomicInteger(0);
config.registerServerReloadCallback(callCount::incrementAndGet);
invokeScheduleServerReload();
invokeScheduleServerReload();
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS)
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1));
// Wait extra to confirm no second invocation
await().during(DEBOUNCE_SECONDS + 1, SECONDS)
.atMost(DEBOUNCE_SECONDS + 2, SECONDS)
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1));
}
@Test
public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() {
AtomicInteger callCount = new AtomicInteger(0);
config.registerServerReloadCallback(callCount::incrementAndGet);
invokeScheduleServerReload();
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS)
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1));
invokeScheduleServerReload();
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS)
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2));
}
private void invokeScheduleServerReload() {
ReflectionTestUtils.invokeMethod(config, "scheduleServerReload");
}
}

192
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java

@ -0,0 +1,192 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.server;
import org.eclipse.leshan.server.LeshanServer;
import org.eclipse.leshan.server.observation.ObservationService;
import org.eclipse.leshan.server.registration.RegistrationService;
import org.eclipse.leshan.server.registration.RegistrationStore;
import org.eclipse.leshan.server.send.SendService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.cache.ota.OtaPackageDataCache;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig;
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer;
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier;
import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore;
import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class LwM2mServerCertificateReloadTest {
@Mock
private LwM2mTransportContext mockContext;
@Mock
private LwM2MTransportServerConfig mockConfig;
@Mock
private OtaPackageDataCache mockOtaCache;
@Mock
private LwM2mUplinkMsgHandler mockHandler;
@Mock
private RegistrationStore mockRegistrationStore;
@Mock
private TbSecurityStore mockSecurityStore;
@Mock
private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier;
@Mock
private TbLwM2MAuthorizer mockAuthorizer;
@Mock
private LwM2mVersionedModelProvider mockModelProvider;
@Mock
private LeshanServer mockLeshanServer;
@Mock
private RegistrationService mockRegistrationService;
@Mock
private ObservationService mockObservationService;
@Mock
private SendService mockSendService;
@Mock
private SslCredentials mockSslCredentials;
private DefaultLwM2mTransportService lwm2mTransportService;
@BeforeEach
public void setup() {
lwm2mTransportService = new DefaultLwM2mTransportService(
mockContext,
mockConfig,
mockOtaCache,
mockHandler,
mockRegistrationStore,
mockSecurityStore,
mockCertificateVerifier,
mockAuthorizer,
mockModelProvider
);
when(mockConfig.getHost()).thenReturn("localhost");
when(mockConfig.getPort()).thenReturn(5683);
when(mockConfig.getSecureHost()).thenReturn("localhost");
when(mockConfig.getSecurePort()).thenReturn(5684);
when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials);
when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService);
when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService);
when(mockLeshanServer.getSendService()).thenReturn(mockSendService);
}
@Test
public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
assertThat(callbackCaptor.getValue()).isNotNull();
}
@Test
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer);
// Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw)
when(mockConfig.getHost()).thenReturn(null);
// With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build.
reloadCallback.run();
verify(mockLeshanServer, never()).stop();
verify(mockLeshanServer, never()).destroy();
// Old server should still be the active one
assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer);
}
@Test
public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer);
LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler);
ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener);
// Force getLhServer() to fail by returning null host
when(mockConfig.getHost()).thenReturn(null);
// Invoke the callback — new server creation will fail, old listeners should stay
callbackCaptor.getValue().run();
verify(mockRegistrationService, never()).removeListener(any());
}
@Test
public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() {
lwm2mTransportService.afterSingletonsInstantiated();
verify(mockConfig, times(1)).registerServerReloadCallback(any());
}
@Test
public void givenCertificateReload_whenServerNull_thenShouldNotThrow() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
ReflectionTestUtils.setField(lwm2mTransportService, "server", null);
// Should not throw - callback catches exceptions internally
callbackCaptor.getValue().run();
}
}

41
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java

@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt;
import io.netty.handler.ssl.SslHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
@ -48,7 +49,7 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@Component("MqttSslHandlerProvider")
@TbMqttSslTransportComponent
public class MqttSslHandlerProvider {
public class MqttSslHandlerProvider implements SmartInitializingSingleton {
@Value("${transport.mqtt.ssl.protocol}")
private String sslProtocol;
@ -66,13 +67,35 @@ public class MqttSslHandlerProvider {
@Qualifier("mqttSslCredentials")
private SslCredentialsConfig mqttSslCredentialsConfig;
private SSLContext sslContext;
private volatile SSLContext sslContext;
@Override
public void afterSingletonsInstantiated() {
// Eagerly build the initial context so the handshake path is a lock-free volatile read.
this.sslContext = createSslContext();
mqttSslCredentialsConfig.registerReloadCallback(() -> {
log.info("MQTT SSL certificates reloaded. Rebuilding SSL context...");
// Build the new context first; if it fails, the old one stays in place, and
// the exception propagates to CertificateReloadManager's retry/backoff logic.
this.sslContext = createSslContext();
log.info("MQTT SSL context rebuilt. New connections will use the new certificate.");
});
}
public SslHandler getSslHandler() {
if (sslContext == null) {
sslContext = createSslContext();
SSLContext ctx = sslContext;
// Defensive lazy init in case afterSingletonsInstantiated hasn't run yet (e.g., test wiring).
// In normal operation ctx is non-null here, so the handshake path is lock-free.
if (ctx == null) {
synchronized (this) {
ctx = sslContext;
if (ctx == null) {
ctx = createSslContext();
sslContext = ctx;
}
}
}
SSLEngine sslEngine = sslContext.createSSLEngine();
SSLEngine sslEngine = ctx.createSSLEngine();
sslEngine.setUseClientMode(false);
sslEngine.setNeedClientAuth(false);
sslEngine.setWantClientAuth(true);
@ -98,7 +121,7 @@ public class MqttSslHandlerProvider {
sslContext.init(km, tm, null);
return sslContext;
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
log.error("Unable to set up SSL context. Reason: {}", e.getMessage(), e);
throw new RuntimeException("Failed to get SSL context", e);
}
}
@ -106,8 +129,8 @@ public class MqttSslHandlerProvider {
private TrustManager getX509TrustManager(TrustManagerFactory tmf) throws Exception {
X509TrustManager x509Tm = null;
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager) {
x509Tm = (X509TrustManager) tm;
if (tm instanceof X509TrustManager x509TrustManager) {
x509Tm = x509TrustManager;
break;
}
}
@ -191,5 +214,7 @@ public class MqttSslHandlerProvider {
return false;
}
}
}
}

3
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java

@ -32,9 +32,6 @@ import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created by ashvayka on 04.10.18.
*/
@Slf4j
@Component
@TbMqttTransportComponent

276
common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java

@ -0,0 +1,276 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.mqtt;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.OutputStreamWriter;
import java.math.BigInteger;
import java.net.InetAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
public class MqttSslCertificateReloadIntegrationTest {
@TempDir
Path tempDir;
@Mock
private TransportService transportService;
@Test
public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception {
KeyPair keyPairA = generateKeyPair();
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA");
KeyPair keyPairB = generateKeyPair();
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, certA);
writeKeyPem(keyFile, keyPairA);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig);
SSLContext ctxA = getProviderSslContext(provider);
X509Certificate servedA;
try (SSLServerSocket ss = createServerSocket(ctxA)) {
servedA = doHandshakeAndGetServerCert(ss);
}
assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal());
writeCertPem(certFile, certB);
writeKeyPem(keyFile, keyPairB);
credentialsConfig.onCertificateFileChanged();
SSLContext ctxB = getProviderSslContext(provider);
assertThat(ctxB).isNotSameAs(ctxA);
X509Certificate servedB;
try (SSLServerSocket ss = createServerSocket(ctxB)) {
servedB = doHandshakeAndGetServerCert(ss);
}
assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal());
assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal());
}
@Test
public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception {
KeyPair keyPair = generateKeyPair();
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, cert);
writeKeyPem(keyFile, keyPair);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig);
SSLContext ctx1 = getProviderSslContext(provider);
assertThat(ctx1).isNotNull();
credentialsConfig.onCertificateFileChanged();
SSLContext ctx2 = getProviderSslContext(provider);
assertThat(ctx2).isNotSameAs(ctx1);
X509Certificate served;
try (SSLServerSocket ss = createServerSocket(ctx2)) {
served = doHandshakeAndGetServerCert(ss);
}
assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal());
}
@Test
public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception {
KeyPair keyPairA = generateKeyPair();
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA");
KeyPair keyPairB = generateKeyPair();
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB");
KeyPair keyPairC = generateKeyPair();
X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC");
Path certFile = tempDir.resolve("server-cert.pem");
Path keyFile = tempDir.resolve("server-key.pem");
writeCertPem(certFile, certA);
writeKeyPem(keyFile, keyPairA);
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig);
SSLContext ctx1 = getProviderSslContext(provider);
writeCertPem(certFile, certB);
writeKeyPem(keyFile, keyPairB);
credentialsConfig.onCertificateFileChanged();
SSLContext ctx2 = getProviderSslContext(provider);
writeCertPem(certFile, certC);
writeKeyPem(keyFile, keyPairC);
credentialsConfig.onCertificateFileChanged();
SSLContext ctx3 = getProviderSslContext(provider);
assertThat(ctx1).isNotSameAs(ctx2);
assertThat(ctx2).isNotSameAs(ctx3);
X509Certificate served;
try (SSLServerSocket ss = createServerSocket(ctx3)) {
served = doHandshakeAndGetServerCert(ss);
}
assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal());
}
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception {
PemSslCredentials pem = new PemSslCredentials();
pem.setCertFile(certFile.toAbsolutePath().toString());
pem.setKeyFile(keyFile.toAbsolutePath().toString());
SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false);
config.setEnabled(true);
config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM);
config.setPem(pem);
config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials());
config.init();
return config;
}
private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) {
MqttSslHandlerProvider provider = new MqttSslHandlerProvider();
ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2");
ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig);
ReflectionTestUtils.setField(provider, "transportService", transportService);
provider.afterSingletonsInstantiated();
return provider;
}
/**
* Triggers SSLContext creation through the provider's getSslHandler() path,
* then extracts the cached SSLContext for direct server socket use.
*/
private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) {
provider.getSslHandler();
return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext");
}
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
return kpg.generateKeyPair();
}
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception {
X500Name subject = new X500Name(subjectDn);
Date now = new Date();
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1));
return new JcaX509CertificateConverter().getCertificate(
new JcaX509v3CertificateBuilder(
subject, BigInteger.valueOf(System.nanoTime()), now, expiry,
subject, kp.getPublic())
.build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate())));
}
private void writeCertPem(Path path, X509Certificate cert) throws Exception {
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded()));
}
}
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception {
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded()));
}
}
private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception {
return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress());
}
private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception {
Thread acceptor = new Thread(() -> {
try (var conn = serverSocket.accept()) {
conn.getInputStream().read();
} catch (Exception ignored) {}
});
acceptor.setDaemon(true);
acceptor.start();
SSLContext clientCtx = SSLContext.getInstance("TLSv1.2");
clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null);
try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory()
.createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) {
client.setSoTimeout(5000);
client.startHandshake();
Certificate[] peerCerts = client.getSession().getPeerCertificates();
assertThat(peerCerts).isNotEmpty();
return (X509Certificate) peerCerts[0];
} finally {
acceptor.join(5000);
}
}
private static class TrustAllManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}

197
common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java

@ -0,0 +1,197 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.mqtt;
import io.netty.handler.ssl.SslHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class MqttSslHandlerProviderTest {
@Mock
private SslCredentialsConfig mockCredentialsConfig;
@Mock
private SslCredentials mockCredentials;
@Mock
private TransportService mockTransportService;
private MqttSslHandlerProvider sslHandlerProvider;
@BeforeEach
public void setup() throws Exception {
sslHandlerProvider = new MqttSslHandlerProvider();
ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig);
ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService);
ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2");
KeyManagerFactory mockKmf = mock(KeyManagerFactory.class);
TrustManagerFactory mockTmf = mock(TrustManagerFactory.class);
X509TrustManager mockTrustManager = mock(X509TrustManager.class);
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials);
when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf);
when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf);
when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]);
when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager});
}
@Test
public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() {
sslHandlerProvider.afterSingletonsInstantiated();
SslHandler handler1 = sslHandlerProvider.getSslHandler();
SslHandler handler2 = sslHandlerProvider.getSslHandler();
assertThat(handler1).isNotNull();
assertThat(handler2).isNotNull();
assertThat(handler1).isNotSameAs(handler2);
SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(context).isNotNull();
}
@Test
public void givenCertificatesReloaded_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() {
sslHandlerProvider.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(initialContext).isNotNull();
reloadCallback.run();
// After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free.
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(contextAfterReload).isNotNull();
assertThat(contextAfterReload).isNotSameAs(initialContext);
SslHandler handler = sslHandlerProvider.getSslHandler();
assertThat(handler).isNotNull();
}
@Test
public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception {
sslHandlerProvider.afterSingletonsInstantiated();
SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(contextBefore).isNotNull();
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(5);
List<SslHandler> handlers = new CopyOnWriteArrayList<>();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
startLatch.await();
handlers.add(sslHandlerProvider.getSslHandler());
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
boolean completed = doneLatch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull());
// Concurrent handshakes read the same pre-built context without the old sync bottleneck.
SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(contextAfter).isSameAs(contextBefore);
}
@Test
public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() {
sslHandlerProvider.afterSingletonsInstantiated();
sslHandlerProvider.getSslHandler();
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(initialContext).isNotNull();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(contextAfterReload).isNotNull();
assertThat(contextAfterReload).isNotSameAs(initialContext);
}
@Test
public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() {
sslHandlerProvider.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
SSLContext context1;
SSLContext context2;
SSLContext context3;
sslHandlerProvider.getSslHandler();
context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(context1).isNotNull();
reloadCallback.run();
sslHandlerProvider.getSslHandler();
context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(context2).isNotNull();
assertThat(context2).isNotSameAs(context1);
reloadCallback.run();
sslHandlerProvider.getSslHandler();
context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(context3).isNotNull();
assertThat(context3).isNotSameAs(context2);
assertThat(context3).isNotSameAs(context1);
}
}

5
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java

@ -19,9 +19,13 @@ import lombok.Getter;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.queue.discovery.event.TbApplicationEvent;
import java.io.Serial;
public final class DeviceDeletedEvent extends TbApplicationEvent {
@Serial
private static final long serialVersionUID = -7453664970966733857L;
@Getter
private final DeviceId deviceId;
@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent {
super(new Object());
this.deviceId = deviceId;
}
}

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java

@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMs
import java.util.Optional;
import java.util.UUID;
/**
* Created by ashvayka on 04.10.18.
*/
public interface SessionMsgListener {
void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse);

5
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java

@ -30,9 +30,6 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent;
import java.util.concurrent.ExecutorService;
/**
* Created by ashvayka on 15.10.18.
*/
@Slf4j
@Data
public abstract class TransportContext {
@ -77,6 +74,4 @@ public abstract class TransportContext {
return serviceInfoProvider.getServiceId();
}
}

4
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java

@ -66,9 +66,6 @@ import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created by ashvayka on 04.10.18.
*/
public interface TransportService {
GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg);
@ -162,4 +159,5 @@ public interface TransportService {
boolean hasSession(SessionInfoProto sessionInfo);
void createGaugeStats(String openConnections, AtomicInteger connectionsCounter);
}

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java

@ -15,9 +15,6 @@
*/
package org.thingsboard.server.common.transport;
/**
* Created by ashvayka on 04.10.18.
*/
public interface TransportServiceCallback<T> {
TransportServiceCallback<Void> EMPTY = new TransportServiceCallback<Void>() {

92
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java

@ -37,41 +37,55 @@ import java.util.Enumeration;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
public abstract class AbstractSslCredentials implements SslCredentials {
private char[] keyPasswordArray;
private record SslState(
char[] keyPasswordArray,
KeyStore keyStore,
PrivateKey privateKey,
PublicKey publicKey,
X509Certificate[] chain,
X509Certificate[] trusts
) {}
private KeyStore keyStore;
private PrivateKey privateKey;
private PublicKey publicKey;
private X509Certificate[] chain;
private X509Certificate[] trusts;
private final AtomicReference<SslState> state = new AtomicReference<>();
@Override
public void init(boolean trustsOnly) throws IOException, GeneralSecurityException {
SslState newState = buildState(trustsOnly);
state.set(newState);
}
@Override
public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException {
init(trustsOnly);
}
private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException {
String keyPassword = getKeyPassword();
char[] keyPasswordArray;
if (StringUtils.isEmpty(keyPassword)) {
this.keyPasswordArray = new char[0];
keyPasswordArray = new char[0];
} else {
this.keyPasswordArray = keyPassword.toCharArray();
keyPasswordArray = keyPassword.toCharArray();
}
this.keyStore = this.loadKeyStore(trustsOnly, this.keyPasswordArray);
Set<X509Certificate> trustedCerts = getTrustedCerts(this.keyStore, trustsOnly);
this.trusts = trustedCerts.toArray(new X509Certificate[0]);
KeyStore keyStore = this.loadKeyStore(trustsOnly, keyPasswordArray);
Set<X509Certificate> trustedCerts = getTrustedCerts(keyStore, trustsOnly);
X509Certificate[] trusts = trustedCerts.toArray(new X509Certificate[0]);
PrivateKey privateKey = null;
PublicKey publicKey = null;
X509Certificate[] chain = null;
if (!trustsOnly) {
PrivateKeyEntry privateKeyEntry = null;
String keyAlias = this.getKeyAlias();
if (!StringUtils.isEmpty(keyAlias)) {
privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, keyAlias, this.keyPasswordArray);
privateKeyEntry = tryGetPrivateKeyEntry(keyStore, keyAlias, keyPasswordArray);
} else {
for (Enumeration<String> e = this.keyStore.aliases(); e.hasMoreElements(); ) {
for (Enumeration<String> e = keyStore.aliases(); e.hasMoreElements(); ) {
String alias = e.nextElement();
privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, alias, this.keyPasswordArray);
privateKeyEntry = tryGetPrivateKeyEntry(keyStore, alias, keyPasswordArray);
if (privateKeyEntry != null) {
this.updateKeyAlias(alias);
break;
@ -82,50 +96,61 @@ public abstract class AbstractSslCredentials implements SslCredentials {
throw new IllegalArgumentException("Failed to get private key from the keystore or pem files. " +
"Please check if the private key exists in the keystore or pem files and if the provided private key password is valid.");
}
this.chain = asX509Certificates(privateKeyEntry.getCertificateChain());
this.privateKey = privateKeyEntry.getPrivateKey();
if (this.chain.length > 0) {
this.publicKey = this.chain[0].getPublicKey();
chain = asX509Certificates(privateKeyEntry.getCertificateChain());
privateKey = privateKeyEntry.getPrivateKey();
if (chain.length > 0) {
publicKey = chain[0].getPublicKey();
}
}
return new SslState(keyPasswordArray, keyStore, privateKey, publicKey, chain, trusts);
}
private SslState getState() {
SslState s = state.get();
if (s == null) {
throw new IllegalStateException("SSL credentials not initialized. Call init() first.");
}
return s;
}
@Override
public KeyStore getKeyStore() {
return this.keyStore;
return getState().keyStore;
}
@Override
public PrivateKey getPrivateKey() {
return this.privateKey;
return getState().privateKey;
}
@Override
public PublicKey getPublicKey() {
return this.publicKey;
return getState().publicKey;
}
@Override
public X509Certificate[] getCertificateChain() {
return this.chain;
return getState().chain;
}
@Override
public X509Certificate[] getTrustedCertificates() {
return this.trusts;
return getState().trusts;
}
@Override
public TrustManagerFactory createTrustManagerFactory() throws NoSuchAlgorithmException, KeyStoreException {
SslState s = getState();
TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmFactory.init(this.keyStore);
tmFactory.init(s.keyStore);
return tmFactory;
}
@Override
public KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException {
SslState s = getState();
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(this.keyStore, this.keyPasswordArray);
kmf.init(s.keyStore, s.keyPasswordArray);
return kmf;
}
@ -133,7 +158,7 @@ public abstract class AbstractSslCredentials implements SslCredentials {
public String getValueFromSubjectNameByKey(String subjectName, String key) {
String[] dns = subjectName.split(",");
Optional<String> cn = (Arrays.stream(dns).filter(dn -> dn.contains(key + "="))).findFirst();
String value = cn.isPresent() ? cn.get().replace(key + "=", "") : null;
String value = cn.map(s -> s.replace(key + "=", "")).orElse(null);
return StringUtils.isNotEmpty(value) ? value : null;
}
@ -189,7 +214,7 @@ public abstract class AbstractSslCredentials implements SslCredentials {
if (cert instanceof X509Certificate) {
if (trustsOnly) {
// is CA certificate
if (((X509Certificate) cert).getBasicConstraints()>=0) {
if (((X509Certificate) cert).getBasicConstraints() >= 0) {
set.add((X509Certificate) cert);
}
} else {
@ -203,12 +228,12 @@ public abstract class AbstractSslCredentials implements SslCredentials {
if (trustsOnly) {
for (Certificate cert : certs) {
// is CA certificate
if (((X509Certificate) cert).getBasicConstraints()>=0) {
if (((X509Certificate) cert).getBasicConstraints() >= 0) {
set.add((X509Certificate) cert);
}
}
} else {
set.add((X509Certificate)certs[0]);
set.add((X509Certificate) certs[0]);
}
}
}
@ -216,4 +241,5 @@ public abstract class AbstractSslCredentials implements SslCredentials {
} catch (KeyStoreException ignored) {}
return Collections.unmodifiableSet(set);
}
}

14
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java

@ -22,8 +22,11 @@ import org.thingsboard.server.common.data.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.util.Collections;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@ -54,4 +57,15 @@ public class KeystoreSslCredentials extends AbstractSslCredentials {
protected void updateKeyAlias(String keyAlias) {
this.keyAlias = keyAlias;
}
@Override
public List<Path> getCertificateFilePaths() {
if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
// Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as
// baseline, so a late-appearing file (e.g., mounted after boot) will be detected and trigger a reload.
return Collections.singletonList(Path.of(storeFile).toAbsolutePath());
}
return Collections.emptyList();
}
}

43
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java

@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
@ -76,13 +77,13 @@ public class PemSslCredentials extends AbstractSslCredentials {
if (object instanceof X509CertificateHolder) {
X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object);
certificates.add(x509Cert);
} else if (object instanceof PEMEncryptedKeyPair) {
} else if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) {
PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray);
privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate();
} else if (object instanceof PEMKeyPair) {
privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate();
} else if (object instanceof PrivateKeyInfo) {
privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object);
privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate();
} else if (object instanceof PEMKeyPair pemKeyPair) {
privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate();
} else if (object instanceof PrivateKeyInfo privateKeyInfo) {
privateKey = keyConverter.getPrivateKey(privateKeyInfo);
}
}
}
@ -93,15 +94,15 @@ public class PemSslCredentials extends AbstractSslCredentials {
try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) {
Object object;
while ((object = pemParser.readObject()) != null) {
if (object instanceof PEMEncryptedKeyPair) {
if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) {
PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray);
privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate();
privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate();
break;
} else if (object instanceof PEMKeyPair) {
privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate();
} else if (object instanceof PEMKeyPair pemKeyPair) {
privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate();
break;
} else if (object instanceof PrivateKeyInfo) {
privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object);
} else if (object instanceof PrivateKeyInfo privateKeyInfo) {
privateKey = keyConverter.getPrivateKey(privateKeyInfo);
}
}
}
@ -138,6 +139,22 @@ public class PemSslCredentials extends AbstractSslCredentials {
}
@Override
protected void updateKeyAlias(String keyAlias) {
protected void updateKeyAlias(String keyAlias) {}
@Override
public List<Path> getCertificateFilePaths() {
List<Path> paths = new ArrayList<>();
addIfFileSystemPath(paths, certFile);
addIfFileSystemPath(paths, keyFile);
return paths;
}
private static void addIfFileSystemPath(List<Path> paths, String filePath) {
if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
// Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as
// baseline, so a late-appearing file (e.g. mounted after boot) will be detected and trigger a reload.
paths.add(Path.of(filePath).toAbsolutePath());
}
}
}

7
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java

@ -18,6 +18,7 @@ package org.thingsboard.server.common.transport.config.ssl;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
@ -26,11 +27,14 @@ import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.X509Certificate;
import java.util.List;
public interface SslCredentials {
void init(boolean trustsOnly) throws IOException, GeneralSecurityException;
void reload(boolean trustsOnly) throws IOException, GeneralSecurityException;
KeyStore getKeyStore();
String getKeyPassword();
@ -50,4 +54,7 @@ public interface SslCredentials {
KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException;
String getValueFromSubjectNameByKey(String subjectName, String key);
List<Path> getCertificateFilePaths();
}

30
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java

@ -19,6 +19,9 @@ import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
@Data
public class SslCredentialsConfig {
@ -33,6 +36,8 @@ public class SslCredentialsConfig {
private final String name;
private final boolean trustsOnly;
private final List<Runnable> reloadCallbacks = new CopyOnWriteArrayList<>();
public SslCredentialsConfig(String name, boolean trustsOnly) {
this.name = name;
this.trustsOnly = trustsOnly;
@ -62,4 +67,29 @@ public class SslCredentialsConfig {
}
}
public void onCertificateFileChanged() {
log.info("{}: Certificate file changed. Reloading SSL credentials...", name);
try {
this.credentials.reload(this.trustsOnly);
} catch (Exception e) {
log.error("{}: Failed to reload SSL credentials", name, e);
// Rethrow, so CertificateReloadManager's watcher counts this as a failure
// and applies MAX_CONSECUTIVE_FAILURES backoff instead of treating it as a successful reload.
throw new RuntimeException(name + ": Failed to reload SSL credentials", e);
}
log.info("{}: SSL credentials reloaded successfully.", name);
for (Runnable callback : reloadCallbacks) {
try {
callback.run();
} catch (Exception e) {
log.error("{}: Error executing reload callback", name, e);
}
}
}
public void registerReloadCallback(Runnable callback) {
this.reloadCallbacks.add(callback);
}
}

120
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java

@ -15,11 +15,14 @@
*/
package org.thingsboard.server.common.transport.config.ssl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.ssl.NoSuchSslBundleException;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.ssl.SslStoreBundle;
@ -30,71 +33,124 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Slf4j
@Component
@ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'")
public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory>, SmartInitializingSingleton {
@Bean
@ConfigurationProperties(prefix = "server.ssl.credentials")
public SslCredentialsConfig httpServerSslCredentials() {
return new SslCredentialsConfig("HTTP Server SSL Credentials", false);
}
private static final String DEFAULT_BUNDLE_NAME = "default";
private final ServerProperties serverProperties;
private final List<Consumer<SslBundle>> updateHandlers = new CopyOnWriteArrayList<>();
@Autowired
@Qualifier("httpServerSslCredentials")
private SslCredentialsConfig httpServerSslCredentialsConfig;
@Autowired
SslBundles sslBundles;
private final ServerProperties serverProperties;
private SslBundles sslBundles;
public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) {
this.serverProperties = serverProperties;
}
@Bean
@ConfigurationProperties(prefix = "server.ssl.credentials")
public SslCredentialsConfig httpServerSslCredentials() {
return new SslCredentialsConfig("HTTP Server SSL Credentials", false);
}
@Bean
public SslBundles sslBundles() {
return new DynamicSslBundles();
}
@Override
public void customize(ConfigurableServletWebServerFactory factory) {
SslCredentials sslCredentials = this.httpServerSslCredentialsConfig.getCredentials();
SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials();
Ssl ssl = serverProperties.getSsl();
ssl.setBundle("default");
ssl.setKeyAlias(sslCredentials.getKeyAlias());
ssl.setKeyPassword(sslCredentials.getKeyPassword());
ssl.setBundle(DEFAULT_BUNDLE_NAME);
ssl.setKeyAlias(credentials.getKeyAlias());
ssl.setKeyPassword(credentials.getKeyPassword());
factory.setSsl(ssl);
factory.setSslBundles(sslBundles);
}
@Bean
public SslBundles sslBundles() {
@Override
public void afterSingletonsInstantiated() {
httpServerSslCredentialsConfig.registerReloadCallback(this::reloadSslCertificates);
}
private void reloadSslCertificates() {
try {
log.info("Reloading HTTP Server SSL certificates...");
SslBundle newBundle = createSslBundle();
notifyUpdateHandlers(newBundle);
log.info("HTTP Server SSL certificates reloaded successfully");
} catch (Exception e) {
log.error("Failed to reload HTTP Server SSL certificates", e);
}
}
private SslBundle createSslBundle() {
SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials();
SslStoreBundle storeBundle = SslStoreBundle.of(
httpServerSslCredentialsConfig.getCredentials().getKeyStore(),
httpServerSslCredentialsConfig.getCredentials().getKeyPassword(),
credentials.getKeyStore(),
credentials.getKeyPassword(),
null
);
return new SslBundles() {
@Override
public SslBundle getBundle(String name) {
return SslBundle.of(storeBundle);
}
return SslBundle.of(storeBundle);
}
@Override
public List<String> getBundleNames() {
return List.of("default");
private void notifyUpdateHandlers(SslBundle newBundle) {
for (Consumer<SslBundle> handler : updateHandlers) {
try {
handler.accept(newBundle);
} catch (Exception e) {
log.error("Failed to notify SSL bundle update handler", e);
}
}
}
@Override
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// no-op
private class DynamicSslBundles implements SslBundles {
@Override
public SslBundle getBundle(String name) {
if (!DEFAULT_BUNDLE_NAME.equals(name)) {
throw new NoSuchSslBundleException(name, "Unknown SSL bundle: " + name);
}
return createSslBundle();
}
@Override
public List<String> getBundleNames() {
return List.of(DEFAULT_BUNDLE_NAME);
}
@Override
public void addBundleRegisterHandler(BiConsumer<String, SslBundle> handler) {
// no-op
@Override
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
if (DEFAULT_BUNDLE_NAME.equals(name)) {
updateHandlers.add(handler);
log.debug("Registered SSL bundle update handler for bundle: {}", name);
} else {
log.warn("Attempted to register update handler for unknown bundle: {}", name);
}
};
}
@Override
public void addBundleRegisterHandler(BiConsumer<String, SslBundle> registerHandler) {
log.debug("addBundleRegisterHandler is not supported for dynamic SSL bundles");
}
}
}

299
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java

@ -0,0 +1,299 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.queue.util.TbTransportComponent;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@TbTransportComponent
public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean {
private static final int MAX_CONSECUTIVE_FAILURES = 10;
@Value("${transport.ssl.certificate.reload.enabled:true}")
private boolean reloadEnabled;
@Value("${transport.ssl.certificate.reload.check_interval_seconds:60}")
private long checkIntervalInSeconds;
@Autowired
protected ApplicationContext applicationContext;
private final Map<String, CertificateWatcher> watchers = new ConcurrentHashMap<>();
private volatile ScheduledExecutorService scheduler;
public void registerWatcher(String name, Path certPath, Runnable reloadCallback) {
registerWatcher(name, List.of(certPath), reloadCallback);
}
public void registerWatcher(String name, List<Path> certPaths, Runnable reloadCallback) {
watchers.put(name, new CertificateWatcher(certPaths, reloadCallback));
log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size());
}
private void checkCertificates() {
watchers.forEach((name, watcher) -> {
try {
watcher.checkAndReload(name);
} catch (Exception e) {
log.error("Error checking certificate for {}: {}", name, e.getMessage(), e);
}
});
}
private void discoverAndRegisterSslCredentials() {
try {
Map<String, SslCredentialsConfig> sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class);
log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size());
for (Map.Entry<String, SslCredentialsConfig> entry : sslConfigBeans.entrySet()) {
String beanName = entry.getKey();
SslCredentialsConfig config = entry.getValue();
try {
if (!config.isEnabled()) {
log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName);
continue;
}
SslCredentials credentials = config.getCredentials();
if (credentials == null) {
log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName);
continue;
}
List<Path> filePaths = credentials.getCertificateFilePaths();
if (filePaths == null || filePaths.isEmpty()) {
log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName);
continue;
}
// Register all configured paths, including those that don't exist yet — the watcher uses
// mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are
// picked up and trigger a reload on the next poll.
List<Path> pathsToWatch = new ArrayList<>(filePaths.size());
for (Path filePath : filePaths) {
if (filePath == null) {
continue;
}
pathsToWatch.add(filePath);
if (!Files.exists(filePath)) {
log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears",
filePath, config.getName());
}
}
if (!pathsToWatch.isEmpty()) {
registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged);
log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch);
}
} catch (Exception e) {
log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e);
}
}
} catch (Exception e) {
log.error("Error discovering SSL credentials configs", e);
}
}
@Override
public void destroy() throws Exception {
if (scheduler != null) {
scheduler.shutdown();
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
}
}
@Override
public void afterSingletonsInstantiated() {
if (!reloadEnabled) {
log.trace("Auto-reload of certificates is disabled. Skipping initialization...");
return;
}
log.info("Initializing Certificate Reload Manager...");
discoverAndRegisterSslCredentials();
scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager"));
scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS);
}
static class CertificateWatcher {
private final List<Path> paths;
private final Runnable reloadCallback;
private final Map<Path, Long> lastModifiedMap;
private final Map<Path, String> lastChecksumMap;
private int consecutiveFailures;
private String failedCombinedChecksum;
CertificateWatcher(List<Path> paths, Runnable reloadCallback) {
this.paths = paths;
this.reloadCallback = reloadCallback;
this.lastModifiedMap = new HashMap<>();
this.lastChecksumMap = new HashMap<>();
for (Path path : paths) {
lastModifiedMap.put(path, getLastModifiedTime(path));
lastChecksumMap.put(path, calculateChecksum(path));
}
this.consecutiveFailures = 0;
}
synchronized void checkAndReload(String name) {
boolean anyModifiedChanged = false;
for (Path path : paths) {
long currentModified = getLastModifiedTime(path);
Long lastModified = lastModifiedMap.getOrDefault(path, 0L);
if (currentModified != lastModified) {
anyModifiedChanged = true;
break;
}
}
if (!anyModifiedChanged) {
return;
}
// Capture mtimes and checksums together before the callback runs.
// Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll.
Map<Path, Long> currentModifiedTimes = new HashMap<>();
Map<Path, String> currentChecksums = new HashMap<>();
StringBuilder combined = new StringBuilder();
for (Path path : paths) {
currentModifiedTimes.put(path, getLastModifiedTime(path));
String checksum = calculateChecksum(path);
currentChecksums.put(path, checksum);
if (!combined.isEmpty()) {
combined.append("|");
}
combined.append(path).append("=").append(checksum);
}
String combinedChecksum = combined.toString();
// Build old combined checksum for comparison
StringBuilder oldCombined = new StringBuilder();
for (Path path : paths) {
if (!oldCombined.isEmpty()) {
oldCombined.append("|");
}
oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, ""));
}
String oldCombinedChecksum = oldCombined.toString();
if (combinedChecksum.equals(oldCombinedChecksum)) {
// Content unchanged, just update modification times
for (Path path : paths) {
lastModifiedMap.put(path, currentModifiedTimes.get(path));
}
return;
}
if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) {
// File content has changed since the last failure - reset and retry
consecutiveFailures = 0;
failedCombinedChecksum = null;
}
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
// Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle
for (Path path : paths) {
lastModifiedMap.put(path, currentModifiedTimes.get(path));
}
return;
}
try {
log.info("Certificate change detected for: {}. Triggering reload...", name);
reloadCallback.run();
for (Path path : paths) {
lastModifiedMap.put(path, currentModifiedTimes.get(path));
lastChecksumMap.put(path, currentChecksums.get(path));
}
consecutiveFailures = 0;
failedCombinedChecksum = null;
} catch (Exception e) {
consecutiveFailures++;
failedCombinedChecksum = combinedChecksum;
// Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries
// (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum).
log.error("Failed to reload certificate for {} (attempt {}/{}): {}",
name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e);
}
}
private long getLastModifiedTime(Path path) {
try {
if (!Files.exists(path)) {
return 0;
}
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException e) {
return 0;
}
}
private String calculateChecksum(Path path) {
try {
if (!Files.exists(path)) {
return "";
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] buf = new byte[8192];
try (InputStream is = Files.newInputStream(path)) {
int bytesRead;
while ((bytesRead = is.read(buf)) != -1) {
md.update(buf, 0, bytesRead);
}
}
return Base64.getEncoder().encodeToString(md.digest());
} catch (Exception e) {
log.warn("Failed to calculate checksum for certificate file: {}", path, e);
return "";
}
}
}
}

16
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java

@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* Created by ashvayka on 17.10.18.
*/
@Slf4j
@Service
@TbTransportComponent
@ -789,7 +786,7 @@ public class DefaultTransportService extends TransportActivityManager implements
TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build();
ScheduledFuture executorFuture = scheduler.schedule(() -> {
ScheduledFuture<?> executorFuture = scheduler.schedule(() -> {
listener.onRemoteSessionCloseCommand(sessionId, notification);
deregisterSession(sessionInfo);
}, timeout, TimeUnit.MILLISECONDS);
@ -1169,6 +1166,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onFailure(Throwable t) {
DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t));
}
}
private static class StatsCallback implements TbQueueCallback {
@ -1183,16 +1181,19 @@ public class DefaultTransportService extends TransportActivityManager implements
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
stats.incrementSuccessful();
if (callback != null)
if (callback != null) {
callback.onSuccess(metadata);
}
}
@Override
public void onFailure(Throwable t) {
stats.incrementFailed();
if (callback != null)
if (callback != null) {
callback.onFailure(t);
}
}
}
private class MsgPackCallback implements TbQueueCallback {
@ -1215,6 +1216,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onFailure(Throwable t) {
DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t));
}
}
private class ApiStatsProxyCallback<T> implements TransportServiceCallback<T> {
@ -1244,6 +1246,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onError(Throwable e) {
callback.onError(e);
}
}
@Override
@ -1270,4 +1273,5 @@ public class DefaultTransportService extends TransportActivityManager implements
log.info("Transport Stats: {}", values);
}
}
}

8
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java

@ -21,9 +21,6 @@ import org.thingsboard.server.gen.transport.TransportProtos;
import java.util.concurrent.ScheduledFuture;
/**
* Created by ashvayka on 15.10.18.
*/
@Data
public class SessionMetaData {
@ -47,11 +44,8 @@ public class SessionMetaData {
this.scheduledFuture = scheduledFuture;
}
public ScheduledFuture getScheduledFuture() {
return scheduledFuture;
}
public boolean hasScheduledFuture() {
return null != this.scheduledFuture;
}
}

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java

@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service;
import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg;
import org.thingsboard.server.queue.kafka.TbKafkaEncoder;
/**
* Created by ashvayka on 05.10.18.
*/
public class ToRuleEngineMsgEncoder implements TbKafkaEncoder<ToRuleEngineMsg> {
@Override
public byte[] encode(ToRuleEngineMsg value) {

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java

@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder;
import java.io.IOException;
/**
* Created by ashvayka on 05.10.18.
*/
public class ToTransportMsgResponseDecoder implements TbKafkaDecoder<ToTransportMsg> {
@Override

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java

@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service;
import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg;
import org.thingsboard.server.queue.kafka.TbKafkaEncoder;
/**
* Created by ashvayka on 05.10.18.
*/
public class TransportApiRequestEncoder implements TbKafkaEncoder<TransportApiRequestMsg> {
@Override
public byte[] encode(TransportApiRequestMsg value) {

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java

@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder;
import java.io.IOException;
/**
* Created by ashvayka on 05.10.18.
*/
public class TransportApiResponseDecoder implements TbKafkaDecoder<TransportApiResponseMsg> {
@Override

3
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java

@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos;
import java.util.Optional;
import java.util.UUID;
/**
* @author Andrew Shvayka
*/
@Data
public abstract class DeviceAwareSessionContext implements SessionContext {

1
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java

@ -31,4 +31,5 @@ public interface SessionContext {
void onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile);
void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional<DeviceProfile> deviceProfileOpt);
}

16
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java

@ -27,8 +27,7 @@ import java.util.regex.Pattern;
public class JsonUtils {
private static final Pattern BASE64_PATTERN =
Pattern.compile("^[A-Za-z0-9+/]+={0,2}$");
private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$");
public static JsonObject getJsonObject(List<KeyValueProto> tsKv) {
JsonObject json = new JsonObject();
@ -68,12 +67,12 @@ public class JsonUtils {
}
return JsonParser.parseString((String) value);
}
} else if (value instanceof Boolean) {
return new JsonPrimitive((Boolean) value);
} else if (value instanceof Double) {
return new JsonPrimitive((Double) value);
} else if (value instanceof Float) {
return new JsonPrimitive((Float) value);
} else if (value instanceof Boolean booleanValue) {
return new JsonPrimitive(booleanValue);
} else if (value instanceof Double doubleValue) {
return new JsonPrimitive(doubleValue);
} else if (value instanceof Float floatValue) {
return new JsonPrimitive(floatValue);
} else {
throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName());
}
@ -91,4 +90,5 @@ public class JsonUtils {
public static boolean isBase64(String value) {
return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches();
}
}

7
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java

@ -31,10 +31,6 @@ import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
/**
* @author Valerii Sosliuk
*/
@Slf4j
public class SslUtil {
@ -51,7 +47,7 @@ public class SslUtil {
String begin = "-----BEGIN CERTIFICATE-----";
String end = "-----END CERTIFICATE-----";
StringBuilder stringBuilder = new StringBuilder();
for (Certificate cert: chain) {
for (Certificate cert : chain) {
stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n");
}
return stringBuilder.toString();
@ -85,4 +81,5 @@ public class SslUtil {
RDN cn = x500name.getRDNs(BCStyle.CN)[0];
return IETFUtils.valueToString(cn.getFirst().getValue());
}
}

185
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java

@ -0,0 +1,185 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.config.ssl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class SslCredentialsConfigTest {
@Mock
private SslCredentials mockCredentials;
private SslCredentialsConfig config;
@BeforeEach
public void setup() {
config = new SslCredentialsConfig("Test SSL Config", false);
}
@Test
public void givenConfig_whenCreated_thenShouldHaveCorrectName() {
assertThat(config.getName()).isEqualTo("Test SSL Config");
assertThat(config.isTrustsOnly()).isFalse();
}
@Test
public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() {
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true);
assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue();
}
@Test
public void givenCallback_whenRegistered_thenShouldBeStoredInList() {
AtomicInteger callCount = new AtomicInteger(0);
config.registerReloadCallback(callCount::incrementAndGet);
config.setCredentials(mockCredentials);
try {
doNothing().when(mockCredentials).reload(false);
} catch (Exception e) {
throw new RuntimeException(e);
}
config.onCertificateFileChanged();
assertThat(callCount.get()).isEqualTo(1);
}
@Test
public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception {
AtomicInteger callback1Count = new AtomicInteger(0);
AtomicInteger callback2Count = new AtomicInteger(0);
AtomicInteger callback3Count = new AtomicInteger(0);
config.registerReloadCallback(callback1Count::incrementAndGet);
config.registerReloadCallback(callback2Count::incrementAndGet);
config.registerReloadCallback(callback3Count::incrementAndGet);
config.setCredentials(mockCredentials);
doNothing().when(mockCredentials).reload(false);
config.onCertificateFileChanged();
assertThat(callback1Count.get()).isEqualTo(1);
assertThat(callback2Count.get()).isEqualTo(1);
assertThat(callback3Count.get()).isEqualTo(1);
}
@Test
public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception {
AtomicInteger callback1Count = new AtomicInteger(0);
AtomicInteger callback2Count = new AtomicInteger(0);
config.registerReloadCallback(() -> {
callback1Count.incrementAndGet();
throw new RuntimeException("Simulated callback failure");
});
config.registerReloadCallback(callback2Count::incrementAndGet);
config.setCredentials(mockCredentials);
doNothing().when(mockCredentials).reload(false);
config.onCertificateFileChanged();
assertThat(callback1Count.get()).isEqualTo(1);
assertThat(callback2Count.get()).isEqualTo(1);
}
@Test
public void givenCredentialsReloadFails_whenCertificateChanged_thenShouldRethrowAndNotCallCallbacks() throws Exception {
AtomicInteger callbackCount = new AtomicInteger(0);
config.registerReloadCallback(callbackCount::incrementAndGet);
config.setCredentials(mockCredentials);
doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false);
assertThatThrownBy(() -> config.onCertificateFileChanged())
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Failed to reload SSL credentials");
assertThat(callbackCount.get()).isEqualTo(0);
}
@Test
public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception {
config.setCredentials(mockCredentials);
doNothing().when(mockCredentials).reload(false);
config.onCertificateFileChanged();
verify(mockCredentials).reload(false);
}
@Test
public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception {
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true);
trustsOnlyConfig.setCredentials(mockCredentials);
doNothing().when(mockCredentials).reload(true);
trustsOnlyConfig.onCertificateFileChanged();
verify(mockCredentials).reload(true);
}
@Test
public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception {
AtomicInteger totalCallbacks = new AtomicInteger(0);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
startLatch.await();
config.registerReloadCallback(totalCallbacks::incrementAndGet);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
boolean completed = doneLatch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
config.setCredentials(mockCredentials);
doNothing().when(mockCredentials).reload(false);
config.onCertificateFileChanged();
assertThat(totalCallbacks.get()).isEqualTo(10);
}
}

277
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java

@ -0,0 +1,277 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.config.ssl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.test.util.ReflectionTestUtils;
import java.security.KeyStore;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class SslCredentialsWebServerCustomizerTest {
@Mock
private ServerProperties mockServerProperties;
@Mock
private SslCredentialsConfig mockCredentialsConfig;
@Mock
private SslCredentials mockCredentials;
@Mock
private KeyStore mockKeyStore;
private SslCredentialsWebServerCustomizer customizer;
@BeforeEach
public void setup() throws Exception {
customizer = new SslCredentialsWebServerCustomizer(mockServerProperties);
ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig);
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials);
when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore);
when(mockCredentials.getKeyPassword()).thenReturn("password");
when(mockCredentials.getKeyAlias()).thenReturn("server");
X509Certificate mockCert = mock(X509Certificate.class);
when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes());
when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert});
}
@Test
public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() {
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
assertThat(callbackCaptor.getValue()).isNotNull();
}
@Test
public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() {
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
reloadCallback.run();
verify(mockCredentialsConfig, times(1)).getCredentials();
}
@Test
public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() {
SslBundles sslBundles = customizer.sslBundles();
SslBundle bundle = sslBundles.getBundle("default");
assertThat(bundle).isNotNull();
}
@Test
public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() {
SslBundles sslBundles = customizer.sslBundles();
List<String> bundleNames = sslBundles.getBundleNames();
assertThat(bundleNames).containsExactly("default");
}
@Test
public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handlerCallCount = new AtomicInteger(0);
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet();
sslBundles.addBundleUpdateHandler("default", handler);
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
assertThat(handlerCallCount.get()).isEqualTo(1);
}
@Test
public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handlerCallCount = new AtomicInteger(0);
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet();
sslBundles.addBundleUpdateHandler("wrong-bundle", handler);
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
assertThat(handlerCallCount.get()).isEqualTo(0);
}
@Test
public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handler1CallCount = new AtomicInteger(0);
AtomicInteger handler2CallCount = new AtomicInteger(0);
AtomicInteger handler3CallCount = new AtomicInteger(0);
sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet());
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet());
sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet());
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
assertThat(handler1CallCount.get()).isEqualTo(1);
assertThat(handler2CallCount.get()).isEqualTo(1);
assertThat(handler3CallCount.get()).isEqualTo(1);
}
@Test
public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handlerCallCount = new AtomicInteger(0);
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet());
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
reloadCallback.run();
reloadCallback.run();
reloadCallback.run();
assertThat(handlerCallCount.get()).isEqualTo(3);
}
@Test
public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handler1CallCount = new AtomicInteger(0);
AtomicInteger handler2CallCount = new AtomicInteger(0);
sslBundles.addBundleUpdateHandler("default", bundle -> {
handler1CallCount.incrementAndGet();
throw new RuntimeException("Handler 1 failed");
});
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet());
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
assertThat(handler1CallCount.get()).isEqualTo(1);
assertThat(handler2CallCount.get()).isEqualTo(1);
}
@Test
public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception {
SslBundles sslBundles = customizer.sslBundles();
AtomicInteger handlerCallCount = new AtomicInteger(0);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(5);
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet());
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
startLatch.await();
reloadCallback.run();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
boolean completed = doneLatch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
assertThat(handlerCallCount.get()).isEqualTo(5);
}
@Test
public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() {
when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials"));
customizer.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
}
@Test
public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() {
SslBundles sslBundles = customizer.sslBundles();
SslBundle bundle1 = sslBundles.getBundle("default");
SslBundle bundle2 = sslBundles.getBundle("default");
assertThat(bundle1).isNotNull();
assertThat(bundle2).isNotNull();
}
@Test
public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() {
SslCredentialsConfig config = customizer.httpServerSslCredentials();
assertThat(config).isNotNull();
assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials");
assertThat(config.isTrustsOnly()).isFalse();
}
}

355
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java

@ -0,0 +1,355 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
public class CertificateReloadManagerTest {
@TempDir
Path tempDir;
private CertificateReloadManager certificateReloadManager;
private Path certFile;
@BeforeEach
public void setup() throws IOException {
certificateReloadManager = new CertificateReloadManager();
certFile = tempDir.resolve("test-cert.pem");
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n");
}
@AfterEach
public void teardown() throws Exception {
if (certificateReloadManager != null) {
certificateReloadManager.destroy();
}
}
private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException {
Files.writeString(path, content);
await().atMost(2, SECONDS)
.pollInterval(10, MILLISECONDS)
.until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime);
}
private long mtime(Path path) throws IOException {
return Files.getLastModifiedTime(path).toMillis();
}
@Test
public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(1);
}
@Test
public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(0);
}
@Test
public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L;
Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime));
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(0);
}
@Test
public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
Files.delete(certFile);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(1);
}
@Test
public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception {
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {});
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler);
certificateReloadManager.destroy();
assertThat(scheduler.isShutdown()).isTrue();
assertThat(scheduler.isTerminated()).isTrue();
}
@Test
public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception {
Path keyFile = tempDir.resolve("test-key.pem");
Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n");
AtomicInteger certReloadCount = new AtomicInteger(0);
AtomicInteger keyReloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet);
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet);
long baseline = mtime(keyFile);
writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(keyReloadCount.get()).isEqualTo(1);
assertThat(certReloadCount.get()).isEqualTo(0);
}
@Test
public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception {
Path cert2File = tempDir.resolve("test-cert2.pem");
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n");
AtomicInteger reload1Count = new AtomicInteger(0);
AtomicInteger reload2Count = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet);
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet);
long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reload1Count.get()).isEqualTo(1);
assertThat(reload2Count.get()).isEqualTo(1);
}
@Test
public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception {
Path cert2File = tempDir.resolve("test-cert2.pem");
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n");
AtomicInteger reload2Count = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert1", certFile, () -> {
throw new RuntimeException("Simulated reload failure");
});
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet);
long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reload2Count.get()).isEqualTo(1);
}
@Test
public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
Files.delete(certFile);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(1);
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n");
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(2);
}
@Test
public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
for (int i = 0; i < 5; i++) {
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n");
}
await().atMost(2, SECONDS)
.pollInterval(10, MILLISECONDS)
.until(() -> mtime(certFile) != baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(1);
}
@Test
public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(5);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
startLatch.await();
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
boolean completed = doneLatch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
assertThat(reloadCount.get()).isEqualTo(1);
}
@Test
public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
String originalContent = Files.readString(certFile);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, originalContent, baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(0);
}
@Test
public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception {
AtomicInteger reloadAttempts = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {
reloadAttempts.incrementAndGet();
throw new RuntimeException("Persistent failure");
});
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
}
assertThat(reloadAttempts.get()).isEqualTo(10);
}
@Test
public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception {
AtomicInteger reloadAttempts = new AtomicInteger(0);
AtomicInteger shouldFail = new AtomicInteger(1);
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {
reloadAttempts.incrementAndGet();
if (shouldFail.get() == 1) {
throw new RuntimeException("Transient failure");
}
});
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(1);
shouldFail.set(0);
long baseline2 = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(2);
}
@Test
public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception {
AtomicInteger reloadAttempts = new AtomicInteger(0);
AtomicInteger shouldFail = new AtomicInteger(1);
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {
reloadAttempts.incrementAndGet();
if (shouldFail.get() == 1) {
throw new RuntimeException("Persistent failure");
}
});
long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
}
assertThat(reloadAttempts.get()).isEqualTo(10);
shouldFail.set(0);
long baseline2 = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(11);
}
}

9
transport/coap/src/main/resources/tb-coap-transport.yml

@ -170,6 +170,15 @@ transport:
enabled: "${TB_TRANSPORT_STATS_ENABLED:true}"
# Interval of transport statistics logging
print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
coap:

9
transport/http/src/main/resources/tb-http-transport.yml

@ -201,6 +201,15 @@ transport:
enabled: "${TB_TRANSPORT_STATS_ENABLED:true}"
# Interval of transport statistics logging
print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# Queue configuration parameters
queue:

9
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -301,6 +301,15 @@ transport:
enabled: "${TB_TRANSPORT_STATS_ENABLED:true}"
# Interval of transport statistics logging
print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# Queue configuration properties
queue:

9
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -234,6 +234,15 @@ transport:
max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}"
# Timeout to expire block IP addresses
ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# Queue configuration parameters
queue:

Loading…
Cancel
Save