Browse Source

Merge remote-tracking branch 'origin/lts-4.2' into fix/ai-provider-ssrf-protection

pull/15412/head
Oleksandra Matviienko 5 months ago
parent
commit
76f308e432
  1. 19
      application/src/main/data/upgrade/lts/schema_update.sql
  2. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 6
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  4. 51
      application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
  5. 2
      application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
  6. 4
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  7. 85
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  8. 1
      application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
  9. 24
      application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
  10. 24
      application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
  11. 3
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
  12. 3
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  13. 2
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
  14. 5
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
  15. 13
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  16. 3
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java
  17. 74
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  18. 23
      application/src/main/resources/thingsboard.yml
  19. 47
      application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
  20. 9
      application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
  21. 18
      application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
  22. 7
      application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
  23. 41
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  24. 349
      application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
  25. 159
      application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
  26. 160
      application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
  27. 9
      application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
  28. 447
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
  29. 11
      common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
  30. 149
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
  31. 6
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  32. 349
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
  33. 246
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
  34. 8
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
  35. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
  36. 20
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
  37. 40
      common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
  38. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
  39. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java
  40. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java
  41. 60
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
  42. 29
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java
  43. 62
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  44. 106
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java
  45. 198
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java
  46. 106
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java
  47. 192
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java
  48. 41
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java
  49. 3
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java
  50. 276
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java
  51. 197
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java
  52. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java
  53. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java
  54. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java
  55. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  56. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java
  57. 92
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java
  58. 14
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java
  59. 43
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java
  60. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java
  61. 30
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java
  62. 120
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  63. 299
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
  64. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  65. 8
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java
  66. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java
  67. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java
  68. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java
  69. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java
  70. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java
  71. 1
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java
  72. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java
  73. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java
  74. 185
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java
  75. 277
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java
  76. 355
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
  77. 13
      dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java
  78. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java
  79. 6
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  80. 19
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java
  81. 14
      dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java
  82. 47
      dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java
  83. 1
      dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider
  84. 2
      dao/src/test/resources/nosql-test.properties
  85. 2
      dao/src/test/resources/sql-test.properties
  86. 2
      dao/src/test/resources/timescale-test.properties
  87. 57
      edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java
  88. 32
      pom.xml
  89. 6
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  90. 49
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java
  91. 200
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  92. 7
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
  93. 2
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  94. 153
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  95. 18
      tools/pom.xml
  96. 9
      transport/coap/src/main/resources/tb-coap-transport.yml
  97. 9
      transport/http/src/main/resources/tb-http-transport.yml
  98. 9
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  99. 9
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  100. 36
      ui-ngx/package.json

19
application/src/main/data/upgrade/lts/schema_update.sql

@ -0,0 +1,19 @@
--
-- 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.
--
-- LTS cumulative schema update file.
-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.).
-- This file is executed by SystemPatchApplier on every version increase within the LTS family.

5
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -35,6 +35,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.SmsService;
@ -684,6 +685,10 @@ public class ActorSystemContext {
@Getter @Getter
private MqttClientSettings mqttClientSettings; private MqttClientSettings mqttClientSettings;
@Autowired(required = false)
@Getter
private TbHttpClientSettings tbHttpClientSettings;
@Getter @Getter
@Setter @Setter
private TbActorSystem actorSystem; private TbActorSystem actorSystem;

6
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
@ -1062,6 +1063,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getMqttClientSettings(); return mainCtx.getMqttClientSettings();
} }
@Override
public TbHttpClientSettings getTbHttpClientSettings() {
return mainCtx.getTbHttpClientSettings();
}
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData(); TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString()); metaData.putValue("ruleNodeId", ruleNodeId.toString());

51
application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java

@ -0,0 +1,51 @@
/**
* 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.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.server.queue.util.TbRuleEngineComponent;
@TbRuleEngineComponent
@Component
public class TbHttpClientSettingsComponent implements TbHttpClientSettings {
@Value("${actors.rule.external.http_client.max_parallel_requests:0}")
private int maxParallelRequests;
@Value("${actors.rule.external.http_client.max_pending_requests:0}")
private int maxPendingRequests;
@Value("${actors.rule.external.http_client.pool_max_connections:0}")
private int poolMaxConnections;
@Override
public int getMaxParallelRequests() {
return maxParallelRequests;
}
@Override
public int getMaxPendingRequests() {
return maxPendingRequests;
}
@Override
public int getPoolMaxConnections() {
return poolMaxConnections;
}
}

2
application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java

@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AlarmId;
@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController {
AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); AlarmId alarmId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE);
alarmComment.setAlarmId(alarmId); alarmComment.setAlarmId(alarmId);
alarmComment.setType(AlarmCommentType.OTHER);
return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser());
} }

4
application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java

@ -181,8 +181,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
if (chatModelConfig.frequencyPenalty() != null) { if (chatModelConfig.frequencyPenalty() != null) {
generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
} }
if (chatModelConfig.frequencyPenalty() != null) { if (chatModelConfig.presencePenalty() != null) {
generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
} }
if (chatModelConfig.maxOutputTokens() != null) { if (chatModelConfig.maxOutputTokens() != null) {
generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());

85
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java

@ -19,6 +19,7 @@ import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService;
@ -29,18 +30,23 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@ -183,6 +189,85 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField);
} }
@EventListener(ComponentLifecycleMsg.class)
public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
switch (event.getEntityId().getEntityType()) {
case TENANT:
if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
evictTenantCfs(event.getTenantId());
}
break;
case DEVICE, ASSET, DEVICE_PROFILE, ASSET_PROFILE:
if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
evictEntityCfs(event.getEntityId());
}
break;
case CALCULATED_FIELD:
if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
} else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) {
updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
} else if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
evict((CalculatedFieldId) event.getEntityId());
}
break;
}
}
private void evictTenantCfs(TenantId tenantId) {
var removedCfEntityIds = new HashSet<EntityId>();
var removedLinkEntityIds = new HashSet<EntityId>();
var toRemove = calculatedFields.entrySet().stream()
.filter(e -> e.getValue().getTenantId().equals(tenantId))
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
toRemove.forEach(cfId -> {
CalculatedField cf = calculatedFields.remove(cfId);
List<CalculatedFieldLink> links = calculatedFieldLinks.remove(cfId);
if (links != null) {
links.forEach(link -> removedLinkEntityIds.add(link.getEntityId()));
}
calculatedFieldsCtx.remove(cfId);
if (cf != null) {
removedCfEntityIds.add(cf.getEntityId());
}
});
removedCfEntityIds.forEach(entityId -> {
entityIdCalculatedFields.compute(entityId, (k, cfs) -> {
if (cfs != null) {
cfs.removeIf(cf -> toRemove.contains(cf.getId()));
return cfs.isEmpty() ? null : cfs;
}
return null;
});
});
removedLinkEntityIds.forEach(entityId -> {
entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> {
if (links != null) {
links.removeIf(link -> toRemove.contains(link.getCalculatedFieldId()));
return links.isEmpty() ? null : links;
}
return null;
}));
});
}
private void evictEntityCfs(EntityId entityId) {
List<CalculatedField> cfs = entityIdCalculatedFields.remove(entityId);
if (cfs != null) {
var cfIds = new HashSet<CalculatedFieldId>();
cfs.forEach(cf -> {
calculatedFields.remove(cf.getId());
calculatedFieldLinks.remove(cf.getId());
calculatedFieldsCtx.remove(cf.getId());
cfIds.add(cf.getId());
log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf);
});
entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId())));
}
entityIdCalculatedFieldLinks.remove(entityId);
}
private Lock getFetchLock(CalculatedFieldId id) { private Lock getFetchLock(CalculatedFieldId id) {
return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock());
} }

1
application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java

@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
public void upgradeDatabase() { public void upgradeDatabase() {
log.info("Updating schema..."); log.info("Updating schema...");
loadSql(getSchemaUpdateFile("basic")); loadSql(getSchemaUpdateFile("basic"));
loadSql(getSchemaUpdateFile("lts"));
log.info("Schema updated."); log.info("Schema updated.");
} }

24
application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java

@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile; package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.asset.AssetProfile;
@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.asset.AssetService;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache {
} }
} }
@EventListener(ComponentLifecycleMsg.class)
public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
switch (event.getEntityId().getEntityType()) {
case TENANT:
if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
TenantId tenantId = event.getTenantId();
Set<AssetProfileId> toRemove = assetProfilesMap.values().stream()
.filter(assetProfile -> assetProfile.getTenantId().equals(tenantId))
.map(AssetProfile::getId)
.collect(Collectors.toSet());
assetProfilesMap.keySet().removeAll(toRemove);
assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
profileListeners.remove(tenantId);
assetProfileListeners.remove(tenantId);
}
break;
}
}
private void notifyProfileListeners(AssetProfile profile) { private void notifyProfileListeners(AssetProfile profile) {
ConcurrentMap<EntityId, Consumer<AssetProfile>> tenantListeners = profileListeners.get(profile.getTenantId()); ConcurrentMap<EntityId, Consumer<AssetProfile>> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) { if (tenantListeners != null) {

24
application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java

@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile; package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfile;
@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.DeviceService;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache {
} }
} }
@EventListener(ComponentLifecycleMsg.class)
public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
switch (event.getEntityId().getEntityType()) {
case TENANT:
if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
TenantId tenantId = event.getTenantId();
Set<DeviceProfileId> toRemove = deviceProfilesMap.values().stream()
.filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId))
.map(DeviceProfile::getId)
.collect(Collectors.toSet());
deviceProfilesMap.keySet().removeAll(toRemove);
devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
profileListeners.remove(tenantId);
deviceProfileListeners.remove(tenantId);
}
break;
}
}
private void notifyProfileListeners(DeviceProfile profile) { private void notifyProfileListeners(DeviceProfile profile) {
ConcurrentMap<EntityId, Consumer<DeviceProfile>> tenantListeners = profileListeners.get(profile.getTenantId()); ConcurrentMap<EntityId, Consumer<DeviceProfile>> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) { if (tenantListeners != null) {

3
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java

@ -90,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
PartitionService partitionService, PartitionService partitionService,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService, JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) { CalculatedFieldStateService stateService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService); eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory; this.queueFactory = tbQueueFactory;
this.stateService = stateService; this.stateService = stateService;

3
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -179,9 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
TbImageService imageService, TbImageService imageService,
TbResourceDataCache tbResourceDataCache, TbResourceDataCache tbResourceDataCache,
RuleEngineCallService ruleEngineCallService, RuleEngineCallService ruleEngineCallService,
CalculatedFieldCache calculatedFieldCache,
EdqsService edqsService) { EdqsService edqsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService); eventPublisher, jwtSettingsService);
this.stateService = stateService; this.stateService = stateService;
this.localSubscriptionService = localSubscriptionService; this.localSubscriptionService = localSubscriptionService;

2
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java

@ -87,7 +87,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService<ToEdge
public DefaultTbEdgeConsumerService(TbCoreQueueFactory tbCoreQueueFactory, ActorSystemContext actorContext, public DefaultTbEdgeConsumerService(TbCoreQueueFactory tbCoreQueueFactory, ActorSystemContext actorContext,
StatsFactory statsFactory, EdgeContextComponent edgeCtx) { StatsFactory statsFactory, EdgeContextComponent edgeCtx) {
super(actorContext, null, null, null, null, null, null, null, super(actorContext, null, null, null, null, null, null,
null, null); null, null);
this.edgeCtx = edgeCtx; this.edgeCtx = edgeCtx;
this.stats = new EdgeConsumerStats(statsFactory); this.stats = new EdgeConsumerStats(statsFactory);

5
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java

@ -84,9 +84,8 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
TbApiUsageStateService apiUsageStateService, TbApiUsageStateService apiUsageStateService,
PartitionService partitionService, PartitionService partitionService,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService, JwtSettingsService jwtSettingsService) {
CalculatedFieldCache calculatedFieldCache) { super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx; this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService; this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService; this.queueService = queueService;

13
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -26,7 +26,6 @@ import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId;
@ -47,7 +46,6 @@ import org.thingsboard.server.queue.discovery.TbApplicationEventListener;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.queue.TbPackCallback; import org.thingsboard.server.service.queue.TbPackCallback;
@ -75,7 +73,6 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
protected final TbDeviceProfileCache deviceProfileCache; protected final TbDeviceProfileCache deviceProfileCache;
protected final TbAssetProfileCache assetProfileCache; protected final TbAssetProfileCache assetProfileCache;
protected final TbResourceDataCache tbResourceDataCache; protected final TbResourceDataCache tbResourceDataCache;
protected final CalculatedFieldCache calculatedFieldCache;
protected final TbApiUsageStateService apiUsageStateService; protected final TbApiUsageStateService apiUsageStateService;
protected final PartitionService partitionService; protected final PartitionService partitionService;
protected final ApplicationEventPublisher eventPublisher; protected final ApplicationEventPublisher eventPublisher;
@ -197,15 +194,7 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.DELETED) { if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.DELETED) {
apiUsageStateService.onCustomerDelete((CustomerId) componentLifecycleMsg.getEntityId()); apiUsageStateService.onCustomerDelete((CustomerId) componentLifecycleMsg.getEntityId());
} }
} else if (EntityType.CALCULATED_FIELD.equals(componentLifecycleMsg.getEntityId().getEntityType())) { } else if (EntityType.TB_RESOURCE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.CREATED) {
calculatedFieldCache.addCalculatedField(tenantId, (CalculatedFieldId) componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.UPDATED) {
calculatedFieldCache.updateCalculatedField(tenantId, (CalculatedFieldId) componentLifecycleMsg.getEntityId());
} else {
calculatedFieldCache.evict((CalculatedFieldId) componentLifecycleMsg.getEntityId());
}
} else if (EntityType.TB_RESOURCE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
tbResourceDataCache.evictResourceData(tenantId, new TbResourceId(componentLifecycleMsg.getEntityId().getId())); tbResourceDataCache.evictResourceData(tenantId, new TbResourceId(componentLifecycleMsg.getEntityId().getId()));
} }

3
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java

@ -45,12 +45,11 @@ public abstract class AbstractPartitionBasedConsumerService<N extends com.google
TbDeviceProfileCache deviceProfileCache, TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache, TbAssetProfileCache assetProfileCache,
TbResourceDataCache tbResourceDataCache, TbResourceDataCache tbResourceDataCache,
CalculatedFieldCache calculatedFieldCache,
TbApiUsageStateService apiUsageStateService, TbApiUsageStateService apiUsageStateService,
PartitionService partitionService, PartitionService partitionService,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService) { JwtSettingsService jwtSettingsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
} }
@PostConstruct @PostConstruct

74
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -28,6 +28,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
@ -40,7 +41,9 @@ import java.net.URL;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.NoSuchFileException; import java.nio.file.NoSuchFileException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@ -48,7 +51,7 @@ import java.util.stream.Stream;
/** /**
* Runs at application startup and applies no-downtime data updates * Runs at application startup and applies no-downtime data updates
* when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1).
*/ */
@Slf4j @Slf4j
@Component @Component
@ -64,6 +67,7 @@ public class SystemPatchApplier {
private final InstallScripts installScripts; private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService; private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService; private final WidgetTypeService widgetTypeService;
private final ImageService imageService;
@PostConstruct @PostConstruct
private void init() { private void init() {
@ -91,12 +95,17 @@ public class SystemPatchApplier {
} }
try { try {
updateLtsSqlSchema();
updateSqlViews(); updateSqlViews();
log.info("Updated sql database views"); log.info("Updated sql database views");
int updated = updateWidgetTypes(); int updated = updateWidgetTypes();
log.info("Updated {} widget types", updated); log.info("Updated {} widget types", updated);
int createdImages = createMissingSystemImages();
log.info("Created {} new system images", createdImages);
schemaSettingsService.updateSchemaVersion(); schemaSettingsService.updateSchemaVersion();
log.info("System data patch update completed successfully"); log.info("System data patch update completed successfully");
@ -119,17 +128,37 @@ public class SystemPatchApplier {
return false; return false;
} }
if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
return false; return false;
} }
log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
return true; return true;
} }
private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
&& packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; return false;
}
if (packageVersion.maintenance != dbVersion.maintenance) {
return packageVersion.maintenance > dbVersion.maintenance;
}
return packageVersion.patch > dbVersion.patch;
}
private void updateLtsSqlSchema() {
Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
if (!Files.exists(sqlFile)) {
log.trace("LTS schema update file does not exist: {}", sqlFile);
return;
}
try {
String sql = Files.readString(sqlFile);
jdbcTemplate.execute(sql);
log.info("Applied LTS SQL schema update from {}", sqlFile);
} catch (IOException e) {
throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
}
} }
private void updateSqlViews() { private void updateSqlViews() {
@ -192,6 +221,39 @@ public class SystemPatchApplier {
return false; return false;
} }
private int createMissingSystemImages() {
AtomicInteger created = new AtomicInteger();
Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images");
if (!Files.exists(imagesDir)) {
log.warn("System images directory does not exist: {}", imagesDir);
return 0;
}
Set<String> existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
try (Stream<Path> dirStream = listDir(imagesDir).filter(Files::isRegularFile)) {
dirStream.forEach(path -> {
String resourceKey = path.getFileName().toString();
if (existingKeys.contains(resourceKey)) {
log.trace("System image already exists, skipping: {}", resourceKey);
return;
}
try {
byte[] data = Files.readAllBytes(path);
imageService.createOrUpdateSystemImage(resourceKey, data);
created.incrementAndGet();
log.trace("Created system image: {}", resourceKey);
} catch (Exception e) {
log.error("Unable to create system image from file: [{}]", path);
throw new RuntimeException("Unable to create system image " + resourceKey, e);
}
});
}
return created.get();
}
private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) {
if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) { if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) {
return true; return true;

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

@ -567,6 +567,18 @@ actors:
# Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24). # Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24).
# Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp" # Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp"
ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}" ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}"
http_client:
# Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance.
# Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins).
max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}"
# Server-level ceiling for the pending-request queue depth per external HTTP rule node instance.
# Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction.
max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}"
# Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance.
# Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2
# (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections.
# 0 = use reactor-netty default.
pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}"
rpc: rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery. # Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@ -1394,6 +1406,15 @@ transport:
branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}" branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}"
# Fetch frequency in hours for gateways dashboard repository # Fetch frequency in hours for gateways dashboard repository
fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Interval in seconds for certificate reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters # CoAP server parameters
coap: coap:
@ -1530,7 +1551,7 @@ device:
pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}"
gateway: gateway:
# The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch
image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}" image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}"
# Edges parameters # Edges parameters
edges: edges:

