Browse Source

Merge branch 'lts-4.3' into feature/lts-4.3-iot-hub

pull/15646/head
Igor Kulikov 4 weeks ago
parent
commit
b4da3e06b8
  1. 3
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 3
      application/src/main/data/json/system/widget_bundles/html_widgets.json
  3. 52
      application/src/main/data/json/system/widget_types/html_container.json
  4. 2
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  5. 176
      application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
  6. 8
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  7. 9
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  8. 1
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  9. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
  10. 17
      application/src/main/resources/thingsboard.yml
  11. 51
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  12. 69
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  13. 78
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  14. 149
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
  15. 6
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  16. 349
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
  17. 246
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
  18. 8
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
  19. 20
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
  20. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
  21. 2
      common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
  22. 40
      common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
  23. 4
      common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
  24. 4
      common/queue/pom.xml
  25. 56
      common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java
  26. 160
      common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java
  27. 2
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java
  28. 13
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java
  29. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java
  30. 4
      common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java
  31. 60
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
  32. 29
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java
  33. 62
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  34. 106
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java
  35. 198
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java
  36. 106
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java
  37. 192
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java
  38. 41
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java
  39. 3
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java
  40. 51
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java
  41. 276
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java
  42. 197
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java
  43. 111
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java
  44. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java
  45. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java
  46. 5
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java
  47. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  48. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java
  49. 92
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java
  50. 14
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java
  51. 43
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java
  52. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java
  53. 30
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java
  54. 120
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  55. 299
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
  56. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  57. 8
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java
  58. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java
  59. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java
  60. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java
  61. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java
  62. 3
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java
  63. 1
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java
  64. 16
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java
  65. 7
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java
  66. 185
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java
  67. 277
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java
  68. 355
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
  69. 26
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java
  70. 4
      dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java
  71. 2
      dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java
  72. 25
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
  73. 25
      msa/js-executor/pom.xml
  74. 11
      msa/pom.xml
  75. 8
      msa/tb/docker-cassandra/Dockerfile
  76. 2
      msa/web-ui/pom.xml
  77. 6
      msa/web-ui/yarn.lock
  78. 75
      pom.xml
  79. 4
      rule-engine/rule-engine-components/pom.xml
  80. 4
      tools/pom.xml
  81. 9
      transport/coap/src/main/resources/tb-coap-transport.yml
  82. 9
      transport/http/src/main/resources/tb-http-transport.yml
  83. 9
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  84. 9
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  85. 36
      ui-ngx/package.json
  86. 0
      ui-ngx/patches/@angular+build+20.3.24.patch
  87. 2
      ui-ngx/patches/@angular+core+20.3.19.patch
  88. 2
      ui-ngx/pom.xml
  89. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  90. 20
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  91. 62
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  92. 2
      ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts
  93. 318
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  94. 68
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  95. 4
      ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts
  96. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts
  97. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts
  98. 12
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts
  99. 128
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  100. 128
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss

3
application/src/main/data/json/system/widget_bundles/cards.json

@ -24,6 +24,7 @@
"cards.html_value_card",
"cards.markdown_card",
"cards.simple_card",
"unread_notifications"
"unread_notifications",
"html_container"
]
}

3
application/src/main/data/json/system/widget_bundles/html_widgets.json

@ -11,6 +11,7 @@
"widgetTypeFqns": [
"cards.html_card",
"cards.html_value_card",
"cards.markdown_card"
"cards.markdown_card",
"html_container"
]
}

52
application/src/main/data/json/system/widget_types/html_container.json

File diff suppressed because one or more lines are too long

2
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java

@ -485,7 +485,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
state.init(false);
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isCfHasRelationPathQuerySource()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
@ -494,6 +493,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
Map<String, ArgumentEntry> arguments = fetchArguments(ctx);
state.update(arguments, ctx);
state.init(false);
state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
states.put(ctx.getCfId(), state);

176
application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java

