Browse Source

Added ssl/tls auto-rotation of certificates

pull/14417/head
Andrii Landiak 2 days ago
parent
commit
73a97e7ab3
  1. 9
      application/src/main/resources/thingsboard.yml
  2. 92
      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. 143
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
  5. 8
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
  6. 19
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
  7. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java
  8. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java
  9. 34
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
  10. 30
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java
  11. 32
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  12. 50
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java
  13. 207
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java
  14. 183
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java
  15. 28
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java
  16. 3
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java
  17. 197
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java
  18. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java
  19. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java
  20. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java
  21. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  22. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java
  23. 14
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java
  24. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java
  25. 49
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java
  26. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java
  27. 27
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java
  28. 109
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  29. 193
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
  30. 8
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  31. 8
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java
  32. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java
  33. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java
  34. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java
  35. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java
  36. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java
  37. 1
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java
  38. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java
  39. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java
  40. 277
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java
  41. 175
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
  42. 9
      transport/coap/src/main/resources/tb-coap-transport.yml
  43. 9
      transport/http/src/main/resources/tb-http-transport.yml
  44. 9
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  45. 9
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml

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

@ -1315,6 +1315,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}"
# Check interval in seconds for certificates reload
check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}"
# CoAP server parameters
coap:

92
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,7 +44,7 @@ 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;
@ -53,11 +55,30 @@ public class DefaultCoapServerService implements CoapServerService {
private ScheduledExecutorService dtlsSessionsExecutor;
private DTLSConnector dtlsConnector;
private 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,51 @@ 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;
}
private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException {
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 = new DTLSConnector(dtlsConnectorConfig);
dtlsCoapEndpointBuilder.setConnector(dtlsConnector);
dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build();
server.addEndpoint(dtlsCoapEndpoint);
tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
}
private synchronized void recreateDtlsEndpoint() throws IOException {
if (dtlsCoapEndpoint != null) {
log.info("Stopping old DTLS endpoint...");
dtlsCoapEndpoint.stop();
server.getEndpoints().remove(dtlsCoapEndpoint);
if (dtlsConnector != null) {
dtlsConnector.destroy();
}
dtlsCoapEndpoint.destroy();
log.info("Old DTLS endpoint stopped and removed.");
}
Configuration networkConfig = createNetworkConfiguration();
log.info("Creating new DTLS endpoint with updated certificates...");
createDtlsEndpoint(networkConfig);
dtlsCoapEndpoint.start();
log.info("New DTLS endpoint started successfully.");
}
}

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;
}
}
}

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