47
application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java

@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -223,6 +225,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
.andExpect(status().isOk()); .andExpect(status().isOk());
Optional<AlarmCommentInfo> systemCommentOpt = doGetTyped(
"/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference<PageData<AlarmCommentInfo>>() {
}
).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
assertThat(systemCommentOpt).isPresent();
AlarmCommentInfo systemComment = systemCommentOpt.get();
assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment",
TENANT_ADMIN_EMAIL));
AlarmComment expectedAlarmComment = AlarmComment.builder() AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId()) .alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM) .type(AlarmCommentType.SYSTEM)
@ -356,6 +370,39 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
Assert.assertTrue("Created alarm doesn't match the found one!", equals); Assert.assertTrue("Created alarm doesn't match the found one!", equals);
} }
@Test
public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception {
loginTenantAdmin();
AlarmComment alarmComment = AlarmComment.builder()
.type(AlarmCommentType.SYSTEM)
.comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin")))
.build();
AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class);
assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER);
// acknowledge alarm to create system comment
doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk());
Optional<AlarmCommentInfo> systemCommentOpt = doGetTyped(
"/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference<PageData<AlarmCommentInfo>>() {
}
).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
assertThat(systemCommentOpt).isPresent();
AlarmCommentInfo systemComment = systemCommentOpt.get();
// system comment can't be updated with other type
systemComment.setType(AlarmCommentType.OTHER);
doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("System alarm comment can't be updated!")));
// system comment can't be updated with other text
systemComment.setType(AlarmCommentType.SYSTEM);
systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment")));
doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("System alarm comment can't be updated!")));
}
private AlarmComment createAlarmComment(AlarmId alarmId, String text) { private AlarmComment createAlarmComment(AlarmId alarmId, String text) {
AlarmComment alarmComment = AlarmComment.builder() AlarmComment alarmComment = AlarmComment.builder()
.comment(JacksonUtil.newObjectNode().set("text", new TextNode(text))) .comment(JacksonUtil.newObjectNode().set("text", new TextNode(text)))

9
application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java

@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
private DeviceProfileId mqttDeviceProfileId; private DeviceProfileId mqttDeviceProfileId;
private DeviceProfileId coapDeviceProfileId; private DeviceProfileId coapDeviceProfileId;
@Value("${device.connectivity.gateway.image_version:3.7-stable}") @Value("${device.connectivity.gateway.image_version:3.8-stable}")
private String gatewayImageVersion; private String gatewayImageVersion;
@Before @Before
@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
"\n" + "\n" +
" # Environment variables\n" + " # Environment variables\n" +
" environment:\n" + " environment:\n" +
" - host=host.docker.internal\n" + " - TB_GW_HOST=host.docker.internal\n" +
" - port=1883\n" + " - TB_GW_PORT=1883\n" +
" - accessToken=" + credentials.getCredentialsId() + "\n" + " - TB_GW_SECURITY_TYPE=accessToken\n" +
" - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" +
"\n" + "\n" +
" # Volumes bind\n" + " # Volumes bind\n" +
" volumes:\n" + " volumes:\n" +

18
application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java

@ -54,11 +54,13 @@ import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.device.DeviceProfileDao; import org.thingsboard.server.dao.device.DeviceProfileDao;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.awaitility.Awaitility;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -1028,11 +1030,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
Mockito.reset(tbClusterService, auditLogService); // The request may hit a transient TenantNotFoundException right after the @Before tenant creation
// if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the
doPost("/api/deviceProfile", deviceProfile) // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the
.andExpect(status().isBadRequest()) // retry loop so the subsequent verify* assertions see only the invocations from the last attempt.
.andExpect(statusReason(containsString(errorMsg))); Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.ignoreExceptions().untilAsserted(() -> {
Mockito.reset(tbClusterService, auditLogService);
doPost("/api/deviceProfile", deviceProfile)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString(errorMsg)));
});
testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(),
tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg));