@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.core.converter.ModelConverters;
import io.swagger.v3.core.converter.ResolvedSchema;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.oas.models.Components;
@ -166,6 +167,9 @@ public class SwaggerConfiguration {
if (StringUtils.isEmpty(apiVersion)) {
apiVersion = appVersion;
}
if (apiVersion != null && apiVersion.endsWith("-SNAPSHOT")) {
apiVersion = apiVersion.substring(0, apiVersion.length() - "-SNAPSHOT".length());
}
Info info = new Info()
.title(title)
@ -373,13 +377,26 @@ public class SwaggerConfiguration {
._enum(Arrays.stream(ThingsboardErrorCode.values())
.map(ThingsboardErrorCode::getErrorCode)
.collect(Collectors.toList()));
openAPI.getComponents()
.addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema)
.addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema)
.addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema)
.addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema)
.addSchemas("ThingsboardErrorCode", errorCodeSchema)
.addSchemas("AiChatModelConfig", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(AiChatModelConfig.class)).schema);
Components components = openAPI.getComponents();
registerSchema(components, "LoginRequest", LoginRequest.class);
registerSchema(components, "LoginResponse", LoginResponse.class);
registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class);
registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class);
components.addSchemas("ThingsboardErrorCode", errorCodeSchema);
registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class);
}
private static void registerSchema(Components components, String name, Class<?> cls) {
ResolvedSchema resolved = ModelConverters.getInstance()
.readAllAsResolvedSchema(new AnnotatedType().type(cls));
components.addSchemas(name, resolved.schema);
if (resolved.referencedSchemas != null) {
resolved.referencedSchemas.forEach((refName, refSchema) -> {
if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) {
components.addSchemas(refName, refSchema);
}
});
}
}
private OperationCustomizer operationCustomizer() {
@ -528,6 +545,12 @@ public class SwaggerConfiguration {
reorderSchemaProperties(schema, propOrder);
});
// Synthesize a request-body example for every schema that uses a discriminator.
// Without this, Swagger UI shows only the discriminator-property field for
// polymorphic types (the parent schema doesn't know which oneOf branch to pick).
// We resolve the first declared subtype and inline its full property tree.
schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas));
// Fix polymorphic request/response bodies: replace inline oneOf with base type $ref
paths.values().stream()
.flatMap(pathItem -> pathItem.readOperationsMap().values().stream())
@ -858,6 +881,145 @@ public class SwaggerConfiguration {
}
}
private static final int MAX_EXAMPLE_DEPTH = 4;
/**
* If {@code schema} has a discriminator, populate examples for the parent and every
* concrete subtype it maps to. Each subtype gets its own example with the discriminator
* field set to the mapping value that points at it, so fields typed as a specific
* subtype (e.g. {@code EntityView.id} {@code EntityViewId}) resolve to a correct
* example without falling back to the parent's.
*/
@SuppressWarnings("unchecked")
private void fillDiscriminatorExample(Schema<?> schema, Map<String, Schema> allSchemas) {
var discriminator = schema.getDiscriminator();
if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) {
return;
}
// 1. Populate an example on each mapped subtype.
for (var entry : discriminator.getMapping().entrySet()) {
String discriminatorValue = entry.getKey();
String subtypeRef = entry.getValue();
String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1);
Schema<?> subtype = allSchemas.get(subtypeName);
if (subtype == null || subtype.getExample() != null) {
continue;
}
Map<String, Object> example = new LinkedHashMap<>();
buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0);
if (example.isEmpty()) {
continue;
}
example.put(discriminator.getPropertyName(), discriminatorValue);
subtype.setExample(example);
}
// 2. Mirror a subtype's example onto the parent so a field typed as the parent
// interface still gets a complete example. Prefer the subtype whose mapping key
// matches the example declared on the discriminator property itself
// (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not
// the alphabetically first AdminSettingsId). Fall back to the first mapping entry.
if (schema.getExample() == null) {
String preferredValue = null;
if (schema.getProperties() != null) {
Schema<?> discProp = (Schema<?>) schema.getProperties().get(discriminator.getPropertyName());
if (discProp != null && discProp.getExample() != null) {
preferredValue = discProp.getExample().toString();
}
}
String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null;
if (chosenRef == null) {
chosenRef = discriminator.getMapping().values().iterator().next();
}
String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1);
Schema<?> chosenSubtype = allSchemas.get(chosenSubtypeName);
if (chosenSubtype != null && chosenSubtype.getExample() != null) {
schema.setExample(chosenSubtype.getExample());
}
}
}
@SuppressWarnings("unchecked")
private void buildSchemaExample(String schemaName, Map<String, Schema> allSchemas,
Map<String, Object> result, Set<String> visited, int depth) {
if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) {
return;
}
Schema<?> schema = allSchemas.get(schemaName);
if (schema == null) {
return;
}
// Walk parents first so own properties (added later) override inherited entries.
if (schema.getAllOf() != null) {
String selfRef = "#/components/schemas/" + schemaName;
for (Schema<?> allOfElement : schema.getAllOf()) {
String ref = allOfElement.get$ref();
if (ref != null) {
String refName = ref.substring(ref.lastIndexOf('/') + 1);
buildSchemaExample(refName, allSchemas, result, visited, depth);
// If the parent uses a discriminator, this schema is one of its mapping
// targets — override the discriminator field with the value that points
// back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS").
Schema<?> parentSchema = allSchemas.get(refName);
if (parentSchema != null && parentSchema.getDiscriminator() != null
&& parentSchema.getDiscriminator().getMapping() != null) {
parentSchema.getDiscriminator().getMapping().entrySet().stream()
.filter(e -> selfRef.equals(e.getValue()))
.map(Map.Entry::getKey)
.findFirst()
.ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value));
}
} else if (allOfElement.getProperties() != null) {
allOfElement.getProperties().forEach((k, v) ->
result.put(k, sampleValue((Schema<?>) v, allSchemas, visited, depth + 1)));
}
}
}
if (schema.getProperties() != null) {
schema.getProperties().forEach((k, v) ->
result.put(k, sampleValue((Schema<?>) v, allSchemas, visited, depth + 1)));
}
}
@SuppressWarnings("unchecked")
private Object sampleValue(Schema<?> propSchema, Map<String, Schema> allSchemas,
Set<String> visited, int depth) {
if (propSchema == null) {
return null;
}
if (propSchema.getExample() != null) {
return propSchema.getExample();
}
String ref = propSchema.get$ref();
if (ref != null) {
String refName = ref.substring(ref.lastIndexOf('/') + 1);
Schema<?> refSchema = allSchemas.get(refName);
if (refSchema != null && refSchema.getExample() != null) {
return refSchema.getExample();
}
if (depth >= MAX_EXAMPLE_DEPTH) {
return Map.of();
}
Map<String, Object> nested = new LinkedHashMap<>();
buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1);
return nested;
}
if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) {
return propSchema.getEnum().get(0);
}
String type = propSchema.getType();
if (type == null) {
return null;
}
return switch (type) {
case "string" -> "string";
case "integer", "number" -> 0;
case "boolean" -> false;
case "array" -> List.of();
case "object" -> Map.of();
default -> null;
};
}
@SuppressWarnings("unchecked")
private void deduplicateAllOfProperties(Schema<?> schema, Map<String, Schema> allSchemas, Set<String> ownProps) {
if (schema.getAllOf() == null) {

8
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java

@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override
public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) {
ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
ChatModel langChainChatModel;
try {
langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
} catch (Throwable t) {
return FluentFuture.from(Futures.immediateFailedFuture(t));
}
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}

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

@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey())
@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
validateBaseUrl(providerConfig.endpoint());
return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion())
@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId())
@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
return builder.build();
}
private static void validateBaseUrl(String url) {
SsrfProtectionValidator.validateUri(URI.create(url));
}
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}

1
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
ruleState.setActive(null);
AlarmCondition condition = rule.getCondition();
if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) {
ruleState.cancelDurationCheckFuture();
reevalNeeded.set(true);
}
}

12
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java

@ -256,10 +256,7 @@ public class AlarmRuleState {
firstEventTs = 0L;
lastCheckTs = 0L;
duration = 0L;
if (durationCheckFuture != null) {
durationCheckFuture.cancel(true);
durationCheckFuture = null;
}
cancelDurationCheckFuture();
}
public void setDurationCheckFuture(ScheduledFuture<?> durationCheckFuture) {
@ -270,6 +267,13 @@ public class AlarmRuleState {
this.durationCheckFuture = durationCheckFuture;
}
public void cancelDurationCheckFuture() {
if (durationCheckFuture != null) {
durationCheckFuture.cancel(true);
durationCheckFuture = null;
}
}
public boolean isEmpty() {
return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null;
}

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

@ -58,6 +58,14 @@ server:
http2:
# Enable/disable HTTP/2 support
enabled: "${HTTP2_ENABLED:true}"
# HTTP response compression
compression:
# Enable/disable HTTP response compression
enabled: "${HTTP_COMPRESSION_ENABLED:false}"
# Minimum size (in bytes) required for a response before compression is applied
min_response_size: "${HTTP_COMPRESSION_MIN_RESPONSE_SIZE:2048}"
# Comma-separated list of MIME types that should be compressed
mime_types: "${HTTP_COMPRESSION_MIME_TYPES:text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml}"
# Log errors with stacktrace when REST API throws an exception with the message "Please contact sysadmin"
log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}"
ws:
@ -1426,6 +1434,15 @@ transport:
branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.3.0}"
# Fetch frequency in hours for gateways dashboard repository
fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}"
ssl:
# SSL/TLS settings for the transport layer
certificate:
# X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
reload:
# Enable/disable automatic SSL certificates reload
enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
# Interval in seconds for certificate reload
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
coap:

51
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -401,6 +401,57 @@ public class AlarmRulesTest extends AbstractControllerTest {
});
}
@Test
public void testChangeDurationConditionFromStaticToDynamic() throws Exception {
Argument temperatureArgument = new Argument();
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
temperatureArgument.setDefaultValue("0");
Map<String, Argument> arguments = Map.of(
"temperature", temperatureArgument
);
long staticDurationMs = 5000L;
Map<AlarmSeverity, Condition> createRules = Map.of(
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, staticDurationMs)
);
AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
arguments, createRules, null);
CalculatedFieldId alarmRuleId = alarmRule.getId();
// post telemetry to trigger condition and wait for the static-phase eval to produce a debug event,
// which guarantees firstEventTs > 0 in AlarmRuleState before we trigger REINIT
postTelemetry(deviceId, "{\"temperature\":50}");
await().atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> getDebugEvents(alarmRuleId, 1),
events -> !events.isEmpty() && !events.get(0).getId().equals(latestEventId));
// update CF: add attribute argument and switch duration from static to dynamic
AlarmCalculatedFieldConfiguration configuration = alarmRule.getConfiguration();
Argument durationArgument = new Argument();
durationArgument.setRefEntityKey(new ReferencedEntityKey("durationThreshold",
ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
durationArgument.setDefaultValue("-1");
configuration.getArguments().put("durationThreshold", durationArgument);
DurationAlarmCondition durationCondition = (DurationAlarmCondition)
configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition();
durationCondition.setValue(new AlarmConditionValue<>(null, "durationThreshold"));
alarmRule = saveAlarmRule(alarmRule);
long dynamicDurationMs = 3000L;
postAttributes(deviceId, AttributeScope.SERVER_SCOPE,
"{\"durationThreshold\":" + dynamicDurationMs + "}");
checkAlarmResult(alarmRule, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
}
@Test
public void testCreateAlarm_currentOwnerArgument() throws Exception {
Argument temperatureArgument = new Argument();

69
application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java

@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.dto.TbChatRequest;
import org.thingsboard.server.common.data.ai.dto.TbChatResponse;
import org.thingsboard.server.common.data.ai.dto.TbContent;
import org.thingsboard.server.common.data.ai.dto.TbUserMessage;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest {
assertThat(updatedModel.getExternalId()).isNull();
}
@Test
public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception {
// GIVEN
loginTenantAdmin();
SsrfProtectionValidator.setEnabled(true);
try {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:22/")
.apiKey("test-api-key")
.build())
.modelId("gpt-4o")
.build();
AiModel model = AiModel.builder()
.tenantId(tenantId)
.name("SSRF test model")
.configuration(modelConfig)
.build();
// WHEN
ResultActions result = doPost("/api/ai/model", model);
// THEN
result.andExpect(status().isBadRequest());
} finally {
SsrfProtectionValidator.setEnabled(false);
}
}
@Test
public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception {
// GIVEN
loginTenantAdmin();
SsrfProtectionValidator.setEnabled(true);
try {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://10.0.0.1:8080/")
.apiKey("test-api-key")
.build())
.modelId("gpt-4o")
.build();
var chatRequest = new TbChatRequest(
null,
new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))),
modelConfig);
// WHEN
TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk());
// THEN
assertThat(response).isInstanceOf(TbChatResponse.Failure.class);
assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid");
} finally {
SsrfProtectionValidator.setEnabled(false);
}
}
/* --- Get by ID API tests --- */
@Test

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