@ -0,0 +1,143 @@
/**
* 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.scandium.DTLSConnector;
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 java.util.List;
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.mock;
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_whenDtlsEndpointExists_thenShouldRecreateDtlsEndpoint() {
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
when(mockCoapServer.getEndpoints()).thenReturn(mock(List.class));
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 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_thenShouldHandleGracefully() {
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();
}
}

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

19
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,16 @@ public class ResourceUtils {
return resourceFile.getAbsolutePath();
} else {
URL url = classLoader.getResource(filePath);
return url.toURI().toString();
return url != null ? url.toURI().toString() : null;
}
} 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);
}
}
}

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 {
}
};
}
}

34
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;
@ -65,6 +66,19 @@ public class LwM2MTransportBootstrapService {
private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier;
private 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() {
log.info("Starting LwM2M transport bootstrap server...");
@ -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,18 @@ public class LwM2MTransportBootstrapService {
builder.setTrustedCertificates(new X509Certificate[0]);
}
}
private synchronized void recreateBootstrapServer() {
if (server != null) {
log.info("Stopping old LwM2M Bootstrap server...");
server.destroy();
log.info("Old LwM2M Bootstrap server stopped.");
}
log.info("Creating new LwM2M Bootstrap server with updated certificates...");
this.server = getLhBootstrapServer();
this.server.start();
log.info("New LwM2M Bootstrap server started successfully.");
}
}

30
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,10 @@ 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.ArrayList;
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 +67,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();
}
}

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.transport.lwm2m.config;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
@ -31,6 +32,7 @@ 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
@ -134,6 +136,35 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
@Qualifier("lwm2mTrustCredentials")
private SslCredentialsConfig trustCredentialsConfig;
private final List<Runnable> serverReloadCallbacks = new CopyOnWriteArrayList<>();
@PostConstruct
public void init() {
credentialsConfig.registerReloadCallback(() -> {
log.info("LwM2M Server DTLS certificates reloaded. Triggering server reload...");
notifyServerReload();
});
trustCredentialsConfig.registerReloadCallback(() -> {
log.info("LwM2M Trust certificates reloaded. Triggering 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 server reload callback", e);
}
}
}
@Override
public SslCredentials getSslCredentials() {
return this.credentialsConfig.getCredentials();
@ -142,4 +173,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
public SslCredentials getTrustSslCredentials() {
return this.trustCredentialsConfig.getCredentials();
}
}

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

@ -15,9 +15,11 @@
*/
package org.thingsboard.server.transport.lwm2m.server;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.eclipse.californium.core.CoapResource;
import org.eclipse.californium.core.CoapServer;
import org.eclipse.californium.elements.config.Configuration;
@ -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};
@ -84,6 +86,20 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService {
private final LwM2mVersionedModelProvider modelProvider;
private LeshanServer server;
private 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,28 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService {
}
}
private synchronized void recreateLwM2mServer() {
if (server != null) {
log.info("Stopping old LwM2M server...");
if (serverListener != null) {
server.getRegistrationService().removeListener(serverListener.registrationListener);
server.getPresenceService().removeListener(serverListener.presenceListener);
server.getObservationService().removeListener(serverListener.observationListener);
server.getSendService().removeListener(serverListener.sendListener);
}
server.destroy();
log.info("Old LwM2M server stopped.");
}
log.info("Creating new LwM2M server with updated certificates...");
this.server = getLhServer();
this.context.setServer(server);
this.startLhServer();
log.info("New LwM2M server started successfully.");
}
@Override
public String getName() {
return DataConstants.LWM2M_TRANSPORT_NAME;

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

@ -0,0 +1,207 @@
/**
* 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.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.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 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_whenInvoked_thenShouldRecreateBootstrapServer() {
LeshanBootstrapServer firstServer = mock(LeshanBootstrapServer.class);
LeshanBootstrapServer secondServer = mock(LeshanBootstrapServer.class);
ReflectionTestUtils.setField(bootstrapService, "server", firstServer);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
reloadCallback.run();
verify(firstServer).destroy();
verify(secondServer, times(0)).destroy();
}
@Test
public void givenBootstrapServerExists_whenRecreate_thenShouldDestroyOldServer() {
LeshanBootstrapServer oldServer = mock(LeshanBootstrapServer.class);
ReflectionTestUtils.setField(bootstrapService, "server", oldServer);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
reloadCallback.run();
verify(oldServer).destroy();
}
@Test
public void givenMultipleReloads_whenInvoked_thenShouldHandleSequentially() {
LeshanBootstrapServer firstServer = mock(LeshanBootstrapServer.class);
LeshanBootstrapServer secondServer = mock(LeshanBootstrapServer.class);
LeshanBootstrapServer thirdServer = mock(LeshanBootstrapServer.class);
ReflectionTestUtils.setField(bootstrapService, "server", firstServer);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
reloadCallback.run();
verify(firstServer, times(1)).destroy();
ReflectionTestUtils.setField(bootstrapService, "server", secondServer);
reloadCallback.run();
verify(secondServer, times(1)).destroy();
ReflectionTestUtils.setField(bootstrapService, "server", thirdServer);
reloadCallback.run();
verify(thirdServer, times(1)).destroy();
}
@Test
public void givenNullServer_whenRecreate_thenShouldHandleGracefully() {
ReflectionTestUtils.setField(bootstrapService, "server", null);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
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_whenInvokedWithException_thenShouldLogError() {
LeshanBootstrapServer faultyServer = mock(LeshanBootstrapServer.class);
ReflectionTestUtils.setField(bootstrapService, "server", faultyServer);
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
bootstrapService.afterSingletonsInstantiated();
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
}
}

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

@ -0,0 +1,183 @@
/**
* 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.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_whenInvoked_thenShouldTriggerServerRecreation() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer);
assertThat(reloadCallback).isNotNull();
}
@Test
public void givenServerWithListeners_whenRecreate_thenShouldRemoveOldListeners() {
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);
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
}
@Test
public void givenMultipleReloadCallbacks_whenInvoked_thenShouldHandleGracefully() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig, times(1)).registerServerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer);
assertThat(reloadCallback).isNotNull();
}
@Test
public void givenCertificateReload_whenServerNull_thenShouldHandleGracefully() {
lwm2mTransportService.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture());
ReflectionTestUtils.setField(lwm2mTransportService, "server", null);
Runnable reloadCallback = callbackCaptor.getValue();
assertThat(reloadCallback).isNotNull();
}
}

28
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,11 +67,24 @@ public class MqttSslHandlerProvider {
@Qualifier("mqttSslCredentials")
private SslCredentialsConfig mqttSslCredentialsConfig;
private SSLContext sslContext;
private volatile SSLContext sslContext;
@Override
public void afterSingletonsInstantiated() {
mqttSslCredentialsConfig.registerReloadCallback(() -> {
log.info("MQTT SSL certificates reloaded. Invalidating SSL context...");
sslContext = null;
log.info("MQTT SSL context invalidated. Will be recreated on next connection.");
});
}
public SslHandler getSslHandler() {
if (sslContext == null) {
sslContext = createSslContext();
synchronized (this) {
if (sslContext == null) {
sslContext = createSslContext();
}
}
}
SSLEngine sslEngine = sslContext.createSSLEngine();
sslEngine.setUseClientMode(false);
@ -98,7 +112,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 +120,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 +205,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

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.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_whenGetSslHandler_thenShouldRecreateSSLContext() {
sslHandlerProvider.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
Runnable reloadCallback = callbackCaptor.getValue();
SslHandler handler1 = sslHandlerProvider.getSslHandler();
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(initialContext).isNotNull();
reloadCallback.run();
assertThat(handler1).isNotNull();
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(contextAfterReload).isNull();
SslHandler handler2 = sslHandlerProvider.getSslHandler();
SSLContext newContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(handler2).isNotNull();
assertThat(newContext).isNotNull();
assertThat(newContext).isNotSameAs(initialContext);
}
@Test
public void givenConcurrentGetSslHandlerCalls_whenSSLContextNull_thenShouldCreateOnlyOnce() throws Exception {
sslHandlerProvider.afterSingletonsInstantiated();
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture());
callbackCaptor.getValue().run();
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
startLatch.await();
SslHandler handler = sslHandlerProvider.getSslHandler();
assertThat(handler).isNotNull();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown();
boolean completed = doneLatch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext");
assertThat(context).isNotNull();
}
@Test
public void givenReloadCallback_whenInvoked_thenShouldInvalidateSSLContext() {
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).isNull();
}
@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>() {

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

@ -90,6 +90,11 @@ public abstract class AbstractSslCredentials implements SslCredentials {
}
}
@Override
public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException {
init(trustsOnly);
}
@Override
public KeyStore getKeyStore() {
return this.keyStore;
@ -133,7 +138,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 +194,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 +208,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 +221,5 @@ public abstract class AbstractSslCredentials implements SslCredentials {
} catch (KeyStoreException ignored) {}
return Collections.unmodifiableSet(set);
}
}

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

@ -20,10 +20,14 @@ import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.ResourceUtils;
import org.thingsboard.server.common.data.StringUtils;
import java.io.File;
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 +58,16 @@ 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)) {
File storeFileObj = new File(storeFile);
if (storeFileObj.exists()) {
return Collections.singletonList(storeFileObj.toPath().toAbsolutePath());
}
}
return Collections.emptyList();
}
}

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

@ -30,9 +30,11 @@ import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder;
import org.thingsboard.server.common.data.ResourceUtils;
import org.thingsboard.server.common.data.StringUtils;
import java.io.File;
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 +78,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 +95,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 +140,27 @@ 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<>();
if (!StringUtils.isEmpty(certFile) && !certFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
File certFileObj = new File(certFile);
if (certFileObj.exists()) {
paths.add(certFileObj.toPath().toAbsolutePath());
}
}
if (!StringUtils.isEmpty(keyFile) && !keyFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
File keyFileObj = new File(keyFile);
if (keyFileObj.exists()) {
paths.add(keyFileObj.toPath().toAbsolutePath());
}
}
return paths;
}
}

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();
}

27
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,26 @@ public class SslCredentialsConfig {
}
}
public void onCertificateFileChanged() {
try {
log.info("{}: Certificate file changed. Reloading SSL credentials...", name);
this.credentials.reload(this.trustsOnly);
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);
}
}
} catch (Exception e) {
log.error("{}: Failed to reload SSL credentials", name, e);
}
}
public void registerReloadCallback(Runnable callback) {
this.reloadCallbacks.add(callback);
}
}

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

@ -15,6 +15,8 @@
*/
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;
@ -30,17 +32,18 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
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")
@ -49,46 +52,98 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
@Autowired
SslBundles sslBundles;
private final ServerProperties serverProperties;
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);
}
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);
}
}
}
private class DynamicSslBundles implements SslBundles {
@Override
public List<String> getBundleNames() {
return List.of("default");
@Override
public SslBundle getBundle(String name) {
if (!DEFAULT_BUNDLE_NAME.equals(name)) {
throw new IllegalArgumentException("Unknown SSL bundle: " + name);
}
return createSslBundle();
}
@Override
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// no-op
@Override
public List<String> getBundleNames() {
return List.of(DEFAULT_BUNDLE_NAME);
}
@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);
}
};
}
}
}

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