7
application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java

@ -57,7 +57,12 @@ public class UserEdgeTest extends AbstractEdgeTest {
User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); User savedTenantAdmin = createUser(newTenantAdmin, "tenant");
Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user)
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when
// user activation completes before the event is processed, in addition to the 2
// messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3.
int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size();
Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount,
credMsgCount == 2 || credMsgCount == 3);
Optional<UserUpdateMsg> userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); Optional<UserUpdateMsg> userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class);
Assert.assertTrue(userUpdateMsgOpt.isPresent()); Assert.assertTrue(userUpdateMsgOpt.isPresent());
UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get();

41
application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java

@ -15,23 +15,42 @@
*/ */
package org.thingsboard.server.service.ai; package org.thingsboard.server.service.ai;
import com.google.cloud.vertexai.api.GenerationConfig;
import dev.langchain4j.model.chat.ChatModel;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidator") @ResourceLock("SsrfProtectionValidator")
class Langchain4jChatModelConfigurerImplTest { class Langchain4jChatModelConfigurerImplTest {
private static final String TEST_SERVICE_ACCOUNT_KEY = """
{
"type": "service_account",
"project_id": "test-project",
"private_key_id": "key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n",
"client_email": "test@test-project.iam.gserviceaccount.com",
"client_id": "123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}
""";
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
@BeforeEach @BeforeEach
@ -100,4 +119,26 @@ class Langchain4jChatModelConfigurerImplTest {
.hasMessageContaining("URI is invalid"); .hasMessageContaining("URI is invalid");
} }
@Test
void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
// GIVEN
var providerConfig = new GoogleVertexAiGeminiProviderConfig(
"test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
);
var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
.providerConfig(providerConfig)
.modelId("gemini-2.0-flash")
.frequencyPenalty(0.3)
.presencePenalty(0.7)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
// THEN
var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
}
} }

349
application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java

@ -0,0 +1,349 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.device.DeviceService;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultCalculatedFieldCacheTest {
@Mock
private CalculatedFieldService calculatedFieldService;
@Mock
private DeviceService deviceService;
@Mock
private AssetService assetService;
@Mock
private CustomerService customerService;
private DefaultCalculatedFieldCache cache;
@BeforeEach
public void setUp() {
cache = new DefaultCalculatedFieldCache(calculatedFieldService, null, null);
}
// --- Tenant deletion tests ---
@Test
public void onComponentLifecycleEvent_tenantDeleted_evictsAllTenantCfsFromAllMaps() {
TenantId tenant1 = new TenantId(UUID.randomUUID());
TenantId tenant2 = new TenantId(UUID.randomUUID());
DeviceId device1 = new DeviceId(UUID.randomUUID());
DeviceId device2 = new DeviceId(UUID.randomUUID());
CalculatedField cf1 = addCfToCache(tenant1, device1);
CalculatedField cf2 = addCfToCache(tenant2, device2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf1.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(device1)).isEmpty();
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
assertThat(cache.getCalculatedFieldsByEntityId(device2)).containsExactly(cf2);
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_removesLinksToLinkedEntities() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId cfEntity = new DeviceId(UUID.randomUUID());
DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, cfEntity, linkedDevice);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
assertThat(cache.getCalculatedField(cf.getId())).isNull();
}
@Test
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictCfs() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, device);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
}
// --- Device/Asset deletion tests ---
@Test
public void onComponentLifecycleEvent_deviceDeleted_evictsCfsForThatDevice() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, device);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_deviceDeleted_removesLinksForLinkedEntities() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
addCfToCache(tenant, device, linkedDevice);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_assetDeleted_evictsCfsForThatAsset() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetId asset = new AssetId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, asset);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, asset, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty();
}
// --- DeviceProfile/AssetProfile deletion tests ---
@Test
public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
addCfToCache(tenant, profileId, linkedDevice);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID());
DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID());
CalculatedField cf1 = addCfToCache(tenant, profile1);
CalculatedField cf2 = addCfToCache(tenant, profile2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf1.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
}
@Test
public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
}
@Test
public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
AssetId linkedAsset = new AssetId(UUID.randomUUID());
addCfToCache(tenant, profileId, linkedAsset);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID());
AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID());
CalculatedField cf1 = addCfToCache(tenant, profile1);
CalculatedField cf2 = addCfToCache(tenant, profile2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf1.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
}
@Test
public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
}
// --- CalculatedField lifecycle tests ---
@Test
public void onComponentLifecycleEvent_calculatedFieldCreated_addsCfToCache() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
CalculatedField cf = buildCalculatedField(cfId, tenant, device, simpleCfConfig());
when(calculatedFieldService.findById(tenant, cfId)).thenReturn(cf);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cfId, ComponentLifecycleEvent.CREATED));
assertThat(cache.getCalculatedField(cfId)).isEqualTo(cf);
assertThat(cache.getCalculatedFieldsByEntityId(device)).containsExactly(cf);
}
@Test
public void onComponentLifecycleEvent_calculatedFieldDeleted_evictsCfFromCache() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, device);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.DELETED));
assertThat(cache.getCalculatedField(cf.getId())).isNull();
assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
}
@Test
public void onComponentLifecycleEvent_calculatedFieldUpdated_refreshesCfInCache() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceId device = new DeviceId(UUID.randomUUID());
CalculatedField cf = addCfToCache(tenant, device);
CalculatedField updatedCf = buildCalculatedField(cf.getId(), tenant, device, simpleCfConfig());
updatedCf.setName("updated-name");
when(calculatedFieldService.findById(tenant, cf.getId())).thenReturn(updatedCf);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.UPDATED));
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf);
}
private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) {
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig());
when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
cache.addCalculatedField(tenantId, cfId);
return cf;
}
private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId, EntityId linkedEntity) {
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
CalculatedFieldConfiguration config = linkedEntityCfConfig(tenantId, cfId, linkedEntity);
CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, config);
when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
cache.addCalculatedField(tenantId, cfId);
return cf;
}
private CalculatedField buildCalculatedField(CalculatedFieldId id, TenantId tenantId, EntityId entityId, CalculatedFieldConfiguration config) {
CalculatedField cf = new CalculatedField();
cf.setId(id);
cf.setTenantId(tenantId);
cf.setEntityId(entityId);
cf.setType(CalculatedFieldType.SIMPLE);
cf.setName("test-cf-" + id.getId());
cf.setConfiguration(config);
return cf;
}
private CalculatedFieldConfiguration simpleCfConfig() {
CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
when(config.getReferencedEntities()).thenReturn(Collections.emptyList());
when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(Collections.emptyList());
return config;
}
private CalculatedFieldConfiguration linkedEntityCfConfig(TenantId tenantId, CalculatedFieldId cfId, EntityId linkedEntity) {
CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
CalculatedFieldLink link = new CalculatedFieldLink(tenantId, linkedEntity, cfId);
when(config.getReferencedEntities()).thenReturn(List.of(linkedEntity));
when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link));
when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link);
return config;
}
}

159
application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java