@ -17,13 +17,25 @@ 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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils;
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.GoogleVertexAiGeminiChatModelConfig;
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.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.OpenAiProviderConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidator")
class Langchain4jChatModelConfigurerImplTest {
private static final String TEST_SERVICE_ACCOUNT_KEY = """
@ -41,6 +53,72 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
@BeforeEach
void enableSsrfProtection() {
SsrfProtectionValidator.setEnabled(true);
}
@AfterEach
void disableSsrfProtection() {
SsrfProtectionValidator.setEnabled(false);
}
@Test
void configureChatModel_openAi_withPrivateIp_shouldThrow() {
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/")
.apiKey("test")
.build())
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/")
.apiKey("test")
.build())
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_ollama_withPrivateIp_shouldThrow() {
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
// GIVEN

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

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

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

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

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

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

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

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

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

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

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

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

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java

@ -48,7 +48,7 @@ import java.util.UUID;
@DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class)
})
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class)
@JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class),

2
common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java

@ -50,7 +50,7 @@ import java.util.List;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class)
@JsonSubTypes({
@Type(name = "DEVICE", value = DeviceExportData.class),
@Type(name = "RULE_CHAIN", value = RuleChainExportData.class),

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

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

4
common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java

@ -17,7 +17,7 @@ package org.thingsboard.server.common.msg;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.crypto.digests.SHA3Digest;
import org.bouncycastle.pqc.legacy.math.linearalgebra.ByteUtils;
import org.bouncycastle.util.encoders.Hex;
/**
* @author Valerii Sosliuk
@ -66,7 +66,7 @@ public class EncryptionUtil {
md.update(dataBytes, 0, dataBytes.length);
byte[] hashedBytes = new byte[256 / 8];
md.doFinal(hashedBytes, 0);
String sha3Hash = ByteUtils.toHexString(hashedBytes);
String sha3Hash = Hex.toHexString(hashedBytes);
return sha3Hash;
}

4
common/queue/pom.xml

@ -68,10 +68,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-pubsub</artifactId>

56
common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java

@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT;
@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu
}
private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) {
Long lastProcessedTs = localCache.get(deduplicationKey);
if (lastProcessedTs == null && !onlyLocalCache) {
Cache externalCache = getExternalCache();
if (externalCache != null) {
lastProcessedTs = externalCache.get(deduplicationKey, Long.class);
} else {
log.warn("Sent notifications cache is not set up");
long deduplicationDuration = getDeduplicationDuration(trigger);
final long now = System.currentTimeMillis();
boolean[] result = {false};
localCache.compute(deduplicationKey, (key, lastProcessedTs) -> {
if (lastProcessedTs == null && !onlyLocalCache) {
Cache externalCache = getExternalCache();
if (externalCache != null) {
lastProcessedTs = externalCache.get(key, Long.class);
if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) {
log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future",
key, lastProcessedTs - now);
lastProcessedTs = null;
}
} else {
log.warn("Sent notifications cache is not set up");
}
}
}
boolean alreadyProcessed = false;
long deduplicationDuration = getDeduplicationDuration(trigger);
if (lastProcessedTs != null) {
long passed = System.currentTimeMillis() - lastProcessedTs;
log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms",
trigger.getType(), deduplicationKey, deduplicationDuration, passed);
if (deduplicationDuration == 0 || passed <= deduplicationDuration) {
alreadyProcessed = true;
if (lastProcessedTs != null) {
long passed = now - lastProcessedTs;
log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms",
trigger.getType(), key, deduplicationDuration, passed);
if (deduplicationDuration == 0 || passed <= deduplicationDuration) {
result[0] = true;
return lastProcessedTs;
}
}
}
if (!alreadyProcessed) {
lastProcessedTs = System.currentTimeMillis();
}
localCache.put(deduplicationKey, lastProcessedTs);
return now;
});
if (!onlyLocalCache) {
if (!alreadyProcessed || deduplicationDuration == 0) {
if (!result[0] || deduplicationDuration == 0) {
// if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl)
Cache externalCache = getExternalCache();
if (externalCache != null) {
externalCache.put(deduplicationKey, lastProcessedTs);
externalCache.put(deduplicationKey, now);
}
}
}
return alreadyProcessed;
return result[0];
}
public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) {

160
common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.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.queue.notification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.notification.rule.NotificationRule;
import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger;
import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DefaultNotificationDeduplicationServiceTest {
private static final int TIMEOUT = 30;
private DefaultNotificationDeduplicationService deduplicationService;
private CacheManager cacheManager;
@BeforeEach
void setUp() {
deduplicationService = new DefaultNotificationDeduplicationService();
deduplicationService.setDeduplicationDurations("");
cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE);
ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager);
}
@Test
void testFirstTriggerIsNotDeduplicated() {
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1));
NotificationRule rule = mockRule();
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse();
}
@Test
void testSecondTriggerIsDeduplicated() {
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1));
NotificationRule rule = mockRule();
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse();
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue();
}
@Test
void testTriggerPassesAfterDeduplicationWindowExpires() {
NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window
NotificationRule rule = mockRule();
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse();
try {
Thread.sleep(200); // wait well past the 50ms window
} catch (InterruptedException ignored) {}
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse();
}
@Test
void testFutureTimestampFromExternalCacheIsDiscarded() {
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1));
NotificationRule rule = mockRule();
String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule);
// Put a timestamp 2 hours in the future into external cache
Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE);
externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2));
// Should NOT be deduplicated — future timestamp must be discarded
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse();
}
@Test
void testValidTimestampFromExternalCacheIsDeduplicated() {
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1));
NotificationRule rule = mockRule();
String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule);
// Put a recent timestamp into external cache
Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE);
externalCache.put(dedupKey, System.currentTimeMillis());
// Should be deduplicated — valid external cache entry
assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue();
}
@Test
void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception {
NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1));
NotificationRule rule = mockRule();
int threadCount = 10;
CyclicBarrier barrier = new CyclicBarrier(threadCount);
List<Boolean> results = new CopyOnWriteArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
try {
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
barrier.await(TIMEOUT, TimeUnit.SECONDS);
} catch (Exception ignored) {}
results.add(deduplicationService.alreadyProcessed(trigger, rule));
});
}
executor.shutdown();
assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue();
assertThat(results).hasSize(threadCount);
assertThat(results.stream().filter(r -> !r).count())
.as("exactly one trigger should pass through deduplication")
.isEqualTo(1);
} finally {
executor.shutdownNow();
}
}
private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) {
NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class);
when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE);
when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key");
when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs);
when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING);
return trigger;
}
private NotificationRule mockRule() {
NotificationRule rule = mock(NotificationRule.class);
when(rule.getDeduplicationKey()).thenReturn("rule:key");
return rule;
}
}

2
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java

@ -73,7 +73,7 @@ public class TbelCfTsRollingArg implements TbelCfArg, Iterable<TbelCfTsDoubleVal
throw new IllegalArgumentException("Rolling argument values are empty.");
}
double max = Double.MIN_VALUE;
double max = -Double.MAX_VALUE;
for (TbelCfTsDoubleVal value : values) {
double val = value.getValue();
if (!ignoreNaN && Double.isNaN(val)) {

13
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java

@ -57,6 +57,19 @@ public class TbelCfTsRollingArgTest {
assertThat(rollingArg.max(false)).isNaN();
}
@Test
void testMaxOverAllNegativeValues() {
TbelCfTsRollingArg negativeArg = new TbelCfTsRollingArg(
new TbTimeWindow(ts - 30000, ts - 10),
List.of(
new TbelCfTsDoubleVal(ts - 10, -50.0),
new TbelCfTsDoubleVal(ts - 20, -100.0),
new TbelCfTsDoubleVal(ts - 30, -75.0)
)
);
assertThat(negativeArg.max()).isEqualTo(-50.0);
}
@Test
void testMin() {
assertThat(rollingArg.min()).isEqualTo(2.0);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

51
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java

@ -78,22 +78,47 @@ public class MqttTransportService implements TbTransportService {
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase()));
log.info("Starting MQTT transport...");
bossGroup = new NioEventLoopGroup(bossGroupThreadCount);
workerGroup = new NioEventLoopGroup(workerGroupThreadCount);
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new MqttTransportServerInitializer(context, false))
.childOption(ChannelOption.SO_KEEPALIVE, keepAlive);
serverChannel = b.bind(host, port).sync().channel();
if (sslEnabled) {
b = new ServerBootstrap();
try {
bossGroup = new NioEventLoopGroup(bossGroupThreadCount);
workerGroup = new NioEventLoopGroup(workerGroupThreadCount);
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new MqttTransportServerInitializer(context, true))
.childHandler(new MqttTransportServerInitializer(context, false))
.childOption(ChannelOption.SO_KEEPALIVE, keepAlive);
sslServerChannel = b.bind(sslHost, sslPort).sync().channel();
serverChannel = b.bind(host, port).sync().channel();
if (sslEnabled) {
b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new MqttTransportServerInitializer(context, true))
.childOption(ChannelOption.SO_KEEPALIVE, keepAlive);
sslServerChannel = b.bind(sslHost, sslPort).sync().channel();
}
} catch (Exception e) {
log.error("Failed to start MQTT transport, releasing resources", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
try {
if (serverChannel != null) {
serverChannel.close().sync();
}
if (sslServerChannel != null) {
sslServerChannel.close().sync();
}
} catch (Exception suppressed) {
e.addSuppressed(suppressed);
} finally {
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
}
throw e;
}
log.info("Mqtt transport started!");
}

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

111
common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java

@ -0,0 +1,111 @@
/**
* 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.channel.Channel;
import io.netty.channel.EventLoopGroup;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.net.BindException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
public class MqttTransportServiceTest {
private static final String HOST = "127.0.0.1";
private MqttTransportService service;
private ServerSocket occupiedSocket;
private int occupiedPort;
@BeforeEach
public void setUp() throws Exception {
occupiedSocket = new ServerSocket(0, 50, InetAddress.getByName(HOST));
occupiedPort = occupiedSocket.getLocalPort();
service = new MqttTransportService();
ReflectionTestUtils.setField(service, "host", HOST);
ReflectionTestUtils.setField(service, "port", occupiedPort);
ReflectionTestUtils.setField(service, "sslEnabled", false);
ReflectionTestUtils.setField(service, "sslHost", HOST);
ReflectionTestUtils.setField(service, "sslPort", 0);
ReflectionTestUtils.setField(service, "leakDetectorLevel", "DISABLED");
ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1);
ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1);
ReflectionTestUtils.setField(service, "keepAlive", true);
ReflectionTestUtils.setField(service, "context", mock(MqttTransportContext.class));
}
@AfterEach
public void tearDown() throws Exception {
if (occupiedSocket != null && !occupiedSocket.isClosed()) {
occupiedSocket.close();
}
}
@Test
public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() {
assertThatThrownBy(() -> service.init())
.isInstanceOf(BindException.class);
EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup");
EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup");
assertThat(boss).isNotNull();
assertThat(worker).isNotNull();
assertThat(boss.isShuttingDown()).isTrue();
assertThat(worker.isShuttingDown()).isTrue();
await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated);
await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated);
}
@Test
public void whenSslBindFailsAfterPlainBound_thenInitThrowsAndClosesPlainChannelAndReleasesNettyResources() {
ReflectionTestUtils.setField(service, "port", 0);
ReflectionTestUtils.setField(service, "sslEnabled", true);
ReflectionTestUtils.setField(service, "sslPort", occupiedPort);
assertThatThrownBy(() -> service.init())
.isInstanceOf(BindException.class);
Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel");
Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel");
EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup");
EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup");
assertThat(serverChannel).isNotNull();
assertThat(sslServerChannel).isNull();
assertThat(boss).isNotNull();
assertThat(worker).isNotNull();
await().atMost(10, TimeUnit.SECONDS).until(() -> !serverChannel.isOpen());
assertThat(boss.isShuttingDown()).isTrue();
assertThat(worker.isShuttingDown()).isTrue();
await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated);
await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

26
dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java

@ -17,13 +17,19 @@ package org.thingsboard.server.dao.service.validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelDao;
import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService;
import java.net.URI;
import java.util.Optional;
@Component
@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator<AiModel> {
if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model reference a non-existent tenant!");
}
// provider URL SSRF validation
if (model.getConfiguration() != null) {
AiProviderConfig providerConfig = model.getConfiguration().providerConfig();
String url = null;
if (providerConfig instanceof OpenAiProviderConfig c) {
url = c.baseUrl();
} else if (providerConfig instanceof AzureOpenAiProviderConfig c) {
url = c.endpoint();
} else if (providerConfig instanceof OllamaProviderConfig c) {
url = c.baseUrl();
}
if (url != null) {
try {
SsrfProtectionValidator.validateUri(URI.create(url));
} catch (Exception e) {
throw new DataValidationException("AI model provider URL is not allowed: " + e.getMessage());
}
}
}
}
}

4
dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java

@ -61,8 +61,10 @@ public interface TsKvRepository extends JpaRepository<TsKvEntity, TsKvCompositeK
@Param("startTs") long startTs,
@Param("endTs") long endTs);
// -1.7976931348623157E308 = -Double.MAX_VALUE — the most negative finite double, used as a "less than any value" sentinel for MAX.
// Double.MIN_VALUE is +4.9E-324 (smallest positive), which would beat any negative real value and corrupt MAX.
@Query("SELECT new TsKvEntity(MAX(COALESCE(tskv.longValue, -9223372036854775807)), " +
"MAX(COALESCE(tskv.doubleValue, java.lang.Double.MIN_VALUE)), " +
"MAX(COALESCE(tskv.doubleValue, -1.7976931348623157E308)), " +
"SUM(CASE WHEN tskv.longValue IS NULL THEN 0 ELSE 1 END), " +
"SUM(CASE WHEN tskv.doubleValue IS NULL THEN 0 ELSE 1 END), " +
"'MAX', MAX(tskv.ts)) FROM TsKvEntity tskv " +

2
dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java

@ -277,7 +277,7 @@ public class AggregatePartitionsFunction implements com.google.common.util.concu
private Optional<TsKvEntry> processMinOrMaxResult(AggregationResult aggResult) {
if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) {
if (aggResult.hasDouble) {
double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(Double.MIN_VALUE);
double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(-Double.MAX_VALUE);
double currentL = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.lValue).orElse(Long.MAX_VALUE) : Optional.ofNullable(aggResult.lValue).orElse(Long.MIN_VALUE);
return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL))));
} else {

25
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java

@ -710,6 +710,31 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue());
}
@Test
public void testFindDeviceMaxAggregationOverNegativeMixedLongAndDoubleTsData() throws Exception {
save(deviceId, 5000, -100L);
save(deviceId, 15000, -50.0);
List<TsKvEntry> list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0,
60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS);
assertEquals(1, list.size());
assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue());
}
@Test
public void testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception {
save(deviceId, 5000, -50.0);
save(deviceId, 15000, -100.0);
save(deviceId, 25000, -75.0);
List<TsKvEntry> list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0,
60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS);
assertEquals(1, list.size());
assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue());
}
@Test
public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception {
save(deviceId, 2000000L, 95);

25
msa/js-executor/pom.xml

@ -52,6 +52,29 @@
<type>exe</type>
<scope>provided</scope>
</dependency>
<!--
Reactor-only ordering dep (NOT a real classpath dependency).
Forces `mvn -T<n>` to serialize this module after web-ui so that
no two `yarn install` / `yarn run pkg` invocations can overlap on
the same agent. Concurrent yarn 1.x processes share ~/.cache/yarn
and have intermittently produced `tsc: not found` failures during
yarn pkg (incomplete typescript extraction in node_modules).
The chain is: ui-ngx -> msa/web-ui -> msa/js-executor.
type=pom + provided + wildcard exclusions keep nothing on the classpath.
-->
<dependency>
<groupId>org.thingsboard.msa</groupId>
<artifactId>web-ui</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
@ -90,7 +113,7 @@
</goals>
<phase>compile</phase>
<configuration>
<arguments>run pkg</arguments>
<arguments>--mutex network run pkg</arguments>
</configuration>
</execution>
</executions>

11
msa/pom.xml

@ -44,7 +44,16 @@
</properties>
<modules>
<!--Modules order is important to speedup parallel build and avoid yarn pgk parallel execution-->
<!--
Module order below is informational only. Yarn-using modules
(web-ui, js-executor) are serialized via reactor-only
<dependency> entries in their own poms, forming the chain
ui-ngx -> web-ui -> js-executor. This prevents
concurrent yarn install / yarn run pkg invocations under `mvn -T<n>`,
which previously caused intermittent `tsc: not found` failures
(incomplete typescript extraction in node_modules from racing
yarn 1.x processes against the shared ~/.cache/yarn).
-->
<module>tb</module>
<module>web-ui</module>
<module>vc-executor</module>

8
msa/tb/docker-cassandra/Dockerfile

@ -42,6 +42,10 @@ ENV CASSANDRA_LOG=/var/log/cassandra
COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/
# Keep base image's customized conffiles (e.g. /etc/java-17-openjdk/security/java.security)
# when apt upgrades openjdk-17-jre-headless transitively as cassandra's java11-runtime provider;
# without this dpkg blocks on a non-interactive conffile prompt and the build fails.
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget nmap procps gnupg2 \
&& echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \
@ -49,7 +53,9 @@ RUN apt-get update \
&& echo "deb https://debian.cassandra.apache.org 40x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \
&& wget -q https://downloads.apache.org/cassandra/KEYS -O- | apt-key add - \
&& apt-get update \
&& apt-get install -y --no-install-recommends cassandra cassandra-tools postgresql-${PG_MAJOR} \
&& apt-get install -y --no-install-recommends \
-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \
cassandra cassandra-tools postgresql-${PG_MAJOR} \
&& rm -rf /var/lib/apt/lists/* \
&& update-rc.d cassandra disable \
&& update-rc.d postgresql disable \

2
msa/web-ui/pom.xml

@ -99,7 +99,7 @@
</goals>
<phase>compile</phase>
<configuration>
<arguments>run pkg</arguments>
<arguments>--mutex network run pkg</arguments>
</configuration>
</execution>
</executions>

6
msa/web-ui/yarn.lock

@ -774,9 +774,9 @@ fn.name@1.x.x:
integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
follow-redirects@^1.0.0:
version "1.15.11"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
forwarded@0.2.0:
version "0.2.0"

75
pom.xml

@ -62,17 +62,21 @@
<pkg.implementationTitle>${project.name}</pkg.implementationTitle>
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<thingsboard.client.version>4.3.1.2-SNAPSHOT</thingsboard.client.version>
<spring-boot.version>3.5.13</spring-boot.version>
<thingsboard.client.version>4.3.1.2</thingsboard.client.version>
<spring-boot.version>3.5.14</spring-boot.version>
<!-- TODO: remove spring-boot-test.version override and the matching dependencyManagement entries below
once Spring Boot 3.5.15+ is released with a fix for the ImportsContextCustomizer regression in 3.5.14
that causes "Duplicate spy definition" failures on legacy @SpyBean fields (see PR #15557). -->
<spring-boot-test.version>3.5.13</spring-boot-test.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<postgresql.version>42.7.11</postgresql.version> <!-- to fix CVE-2026-42198. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
<cassandra.version>4.17.0</cassandra.version>
<metrics.version>4.2.25</metrics.version>
<cassandra-all.version>5.0.4</cassandra-all.version> <!-- tools -->
<cassandra-all.version>5.0.7</cassandra-all.version> <!-- tools; 5.0.7 fixes CVE-2026-27314 -->
<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-io.version>2.16.1</commons-io.version>
<commons-logging.version>1.3.1</commons-logging.version>
<commons-csv.version>1.10.0</commons-csv.version>
@ -90,7 +94,7 @@
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.44</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<lombok.version>1.18.46</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -103,7 +107,7 @@
<swagger-annotations.version>2.2.30</swagger-annotations.version>
<spatial4j.version>0.8</spatial4j.version>
<jts.version>1.19.0</jts.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bouncycastle.version>1.84</bouncycastle.version> <!-- 1.84 fixes CVE-2026-5588, CVE-2026-5598, CVE-2025-14813 -->
<winsw.version>2.0.1</winsw.version>
<sonar.exclusions>org/thingsboard/server/gen/**/*,
org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/*
@ -113,8 +117,7 @@
<!-- IMPORTANT: If you change the version of the kafka client, make sure to synchronize our overwritten implementation of the
org.apache.kafka.common.network.NetworkReceive class in the application module. It addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090.
Here is the source to track https://github.com/apache/kafka/tree/trunk/clients/src/main/java/org/apache/kafka/common/network -->
<kafka.version>3.9.1</kafka.version>
<lz4.version>1.10.1</lz4.version> <!-- to fix CVE-2025-12183 and CVE-2025-66566 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped -->
<kafka.version>3.9.2</kafka.version> <!-- to fix CVE-2026-35554 -->
<bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version>
<aws.sdk.version>1.12.701</aws.sdk.version>
@ -1003,25 +1006,6 @@
<dependencyManagement>
<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>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
@ -1274,21 +1258,23 @@
</exclusion>
</exclusions>
</dependency>
<!-- TODO: remove these two pins once Spring Boot 3.5.15+ ships the fix for the
ImportsContextCustomizer regression in 3.5.14 (see PR #15557). Test artifacts are not
packaged in the runtime image, so pinning them does not affect the CVE fixes. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>${kafka.version}</version>
<exclusions>
<exclusion>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
<version>${lz4.version}</version> <!-- to fix CVE introduced through kafka-clients 3.9.1 -->
</dependency>
<dependency>
<groupId>com.github.springtestdbunit</groupId>
@ -1363,6 +1349,11 @@
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
@ -1564,12 +1555,6 @@
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId>
<version>${cassandra-all.version}</version>
<exclusions>
<exclusion>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.testng</groupId>

4
rule-engine/rule-engine-components/pom.xml

@ -96,10 +96,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-sns</artifactId>

4
tools/pom.xml

@ -73,10 +73,6 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>

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

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

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

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

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

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

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

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

36
ui-ngx/package.json

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

0
ui-ngx/patches/@angular+build+20.3.22.patch → ui-ngx/patches/@angular+build+20.3.24.patch

2
ui-ngx/patches/@angular+core+20.3.18.patch → ui-ngx/patches/@angular+core+20.3.19.patch

@ -1,5 +1,5 @@
diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs
index 35c61af..d89462b 100755
index 4f7d936..4a98b2c 100755
--- a/node_modules/@angular/core/fesm2022/debug_node.mjs
+++ b/node_modules/@angular/core/fesm2022/debug_node.mjs
@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) {

2
ui-ngx/pom.xml

@ -106,7 +106,7 @@
<goal>yarn</goal>
</goals>
<configuration>
<arguments>run build:prod</arguments>
<arguments>--mutex network run build:prod</arguments>
</configuration>
</execution>
</executions>

9
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -150,6 +150,9 @@ import {
ValueStepperBasicConfigComponent
} from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component';
import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component';
import {
HtmlContainerBasicConfigComponent
} from '@home/components/widget/config/basic/html/html-container-basic-config.component';
@NgModule({
declarations: [
@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
ScadaSymbolBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
],
imports: [
CommonModule,
@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
]
})
export class BasicWidgetConfigModule {

20
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetConfigForm">
<tb-html-container-settings formControlName="settings"></tb-html-container-settings>
</ng-container>

62
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts

@ -0,0 +1,62 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings
} from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-basic-config',
templateUrl: './html-container-basic-config.component.html',
styleUrls: ['../basic-config.scss'],
standalone: false
})
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent {
@HostBinding('style.height') height = '100%';
htmlContainerWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.htmlContainerWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})};
this.htmlContainerWidgetConfigForm = this.fb.group({
settings: [settings, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
return this.widgetConfig;
}
}

2
ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts

@ -61,6 +61,6 @@ export class DisplayColumnsPanelComponent {
}
public update() {
this.data.columnsUpdated(this.columns);
this.data.columnsUpdated(this.data.columns);
}
}

318
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts

@ -0,0 +1,318 @@
///
/// 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.
///
import {
Component,
ElementRef,
Inject,
Injector,
Input,
OnInit,
Optional,
Type,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType,
WidgetContainerAngularFunction,
WidgetContainerPlainFunction
} from '@home/components/widget/lib/html/html-container-widget.models';
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils';
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs';
import cssjs from '@core/css/css';
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service';
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { ExceptionData } from '@shared/models/error.models';
import { UtilsService } from '@core/services/utils.service';
import {
flatModulesWithComponents,
ModulesWithComponents,
modulesWithComponentsToTypes,
ResourcesService
} from '@core/services/resources.service';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
@Component({
selector: 'tb-html-container-widget',
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' +
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' +
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' +
'</div> }',
styles: '.tb-widget-error {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' background: rgba(255, 255, 255, .5);\n' +
'\n' +
' span {\n' +
' color: #f00;\n' +
' }\n' +
' }',
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class HtmlContainerWidgetComponent implements OnInit {
@ViewChild('container', {static: true})
containerElmRef: ElementRef<HTMLElement>;
@ViewChild('angularContainer', {static: true})
angularContainer: TbAnchorComponent;
@Input()
ctx: WidgetContext;
private containerInstanceComponentType: Type<any>;
private settings: HtmlContainerWidgetSettings;
widgetErrorData: ExceptionData;
constructor(private elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap,
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>,
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>,
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService,
private utils: UtilsService,
private resources: ResourcesService) {}
ngOnInit(): void {
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})};
this.loadWidgetResources().subscribe(
{
next: () => {
if (this.settings.type === HtmlContainerWidgetType.PLAIN) {
this.initPlain();
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) {
this.initAngular();
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private initPlain(): void {
try {
if (isNotEmptyStr(this.settings.css)) {
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'html-container-' + hashCode(this.settings.css);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, this.settings.css);
$(this.elementRef.nativeElement).addClass(namespace);
}
if (isNotEmptyStr(this.settings.html)) {
$(this.containerElmRef.nativeElement).html(this.settings.html);
}
this.compileAndExecutePlainFunction();
} catch (e) {
this.handleWidgetException(e);
}
}
private compileAndExecutePlainFunction(): void {
if (isNotEmptyTbFunction(this.settings.js)) {
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']);
jsFunction.subscribe({
next: (containerFunction) => {
try {
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
}
private initAngular(): void {
this.loadAngularModules().subscribe(
{
next: (imports) => {
this.compileAngularFunction().subscribe(
{
next: (containerFunction) => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> {
if (isNotEmptyTbFunction(this.settings.js)) {
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']);
} else {
return of(null);
}
}
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void {
this.angularContainer.viewContainerRef.clear();
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this);
const template = this.settings.html || '';
const styles: string[] = [];
if (isNotEmptyStr(this.settings.css)) {
styles.push(this.settings.css);
}
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule];
if (imports && imports.length) {
compileModules = compileModules.concat(imports);
}
const self = () => this;
this.dynamicComponentFactoryService.createDynamicComponent(
class TbContainerInstance {
ngOnInit(): void {
if (containerFunction) {
const instance = self();
try {
containerFunction.apply(this, [instance.ctx]);
} catch (e) {
instance.handleWidgetException(e);
}
}
}
ngOnDestroy(): void {
destroyContainerInstanceResources();
}
},
template,
compileModules,
true, styles
).subscribe({
next: (componentType) => {
this.containerInstanceComponentType = componentType;
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector});
try {
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
{index: 0, injector});
} catch (error) {
this.handleWidgetException(error);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
private destroyContainerInstanceResources() {
if (this.containerInstanceComponentType) {
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType);
this.containerInstanceComponentType = null;
}
}
private handleWidgetException(e: any) {
console.error(e);
this.widgetErrorData = this.utils.processWidgetException(e);
this.ctx.detectChanges();
}
private loadWidgetResources(): Observable<any> {
const resourceTasks: Observable<string>[] = [];
this.settings.resources.filter(r => !r.isModule).forEach(
(resource) => {
resourceTasks.push(
this.resources.loadResource(resource.url).pipe(
catchError(() => of(`Failed to load widget resource: '${resource.url}'`))
)
);
}
);
if (resourceTasks.length) {
return forkJoin(resourceTasks).pipe(
switchMap(msgs => {
let errors: string[];
if (msgs && msgs.length) {
errors = msgs.filter(msg => msg && msg.length > 0);
}
if (errors && errors.length) {
return throwError(() => new Error(errors.join('<br/>')));
} else {
return of(null);
}
}
));
} else {
return of(null);
}
}
private loadAngularModules(): Observable<Type<any>[]> {
const modulesTasks: Observable<ModulesWithComponents | string>[] = [];
this.settings.resources.filter(r => r.isModule).forEach(
(resource) => {
modulesTasks.push(
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe(
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`))
)
);
}
);
if (modulesTasks.length) {
return forkJoin(modulesTasks).pipe(
map(res => {
const msg = res.find(r => typeof r === 'string');
if (msg) {
return msg as string;
} else {
const modulesWithComponentsList = res as ModulesWithComponents[];
return flatModulesWithComponents(modulesWithComponentsList);
}
}),
switchMap(modulesWithComponentsList => {
if (typeof modulesWithComponentsList === 'string') {
return throwError(() => new Error(modulesWithComponentsList));
} else {
const modules = modulesWithComponentsToTypes(modulesWithComponentsList);
return of(modules);
}
})
);
} else {
return of(null);
}
}
}

68
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts

@ -0,0 +1,68 @@
///
/// 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.
///
import { TbFunction } from '@shared/models/js-function.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models';
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models';
import { WidgetResource } from '@shared/models/widget.models';
export enum HtmlContainerWidgetType {
PLAIN = 'PLAIN',
ANGULAR = 'ANGULAR'
}
export interface HtmlContainerWidgetSettings {
type: HtmlContainerWidgetType;
html: string;
css: string;
js: TbFunction;
resources: WidgetResource[];
}
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
type: HtmlContainerWidgetType.PLAIN,
html: '',
css: '',
js: '',
resources: [],
};
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
const containerFunctionCompletions: TbEditorCompletions = {
...{
ctx: {
meta: 'argument',
type: widgetContextCompletions.ctx.type,
description: widgetContextCompletions.ctx.description,
children: widgetContextCompletions.ctx.children
}
}
};
export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions);
export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter(
{...containerFunctionCompletions,
container: {
meta: 'argument',
type: 'HTMLElement',
description: 'Container element of the widget'
}}
);

4
ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts

@ -289,7 +289,7 @@ export default abstract class LeafletMap {
}
private toggleDrawMode(type: string) {
this.map.pm.Draw[type].toggle();
(this.map.pm.Draw[type] as any).toggle();
}
addEditControl() {
@ -373,7 +373,7 @@ export default abstract class LeafletMap {
},
// @ts-ignore
afterClick: (e, ctx) => {
this.map.pm.Draw[ctx.button._button.jsClass].toggle({
(this.map.pm.Draw[ctx.button._button.jsClass] as any).toggle({
snappable: this.options.snappable,
cursorMarker: true,
allowSelfIntersection: false,

3
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts

@ -100,8 +100,9 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem<CirclesDataLayerSettin
});
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.updateCircleShape(data);
this.updateLabel(data, dsData);
}
protected addItemClass(clazz: string): void {

3
ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts

@ -105,8 +105,9 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem<PolygonsDataLayerSett
});
}
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, _dsData: FormattedData<TbMapDatasource>[]): void {
protected doInvalidateCoordinates(data: FormattedData<TbMapDatasource>, dsData: FormattedData<TbMapDatasource>[]): void {
this.updatePolygonShape(data);
this.updateLabel(data, dsData);
}
protected addItemClass(clazz: string): void {

12
ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts

@ -28,7 +28,7 @@ import { Circle, Effect, Element, G, Gradient, Path, Runner, Svg, Text, Timeline
import '@svgdotjs/svg.filter.js';
import tinycolor from 'tinycolor2';
import { WidgetContext } from '@home/models/widget-component.models';
import { Observable, of, shareReplay } from 'rxjs';
import { from, Observable, of, shareReplay } from 'rxjs';
import { isSvgIcon, splitIconName } from '@shared/models/icon.models';
import { catchError, map, take } from 'rxjs/operators';
import { MatIconRegistry } from '@angular/material/icon';
@ -392,7 +392,15 @@ export abstract class PowerButtonShape {
tspan.attr({
'dominant-baseline': 'hanging'
});
return of(textElement);
return from(document.fonts.ready).pipe(
map(() => {
const iconGroup = this.svgShape.group();
textElement.addTo(iconGroup);
const box = iconGroup.bbox();
iconGroup.translate(-box.cx, -box.cy);
return iconGroup;
})
);
}
}

128
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html

@ -0,0 +1,128 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerSettingsForm">
<div class="tb-form-panel no-padding no-border relative h-full">
<div class="flex flex-row items-center">
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-html-container-settings-panel flex flex-1 flex-col" tb-fullscreen [fullscreen]="fullscreen">
<div class="tb-action-expand-button flex flex-row items-center justify-end">
<button mat-stroked-button
matTooltip="{{ 'widget.toggle-fullscreen' | translate }}"
matTooltipPosition="above"
(click)="toggleFullScreen()">
<mat-icon>{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
<span>{{ (fullscreen ? 'fullscreen.exit' : 'fullscreen.fullscreen') | translate }}</span>
</button>
</div>
<div class="flex flex-1">
<mat-tab-group #leftPanel
[mat-stretch-tabs]="fullscreen"
[selectedIndex]="fullscreen ? 2 : 3"
[animationDuration]="tabsAnimationDuration"
[disablePagination]="fullscreen"
class="tb-content"
[class.flex-1]="!fullscreen">
<mat-tab #resourceTab="matTab">
<ng-template mat-tab-label>
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourceTab.isActive || !resourcesFormArray.length"
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div>
</ng-template>
<div class="flex flex-col gap-2 pt-4" [class.px-2]="fullscreen">
@if (resourcesFormArray.length) {
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) {
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl">
<tb-resource-autocomplete class="flex-1"
formControlName="url"
inlineField
hideRequiredMarker required
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
@if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) {
<mat-checkbox formControlName="isModule">
{{ 'widget.resource-is-extension' | translate }}
</mat-checkbox>
}
<button mat-icon-button color="primary"
(click)="removeResource(i)"
matTooltip="{{'widget.remove-resource' | translate}}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
}
} @else {
<span translate
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span>
}
<div>
<button mat-raised-button color="primary"
(click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above">
<span translate>action.add</span>
</button>
</div>
</div>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.css' | translate }}">
<tb-css class="flex-1"
[fillHeight]="true"
formControlName="css"
label="{{ 'widgets.html-container.css' | translate }}">
</tb-css>
</mat-tab>
<mat-tab label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
<tb-html class="flex-1"
[fillHeight]="true"
formControlName="html"
label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
</tb-html>
</mat-tab>
@if (!fullscreen) {
<mat-tab label="{{ 'widgets.html-container.java-script' | translate }}">
<ng-container *ngTemplateOutlet="javascript"></ng-container>
</mat-tab>
}
</mat-tab-group>
<div #rightPanel class="tb-content flex" [class.!hidden]="!fullscreen">
@if (fullscreen) {
<ng-container *ngTemplateOutlet="javascript"></ng-container>
}
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #javascript>
<ng-container [formGroup]="htmlContainerSettingsForm">
<tb-js-func class="flex-1"
[fillHeight]="true"
formControlName="js"
[globalVariables]="functionScopeVariables"
[editorCompleter]="containerFunctionEditorCompleter"
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']"
withModules
functionTitle="{{ 'widgets.html-container.js-function' | translate }}">
</tb-js-func>
</ng-container>
</ng-template>

128
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss

@ -0,0 +1,128 @@
/**
* 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.
*/
.tb-html-container-settings {
height: 100%;
}
.tb-html-container-settings .tb-html-container-settings-panel, .tb-html-container-settings-panel {
position: relative;
background: #fff;
.mat-mdc-tab-body-wrapper {
position: relative;
top: 0;
flex: 1;
}
.tb-action-expand-button {
position: absolute;
top: 4px;
right: 0;
z-index: 2;
}
.gutter {
display: none;
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
&.gutter-horizontal {
cursor: col-resize;
background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png");
}
}
.tb-js-func {
&:not(.tb-fullscreen) {
&.tb-hide-brackets {
padding-bottom: 0;
}
}
}
.tb-html {
position: relative;
&:not(.tb-fullscreen) {
padding-bottom: 0;
}
.tb-html-toolbar {
position: absolute;
top: 0;
right: 8px;
z-index: 8;
.tb-title {
display: none;
}
}
.tb-html-content-panel {
border-top: none;
height: 100%;
}
}
.tb-css {
position: relative;
&:not(.tb-fullscreen) {
.tb-css-content-panel {
margin: 0;
}
}
.tb-css-toolbar {
position: absolute;
top: 0;
right: 8px;
z-index: 8;
.tb-title {
display: none;
}
}
.tb-css-content-panel {
border-top: none;
height: 100%;
}
}
&.tb-fullscreen {
padding: 8px;
gap: 8px;
.tb-action-expand-button {
position: relative;
top: 0;
right: 0;
}
.gutter {
display: block;
}
.tb-content {
border: 1px solid #c0c0c0;
.tb-html {
.tb-html-content-panel {
border: none;
}
}
.tb-css {
.tb-css-content-panel {
border: none;
}
}
.tb-js-func {
padding-top: 8px;
.tb-js-func-toolbar {
padding: 0 5px;
}
.tb-js-func-panel {
border-left: none;
border-right: none;
border-bottom: none;
}
}
}
}
}

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

Loading…
Cancel
Save