@ -0,0 +1,193 @@
/**
* 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.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Base64;
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 {
@Value("${transport.ssl.certificate.reload.enabled:true}")
private boolean reloadEnabled;
@Value("${transport.ssl.certificate.reload.check_interval:60}")
private long checkIntervalInSeconds;
@Autowired
protected ApplicationContext applicationContext;
private final Map<String, CertificateWatcher> watchers = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager"));
public void registerWatcher(String name, Path certPath, Runnable reloadCallback) {
watchers.put(name, new CertificateWatcher(certPath, reloadCallback));
log.info("Registered certificate watcher for: {}", name);
}
private void checkCertificates() {
watchers.forEach((name, watcher) -> {
try {
if (watcher.hasChanged()) {
log.info("Certificate change detected for: {}. Triggering reload...", name);
watcher.reload();
}
} 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 certificate files to watch for: {} ({})", config.getName(), beanName);
continue;
}
for (Path filePath : filePaths) {
if (filePath != null && Files.exists(filePath)) {
String watcherKey = config.getName() + " - " + filePath.getFileName();
registerWatcher(watcherKey, filePath, config::onCertificateFileChanged);
log.info("Registered certificate watcher: {} -> {}", config.getName(), filePath);
} else {
log.warn("Certificate file does not exist: {} (from {})", filePath, config.getName());
}
}
} 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 {
scheduler.shutdown();
}
@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.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS);
}
private static class CertificateWatcher {
private final Path path;
private final Runnable reloadCallback;
private long lastModified;
private String lastChecksum;
CertificateWatcher(Path path, Runnable reloadCallback) {
this.path = path;
this.reloadCallback = reloadCallback;
this.lastModified = getLastModifiedTime();
this.lastChecksum = calculateChecksum();
}
boolean hasChanged() {
long currentModified = getLastModifiedTime();
if (currentModified != lastModified) {
String currentChecksum = calculateChecksum();
return !currentChecksum.equals(lastChecksum);
}
return false;
}
void reload() {
reloadCallback.run();
lastModified = getLastModifiedTime();
lastChecksum = calculateChecksum();
}
private long getLastModifiedTime() {
try {
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException e) {
return 0;
}
}
private String calculateChecksum() {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] bytes = Files.readAllBytes(path);
byte[] hash = md.digest(bytes);
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
log.warn("Failed to calculate checksum for certificate file: {}", path, e);
return "";
}
}
}
}

8
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
@ -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 {
@ -1193,6 +1191,7 @@ public class DefaultTransportService extends TransportActivityManager implements
if (callback != null)
callback.onFailure(t);
}
}
private class MsgPackCallback implements TbQueueCallback {
@ -1215,6 +1214,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 +1244,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onError(Throwable e) {
callback.onError(e);
}
}
@Override
@ -1270,4 +1271,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());
}
}

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();
}
}

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

@ -0,0 +1,175 @@
/**
* 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.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
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();
}
}
@Test
public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception {
CountDownLatch reloadLatch = new CountDownLatch(1);
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {
reloadCount.incrementAndGet();
reloadLatch.countDown();
});
Thread.sleep(100); // Small delay to ensure the initial state is captured
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n");
// Manually trigger check (since a scheduled task runs every 1 minute)
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS);
assertThat(reloadTriggered).isTrue();
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);
Thread.sleep(100);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
Thread.sleep(100);
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);
Thread.sleep(100);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(0);
}
@Test
public void givenWatcherRegistered_whenFileDeleted_thenShouldHandleGracefully() throws Exception {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
Thread.sleep(100);
Files.delete(certFile);
Thread.sleep(100);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
Thread.sleep(100);
}
@Test
public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception {
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {});
certificateReloadManager.destroy();
assertThat(certificateReloadManager).isNotNull();
}
@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");
CountDownLatch certReloadLatch = new CountDownLatch(1);
CountDownLatch keyReloadLatch = new CountDownLatch(1);
certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown);
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown);
Thread.sleep(100);
Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n");
Thread.sleep(100);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS);
assertThat(keyReloaded).isTrue();
assertThat(certReloadLatch.getCount()).isEqualTo(1); // Cert isn't reloaded
}
@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);
Thread.sleep(100);
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n");
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n");
Thread.sleep(100);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
Thread.sleep(200);
assertThat(reload1Count.get()).isEqualTo(1);
assertThat(reload2Count.get()).isEqualTo(1);
}
}

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: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL: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: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL: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: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL: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: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}"
# Queue configuration parameters
queue:

Loading…
Cancel
Save