@ -0,0 +1,159 @@
/**
* 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.service.profile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultTbAssetProfileCacheTest {
@Mock
private AssetProfileService assetProfileService;
@Mock
private AssetService assetService;
private DefaultTbAssetProfileCache cache;
@BeforeEach
public void setUp() {
cache = new DefaultTbAssetProfileCache(assetProfileService, assetService);
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() {
TenantId tenant1 = new TenantId(UUID.randomUUID());
TenantId tenant2 = new TenantId(UUID.randomUUID());
AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant1, profileId1);
loadProfileIntoCache(tenant2, profileId2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
// After deletion tenant1 profile should be reloaded from service on next get
when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null);
assertThat(cache.get(tenant1, profileId1)).isNull();
verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2);
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
AssetId assetId = new AssetId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
loadAssetMappingIntoCache(tenant, assetId, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
// After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload
when(assetService.findAssetById(any(), any())).thenReturn(null);
assertThat(cache.get(tenant, assetId)).isNull();
verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
TenantId tenant = new TenantId(UUID.randomUUID());
EntityId listenerId = new AssetId(UUID.randomUUID());
AtomicInteger callCount = new AtomicInteger();
cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
// Evicting a profile after tenant deletion should not trigger the removed listener
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
cache.evict(tenant, profileId);
assertThat(callCount.get()).isZero();
}
@Test
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
TenantId tenant = new TenantId(UUID.randomUUID());
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
// Profile should still be served from cache without hitting the service again
cache.get(tenant, profileId);
verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId);
}
@Test
public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
TenantId tenant1 = new TenantId(UUID.randomUUID());
TenantId tenant2 = new TenantId(UUID.randomUUID());
AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
loadProfileIntoCache(tenant2, profileId2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1);
}
// --- Helpers ---
private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) {
AssetProfile profile = new AssetProfile();
profile.setId(profileId);
profile.setTenantId(tenantId);
when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile);
cache.get(tenantId, profileId);
return profile;
}
private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) {
Asset asset = new Asset();
asset.setId(assetId);
asset.setAssetProfileId(profileId);
when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset);
cache.get(tenantId, assetId);
}
}

160
application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java

@ -0,0 +1,160 @@
/**
* 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.service.profile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultTbDeviceProfileCacheTest {
@Mock
private DeviceProfileService deviceProfileService;
@Mock
private DeviceService deviceService;
private DefaultTbDeviceProfileCache cache;
@BeforeEach
public void setUp() {
cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService);
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() {
TenantId tenant1 = new TenantId(UUID.randomUUID());
TenantId tenant2 = new TenantId(UUID.randomUUID());
DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant1, profileId1);
loadProfileIntoCache(tenant2, profileId2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
// After deletion tenant1 profile should be reloaded from service on next get
when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null);
assertThat(cache.get(tenant1, profileId1)).isNull();
// tenant2 profile should still be served from cache (no extra service call)
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2);
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
DeviceId deviceId = new DeviceId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
loadDeviceMappingIntoCache(tenant, deviceId, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
// After tenant deletion, device-to-profile mapping should be gone; get() should try to reload
when(deviceService.findDeviceById(any(), any())).thenReturn(null);
assertThat(cache.get(tenant, deviceId)).isNull();
verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction
}
@Test
public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
TenantId tenant = new TenantId(UUID.randomUUID());
EntityId listenerId = new DeviceId(UUID.randomUUID());
AtomicInteger callCount = new AtomicInteger();
cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
// Evicting a profile after tenant deletion should not trigger the removed listener
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
cache.evict(tenant, profileId);
assertThat(callCount.get()).isZero();
}
@Test
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
TenantId tenant = new TenantId(UUID.randomUUID());
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
loadProfileIntoCache(tenant, profileId);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
// Profile should still be served from cache without hitting the service again
cache.get(tenant, profileId);
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId);
}
@Test
public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
TenantId tenant1 = new TenantId(UUID.randomUUID());
TenantId tenant2 = new TenantId(UUID.randomUUID());
DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
loadProfileIntoCache(tenant2, profileId2);
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1);
}
// --- Helpers ---
private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) {
DeviceProfile profile = new DeviceProfile();
profile.setId(profileId);
profile.setTenantId(tenantId);
when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile);
cache.get(tenantId, profileId);
return profile;
}
private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) {
Device device = new Device();
device.setId(deviceId);
device.setDeviceProfileId(profileId);
when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device);
cache.get(tenantId, deviceId);
}
}

9
application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.service.resource; package org.thingsboard.server.service.resource;
import org.awaitility.Awaitility;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
@ -29,6 +30,8 @@ import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.timeout;
@ -61,6 +64,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest {
TbResourceInfo savedResource = tbResourceService.save(resource); TbResourceInfo savedResource = tbResourceService.save(resource);
verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId());
Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNotNull());
TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
assertThat(cachedData.getData()).isEqualTo(data); assertThat(cachedData.getData()).isEqualTo(data);
assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor);
@ -76,8 +81,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest {
TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId());
tbResourceService.delete(resourceById, true, null); tbResourceService.delete(resourceById, true, null);
verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId());
TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(cachedDataAfterDeletion).isEqualTo(null); assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNull());
} }
} }

447
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -32,12 +32,16 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier; import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collections;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -71,9 +75,15 @@ public class SystemPatchApplierTest {
@Mock @Mock
private InstallScripts installScripts; private InstallScripts installScripts;
@Mock
private DatabaseSchemaSettingsService schemaSettingsService;
@Mock @Mock
private WidgetTypeService widgetTypeService; private WidgetTypeService widgetTypeService;
@Mock
private ImageService imageService;
@InjectMocks @InjectMocks
private SystemPatchApplier reconciler; private SystemPatchApplier reconciler;
@ -373,6 +383,247 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any()); verify(widgetTypeService, times(1)).saveWidgetType(any());
} }
// --- isVersionIncreased tests ---
@ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
@MethodSource("provideVersionComparisonTestCases")
void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
assertEquals(expected, result, testName);
}
private static Stream<Arguments> provideVersionComparisonTestCases() {
return Stream.of(
// Maintenance digit increases within same LTS family
Arguments.of("maintenance increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("maintenance increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Patch digit increases within same maintenance
Arguments.of("patch increased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("patch increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
// Both maintenance and patch increased
Arguments.of("maintenance and patch both increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Maintenance increased, patch value is lower (irrelevant — maintenance wins)
Arguments.of("maintenance increased, patch is lower",
new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
// Same version — no increase
Arguments.of("same version",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("same version with non-zero parts",
new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
// Decreased versions — no increase
Arguments.of("maintenance decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
Arguments.of("patch decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
// Different major — different family, skip
Arguments.of("different major",
new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("major decreased",
new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
// Different minor — different LTS family, skip
Arguments.of("minor increased (different LTS family)",
new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("minor decreased",
new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
);
}
// --- isVersionChanged tests ---
@Test
void whenVersionIncreased_thenVersionChangedReturnsTrue() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
assertTrue(result);
}
@Test
void whenVersionNotIncreased_thenVersionChangedReturnsFalse() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
assertFalse(result);
}
@Test
void whenVersionUnparseable_thenVersionChangedReturnsFalse() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
assertFalse(result);
}
@Test
void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad");
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
assertFalse(result);
}
// --- updateLtsSqlSchema tests ---
@Test
void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
}
@Test
void whenLtsSqlFileDoesNotExist_thenSkips() {
Path dataDir = tempDir.resolve("data");
// Don't create the file
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate, never()).execute(anyString());
}
@Test
void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
String sql = "DO $$ BEGIN\n" +
" IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
" CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
" END IF;\n" +
"END $$;\n" +
"ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute(sql);
}
// --- applyPatchIfNeeded flow tests ---
@Test
void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
// LTS SQL was executed
verify(jdbcTemplate).execute("SELECT 1;");
// Schema version was updated
verify(schemaSettingsService).updateSchemaVersion();
}
@Test
void whenVersionNotIncreased_thenSkipsEverything() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
// No lock acquired
verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
// No schema update
verify(schemaSettingsService, never()).updateSchemaVersion();
}
@Test
void whenLockNotAcquired_thenSkipsPatchApplication() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false);
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(schemaSettingsService, never()).updateSchemaVersion();
verify(jdbcTemplate, never()).execute(anyString());
}
@Test
void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(schemaSettingsService).updateSchemaVersion();
}
@Test
void whenDifferentLtsFamily_thenSkipsPatch() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
verify(schemaSettingsService, never()).updateSchemaVersion();
}
private static Stream<Arguments> provideDescriptorComparisonTestCases() { private static Stream<Arguments> provideDescriptorComparisonTestCases() {
return Stream.of( return Stream.of(
Arguments.of("Both null", null, null, true), Arguments.of("Both null", null, null, true),
@ -407,4 +658,200 @@ public class SystemPatchApplierTest {
return widget; return widget;
} }
// --- createMissingSystemImages tests ---
@Test
void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() {
Path dataDir = tempDir.resolve("data");
// Intentionally do not create resources/images dir
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(0, created);
verify(imageService, never()).getAllImageKeysByTenantId(any());
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
@Test
void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(0, created);
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
@Test
void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
byte[] imageBytes = new byte[]{1, 2, 3, 4, 5};
Files.write(imagesDir.resolve("gateway.png"), imageBytes);
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(1, created);
verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes));
}
@Test
void whenSystemImageExistsInDb_thenSkipIt() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3});
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png"));
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(0, created);
verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
@Test
void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
byte[] newImageBytes = new byte[]{9, 9, 9};
byte[] existingImageBytes = new byte[]{1, 1, 1};
Files.write(imagesDir.resolve("new.png"), newImageBytes);
Files.write(imagesDir.resolve("existing.svg"), existingImageBytes);
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg"));
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(1, created);
verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes));
verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class));
}
@Test
void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
Files.createDirectories(imagesDir.resolve("nested"));
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
byte[] imageBytes = new byte[]{5, 6, 7};
Files.write(imagesDir.resolve("logo.png"), imageBytes);
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(1, created);
verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes));
verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class));
}
@Test
void whenMultipleNewImages_thenCreatesAll() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
Files.write(imagesDir.resolve("a.png"), new byte[]{1});
Files.write(imagesDir.resolve("b.svg"), new byte[]{2});
Files.write(imagesDir.resolve("c.jpg"), new byte[]{3});
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
assertEquals(3, created);
verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class));
verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class));
verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class));
}
@Test
void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2});
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class)))
.thenThrow(new RuntimeException("DB error"));
RuntimeException thrown = assertThrows(RuntimeException.class,
() -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
assertTrue(thrown.getMessage().contains("broken.png"));
}
@Test
void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception {
Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
Files.write(imagesDir.resolve("img.png"), new byte[]{1});
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID))
.thenThrow(new RuntimeException("lookup failed"));
assertThrows(RuntimeException.class,
() -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
// --- applyPatchIfNeeded integration with createMissingSystemImages ---
@Test
void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() throws Exception {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
Path imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images");
Files.createDirectories(imagesDir);
byte[] imgBytes = new byte[]{7, 7, 7};
Files.write(imagesDir.resolve("new_icon.svg"), imgBytes);
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes));
verify(schemaSettingsService).updateSchemaVersion();
}
@Test
void whenVersionNotIncreased_thenImagesAreNotTouched() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(imageService, never()).getAllImageKeysByTenantId(any());
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
} }

11
common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java

@ -18,6 +18,8 @@ package org.thingsboard.server.cache;
import org.springframework.data.redis.serializer.SerializationException; import org.springframework.data.redis.serializer.SerializationException;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import java.io.IOException;
public class TbJsonRedisSerializer<K, V> implements TbRedisSerializer<K, V> { public class TbJsonRedisSerializer<K, V> implements TbRedisSerializer<K, V> {
private final Class<V> clazz; private final Class<V> clazz;
@ -33,6 +35,13 @@ public class TbJsonRedisSerializer<K, V> implements TbRedisSerializer<K, V> {
@Override @Override
public V deserialize(K key, byte[] bytes) throws SerializationException { public V deserialize(K key, byte[] bytes) throws SerializationException {
return JacksonUtil.fromBytes(bytes, clazz); if (bytes == null) {
return null;
}
try {
return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz);
} catch (IOException e) {
throw new SerializationException("Failed to deserialize cached value", e);
}
} }
} }

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

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

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

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

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

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

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

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

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

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

3
common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java

@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import java.util.Collection; import java.util.Collection;
import java.util.Set;
public interface ImageService { public interface ImageService {
@ -38,6 +39,8 @@ public interface ImageService {
TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key);
Set<String> getAllImageKeysByTenantId(TenantId tenantId);
TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); TbResourceInfo getPublicImageInfoByKey(String publicResourceKey);
PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink);

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

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

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

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

5
common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java

@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint {
this.value = deduplicate ? TbStringPool.intern(value) : value; this.value = deduplicate ? TbStringPool.intern(value) : value;
} }
@Override
public boolean getBool() {
return Boolean.parseBoolean(value);
}
@Override @Override
public double getDouble() { public double getDouble() {
return Double.parseDouble(value); return Double.parseDouble(value);

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.UUID;
import java.util.function.Consumer; import java.util.function.Consumer;
/**
* @author Andrew Shvayka
*/
@RestController @RestController
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')")
@RequestMapping("/api/v1") @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.springframework.stereotype.Component;
import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportContext;
/**
* Created by ashvayka on 04.10.18.
*/
@Slf4j @Slf4j
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')")
@Component @Component
@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext {
} }
}; };
} }
} }

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

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

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

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.transport.lwm2m.config; package org.thingsboard.server.transport.lwm2m.config;
import jakarta.annotation.PostConstruct;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -27,6 +28,9 @@ import org.springframework.stereotype.Component;
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j @Slf4j
@Component @Component
@ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'")
@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig {
@Qualifier("lwm2mBootstrapCredentials") @Qualifier("lwm2mBootstrapCredentials")
private SslCredentialsConfig credentialsConfig; 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 @Override
public SslCredentials getSslCredentials() { public SslCredentials getSslCredentials() {
return this.credentialsConfig.getCredentials(); return this.credentialsConfig.getCredentials();
} }
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -19,9 +19,13 @@ import lombok.Getter;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.queue.discovery.event.TbApplicationEvent; import org.thingsboard.server.queue.discovery.event.TbApplicationEvent;
import java.io.Serial;
public final class DeviceDeletedEvent extends TbApplicationEvent { public final class DeviceDeletedEvent extends TbApplicationEvent {
@Serial
private static final long serialVersionUID = -7453664970966733857L; private static final long serialVersionUID = -7453664970966733857L;
@Getter @Getter
private final DeviceId deviceId; private final DeviceId deviceId;
@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent {
super(new Object()); super(new Object());
this.deviceId = deviceId; 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.Optional;
import java.util.UUID; import java.util.UUID;
/**
* Created by ashvayka on 04.10.18.
*/
public interface SessionMsgListener { public interface SessionMsgListener {
void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); 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; import java.util.concurrent.ExecutorService;
/**
* Created by ashvayka on 15.10.18.
*/
@Slf4j @Slf4j
@Data @Data
public abstract class TransportContext { public abstract class TransportContext {
@ -77,6 +74,4 @@ public abstract class TransportContext {
return serviceInfoProvider.getServiceId(); 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.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
/**
* Created by ashvayka on 04.10.18.
*/
public interface TransportService { public interface TransportService {
GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg); GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg);
@ -162,4 +159,5 @@ public interface TransportService {
boolean hasSession(SessionInfoProto sessionInfo); boolean hasSession(SessionInfoProto sessionInfo);
void createGaugeStats(String openConnections, AtomicInteger connectionsCounter); 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; package org.thingsboard.server.common.transport;
/**
* Created by ashvayka on 04.10.18.
*/
public interface TransportServiceCallback<T> { public interface TransportServiceCallback<T> {
TransportServiceCallback<Void> EMPTY = new TransportServiceCallback<Void>() { TransportServiceCallback<Void> EMPTY = new TransportServiceCallback<Void>() {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* Created by ashvayka on 17.10.18.
*/
@Slf4j @Slf4j
@Service @Service
@TbTransportComponent @TbTransportComponent
@ -789,7 +786,7 @@ public class DefaultTransportService extends TransportActivityManager implements
TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build(); TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build();
ScheduledFuture executorFuture = scheduler.schedule(() -> { ScheduledFuture<?> executorFuture = scheduler.schedule(() -> {
listener.onRemoteSessionCloseCommand(sessionId, notification); listener.onRemoteSessionCloseCommand(sessionId, notification);
deregisterSession(sessionInfo); deregisterSession(sessionInfo);
}, timeout, TimeUnit.MILLISECONDS); }, timeout, TimeUnit.MILLISECONDS);
@ -1169,6 +1166,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t));
} }
} }
private static class StatsCallback implements TbQueueCallback { private static class StatsCallback implements TbQueueCallback {
@ -1183,16 +1181,19 @@ public class DefaultTransportService extends TransportActivityManager implements
@Override @Override
public void onSuccess(TbQueueMsgMetadata metadata) { public void onSuccess(TbQueueMsgMetadata metadata) {
stats.incrementSuccessful(); stats.incrementSuccessful();
if (callback != null) if (callback != null) {
callback.onSuccess(metadata); callback.onSuccess(metadata);
}
} }
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
stats.incrementFailed(); stats.incrementFailed();
if (callback != null) if (callback != null) {
callback.onFailure(t); callback.onFailure(t);
}
} }
} }
private class MsgPackCallback implements TbQueueCallback { private class MsgPackCallback implements TbQueueCallback {
@ -1215,6 +1216,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t));
} }
} }
private class ApiStatsProxyCallback<T> implements TransportServiceCallback<T> { private class ApiStatsProxyCallback<T> implements TransportServiceCallback<T> {
@ -1244,6 +1246,7 @@ public class DefaultTransportService extends TransportActivityManager implements
public void onError(Throwable e) { public void onError(Throwable e) {
callback.onError(e); callback.onError(e);
} }
} }
@Override @Override
@ -1270,4 +1273,5 @@ public class DefaultTransportService extends TransportActivityManager implements
log.info("Transport Stats: {}", values); 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; import java.util.concurrent.ScheduledFuture;
/**
* Created by ashvayka on 15.10.18.
*/
@Data @Data
public class SessionMetaData { public class SessionMetaData {
@ -47,11 +44,8 @@ public class SessionMetaData {
this.scheduledFuture = scheduledFuture; this.scheduledFuture = scheduledFuture;
} }
public ScheduledFuture getScheduledFuture() {
return scheduledFuture;
}
public boolean hasScheduledFuture() { public boolean hasScheduledFuture() {
return null != this.scheduledFuture; 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.gen.transport.TransportProtos.ToRuleEngineMsg;
import org.thingsboard.server.queue.kafka.TbKafkaEncoder; import org.thingsboard.server.queue.kafka.TbKafkaEncoder;
/**
* Created by ashvayka on 05.10.18.
*/
public class ToRuleEngineMsgEncoder implements TbKafkaEncoder<ToRuleEngineMsg> { public class ToRuleEngineMsgEncoder implements TbKafkaEncoder<ToRuleEngineMsg> {
@Override @Override
public byte[] encode(ToRuleEngineMsg value) { 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; import java.io.IOException;
/**
* Created by ashvayka on 05.10.18.
*/
public class ToTransportMsgResponseDecoder implements TbKafkaDecoder<ToTransportMsg> { public class ToTransportMsgResponseDecoder implements TbKafkaDecoder<ToTransportMsg> {
@Override @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.gen.transport.TransportProtos.TransportApiRequestMsg;
import org.thingsboard.server.queue.kafka.TbKafkaEncoder; import org.thingsboard.server.queue.kafka.TbKafkaEncoder;
/**
* Created by ashvayka on 05.10.18.
*/
public class TransportApiRequestEncoder implements TbKafkaEncoder<TransportApiRequestMsg> { public class TransportApiRequestEncoder implements TbKafkaEncoder<TransportApiRequestMsg> {
@Override @Override
public byte[] encode(TransportApiRequestMsg value) { 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; import java.io.IOException;
/**
* Created by ashvayka on 05.10.18.
*/
public class TransportApiResponseDecoder implements TbKafkaDecoder<TransportApiResponseMsg> { public class TransportApiResponseDecoder implements TbKafkaDecoder<TransportApiResponseMsg> {
@Override @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.Optional;
import java.util.UUID; import java.util.UUID;
/**
* @author Andrew Shvayka
*/
@Data @Data
public abstract class DeviceAwareSessionContext implements SessionContext { 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 onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile);
void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional<DeviceProfile> deviceProfileOpt); 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 { public class JsonUtils {
private static final Pattern BASE64_PATTERN = private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$");
Pattern.compile("^[A-Za-z0-9+/]+={0,2}$");
public static JsonObject getJsonObject(List<KeyValueProto> tsKv) { public static JsonObject getJsonObject(List<KeyValueProto> tsKv) {
JsonObject json = new JsonObject(); JsonObject json = new JsonObject();
@ -68,12 +67,12 @@ public class JsonUtils {
} }
return JsonParser.parseString((String) value); return JsonParser.parseString((String) value);
} }
} else if (value instanceof Boolean) { } else if (value instanceof Boolean booleanValue) {
return new JsonPrimitive((Boolean) value); return new JsonPrimitive(booleanValue);
} else if (value instanceof Double) { } else if (value instanceof Double doubleValue) {
return new JsonPrimitive((Double) value); return new JsonPrimitive(doubleValue);
} else if (value instanceof Float) { } else if (value instanceof Float floatValue) {
return new JsonPrimitive((Float) value); return new JsonPrimitive(floatValue);
} else { } else {
throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName()); throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName());
} }
@ -91,4 +90,5 @@ public class JsonUtils {
public static boolean isBase64(String value) { public static boolean isBase64(String value) {
return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches(); 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.security.cert.X509Certificate;
import java.util.Base64; import java.util.Base64;
/**
* @author Valerii Sosliuk
*/
@Slf4j @Slf4j
public class SslUtil { public class SslUtil {
@ -51,7 +47,7 @@ public class SslUtil {
String begin = "-----BEGIN CERTIFICATE-----"; String begin = "-----BEGIN CERTIFICATE-----";
String end = "-----END CERTIFICATE-----"; String end = "-----END CERTIFICATE-----";
StringBuilder stringBuilder = new StringBuilder(); 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"); stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n");
} }
return stringBuilder.toString(); return stringBuilder.toString();
@ -85,4 +81,5 @@ public class SslUtil {
RDN cn = x500name.getRDNs(BCStyle.CN)[0]; RDN cn = x500name.getRDNs(BCStyle.CN)[0];
return IETFUtils.valueToString(cn.getFirst().getValue()); return IETFUtils.valueToString(cn.getFirst().getValue());
} }
} }

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

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

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

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

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

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

13
dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java

@ -48,13 +48,13 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al
@Override @Override
public AlarmComment createOrUpdateAlarmComment(TenantId tenantId, AlarmComment alarmComment) { public AlarmComment createOrUpdateAlarmComment(TenantId tenantId, AlarmComment alarmComment) {
alarmCommentDataValidator.validate(alarmComment, c -> tenantId); AlarmComment oldAlarmComment = alarmCommentDataValidator.validate(alarmComment, c -> tenantId);
boolean isCreated = alarmComment.getId() == null; boolean isCreated = alarmComment.getId() == null;
AlarmComment result; AlarmComment result;
if (isCreated) { if (isCreated) {
result = createAlarmComment(tenantId, alarmComment); result = createAlarmComment(tenantId, alarmComment);
} else { } else {
result = updateAlarmComment(tenantId, alarmComment); result = updateAlarmComment(tenantId, alarmComment, oldAlarmComment);
} }
if (result != null) { if (result != null) {
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(result) eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(result)
@ -101,18 +101,17 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al
return alarmCommentDao.save(tenantId, alarmComment); return alarmCommentDao.save(tenantId, alarmComment);
} }
private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment) { private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment, AlarmComment oldAlarmComment) {
log.debug("Update Alarm comment : {}", newAlarmComment); log.debug("Update Alarm comment : {}", newAlarmComment);
AlarmComment existing = alarmCommentDao.findAlarmCommentById(tenantId, newAlarmComment.getId().getId()); if (oldAlarmComment != null) {
if (existing != null) {
if (newAlarmComment.getComment() != null) { if (newAlarmComment.getComment() != null) {
JsonNode comment = newAlarmComment.getComment(); JsonNode comment = newAlarmComment.getComment();
((ObjectNode) comment).put("edited", "true"); ((ObjectNode) comment).put("edited", "true");
((ObjectNode) comment).put("editedOn", System.currentTimeMillis()); ((ObjectNode) comment).put("editedOn", System.currentTimeMillis());
existing.setComment(comment); oldAlarmComment.setComment(comment);
} }
return alarmCommentDao.save(tenantId, existing); return alarmCommentDao.save(tenantId, oldAlarmComment);
} }
return null; return null;
} }

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java

@ -85,7 +85,7 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService
private String mqttsPemCertFile; private String mqttsPemCertFile;
@Value("${device.connectivity.coaps.pem_cert_file:}") @Value("${device.connectivity.coaps.pem_cert_file:}")
private String coapsPemCertFile; private String coapsPemCertFile;
@Value("${device.connectivity.gateway.image_version:3.7-stable}") @Value("${device.connectivity.gateway.image_version:3.8-stable}")
private String gatewayImageVersion; private String gatewayImageVersion;
@Override @Override

6
dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java

@ -188,6 +188,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic
return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key); return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key);
} }
@Override
public Set<String> getAllImageKeysByTenantId(TenantId tenantId) {
log.trace("Executing getAllImageKeysByTenantId [{}]", tenantId);
return resourceInfoDao.findKeysByTenantIdAndResourceTypeAndResourceKeyPrefix(tenantId, ResourceType.IMAGE, "");
}
@Override @Override
public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) { public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) {
return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey); return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey);

19
dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java

@ -18,7 +18,9 @@ package org.thingsboard.server.dao.service.validator;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.alarm.AlarmCommentDao;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.DataValidator;
@ -26,6 +28,8 @@ import org.thingsboard.server.dao.service.DataValidator;
@AllArgsConstructor @AllArgsConstructor
public class AlarmCommentDataValidator extends DataValidator<AlarmComment> { public class AlarmCommentDataValidator extends DataValidator<AlarmComment> {
private final AlarmCommentDao alarmCommentDao;
@Override @Override
protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) { protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) {
if (alarmComment.getComment() == null) { if (alarmComment.getComment() == null) {
@ -35,4 +39,19 @@ public class AlarmCommentDataValidator extends DataValidator<AlarmComment> {
throw new DataValidationException("Alarm id should be specified!"); throw new DataValidationException("Alarm id should be specified!");
} }
} }
@Override
protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) {
AlarmComment oldAlarmComment = null;
if (alarmComment.getId() != null) {
oldAlarmComment = alarmCommentDao.findAlarmCommentById(tenantId, alarmComment.getId().getId());
if (oldAlarmComment == null) {
throw new DataValidationException("Can't update non existing alarm comment!");
}
if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM) {
throw new DataValidationException("System alarm comment can't be updated!");
}
}
return oldAlarmComment;
}
} }

14
dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java

@ -117,24 +117,26 @@ public class DeviceConnectivityUtil {
dockerComposeBuilder.append("\n"); dockerComposeBuilder.append("\n");
dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" # Environment variables\n");
dockerComposeBuilder.append(" environment:\n"); dockerComposeBuilder.append(" environment:\n");
dockerComposeBuilder.append(" - host=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); dockerComposeBuilder.append(" - TB_GW_HOST=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n");
dockerComposeBuilder.append(" - port=1883\n"); dockerComposeBuilder.append(" - TB_GW_PORT=1883\n");
switch (deviceCredentials.getCredentialsType()) { switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN: case ACCESS_TOKEN:
dockerComposeBuilder.append(" - accessToken=").append(deviceCredentials.getCredentialsId()).append("\n"); dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n");
dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n");
break; break;
case MQTT_BASIC: case MQTT_BASIC:
dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n");
BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(),
BasicMqttCredentials.class); BasicMqttCredentials.class);
if (credentials != null) { if (credentials != null) {
if (StringUtils.isNotEmpty(credentials.getClientId())) { if (StringUtils.isNotEmpty(credentials.getClientId())) {
dockerComposeBuilder.append(" - clientId=").append(credentials.getClientId()).append("\n"); dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n");
} }
if (StringUtils.isNotEmpty(credentials.getUserName())) { if (StringUtils.isNotEmpty(credentials.getUserName())) {
dockerComposeBuilder.append(" - username=").append(credentials.getUserName()).append("\n"); dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n");
} }
if (StringUtils.isNotEmpty(credentials.getPassword())) { if (StringUtils.isNotEmpty(credentials.getPassword())) {
dockerComposeBuilder.append(" - password=").append(credentials.getPassword()).append("\n"); dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n");
} }
} }
break; break;

47
dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java

@ -0,0 +1,47 @@
/**
* 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.dao;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.TimescaleDBContainerProvider;
/**
* Extends the upstream {@link TimescaleDBContainerProvider} to disable the
* timescaledb-tune entrypoint script via NO_TS_TUNE=true.
*
* Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh
* that crashes the container entrypoint on cgroup v2 hosts (including CI agents)
* when the kernel reports the 64-bit max for memory.max.
*
* Activated by the jdbc:tc:tbtimescaledb:&lt;tag&gt;:///... URL prefix
* registered via META-INF/services.
*/
public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider {
private static final String NAME = "tbtimescaledb";
@Override
public boolean supports(String databaseType) {
return NAME.equals(databaseType);
}
@Override
public JdbcDatabaseContainer newInstance(String tag) {
JdbcDatabaseContainer container = super.newInstance(tag);
container.withEnv("NO_TS_TUNE", "true");
return container;
}
}

1
dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider

@ -0,0 +1 @@
org.thingsboard.server.dao.TbTimescaleDBContainerProvider

2
dao/src/test/resources/nosql-test.properties

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=postgres spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16 spring.datasource.hikari.maximumPoolSize=16

2
dao/src/test/resources/sql-test.properties

@ -14,7 +14,7 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=postgres spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16 spring.datasource.hikari.maximumPoolSize=16

2
dao/src/test/resources/timescale-test.properties

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=postgres spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize = 50 spring.datasource.hikari.maximumPoolSize = 50

57
edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java

@ -19,15 +19,20 @@ import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceProfileType;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.AttributeKv;
import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.edqs.LatestTsKv;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
@ -39,8 +44,10 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.KeyFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public class DeviceTypeFilterTest extends AbstractEDQTest { public class DeviceTypeFilterTest extends AbstractEDQTest {
@ -119,7 +126,50 @@ public class DeviceTypeFilterTest extends AbstractEDQTest {
Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue());
} }
@Test
public void testFindDeviceByBooleanAttributeWithMixedTypes() {
DeviceId device1Id = createLoraDevice("LoRa-1");
DeviceId device2Id = createLoraDevice("LoRa-2");
DeviceId device3Id = createLoraDevice("LoRa-3");
long ts = System.currentTimeMillis();
addOrUpdate(new AttributeKv(device1Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new BooleanDataEntry("active", true), ts), 1L));
addOrUpdate(new AttributeKv(device2Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new BooleanDataEntry("active", false), ts), 1L));
addOrUpdate(new AttributeKv(device3Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new StringDataEntry("active", "true"), ts), 1L));
KeyFilter activeFilter = new KeyFilter();
activeFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "active"));
activeFilter.setValueType(EntityKeyValueType.BOOLEAN);
BooleanFilterPredicate predicate = new BooleanFilterPredicate();
predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL);
predicate.setValue(FilterPredicateValue.fromBoolean(true));
activeFilter.setPredicate(predicate);
var result = repository.countEntitiesByQuery(tenantId, null,
getDeviceTypeQuery("LoRa", List.of(activeFilter)), false);
Assert.assertEquals(2, result);
}
private DeviceId createLoraDevice(String name) {
DeviceId deviceId = new DeviceId(UUID.randomUUID());
Device device = new Device();
device.setId(deviceId);
device.setTenantId(tenantId);
device.setDeviceProfileId(loraProfileId);
device.setName(name);
device.setCreatedTime(42L);
addOrUpdate(EntityType.DEVICE, device);
return deviceId;
}
private static EntityDataQuery getDeviceTypeQuery(String deviceType) { private static EntityDataQuery getDeviceTypeQuery(String deviceType) {
return getDeviceTypeQuery(deviceType, null);
}
private static EntityDataQuery getDeviceTypeQuery(String deviceType, List<KeyFilter> extraFilters) {
DeviceTypeFilter filter = new DeviceTypeFilter(); DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(Collections.singletonList(deviceType)); filter.setDeviceTypes(Collections.singletonList(deviceType));
var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false);
@ -135,7 +185,12 @@ public class DeviceTypeFilterTest extends AbstractEDQTest {
nameFilter.setPredicate(predicate); nameFilter.setPredicate(predicate);
nameFilter.setValueType(EntityKeyValueType.STRING); nameFilter.setValueType(EntityKeyValueType.STRING);
return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); List<KeyFilter> keyFilters = new ArrayList<>();
keyFilters.add(nameFilter);
if (extraFilters != null) {
keyFilters.addAll(extraFilters);
}
return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters);
} }
} }

32
pom.xml

@ -70,6 +70,7 @@
<metrics.version>4.2.25</metrics.version> <metrics.version>4.2.25</metrics.version>
<cassandra-all.version>5.0.4</cassandra-all.version> <!-- tools --> <cassandra-all.version>5.0.4</cassandra-all.version> <!-- tools -->
<guava.version>33.1.0-jre</guava.version> <guava.version>33.1.0-jre</guava.version>
<tomcat.version>10.1.54</tomcat.version> <!-- to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483. TODO: remove when fixed in spring-boot-dependencies -->
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies --> <commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<commons-io.version>2.16.1</commons-io.version> <commons-io.version>2.16.1</commons-io.version>
<commons-logging.version>1.3.1</commons-logging.version> <commons-logging.version>1.3.1</commons-logging.version>
@ -96,6 +97,7 @@
<maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version> <maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version>
<maven-install-plugin.version>3.1.4</maven-install-plugin.version> <maven-install-plugin.version>3.1.4</maven-install-plugin.version>
<maven-deploy-plugin.version>3.1.4</maven-deploy-plugin.version> <maven-deploy-plugin.version>3.1.4</maven-deploy-plugin.version>
<maven-clean-plugin.version>3.5.0</maven-clean-plugin.version>
<springdoc-swagger.version>2.8.8TB</springdoc-swagger.version> <springdoc-swagger.version>2.8.8TB</springdoc-swagger.version>
<swagger-annotations.version>2.2.30</swagger-annotations.version> <swagger-annotations.version>2.2.30</swagger-annotations.version>
<spatial4j.version>0.8</spatial4j.version> <spatial4j.version>0.8</spatial4j.version>
@ -617,7 +619,6 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId> <artifactId>maven-assembly-plugin</artifactId>
<configuration> <configuration>
<skipAssembly>${pkg.skip.zip}</skipAssembly>
<finalName>${pkg.name}</finalName> <finalName>${pkg.name}</finalName>
<descriptors> <descriptors>
<descriptor>${main.dir}/packaging/${pkg.type}/assembly/windows.xml</descriptor> <descriptor>${main.dir}/packaging/${pkg.type}/assembly/windows.xml</descriptor>
@ -630,6 +631,9 @@
<goals> <goals>
<goal>single</goal> <goal>single</goal>
</goals> </goals>
<configuration>
<skipAssembly>${pkg.skip.zip}</skipAssembly>
</configuration>
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
@ -914,6 +918,12 @@
<exclude>**/resources/lwm2m/models/**</exclude> <exclude>**/resources/lwm2m/models/**</exclude>
<exclude>src/main/data/resources/**</exclude> <exclude>src/main/data/resources/**</exclude>
<exclude>.claude/**</exclude> <exclude>.claude/**</exclude>
<exclude>**/lombok.config</exclude>
<exclude>**/eslint.config.mjs</exclude>
<exclude>**/config.monitoring</exclude>
<exclude>**/valkey-certs/**</exclude>
<exclude>**/data/certs/**</exclude>
<exclude>**/*.otf</exclude>
</excludes> </excludes>
<mapping> <mapping>
<proto>JAVADOC_STYLE</proto> <proto>JAVADOC_STYLE</proto>
@ -966,6 +976,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-clean-plugin</artifactId> <artifactId>maven-clean-plugin</artifactId>
<version>${maven-clean-plugin.version}</version>
<inherited>false</inherited> <inherited>false</inherited>
<configuration> <configuration>
<filesets> <filesets>
@ -991,6 +1002,25 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Temporary tomcat version override to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483.
Must be declared before the spring-boot-dependencies BOM import to take precedence.
TODO: remove when fixed in spring-boot-dependencies -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of tomcat version override -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId> <artifactId>spring-boot-dependencies</artifactId>

6
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -438,4 +438,10 @@ public interface TbContext {
MqttClientSettings getMqttClientSettings(); MqttClientSettings getMqttClientSettings();
// Server-level safety caps for the HTTP client used by the REST API Call rule node (read from thingsboard.yml)
default TbHttpClientSettings getTbHttpClientSettings() {
return TbHttpClientSettings.DEFAULT;
}
} }

49
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java

@ -0,0 +1,49 @@
/**
* 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.rule.engine.api;
/**
* Server-level safety caps for the HTTP client used by the REST API Call rule node.
* Values are read from {@code thingsboard.yml} (or the corresponding environment variables)
* and applied as hard ceilings on top of the per-node tenant configuration.
* A value of {@code 0} means no system-level restriction.
*/
public interface TbHttpClientSettings {
/** System ceiling for {@code maxParallelRequestsCount}. 0 = no system limit. */
int getMaxParallelRequests();
/** System ceiling for the pending-request queue depth. 0 = no system limit. */
int getMaxPendingRequests();
/**
* Maximum number of TCP connections in the reactor-netty pool per node instance.
* 0 = use reactor-netty's default: {@code max(availableProcessors, 8) * 2}.
*/
int getPoolMaxConnections();
TbHttpClientSettings DEFAULT = new TbHttpClientSettings() {
@Override
public int getMaxParallelRequests() { return 0; }
@Override
public int getMaxPendingRequests() { return 0; }
@Override
public int getPoolMaxConnections() { return 0; }
};
}

200
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -33,6 +33,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.api.util.TbNodeUtils;
@ -42,6 +43,7 @@ import org.thingsboard.rule.engine.credentials.CredentialsType;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import reactor.core.scheduler.Schedulers;
import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider; import reactor.netty.resources.ConnectionProvider;
import reactor.netty.transport.ProxyProvider; import reactor.netty.transport.ProxyProvider;
@ -53,8 +55,11 @@ import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -84,17 +89,48 @@ public class TbHttpClient {
public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb"; public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb";
private static final long ANOMALY_REPORT_INTERVAL_MS = 60_000;
private final TbRestApiCallNodeConfiguration config; private final TbRestApiCallNodeConfiguration config;
private final String tenantId;
private final String nodeId;
private final TbHttpClientSettings settings;
private EventLoopGroup eventLoopGroup; private EventLoopGroup eventLoopGroup;
private WebClient webClient; private WebClient webClient;
private Semaphore semaphore; private Semaphore semaphore;
private BlockingQueue<PendingTask> pendingQueue;
private final AtomicLong dispatchedCount = new AtomicLong();
private final AtomicLong successCount = new AtomicLong();
private final AtomicLong failureCount = new AtomicLong();
private final AtomicLong droppedFullCount = new AtomicLong();
private final AtomicLong droppedStaleCount = new AtomicLong();
private volatile long lastAnomalyReportAt = 0;
private record PendingTask(
TbContext ctx,
TbMsg msg,
Consumer<TbMsg> onSuccess,
BiConsumer<TbMsg, Throwable> onFailure,
long enqueuedNanos) {}
TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared) throws TbNodeException { TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared) throws TbNodeException {
this(config, eventLoopGroupShared, "n/a", "n/a", TbHttpClientSettings.DEFAULT);
}
TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared,
String tenantId, String nodeId, TbHttpClientSettings settings) throws TbNodeException {
try { try {
this.config = config; this.config = config;
if (config.getMaxParallelRequestsCount() > 0) { this.tenantId = tenantId;
semaphore = new Semaphore(config.getMaxParallelRequestsCount()); this.nodeId = nodeId;
this.settings = settings;
int effectiveParallel = effectiveMax(config.getMaxParallelRequestsCount(), settings.getMaxParallelRequests());
if (effectiveParallel > 0) {
semaphore = new Semaphore(effectiveParallel);
int effectivePending = effectiveMax(0, settings.getMaxPendingRequests());
pendingQueue = effectivePending > 0 ? new LinkedBlockingQueue<>(effectivePending) : new LinkedBlockingQueue<>();
} }
ConnectionProvider connectionProvider = ConnectionProvider ConnectionProvider connectionProvider = ConnectionProvider
@ -155,16 +191,20 @@ public class TbHttpClient {
} }
} }
private int getPoolMaxConnections() { /**
String poolMaxConnectionsEnv = System.getenv("TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS"); * Returns the effective limit: {@code min(userMax, systemMax)} when both are positive,
* {@code systemMax} when only the system ceiling is set, or {@code userMax} otherwise.
* A value of {@code 0} means unlimited.
*/
private static int effectiveMax(int userMax, int systemMax) {
if (systemMax <= 0) return userMax;
if (userMax <= 0) return systemMax;
return Math.min(userMax, systemMax);
}
int poolMaxConnections; private int getPoolMaxConnections() {
if (poolMaxConnectionsEnv != null) { int configured = settings.getPoolMaxConnections();
poolMaxConnections = Integer.parseInt(poolMaxConnectionsEnv); return configured > 0 ? configured : ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS;
} else {
poolMaxConnections = ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS;
}
return poolMaxConnections;
} }
private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException { private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException {
@ -207,54 +247,150 @@ public class TbHttpClient {
if (this.eventLoopGroup != null) { if (this.eventLoopGroup != null) {
this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS);
} }
long full = droppedFullCount.get();
long stale = droppedStaleCount.get();
int availablePermits = semaphore != null ? semaphore.availablePermits() : -1;
if (full > 0 || stale > 0) {
log.warn("[{}][{}] REST API call node destroyed with anomalies: " +
"droppedQueueFull={}, droppedStale={}, dispatched={}, success={}, failure={}, semaphorePermits={}.",
tenantId, nodeId, full, stale,
dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits);
} else {
log.debug("[{}][{}] REST API call node destroyed. dispatched={}, success={}, failure={}, semaphorePermits={}.",
tenantId, nodeId, dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits);
}
} }
public void processMessage(TbContext ctx, TbMsg msg, public void processMessage(TbContext ctx, TbMsg msg,
Consumer<TbMsg> onSuccess, Consumer<TbMsg> onSuccess,
BiConsumer<TbMsg, Throwable> onFailure) { BiConsumer<TbMsg, Throwable> onFailure) {
try { if (semaphore == null) {
if (semaphore != null && !semaphore.tryAcquire(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS)) { doHttpCall(new PendingTask(ctx, msg, onSuccess, onFailure, 0L));
onFailure.accept(msg, new RuntimeException("Timeout during waiting for reply!")); return;
return; }
if (!pendingQueue.offer(new PendingTask(ctx, msg, onSuccess, onFailure, System.nanoTime()))) {
droppedFullCount.incrementAndGet();
log.debug("[{}][{}] REST API call queue full, dropping msg {}.", tenantId, nodeId, msg.getId());
maybeReportAnomalies();
onFailure.accept(msg, new RuntimeException("Max pending requests limit exceeded!"));
return;
}
tryProcess();
}
/**
* Tries to acquire one concurrency slot and fire the next queued task.
* Stale messages (whose message pack has expired) are silently dropped.
* Safe to call from any thread under high concurrency.
*/
private void tryProcess() {
while (true) {
if (!semaphore.tryAcquire()) {
return; // all slots are in use; a callback will call tryProcess() when one frees up
}
PendingTask next = pendingQueue.poll();
if (next == null) {
semaphore.release();
return; // queue is empty; slot released
} }
if (!next.msg().isValid()) {
semaphore.release();
droppedStaleCount.incrementAndGet();
log.debug("[{}][{}] Dropping stale msg {} from REST API call queue (queueDepth={}).",
tenantId, nodeId, next.msg().getId(), pendingQueue.size());
next.onFailure().accept(next.msg(), new RuntimeException("Message is no longer valid. Dropped from queue."));
maybeReportAnomalies();
continue; // slot released — loop to check if there's a valid next item
}
dispatchedCount.incrementAndGet();
if (doHttpCall(next)) {
return; // async HTTP call started — its callback will call tryProcess()
}
// synchronous failure — semaphore already released in doHttpCall, loop to try next task
}
}
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); private void maybeReportAnomalies() {
long now = System.currentTimeMillis();
if (now - lastAnomalyReportAt < ANOMALY_REPORT_INTERVAL_MS) {
return;
}
lastAnomalyReportAt = now;
int queueSize = pendingQueue != null ? pendingQueue.size() : 0;
int queueRemaining = pendingQueue != null ? pendingQueue.remainingCapacity() : Integer.MAX_VALUE;
int availablePermits = semaphore != null ? semaphore.availablePermits() : -1;
log.warn("[{}][{}] REST API call node anomalies: droppedQueueFull={}, droppedStale={} " +
"(dispatched={}, success={}, failure={}, queueDepth={}, queueRemaining={}, semaphorePermits={}).",
tenantId, nodeId,
droppedFullCount.get(), droppedStaleCount.get(),
dispatchedCount.get(), successCount.get(), failureCount.get(),
queueSize, queueRemaining, availablePermits);
}
/**
* Initiates an async HTTP call for the given task.
*
* @return {@code true} if the async subscription was started and the semaphore slot is now
* owned by the callback (which will release it and call {@link #tryProcess()}).
* {@code false} if a synchronous exception occurred before the subscription was
* registered; the semaphore slot has already been released and the caller should
* loop rather than recurse to avoid stack overflow when many queued tasks fail
* synchronously (e.g. misconfigured URL pattern).
*/
private boolean doHttpCall(PendingTask task) {
boolean asyncStarted = false;
try {
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), task.msg());
HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
URI uri = buildEncodedUri(endpointUrl); URI uri = buildEncodedUri(endpointUrl);
RequestBodySpec request = webClient RequestBodySpec req = webClient
.method(method) .method(method)
.uri(uri) .uri(uri)
.headers(headers -> prepareHeaders(headers, msg)); .headers(headers -> prepareHeaders(headers, task.msg()));
if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) ||
HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) &&
!config.isIgnoreRequestBody()) { !config.isIgnoreRequestBody()) {
request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText()))); req.body(BodyInserters.fromValue(getData(task.msg(), config.isParseToPlainText())));
} }
request req.retrieve()
.retrieve()
.toEntity(String.class) .toEntity(String.class)
.subscribe(responseEntity -> { .publishOn(Schedulers.fromExecutor(task.ctx().getExternalCallExecutor()))
.doFinally(signalType -> {
// Runs exactly once after onComplete, onError, or cancel — the only
// place that releases the permit for the async path.
if (semaphore != null) { if (semaphore != null) {
semaphore.release(); semaphore.release();
tryProcess();
} }
})
.subscribe(responseEntity -> {
if (responseEntity.getStatusCode().is2xxSuccessful()) { if (responseEntity.getStatusCode().is2xxSuccessful()) {
onSuccess.accept(processResponse(ctx, msg, responseEntity)); successCount.incrementAndGet();
task.onSuccess().accept(processResponse(task.ctx(), task.msg(), responseEntity));
} else { } else {
onFailure.accept(processFailureResponse(msg, responseEntity), null); failureCount.incrementAndGet();
task.onFailure().accept(processFailureResponse(task.msg(), responseEntity), null);
} }
}, throwable -> { }, throwable -> {
if (semaphore != null) { failureCount.incrementAndGet();
semaphore.release(); task.onFailure().accept(processException(task.msg(), throwable), processThrowable(throwable));
}
onFailure.accept(processException(msg, throwable), processThrowable(throwable));
}); });
} catch (InterruptedException e) { asyncStarted = true;
log.warn("Timeout during waiting for reply!", e); return true;
} catch (Exception e) {
failureCount.incrementAndGet();
task.onFailure().accept(processException(task.msg(), e), processThrowable(e));
return false;
} finally {
// Synchronous permit release: only when the async pipeline was never registered
// (asyncStarted=false). If it was, doFinally owns the release.
// tryProcess() is intentionally not called here — the caller loops iteratively.
if (!asyncStarted && semaphore != null) {
semaphore.release();
}
} }
} }

7
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java

@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.rest;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -58,7 +59,11 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx); super.init(ctx);
TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
httpClient = new TbHttpClient(config, ctx.getSharedEventLoop()); TbHttpClientSettings httpClientSettings = ctx.getTbHttpClientSettings();
httpClient = new TbHttpClient(config, ctx.getSharedEventLoop(),
ctx.getTenantId() != null ? ctx.getTenantId().getId().toString() : "n/a",
ctx.getSelfId() != null ? ctx.getSelfId().getId().toString() : "n/a",
httpClientSettings != null ? httpClientSettings : TbHttpClientSettings.DEFAULT);
} }
@Override @Override

2
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java

@ -27,6 +27,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockserver.integration.ClientAndServer; import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.thingsboard.common.util.DirectListeningExecutor;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
@ -156,6 +157,7 @@ public class TbHttpClientTest {
.build(); .build();
var ctx = mock(TbContext.class); var ctx = mock(TbContext.class);
when(ctx.getExternalCallExecutor()).thenReturn(DirectListeningExecutor.INSTANCE);
when(ctx.transformMsg( when(ctx.transformMsg(
eq(msg), eq(msg),
eq(msg.getMetaData()), eq(msg.getMetaData()),

153
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -27,13 +27,18 @@ import org.apache.http.protocol.HttpRequestHandler;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy; import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.DirectListeningExecutor;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -43,6 +48,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
@ -53,15 +59,23 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30);
@Spy @Spy
private TbRestApiCallNode restNode; private TbRestApiCallNode restNode;
@ -125,6 +139,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
} }
}); });
given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("DELETE"); config.setRequestMethod("DELETE");
config.setHeaders(Collections.singletonMap("Foo", "Bar")); config.setHeaders(Collections.singletonMap("Foo", "Bar"));
@ -148,7 +164,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertNotSame(metaData, metadataCaptor.getValue()); assertNotSame(metaData, metadataCaptor.getValue());
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());
@ -183,6 +199,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
} }
}); });
given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("DELETE"); config.setRequestMethod("DELETE");
config.setHeaders(Collections.singletonMap("Foo", "Bar")); config.setHeaders(Collections.singletonMap("Foo", "Bar"));
@ -206,12 +224,143 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertNotSame(metaData, metadataCaptor.getValue()); assertNotSame(metaData, metadataCaptor.getValue());
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());
} }
@Test
public void givenForceAckTrue_whenOnMsgAndServerReturns200_thenAckedImmediatelyAndEnqueuedForTellNext() throws IOException {
final String path = "/path/to/get";
setupServer("*", new HttpRequestHandler() {
@Override
public void handle(HttpRequest request, HttpResponse response, HttpContext context)
throws HttpException, IOException {
response.setStatusCode(200);
}
});
TbMsg transformedMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
given(ctx.isExternalNodeForceAck()).willReturn(true);
given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE);
given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRequestMethod("GET");
config.setIgnoreRequestBody(true);
config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path));
initWithConfig(config);
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
verify(ctx).ack(msg);
verify(ctx, timeout(TIMEOUT)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS));
verify(ctx, never()).tellSuccess(any());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void givenMaxParallelRequestsCountAndBadUrl_whenOnMsg_thenSemaphoreIsReleasedAndFailureReported(boolean forceAck) throws IOException {
given(ctx.isExternalNodeForceAck()).willReturn(forceAck);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setMaxParallelRequestsCount(1);
config.setRestEndpointUrlPattern("");
initWithConfig(config);
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId)
.ruleNodeId(ruleNodeId)
.build();
restNode.onMsg(ctx, msg);
assertThat(restNode.httpClient.getSemaphore().availablePermits()).isEqualTo(1);
if (forceAck) {
verify(ctx).enqueueForTellFailure(any(), any(Throwable.class));
} else {
verify(ctx).tellFailure(any(), any());
}
}
@Test
public void givenMaxPendingRequestsExceeded_whenOnMsg_thenFailsImmediatelyAndQueuedRequestFiresAfterSlotOpens() throws IOException, InterruptedException {
CountDownLatch releaseResponse = new CountDownLatch(1);
setupServer("*", (request, response, context) -> {
try {
releaseResponse.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
response.setStatusCode(200);
});
given(ctx.isExternalNodeForceAck()).willReturn(false);
given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE);
// Simulate server-level cap: maxPendingRequests=1 via TbHttpClientSettings
given(ctx.getTbHttpClientSettings()).willReturn(new TbHttpClientSettings() {
@Override public int getMaxParallelRequests() { return 0; }
@Override public int getMaxPendingRequests() { return 1; }
@Override public int getPoolMaxConnections() { return 0; }
});
TbMsg transformedMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(originator)
.copyMetaData(metaData)
.data(TbMsg.EMPTY_JSON_OBJECT)
.build();
given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg);
TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setMaxParallelRequestsCount(1);
config.setRequestMethod("GET");
config.setIgnoreRequestBody(true);
config.setRestEndpointUrlPattern(String.format("http://localhost:%d/path", server.getLocalPort()));
initWithConfig(config);
TbMsg msg1 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator)
.copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build();
TbMsg msg2 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator)
.copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build();
TbMsg msg3 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator)
.copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT)
.ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build();
restNode.onMsg(ctx, msg1); // fires immediately (semaphore acquired)
restNode.onMsg(ctx, msg2); // queues (semaphore exhausted, queue has room)
restNode.onMsg(ctx, msg3); // fails immediately (queue full — server-level maxPendingRequests=1)
verify(ctx, timeout(TIMEOUT)).tellFailure(any(), any());
releaseResponse.countDown();
verify(ctx, timeout(TIMEOUT).times(2)).tellSuccess(any());
}
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of( return Stream.of(
// config for version 2 with upgrade from version 0 // config for version 2 with upgrade from version 0

18
tools/pom.xml

@ -54,6 +54,24 @@
<dependency> <dependency>
<groupId>org.apache.cassandra</groupId> <groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId> <artifactId>cassandra-all</artifactId>
<exclusions>
<!-- These sjk-* artifacts pull in com.jrockit.mc / com.oracle.jmc / jdk.org.netbeans
system-scope dependencies with unresolved systemPath properties, which produces
ERROR-level Maven warnings on every build. No ThingsBoard code imports from sjk,
jmc, or netbeans profiler, so excluding them is safe. -->
<exclusion>
<groupId>org.perfkit.sjk.parsers</groupId>
<artifactId>sjk-jfr5</artifactId>
</exclusion>
<exclusion>
<groupId>org.perfkit.sjk.parsers</groupId>
<artifactId>sjk-jfr6</artifactId>
</exclusion>
<exclusion>
<groupId>org.perfkit.sjk.parsers</groupId>
<artifactId>sjk-nps</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>at.yawk.lz4</groupId> <groupId>at.yawk.lz4</groupId>

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

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

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

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

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

@ -301,6 +301,15 @@ transport:
enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" enabled: "${TB_TRANSPORT_STATS_ENABLED:true}"
# Interval of transport statistics logging # Interval of transport statistics logging
print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# Queue configuration properties # Queue configuration properties
queue: 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}" max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}"
# Timeout to expire block IP addresses # Timeout to expire block IP addresses
ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Check interval in seconds for certificates reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# Queue configuration parameters # Queue configuration parameters
queue: queue:

36
ui-ngx/package.json

@ -13,20 +13,20 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "20.3.18", "@angular/animations": "20.3.19",
"@angular/cdk": "20.2.14", "@angular/cdk": "20.2.14",
"@angular/common": "20.3.18", "@angular/common": "20.3.19",
"@angular/compiler": "20.3.18", "@angular/compiler": "20.3.19",
"@angular/core": "20.3.18", "@angular/core": "20.3.19",
"@angular/forms": "20.3.18", "@angular/forms": "20.3.19",
"@angular/material": "20.2.14", "@angular/material": "20.2.14",
"@angular/platform-browser": "20.3.18", "@angular/platform-browser": "20.3.19",
"@angular/platform-browser-dynamic": "20.3.18", "@angular/platform-browser-dynamic": "20.3.19",
"@angular/router": "20.3.18", "@angular/router": "20.3.19",
"@auth0/angular-jwt": "^5.2.0", "@auth0/angular-jwt": "^5.2.0",
"@flowjs/flow.js": "^2.14.1", "@flowjs/flow.js": "^2.14.1",
"@flowjs/ngx-flow": "20.0.2", "@flowjs/ngx-flow": "20.0.2",
"@geoman-io/leaflet-geoman-free": "2.18.3", "@geoman-io/leaflet-geoman-free": "2.19.3",
"@iplab/ngx-color-picker": "^20.0.0", "@iplab/ngx-color-picker": "^20.0.0",
"@mat-datetimepicker/core": "~16.0.1", "@mat-datetimepicker/core": "~16.0.1",
"@mdi/svg": "^7.4.47", "@mdi/svg": "^7.4.47",
@ -45,7 +45,7 @@
"angular2-hotkeys": "^16.0.1", "angular2-hotkeys": "^16.0.1",
"canvas-gauges": "^2.1.7", "canvas-gauges": "^2.1.7",
"core-js": "^3.48.0", "core-js": "^3.48.0",
"dayjs": "1.11.19", "dayjs": "1.11.20",
"echarts": "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz", "echarts": "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz",
"flot": "https://github.com/thingsboard/flot.git#0.9-work", "flot": "https://github.com/thingsboard/flot.git#0.9-work",
"flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master", "flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master",
@ -94,13 +94,13 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-esbuild": "20.0.0", "@angular-builders/custom-esbuild": "20.0.0",
"@angular-devkit/build-angular": "20.3.22", "@angular-devkit/build-angular": "20.3.24",
"@angular-devkit/core": "20.3.22", "@angular-devkit/core": "20.3.24",
"@angular-devkit/schematics": "20.3.22", "@angular-devkit/schematics": "20.3.24",
"@angular/build": "20.3.22", "@angular/build": "20.3.24",
"@angular/cli": "20.3.22", "@angular/cli": "20.3.24",
"@angular/compiler-cli": "20.3.18", "@angular/compiler-cli": "20.3.19",
"@angular/language-service": "20.3.18", "@angular/language-service": "20.3.19",
"@types/ace-diff": "^2.1.4", "@types/ace-diff": "^2.1.4",
"@types/canvas-gauges": "^2.1.8", "@types/canvas-gauges": "^2.1.8",
"@types/flot": "^0.0.36", "@types/flot": "^0.0.36",
@ -139,7 +139,7 @@
"ace-builds": "1.43.6", "ace-builds": "1.43.6",
"tinymce": "6.8.6", "tinymce": "6.8.6",
"@babel/core": "7.28.3", "@babel/core": "7.28.3",
"esbuild": "0.25.9", "esbuild": "0.28.0",
"rollup": "4.59.0", "rollup": "4.59.0",
"jquery.terminal/**/form-data": ">=4.0.4", "jquery.terminal/**/form-data": ">=4.0.4",
"js-beautify/**/minimatch": "^9.0.7" "js-beautify/**/minimatch": "^9.0.7"

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save