Browse Source

Merge branch 'lts-4.3' into release-4.3

# Conflicts:
#	application/pom.xml
#	common/actor/pom.xml
#	common/cache/pom.xml
#	common/cluster-api/pom.xml
#	common/coap-server/pom.xml
#	common/dao-api/pom.xml
#	common/data/pom.xml
#	common/discovery-api/pom.xml
#	common/edge-api/pom.xml
#	common/edqs/pom.xml
#	common/message/pom.xml
#	common/pom.xml
#	common/proto/pom.xml
#	common/queue/pom.xml
#	common/script/pom.xml
#	common/script/remote-js-client/pom.xml
#	common/script/script-api/pom.xml
#	common/stats/pom.xml
#	common/transport/coap/pom.xml
#	common/transport/http/pom.xml
#	common/transport/lwm2m/pom.xml
#	common/transport/mqtt/pom.xml
#	common/transport/pom.xml
#	common/transport/snmp/pom.xml
#	common/transport/transport-api/pom.xml
#	common/util/pom.xml
#	common/version-control/pom.xml
#	dao/pom.xml
#	edqs/pom.xml
#	monitoring/pom.xml
#	msa/black-box-tests/pom.xml
#	msa/edqs/pom.xml
#	msa/js-executor/pom.xml
#	msa/monitoring/pom.xml
#	msa/pom.xml
#	msa/tb-node/pom.xml
#	msa/tb/pom.xml
#	msa/transport/coap/pom.xml
#	msa/transport/http/pom.xml
#	msa/transport/lwm2m/pom.xml
#	msa/transport/mqtt/pom.xml
#	msa/transport/pom.xml
#	msa/transport/snmp/pom.xml
#	msa/vc-executor-docker/pom.xml
#	msa/vc-executor/pom.xml
#	msa/web-ui/pom.xml
#	netty-mqtt/pom.xml
#	pom.xml
#	rest-client/pom.xml
#	rule-engine/pom.xml
#	rule-engine/rule-engine-api/pom.xml
#	rule-engine/rule-engine-components/pom.xml
#	tools/pom.xml
#	transport/coap/pom.xml
#	transport/http/pom.xml
#	transport/lwm2m/pom.xml
#	transport/mqtt/pom.xml
#	transport/pom.xml
#	transport/snmp/pom.xml
#	ui-ngx/pom.xml
release-4.3
Viacheslav Klimov 6 months ago
parent
commit
35b2cd4664
Failed to extract signature
  1. 3
      .gitignore
  2. 16
      TEST_FAST.md
  3. 7
      application/pom.xml
  4. 10
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  5. 64
      application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java
  6. 56
      application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java
  7. 12
      application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java
  8. 19
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  9. 19
      application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java
  10. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
  11. 4
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  12. 4
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
  13. 40
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  14. 11
      application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java
  15. 4
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java
  16. 23
      application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java
  17. 4
      application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java
  18. 4
      application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java
  19. 75
      application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java
  20. 94
      application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java
  21. 92
      application/src/main/resources/thingsboard.yml
  22. 11
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  23. 12
      application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
  24. 21
      application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
  25. 71
      application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
  26. 272
      application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
  27. 8
      application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
  28. 10
      application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java
  29. 144
      application/src/test/java/org/thingsboard/server/service/ttl/NotificationsCleanUpServiceTest.java
  30. 136
      application/src/test/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpServiceTest.java
  31. 11
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java
  32. 7
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java
  33. 1
      application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java
  34. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java
  35. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java
  36. 1
      application/src/test/resources/application-test.properties
  37. 2
      build.sh
  38. 2
      common/actor/pom.xml
  39. 2
      common/cache/pom.xml
  40. 2
      common/cluster-api/pom.xml
  41. 2
      common/coap-server/pom.xml
  42. 2
      common/dao-api/pom.xml
  43. 2
      common/data/pom.xml
  44. 4
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java
  45. 4
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java
  46. 4
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java
  47. 4
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java
  48. 5
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java
  49. 5
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java
  50. 5
      common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java
  51. 5
      common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java
  52. 4
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
  53. 2
      common/discovery-api/pom.xml
  54. 2
      common/edge-api/pom.xml
  55. 2
      common/edge-api/src/main/proto/edge.proto
  56. 2
      common/edqs/pom.xml
  57. 2
      common/message/pom.xml
  58. 2
      common/pom.xml
  59. 2
      common/proto/pom.xml
  60. 2
      common/queue/pom.xml
  61. 2
      common/script/pom.xml
  62. 2
      common/script/remote-js-client/pom.xml
  63. 2
      common/script/script-api/pom.xml
  64. 2
      common/stats/pom.xml
  65. 2
      common/transport/coap/pom.xml
  66. 2
      common/transport/http/pom.xml
  67. 2
      common/transport/lwm2m/pom.xml
  68. 18
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java
  69. 14
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java
  70. 18
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java
  71. 137
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStoreTest.java
  72. 2
      common/transport/mqtt/pom.xml
  73. 22
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java
  74. 31
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
  75. 2
      common/transport/pom.xml
  76. 2
      common/transport/snmp/pom.xml
  77. 2
      common/transport/transport-api/pom.xml
  78. 2
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  79. 6
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  80. 18
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  81. 2
      common/util/pom.xml
  82. 56
      common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java
  83. 244
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  84. 2
      common/version-control/pom.xml
  85. 3
      dao/pom.xml
  86. 10
      dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java
  87. 2
      dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java
  88. 58
      dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java
  89. 7
      dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java
  90. 3
      dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java
  91. 8
      dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java
  92. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java
  93. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRequestRepository.java
  94. 10
      dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java
  95. 10
      dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageRepository.java
  96. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java
  97. 11
      dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java
  98. 52
      dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java
  99. 2
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java
  100. 133
      dao/src/test/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDaoTest.java

3
.gitignore

@ -37,4 +37,5 @@ rebuild-docker.sh
*/.run/** */.run/**
.run/** .run/**
.run .run
.claude/ .claude
.planning

16
TEST_FAST.md

@ -6,7 +6,9 @@ export MAVEN_OPTS="-Xmx1024m"
export NODE_OPTIONS="--max_old_space_size=4096" export NODE_OPTIONS="--max_old_space_size=4096"
export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError" export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError"
mvn clean install -T6 -DskipTests # Compile and install all modules, skip packaging artifacts not needed for tests
mvn clean install -T6 -DskipTests -Dpkg.skip=true
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4 mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
mvn test -pl dao -Dparallel=packages -DforkCount=4 mvn test -pl dao -Dparallel=packages -DforkCount=4
@ -31,6 +33,18 @@ mvn test -pl application -Dtest='
' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 ' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
``` ```
## pkg.skip.* flags reference
Use `-Dpkg.skip=true` to skip all packaging at once (equivalent to all four flags below).
| Flag | Skips | Safe to skip for tests? |
|----------------------------|-------------------------------------------|--------------------------------------------------------------|
| `-Dpkg.skip=true` | All of the below (bootjar + deb + rpm + zip) | Yes |
| `-Dpkg.skip.bootjar=true` | `spring-boot:repackage` (`*-boot.jar`) | Yes — tests use the regular `.jar`, not the fat boot jar |
| `-Dpkg.skip.deb=true` | Gradle `buildDeb` + Maven `attach-artifact` | Yes — MSA docker modules copy the DEB from `target/` directly |
| `-Dpkg.skip.rpm=true` | Gradle `buildRpm` | Yes — no test depends on the RPM |
| `-Dpkg.skip.zip=true` | `maven-assembly-plugin` Windows ZIP | Yes — no test depends on the ZIP |
## Testcontainers compatibility with the Docker API workaround ## Testcontainers compatibility with the Docker API workaround
In case your tests failed to run testcontainers due to unsupported Docker API version In case your tests failed to run testcontainers due to unsupported Docker API version

7
application/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>application</artifactId> <artifactId>application</artifactId>
@ -35,7 +35,6 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/..</main.dir> <main.dir>${basedir}/..</main.dir>
<pkg.type>java</pkg.type> <pkg.type>java</pkg.type>
<pkg.disabled>false</pkg.disabled>
<pkg.process-resources.phase>process-resources</pkg.process-resources.phase> <pkg.process-resources.phase>process-resources</pkg.process-resources.phase>
<pkg.package.phase>package</pkg.package.phase> <pkg.package.phase>package</pkg.package.phase>
<pkg.name>thingsboard</pkg.name> <pkg.name>thingsboard</pkg.name>
@ -493,10 +492,6 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId> <artifactId>maven-assembly-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
</plugin>
<plugin> <plugin>
<groupId>org.xolstice.maven.plugins</groupId> <groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId> <artifactId>protobuf-maven-plugin</artifactId>

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

@ -622,11 +622,21 @@ public class ActorSystemContext {
@Value("${actors.rule.external.ssrf_additional_blocked_hosts:}") @Value("${actors.rule.external.ssrf_additional_blocked_hosts:}")
private List<String> ssrfAdditionalBlockedHosts; private List<String> ssrfAdditionalBlockedHosts;
@Value("${actors.rule.external.ssrf_allowed_hosts:}")
private List<String> ssrfAllowedHosts;
@PostConstruct @PostConstruct
public void init() { public void init() {
this.localCacheType = "caffeine".equals(cacheType); this.localCacheType = "caffeine".equals(cacheType);
SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled); SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled);
SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts); SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts);
SsrfProtectionValidator.setAllowedHosts(ssrfAllowedHosts);
if (!ssrfProtectionEnabled) {
log.warn("SSRF protection for external rule nodes is DISABLED. This allows rule chains to make HTTP requests to " +
"internal/private network addresses including cloud metadata endpoints. It is strongly recommended to " +
"enable SSRF protection by setting SSRF_PROTECTION_ENABLED=true. If your rule chains need to access " +
"devices on local networks, use SSRF_ALLOWED_HOSTS to whitelist specific addresses or ranges.");
}
} }
@Value("${actors.tenant.create_components_on_init:true}") @Value("${actors.tenant.create_components_on_init:true}")

64
application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.header.writers.StaticHeadersWriter;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Slf4j
@Component
@RequiredArgsConstructor
public class HttpSecurityHeadersCustomizer {
private final HttpSecurityHeadersProperties properties;
public void customize(HeadersConfigurer<?> headers) {
if (properties.getXContentTypeOptions().isEnabled()) {
headers.contentTypeOptions(config -> {});
}
if (properties.getReferrerPolicy().isEnabled()) {
headers.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", properties.getReferrerPolicy().getValue()));
}
if (properties.getXFrameOptions().isEnabled()) {
String value = properties.getXFrameOptions().getValue();
if ("DENY".equalsIgnoreCase(value)) {
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny);
} else {
if (!"SAMEORIGIN".equalsIgnoreCase(value)) {
log.warn("Unrecognized X-Frame-Options value '{}', falling back to SAMEORIGIN. Valid values: DENY, SAMEORIGIN", value);
}
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin);
}
}
if (properties.getContentSecurityPolicy().isEnabled() && StringUtils.hasText(properties.getContentSecurityPolicy().getValue())) {
headers.contentSecurityPolicy(csp -> {
csp.policyDirectives(properties.getContentSecurityPolicy().getValue());
if (properties.getContentSecurityPolicy().isReportOnly()) {
csp.reportOnly();
}
});
}
}
}

56
application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java

@ -0,0 +1,56 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "security.headers")
@Data
public class HttpSecurityHeadersProperties {
private XContentTypeOptions xContentTypeOptions = new XContentTypeOptions();
private ReferrerPolicy referrerPolicy = new ReferrerPolicy();
private XFrameOptions xFrameOptions = new XFrameOptions();
private ContentSecurityPolicy contentSecurityPolicy = new ContentSecurityPolicy();
@Data
public static class XContentTypeOptions {
private boolean enabled = true;
}
@Data
public static class ReferrerPolicy {
private boolean enabled = true;
private String value = "strict-origin-when-cross-origin";
}
@Data
public static class XFrameOptions {
private boolean enabled = false;
private String value = "SAMEORIGIN";
}
@Data
public static class ContentSecurityPolicy {
private boolean enabled = false;
private String value = "";
private boolean reportOnly = false;
}
}

12
application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.config; package org.thingsboard.server.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -33,11 +34,16 @@ import org.springframework.security.web.SecurityFilterChain;
@ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'") @ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'")
public class TbRuleEngineSecurityConfiguration { public class TbRuleEngineSecurityConfiguration {
@Autowired
private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer;
@Bean @Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception { SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers http.headers(headers -> {
.cacheControl(config -> {}) headers.defaultsDisabled();
.frameOptions(config -> {}).disable()) headers.cacheControl(config -> {});
httpSecurityHeadersCustomizer.customize(headers);
})
.cors(cors -> {}) .cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(config -> config .authorizeHttpRequests(config -> config

19
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -143,6 +143,9 @@ public class ThingsboardSecurityConfiguration {
@Autowired @Autowired
private AuthExceptionHandler authExceptionHandler; private AuthExceptionHandler authExceptionHandler;
@Autowired
private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer;
@Bean @Bean
protected PayloadSizeFilter payloadSizeFilter() { protected PayloadSizeFilter payloadSizeFilter() {
return new PayloadSizeFilter(maxPayloadSizeConfig); return new PayloadSizeFilter(maxPayloadSizeConfig);
@ -231,9 +234,11 @@ public class ThingsboardSecurityConfiguration {
http http
.securityMatchers(matchers -> matchers .securityMatchers(matchers -> matchers
.requestMatchers("/*.js", "/*.css", "/*.ico", "/assets/**", "/static/**")) .requestMatchers("/*.js", "/*.css", "/*.ico", "/assets/**", "/static/**"))
.headers(header -> header .headers(headers -> {
.defaultsDisabled() headers.defaultsDisabled();
.addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public"))) headers.addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public"));
httpSecurityHeadersCustomizer.customize(headers);
})
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()) .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
.requestCache(RequestCacheConfigurer::disable) .requestCache(RequestCacheConfigurer::disable)
.securityContext(AbstractHttpConfigurer::disable) .securityContext(AbstractHttpConfigurer::disable)
@ -243,8 +248,12 @@ public class ThingsboardSecurityConfiguration {
@Bean @Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception { SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers.defaultsDisabled() http.headers(headers -> {
.crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN))) headers.defaultsDisabled();
headers.cacheControl(config -> {});
headers.crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN));
httpSecurityHeadersCustomizer.customize(headers);
})
.cors(cors -> {}) .cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(config -> {}) .exceptionHandling(config -> {})

19
application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.exception; package org.thingsboard.server.exception;
import jakarta.persistence.PersistenceException;
import jakarta.servlet.RequestDispatcher; import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException;
import org.thingsboard.server.common.msg.tools.TbRateLimitsException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
@ -162,8 +164,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
handleAuthenticationException(authenticationException, response); handleAuthenticationException(authenticationException, response);
} else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) { } else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException); handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException);
} else if (exception instanceof DataAccessException e) { } else if (exception instanceof DataAccessException || exception instanceof PersistenceException) {
handleDatabaseException(e, response); handleDatabaseException(exception, response);
} else { } else {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(), JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(),
@ -217,8 +219,17 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
private void handleDatabaseException(Throwable databaseException, HttpServletResponse response) throws IOException { private void handleDatabaseException(Throwable databaseException, HttpServletResponse response) throws IOException {
ThingsboardErrorResponse errorResponse; ThingsboardErrorResponse errorResponse;
if (databaseException instanceof ConstraintViolationException) { ConstraintViolationException constraintViolationException = DaoUtil.extractConstraintViolation(databaseException);
errorResponse = ThingsboardErrorResponse.of(ExceptionUtils.getRootCause(databaseException).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST); if (constraintViolationException != null) {
log.debug("Constraint violation: {}", ExceptionUtils.getRootCauseMessage(databaseException));
String constraintName = constraintViolationException.getConstraintName();
String userMessage;
if (constraintName != null && !constraintName.isEmpty()) {
userMessage = "Constraint violation: " + constraintName;
} else {
userMessage = "Constraint violation";
}
errorResponse = ThingsboardErrorResponse.of(userMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST);
} else { } else {
log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException)); log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException));
errorResponse = ThingsboardErrorResponse.of("Database error", ThingsboardErrorCode.DATABASE, HttpStatus.INTERNAL_SERVER_ERROR); errorResponse = ThingsboardErrorResponse.of("Database error", ThingsboardErrorCode.DATABASE, HttpStatus.INTERNAL_SERVER_ERROR);

2
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java

@ -41,7 +41,7 @@ public interface CalculatedFieldCache {
List<CalculatedFieldCtx> getCalculatedFieldCtxsByEntityId(EntityId entityId); List<CalculatedFieldCtx> getCalculatedFieldCtxsByEntityId(EntityId entityId);
Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType); Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType);
boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter); boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter);

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

@ -152,9 +152,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
} }
@Override @Override
public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType) { public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType) {
return calculatedFields.values().stream() return calculatedFields.values().stream()
.filter(cf -> cfType.equals(cf.getType())) .filter(cf -> cf.getTenantId().equals(tenantId) && cfType.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId())); .map(cf -> getCalculatedFieldCtx(cf.getId()));
} }

4
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java

@ -189,12 +189,12 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
} }
} }
boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter); boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(tenantId, CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter);
if (hasMatchesEntityAggCfs) { if (hasMatchesEntityAggCfs) {
return true; return true;
} }
List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(tenantId, CalculatedFieldType.RELATED_ENTITIES_AGGREGATION)
.filter(relatedEntityFilter) .filter(relatedEntityFilter)
.toList(); .toList();
for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) { for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) {

40
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import io.grpc.Server; import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@ -37,7 +40,8 @@ import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EdgeId;
@ -67,7 +71,6 @@ import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
@ -112,6 +115,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private String certFileResource; private String certFileResource;
@Value("${edges.rpc.ssl.private_key}") @Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource; private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.state.persistToTelemetry:false}") @Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry; private boolean persistToTelemetry;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}") @Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
@ -176,9 +181,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
.addService(this); .addService(this);
if (sslEnabled) { if (sslEnabled) {
try { try {
InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource); setupSsl(builder);
InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource);
builder.useTransportSecurity(certFileIs, privateKeyFileIs);
} catch (Exception e) { } catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e); throw new RuntimeException("Unable to set up SSL context!", e);
@ -199,6 +202,33 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
log.info("Edge RPC service initialized!"); log.info("Edge RPC service initialized!");
} }
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) throws Exception {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
}
@PreDestroy @PreDestroy
public void destroy() { public void destroy() {
if (server != null) { if (server != null) {

11
application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java

@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.info.NotificationInfo;
@ -109,10 +110,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
adaptiveCard.getActions().add(actionOpenUrl); adaptiveCard.getActions().add(actionOpenUrl);
} }
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers); HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers);
restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); restTemplate.postForEntity(webhookUri, request, String.class);
} }
private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException { private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException {
@ -139,10 +143,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
teamsMessageCard.setPotentialAction(List.of(actionCard)); teamsMessageCard.setPotentialAction(List.of(actionCard));
} }
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers); HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers);
restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); restTemplate.postForEntity(webhookUri, request, String.class);
} }
private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException { private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException {

4
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java

@ -23,12 +23,15 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.dao.oauth2.OAuth2User;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import java.net.URI;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
@Service(value = "customOAuth2ClientMapper") @Service(value = "customOAuth2ClientMapper")
@ -64,6 +67,7 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme
throw new RuntimeException("Can't convert principal to JSON string", e); throw new RuntimeException("Can't convert principal to JSON string", e);
} }
try { try {
SsrfProtectionValidator.validateUri(new URI(custom.getUrl()));
return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody();
} catch (Exception e) { } catch (Exception e) {
log.error("There was an error during connection to custom mapper endpoint", e); log.error("There was an error during connection to custom mapper endpoint", e);

23
application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java

@ -34,6 +34,7 @@ import org.springframework.web.socket.CloseStatus;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
@ -242,7 +243,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId()); log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId(), t);
if (t instanceof ResultSetSizeLimitExceededException) {
sendError(finalCtx, t);
}
} }
}, wsCallBackExecutor); }, wsCallBackExecutor);
} }
@ -258,7 +262,18 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
handleLatestCmd(ctx, cmd.getLatestCmd()); handleLatestCmd(ctx, cmd.getLatestCmd());
} }
if (cmd.getTsCmd() != null) { if (cmd.getTsCmd() != null) {
handleTimeSeriesCmd(ctx, cmd.getTsCmd()); Futures.addCallback(handleTimeSeriesCmd(ctx, cmd.getTsCmd()), new FutureCallback<>() {
@Override
public void onSuccess(TbEntityDataSubCtx result) {}
@Override
public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to process timeseries command", ctx.getSessionId(), ctx.getCmdId(), t);
if (t instanceof ResultSetSizeLimitExceededException) {
sendError(ctx, t);
}
}
}, wsCallBackExecutor);
} }
} else { } else {
checkAndSendInitialData(ctx); checkAndSendInitialData(ctx);
@ -268,6 +283,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
} }
} }
private void sendError(TbEntityDataSubCtx ctx, Throwable t) {
ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage()));
}
private void checkAndSendInitialData(@Nullable TbEntityDataSubCtx theCtx) { private void checkAndSendInitialData(@Nullable TbEntityDataSubCtx theCtx) {
if (!theCtx.isInitialDataSent()) { if (!theCtx.isInitialDataSent()) {
EntityDataUpdate update = new EntityDataUpdate(theCtx.getCmdId(), theCtx.getData(), null, theCtx.getMaxEntitiesPerDataSubscription()); EntityDataUpdate update = new EntityDataUpdate(theCtx.getCmdId(), theCtx.getData(), null, theCtx.getMaxEntitiesPerDataSubscription());

4
application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java

@ -348,7 +348,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer
if (sub.isLatestValues()) { if (sub.isLatestValues()) {
for (TsKvEntry kv : data) { for (TsKvEntry kv : data) {
Long stateTs = keyStates.get(kv.getKey()); Long stateTs = keyStates.get(kv.getKey());
if (stateTs == null || kv.getTs() >= stateTs) { if (stateTs == null || kv.getTs() >= stateTs || kv.isDeletedEntry()) {
if (updateData == null) { if (updateData == null) {
updateData = new ArrayList<>(); updateData = new ArrayList<>();
} }
@ -362,7 +362,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer
for (TsKvEntry kv : data) { for (TsKvEntry kv : data) {
Long stateTs = keyStates.get(kv.getKey()); Long stateTs = keyStates.get(kv.getKey());
if (stateTs != null) { if (stateTs != null) {
if (!sub.isLatestValues() || kv.getTs() >= stateTs) { if (!sub.isLatestValues() || kv.getTs() >= stateTs || kv.isDeletedEntry()) {
if (updateData == null) { if (updateData == null) {
updateData = new ArrayList<>(); updateData = new ArrayList<>();
} }

4
application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java

@ -32,4 +32,8 @@ public abstract class AbstractCleanUpService {
return partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); return partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition();
} }
protected boolean isTenantPartitionMine(TenantId tenantId) {
return partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition();
}
} }

75
application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java

@ -20,33 +20,41 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationRequestConfig; import org.thingsboard.server.common.data.notification.NotificationRequestConfig;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.notification.NotificationRequestDao; import org.thingsboard.server.dao.notification.NotificationRequestDao;
import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.PartitionService;
import java.time.Instant;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.dao.model.ModelConstants.NOTIFICATION_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.NOTIFICATION_TABLE_NAME;
@Slf4j
@Service @Service
@ConditionalOnExpression("${sql.ttl.notifications.enabled:true} && ${sql.ttl.notifications.ttl:0} > 0") @ConditionalOnExpression("${sql.ttl.notifications.enabled:true} && ${sql.ttl.notifications.ttl:0} > 0")
@Slf4j
public class NotificationsCleanUpService extends AbstractCleanUpService { public class NotificationsCleanUpService extends AbstractCleanUpService {
private final SqlPartitioningRepository partitioningRepository; private final SqlPartitioningRepository partitioningRepository;
private final NotificationRequestDao notificationRequestDao; private final NotificationRequestDao notificationRequestDao;
private final TenantService tenantService;
@Value("${sql.ttl.notifications.ttl:2592000}") @Value("${sql.ttl.notifications.ttl:2592000}")
private long ttlInSec; private long ttlInSec;
@Value("${sql.notifications.partition_size:168}") @Value("${sql.notifications.partition_size:168}")
private int partitionSizeInHours; private int partitionSizeInHours;
@Value("${sql.ttl.notifications.removal_batch_size:10000}")
private int removalBatchSize;
public NotificationsCleanUpService(PartitionService partitionService, SqlPartitioningRepository partitioningRepository, public NotificationsCleanUpService(PartitionService partitionService, SqlPartitioningRepository partitioningRepository,
NotificationRequestDao notificationRequestDao) { NotificationRequestDao notificationRequestDao, TenantService tenantService) {
super(partitionService); super(partitionService);
this.partitioningRepository = partitioningRepository; this.partitioningRepository = partitioningRepository;
this.notificationRequestDao = notificationRequestDao; this.notificationRequestDao = notificationRequestDao;
this.tenantService = tenantService;
} }
@Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.notifications.checking_interval_ms:86400000})}", @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.notifications.checking_interval_ms:86400000})}",
@ -54,18 +62,65 @@ public class NotificationsCleanUpService extends AbstractCleanUpService {
public void cleanUp() { public void cleanUp() {
long expTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttlInSec); long expTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttlInSec);
long partitionDurationMs = TimeUnit.HOURS.toMillis(partitionSizeInHours); long partitionDurationMs = TimeUnit.HOURS.toMillis(partitionSizeInHours);
if (!isSystemTenantPartitionMine()) { if (isSystemTenantPartitionMine()) {
partitioningRepository.dropPartitionsBefore(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs);
} else {
partitioningRepository.cleanupPartitionsCache(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs); partitioningRepository.cleanupPartitionsCache(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs);
return;
} }
long lastRemovedNotificationTs = partitioningRepository.dropPartitionsBefore(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs); long gap = TimeUnit.MINUTES.toMillis(10);
if (lastRemovedNotificationTs > 0) { long requestExpTime = expTime - TimeUnit.SECONDS.toMillis(NotificationRequestConfig.MAX_SENDING_DELAY) - gap;
long gap = TimeUnit.MINUTES.toMillis(10); cleanUpNotificationRequests(requestExpTime);
long requestExpTime = lastRemovedNotificationTs - TimeUnit.SECONDS.toMillis(NotificationRequestConfig.MAX_SENDING_DELAY) - gap; }
int removed = notificationRequestDao.removeAllByCreatedTimeBefore(requestExpTime);
log.info("Removed {} outdated notification requests older than {}", removed, requestExpTime); private void cleanUpNotificationRequests(long expirationTime) {
log.info("Starting notification requests cleanup for records older than {}", Instant.ofEpochMilli(expirationTime));
int totalRemoved = 0;
int tenantsProcessed = 0;
// Clean up SYSADMIN's notification requests on the system node only
if (isSystemTenantPartitionMine()) {
try {
totalRemoved += cleanUpByTenant(TenantId.SYS_TENANT_ID, expirationTime);
} catch (Exception e) {
log.warn("Failed to clean up notification requests for sysadmin {}", TenantId.SYS_TENANT_ID, e);
}
}
// Each node cleans up notification requests for its own tenants
PageDataIterable<TenantId> tenants = new PageDataIterable<>(tenantService::findTenantsIds, 10_000);
for (TenantId tenantId : tenants) {
try {
if (!isTenantPartitionMine(tenantId)) {
continue;
}
int tenantRemoved = cleanUpByTenant(tenantId, expirationTime);
totalRemoved += tenantRemoved;
tenantsProcessed++;
if (tenantRemoved > 0) {
log.trace("Removed {} notification requests for tenant {}", tenantRemoved, tenantId);
}
} catch (Exception e) {
log.warn("Failed to clean up notification requests for tenant {}", tenantId, e);
}
} }
log.info("Notification requests cleanup completed. Processed {} tenants, removed {} total records older than {}", tenantsProcessed, totalRemoved, Instant.ofEpochMilli(expirationTime));
}
private int cleanUpByTenant(TenantId tenantId, long expirationTime) {
int totalRemoved = 0;
int batchRemoved;
do {
batchRemoved = notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, removalBatchSize);
totalRemoved += batchRemoved;
if (batchRemoved > 0) {
log.trace("Removed {} notification requests in batch for tenant {}", batchRemoved, tenantId);
}
} while (batchRemoved >= removalBatchSize);
return totalRemoved;
} }
} }

94
application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java

@ -15,69 +15,87 @@
*/ */
package org.thingsboard.server.service.ttl.rpc; package org.thingsboard.server.service.ttl.rpc;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.dao.rpc.RpcDao; import org.thingsboard.server.dao.rpc.RpcDao;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.ttl.AbstractCleanUpService;
import java.util.Date; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@TbCoreComponent
@Service
@Slf4j @Slf4j
@RequiredArgsConstructor @Service
public class RpcCleanUpService { @TbCoreComponent
@Value("${sql.ttl.rpc.enabled}") @ConditionalOnExpression("${sql.ttl.rpc.enabled:true}")
private boolean ttlTaskExecutionEnabled; public class RpcCleanUpService extends AbstractCleanUpService {
@Value("${sql.ttl.rpc.removal_batch_size:10000}")
private int removalBatchSize;
private final RpcDao rpcDao;
private final TenantService tenantService; private final TenantService tenantService;
private final PartitionService partitionService;
private final TbTenantProfileCache tenantProfileCache; private final TbTenantProfileCache tenantProfileCache;
private final RpcDao rpcDao;
public RpcCleanUpService(TenantService tenantService, PartitionService partitionService, TbTenantProfileCache tenantProfileCache, RpcDao rpcDao) {
super(partitionService);
this.tenantService = tenantService;
this.tenantProfileCache = tenantProfileCache;
this.rpcDao = rpcDao;
}
@Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.rpc.checking_interval})}", fixedDelayString = "${sql.ttl.rpc.checking_interval}") @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.rpc.checking_interval})}", fixedDelayString = "${sql.ttl.rpc.checking_interval}")
public void cleanUp() { public void cleanUp() {
if (ttlTaskExecutionEnabled) { PageDataIterable<TenantId> tenants = new PageDataIterable<>(tenantService::findTenantsIds, 10_000);
PageLink tenantsBatchRequest = new PageLink(10_000, 0); for (TenantId tenantId : tenants) {
PageData<TenantId> tenantsIds; try {
do { if (!isTenantPartitionMine(tenantId)) {
tenantsIds = tenantService.findTenantsIds(tenantsBatchRequest); continue;
for (TenantId tenantId : tenantsIds.getData()) {
if (!partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition()) {
continue;
}
Optional<DefaultTenantProfileConfiguration> tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration();
if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getRpcTtlDays() == 0) {
continue;
}
long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getRpcTtlDays());
long expirationTime = System.currentTimeMillis() - ttl;
int totalRemoved = rpcDao.deleteOutdatedRpcByTenantId(tenantId, expirationTime);
if (totalRemoved > 0) {
log.info("Removed {} outdated rpc(s) for tenant {} older than {}", totalRemoved, tenantId, new Date(expirationTime));
}
} }
tenantsBatchRequest = tenantsBatchRequest.nextPageLink(); Optional<DefaultTenantProfileConfiguration> tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration();
} while (tenantsIds.hasNext()); if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getRpcTtlDays() == 0) {
continue;
}
long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getRpcTtlDays());
long expirationTime = System.currentTimeMillis() - ttl;
int totalRemoved = cleanUpByTenant(tenantId, expirationTime);
if (totalRemoved > 0) {
log.info("Removed {} outdated rpc(s) for tenant {} older than {}", totalRemoved, tenantId, Instant.ofEpochMilli(expirationTime));
}
} catch (Exception e) {
log.warn("Failed to clean up rpc by ttl for tenant {}", tenantId, e);
}
} }
} }
private int cleanUpByTenant(TenantId tenantId, long expirationTime) {
int totalRemoved = 0;
int batchRemoved;
do {
batchRemoved = rpcDao.deleteOutdatedRpcByTenantIdBatch(tenantId, expirationTime, removalBatchSize);
totalRemoved += batchRemoved;
if (batchRemoved > 0) {
log.trace("Removed {} rpc in batch for tenant {}", batchRemoved, tenantId);
}
} while (batchRemoved >= removalBatchSize);
return totalRemoved;
}
} }

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

@ -182,6 +182,60 @@ security:
path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}" path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}"
# The password of the cacerts keystore file # The password of the cacerts keystore file
password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}"
# HTTP security response headers configuration.
# These headers are set on responses from the ThingsBoard backend (tb-node).
# In microservice deployments, the web-ui (Express.js) has its own header configuration
# under msa/web-ui/config/ using the same environment variable names.
headers:
# X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type.
# Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type.
x-content-type-options:
# Enable/disable X-Content-Type-Options header. Prevents browsers from MIME-sniffing the Content-Type
enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}"
# Referrer-Policy header controls how much referrer info the browser sends with requests.
# The default 'strict-origin-when-cross-origin' matches the browser's built-in default,
# so enabling this does not change existing behavior — it just makes the policy explicit.
# Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin,
# same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
referrer-policy:
# Enable/disable Referrer-Policy header
enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}"
# Referrer-Policy header value
value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}"
# X-Frame-Options header protects against clickjacking attacks by preventing the page
# from being loaded in iframes on other domains.
# Disabled by default because ThingsBoard supports multi-domain deployments where
# the platform may be embedded in iframes on customer domains.
# WARNING: Enabling with DENY will block ALL iframe embedding including dashboards
# embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only.
x-frame-options:
# Enable/disable X-Frame-Options header. Protects against clickjacking attacks
enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}"
# Valid values: DENY, SAMEORIGIN
value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}"
# Content-Security-Policy header mitigates XSS and data injection attacks by restricting
# which resources the browser is allowed to load.
# Disabled by default because ThingsBoard supports multi-domain deployments and
# because custom HTML Card widgets may use inline scripts, inline styles, and
# external resources that a restrictive CSP would block.
# WARNING when enabling: A strict CSP (e.g. script-src 'self') will break:
# - HTML Card widgets with inline JavaScript
# - Custom widget types with inline scripts/styles
# - Widgets loading external resources (images, fonts, scripts)
# - Dashboard embedding via iframes (if frame-ancestors is restrictive)
# Use 'report-only: true' first to test the impact before enforcing.
# The default value covers core ThingsBoard functionality including OpenStreetMap, ArcGIS maps,
# solution template previews, WebSocket telemetry, and chart Web Workers.
# NOTE: Google Maps requires adding Google domains to script-src, which is not included by default.
# To add Google Maps support, append to script-src: https://maps.googleapis.com https://maps.gstatic.com
content-security-policy:
# Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks
enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}"
# Full CSP directive string. The default value is a working policy for most ThingsBoard deployments
value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://img.thingsboard.io https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.arcgis.com; font-src 'self' data:; connect-src 'self' ws: wss: https://*.arcgis.com https://*.arcgisonline.com; worker-src 'self' blob:; frame-ancestors 'self'}"
# If true, uses Content-Security-Policy-Report-Only header instead — the browser
# reports violations but does not enforce them. Use for testing before enforcing.
report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}"
# Mail settings parameters # Mail settings parameters
mail: mail:
@ -430,10 +484,11 @@ sql:
edge_events_ttl: "${SQL_TTL_EDGE_EVENTS_TTL:2628000}" # Number of seconds. The current value corresponds to one month edge_events_ttl: "${SQL_TTL_EDGE_EVENTS_TTL:2628000}" # Number of seconds. The current value corresponds to one month
alarms: alarms:
checking_interval: "${SQL_ALARMS_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours checking_interval: "${SQL_ALARMS_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours
removal_batch_size: "${SQL_ALARMS_TTL_REMOVAL_BATCH_SIZE:3000}" # To delete outdated alarms not all at once but in batches removal_batch_size: "${SQL_ALARMS_TTL_REMOVAL_BATCH_SIZE:3000}" # Batch size for records removal
rpc: rpc:
enabled: "${SQL_TTL_RPC_ENABLED:true}" # Enable/disable TTL (Time To Live) for rpc call records enabled: "${SQL_TTL_RPC_ENABLED:true}" # Enable/disable TTL (Time To Live) for rpc call records
checking_interval: "${SQL_RPC_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours checking_interval: "${SQL_RPC_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours
removal_batch_size: "${SQL_RPC_TTL_REMOVAL_BATCH_SIZE:10000}" # Batch size for records removal
audit_logs: audit_logs:
enabled: "${SQL_TTL_AUDIT_LOGS_ENABLED:true}" # Enable/disable TTL (Time To Live) for audit log records enabled: "${SQL_TTL_AUDIT_LOGS_ENABLED:true}" # Enable/disable TTL (Time To Live) for audit log records
ttl: "${SQL_TTL_AUDIT_LOGS_SECS:0}" # Disabled by default. The accuracy of the cleanup depends on the sql.audit_logs.partition_size ttl: "${SQL_TTL_AUDIT_LOGS_SECS:0}" # Disabled by default. The accuracy of the cleanup depends on the sql.audit_logs.partition_size
@ -442,6 +497,7 @@ sql:
enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records
ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days
checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
removal_batch_size: "${SQL_TTL_NOTIFICATIONS_REMOVAL_BATCH_SIZE:10000}" # Batch size for records removal
api_keys: api_keys:
enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records
checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day
@ -519,6 +575,10 @@ actors:
# Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames). # Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames).
# Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata" # Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata"
ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}" ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}"
# Comma-separated list of allowed destinations that bypass SSRF blocking (IPs, CIDR subnets, or hostnames).
# Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24).
# Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp"
ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}"
rpc: rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery. # Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@ -804,21 +864,28 @@ updates:
# Enable/disable checks for the new version # Enable/disable checks for the new version
enabled: "${UPDATES_ENABLED:true}" enabled: "${UPDATES_ENABLED:true}"
# Spring CORS configuration parameters # Spring CORS configuration parameters.
# Controls the Access-Control-Allow-Origin and Access-Control-Allow-Credentials response headers.
# WARNING: The default configuration allows cross-origin requests from ANY domain with credentials.
# This means any website can make API requests on behalf of an authenticated user if the token
# is accessible (e.g., via XSS). For production deployments, restrict to your domain(s):
# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://your-domain.com
# For multi-domain deployments, list all allowed domains comma-separated:
# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://domain1.com,https://domain2.com
spring.mvc.cors: spring.mvc.cors:
mappings: mappings:
# Intercept path # Intercept path
"[/api/**]": "[/api/**]":
#Comma-separated list of origins to allow. '*' allows all origins. When not set, CORS support is disabled. #Comma-separated list of origins to allow. '*' allows all origins. When not set, CORS support is disabled.
allowed-origin-patterns: "*" allowed-origin-patterns: "${TB_CORS_ALLOWED_ORIGIN_PATTERNS:*}"
#Comma-separated list of methods to allow. '*' allows all methods. #Comma-separated list of methods to allow. '*' allows all methods.
allowed-methods: "*" allowed-methods: "${TB_CORS_ALLOWED_METHODS:*}"
#Comma-separated list of headers to allow in a request. '*' allows all headers. #Comma-separated list of headers to allow in a request. '*' allows all headers.
allowed-headers: "*" allowed-headers: "${TB_CORS_ALLOWED_HEADERS:*}"
#How long, in seconds, the response from a pre-flight request can be cached by clients. #How long, in seconds, the response from a pre-flight request can be cached by clients.
max-age: "1800" max-age: "${TB_CORS_MAX_AGE:1800}"
#Set whether credentials are supported. When not set, credentials are not supported. #Set whether credentials are supported. When not set, credentials are not supported.
allow-credentials: "true" allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}"
# General spring parameters # General spring parameters
spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed.
@ -1505,10 +1572,17 @@ edges:
ssl: ssl:
# Enable/disable SSL support # Enable/disable SSL support
enabled: "${EDGES_RPC_SSL_ENABLED:false}" enabled: "${EDGES_RPC_SSL_ENABLED:false}"
# Cert file to be used during TLS connectivity to the cloud # Path to the server certificate file (holds server certificate or certificate chain, may include server private key).
# Accepts an absolute filesystem path (e.g. /etc/thingsboard/certChainFile.pem),
# a relative path resolved against the working directory first then the classpath,
# or a classpath resource with the explicit "classpath:" prefix (e.g. classpath:conf/certChainFile.pem).
cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}"
# Private key file associated with the Cert certificate. This key is used in the encryption process during a secure connection # Path to the server certificate private key file. Optional if the private key is already present in the cert file above.
# Supports the same path resolution as 'cert': absolute, relative/classpath, or "classpath:" prefix.
# Leave empty when using a combined PEM cert that already contains the private key.
private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}"
# Server certificate private key password (optional). Leave empty if the key is not encrypted.
key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}"
# Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes
max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}"
# Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation. # Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation.

11
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -422,6 +422,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
public void teardownWebTest() throws Exception { public void teardownWebTest() throws Exception {
log.debug("Executing web test teardown"); log.debug("Executing web test teardown");
// Drain any pending housekeeper work left by the test body (e.g., bulk tenant deletes)
// before proceeding with teardown deletions, to avoid 90s per-tenant wait timing out.
awaitHousekeeperDrained();
loginSysAdmin(); loginSysAdmin();
deleteTenant(tenantId); deleteTenant(tenantId);
deleteDifferentTenant(); deleteDifferentTenant();
@ -453,6 +457,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
.until(() -> storage.getLag("tb_housekeeper") == 0); .until(() -> storage.getLag("tb_housekeeper") == 0);
} }
protected void awaitHousekeeperDrained() {
Awaitility.await("housekeeper drained").atMost(5, TimeUnit.MINUTES).during(300, TimeUnit.MILLISECONDS)
.until(() -> storage.getLag("tb_housekeeper") == 0);
}
private List<Tenant> getAllTenants() throws Exception { private List<Tenant> getAllTenants() throws Exception {
List<Tenant> loadedTenants = new ArrayList<>(); List<Tenant> loadedTenants = new ArrayList<>();
PageLink pageLink = new PageLink(10); PageLink pageLink = new PageLink(10);
@ -1141,7 +1150,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
assertThat(findRelationsByTo(entityTo)).hasSize(1); assertThat(findRelationsByTo(entityTo)).hasSize(1);
doDelete(urlDelete) doDelete(urlDelete)
.andExpect(status().isInternalServerError()); .andExpect(status().isBadRequest());
assertThat(findRelationsByTo(entityTo)).hasSize(1); assertThat(findRelationsByTo(entityTo)).hasSize(1);
} finally { } finally {

12
application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java

@ -41,6 +41,7 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.util.TestSocketUtils;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
@ -90,8 +91,6 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT;
import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_URL;
@TestPropertySource(properties = { @TestPropertySource(properties = {
"transport.mqtt.enabled=true", "transport.mqtt.enabled=true",
@ -101,6 +100,15 @@ import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.
@ContextConfiguration(classes = {EntityViewControllerTest.Config.class}) @ContextConfiguration(classes = {EntityViewControllerTest.Config.class})
@DaoSqlTest @DaoSqlTest
public class EntityViewControllerTest extends AbstractControllerTest { public class EntityViewControllerTest extends AbstractControllerTest {
// Must NOT be imported from AbstractMqttIntegrationTest. That field is a static final initialized
// once per JVM. Other test classes (e.g. MqttGatewayRateLimitsTest, DeviceEdgeTest) share the same
// constant but produce a different Spring context cache key, so Spring creates a separate
// ApplicationContext for each of them. Every context starts its own MqttTransportService and tries
// to bind the same port, causing BindException when tests run in the same Surefire JVM fork.
// Declaring the port here gives this context its own independently allocated port.
static final int MQTT_PORT = TestSocketUtils.findAvailableTcpPort();
static final String MQTT_URL = "tcp://localhost:" + MQTT_PORT;
@DynamicPropertySource @DynamicPropertySource
static void props(DynamicPropertyRegistry registry) { static void props(DynamicPropertyRegistry registry) {
log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); log.warn("transport.mqtt.bind_port = {}", MQTT_PORT);

21
application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java

@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.exception.TenantNotFoundException;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
@ -936,6 +937,26 @@ public class TenantControllerTest extends AbstractControllerTest {
Mockito.reset(tbClusterService); Mockito.reset(tbClusterService);
} }
@Test
public void testSaveTenantWithNonExistentTenantProfileId() throws Exception {
loginSysAdmin();
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
tenant.setTenantProfileId(new TenantProfileId(UUID.randomUUID()));
String responseBody = doPost("/api/tenant", tenant)
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
// Verify sanitized message format
assertThat(responseBody).contains("Constraint violation: fk_tenant_profile");
// Verify raw SQL details are not returned
assertThat(responseBody).doesNotContain("could not execute statement");
assertThat(responseBody).doesNotContain("insert or update on table");
assertThat(responseBody).doesNotContain("tenant_profile_id");
assertThat(responseBody).doesNotContain("is not present in table");
}
private void testBroadcastEntityStateChangeEventNeverTenant() { private void testBroadcastEntityStateChangeEventNeverTenant() {
Mockito.verify(tbClusterService, never()).onTenantChange(Mockito.any(Tenant.class), Mockito.verify(tbClusterService, never()).onTenantChange(Mockito.any(Tenant.class),
Mockito.isNull()); Mockito.isNull());

71
application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java

@ -19,13 +19,16 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.After; import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
@ -60,7 +63,9 @@ import org.thingsboard.server.common.data.query.NumericFilterPredicate;
import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.service.subscription.SubscriptionErrorCode; import org.thingsboard.server.service.subscription.SubscriptionErrorCode;
import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
@ -95,6 +100,9 @@ public class WebsocketApiTest extends AbstractControllerTest {
@Autowired @Autowired
private TelemetrySubscriptionService tsService; private TelemetrySubscriptionService tsService;
@SpyBean
private TimeseriesService timeseriesService;
Device device; Device device;
DeviceTypeFilter dtf; DeviceTypeFilter dtf;
@ -726,6 +734,41 @@ public class WebsocketApiTest extends AbstractControllerTest {
Assert.assertNull(msg); Assert.assertNull(msg);
} }
@Test
public void testShouldSendWsUpdateMessageWhenTelemetryWasDeleted() throws Exception {
long now = System.currentTimeMillis() - 100;
TsKvEntry dataPoint = new BasicTsKvEntry(now, new LongDataEntry("temperature", 42L));
List<TsKvEntry> tsData = List.of(dataPoint);
sendTelemetry(device, tsData);
List<EntityKey> keys = List.of(new EntityKey(EntityKeyType.TIME_SERIES, "temperature"));
EntityDataUpdate update = getWsClient().subscribeLatestUpdate(keys, dtf);
Assert.assertEquals(1, update.getCmdId());
PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"));
Assert.assertEquals(now, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs());
Assert.assertEquals("42", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue());
// delete telemetry
getWsClient().registerWaitForUpdate();
doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete?keys=temperature&deleteAllDataForKeys=true", String.class);
update = getWsClient().parseDataReply(getWsClient().waitForUpdate());
Assert.assertEquals(1, update.getCmdId());
List<EntityData> listData = update.getUpdate();
Assert.assertNotNull(listData);
Assert.assertEquals(1, listData.size());
Assert.assertEquals(device.getId(), listData.get(0).getEntityId());
Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES));
TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature");
Assert.assertEquals(new TsValue(0, ""), tsValue);
}
@Test @Test
public void testEntityDataLatestAttrWsCmd() throws Exception { public void testEntityDataLatestAttrWsCmd() throws Exception {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@ -965,6 +1008,34 @@ public class WebsocketApiTest extends AbstractControllerTest {
} }
@Test
public void testHistoryCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception {
ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L);
Mockito.doReturn(Futures.immediateFailedFuture(exception))
.when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any());
List<String> keys = List.of("temperature");
long now = System.currentTimeMillis();
EntityDataUpdate errorUpdate = getWsClient().sendHistoryCmd(keys, now, TimeUnit.HOURS.toMillis(1), dtf);
assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode());
assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage());
}
@Test
public void testTimeSeriesCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception {
ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L);
Mockito.doReturn(Futures.immediateFailedFuture(exception))
.when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any());
List<String> keys = List.of("temperature");
long now = System.currentTimeMillis();
EntityDataUpdate errorUpdate = getWsClient().subscribeTsUpdate(keys, now, TimeUnit.HOURS.toMillis(1), dtf);
assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode());
assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage());
}
private void sendTelemetry(Device device, List<TsKvEntry> tsData) throws InterruptedException { private void sendTelemetry(Device device, List<TsKvEntry> tsData) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
tsService.saveTimeseries(TimeseriesSaveRequest.builder() tsService.saveTimeseries(TimeseriesSaveRequest.builder()

272
application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java

@ -0,0 +1,272 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import java.io.ByteArrayInputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.security.spec.ECGenParameterSpec;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
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;
/**
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method.
* <p>
* Covers:
* 1. Separate cert and key PEM inputs
* 2. Combined PEM (cert + key in one file)
* 3. Encrypted private key with password
* 4. Missing key in combined PEM → error
* <p>
* Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384.
*/
class EdgeGrpcSslTest {
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
enum KeyType {
RSA_2048("RSA", 2048, null, "SHA256withRSA"),
RSA_4096("RSA", 4096, null, "SHA256withRSA"),
EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"),
EC_P384("EC", 384, "secp384r1", "SHA384withECDSA");
final String algorithm;
final int size;
final String curve;
final String sigAlg;
KeyType(String algorithm, int size, String curve, String sigAlg) {
this.algorithm = algorithm;
this.size = size;
this.curve = curve;
this.sigAlg = sigAlg;
}
KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm);
if (curve != null) {
kpg.initialize(new ECGenParameterSpec(curve));
} else {
kpg.initialize(size);
}
return kpg.generateKeyPair();
}
}
private final List<Path> tempFiles = new ArrayList<>();
private Server server;
private ManagedChannel channel;
@AfterEach
void cleanup() throws Exception {
if (channel != null) {
channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS);
}
if (server != null) {
server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS);
}
for (Path p : tempFiles) {
Files.deleteIfExists(p);
}
}
@ParameterizedTest(name = "separateCertAndKey_{0}")
@EnumSource(KeyType.class)
void separateCertAndKey(KeyType keyType) throws Exception {
KeyPair kp = keyType.generateKeyPair();
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
Path certFile = writeTempPem("cert", cert);
Path keyFile = writeTempPem("key", kp.getPrivate());
server = startServer(certFile.toString(), keyFile.toString(), null);
assertTlsConnectivity(cert);
}
@ParameterizedTest(name = "combinedPemWithCertAndKey_{0}")
@EnumSource(KeyType.class)
void combinedPemWithCertAndKey(KeyType keyType) throws Exception {
KeyPair kp = keyType.generateKeyPair();
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
Path combinedFile = writeTempPem("combined", cert, kp.getPrivate());
server = startServer(combinedFile.toString(), "", null);
assertTlsConnectivity(cert);
}
// RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY),
// which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials
// to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null".
@ParameterizedTest(name = "encryptedPrivateKey_{0}")
@EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"})
void encryptedPrivateKey(KeyType keyType) throws Exception {
KeyPair kp = keyType.generateKeyPair();
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
String password = "test-password";
Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate());
server = startServer(combinedFile.toString(), "", password);
assertTlsConnectivity(cert);
}
@ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}")
@EnumSource(KeyType.class)
void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception {
KeyPair kp = keyType.generateKeyPair();
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
Path certOnlyFile = writeTempPem("cert-only", cert);
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
.isInstanceOf(IllegalArgumentException.class);
}
// --- Server startup using production EdgeGrpcService.setupSsl() ---
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
EdgeGrpcService edgeGrpcService = new EdgeGrpcService();
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource);
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource);
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : "");
NettyServerBuilder builder = NettyServerBuilder.forPort(0)
.addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {});
edgeGrpcService.setupSsl(builder);
return builder.build().start();
}
private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception {
String certPem = toPem(trustedCert);
var clientSsl = GrpcSslContexts.forClient()
.trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8)))
.build();
channel = NettyChannelBuilder.forAddress("localhost", server.getPort())
.sslContext(clientSsl)
.build();
channel.getState(true); // trigger connection attempt
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS)
.pollInterval(50, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
var state = channel.getState(false);
if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) {
throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE");
}
assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY);
});
}
// --- Cert/key generation ---
private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception {
X500Name subject = new X500Name("CN=localhost");
Date now = new Date();
return new JcaX509CertificateConverter().getCertificate(
new JcaX509v3CertificateBuilder(
subject, BigInteger.ONE, now,
new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)),
subject, kp.getPublic())
.build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate())));
}
// --- PEM file helpers ---
private String toPem(Object obj) throws Exception {
java.io.StringWriter sw = new java.io.StringWriter();
try (JcaPEMWriter w = new JcaPEMWriter(sw)) {
w.writeObject(obj);
}
return sw.toString();
}
private Path writeTempPem(String prefix, Object... objects) throws Exception {
Path p = Files.createTempFile(prefix + "-", ".pem");
tempFiles.add(p);
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) {
for (Object o : objects) {
w.writeObject(toPkcs8IfKey(o));
}
}
return p;
}
private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception {
Path p = Files.createTempFile(prefix + "-", ".pem");
tempFiles.add(p);
var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC")
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(password.toCharArray());
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) {
for (Object o : objects) {
if (o instanceof PrivateKey) {
w.writeObject(o, encryptor);
} else {
w.writeObject(o);
}
}
}
return p;
}
private Object toPkcs8IfKey(Object o) {
if (o instanceof PrivateKey pk) {
return new PemObject("PRIVATE KEY", pk.getEncoded());
}
return o;
}
}

8
application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java

@ -115,6 +115,8 @@ import java.util.Map;
import java.util.Random; import java.util.Random;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -1749,13 +1751,13 @@ public class EntityServiceTest extends AbstractControllerTest {
} }
@Test @Test
public void testFindTenantTelemetry() { public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin // save timeseries by sys admin
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, new DoubleDataEntry("temperature", 45.5)); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, new DoubleDataEntry("temperature", 45.5));
timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries); timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries).get(TIMEOUT, TimeUnit.SECONDS);
AttributeKvEntry attr = new BaseAttributeKvEntry(new LongDataEntry("attr", 10L), 42L); AttributeKvEntry attr = new BaseAttributeKvEntry(new LongDataEntry("attr", 10L), 42L);
attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)); attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)).get(TIMEOUT, TimeUnit.SECONDS);
SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); SingleEntityFilter singleEntityFilter = new SingleEntityFilter();
singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId));

10
application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java

@ -23,8 +23,8 @@ import org.junit.Test;
import org.mockito.ArgumentMatcher; import org.mockito.ArgumentMatcher;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; import org.thingsboard.rule.engine.metadata.TbGetAttributesNode;
@ -127,10 +127,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
}) })
public class HousekeeperServiceTest extends AbstractControllerTest { public class HousekeeperServiceTest extends AbstractControllerTest {
@SpyBean @MockitoSpyBean
private HousekeeperService housekeeperService; private HousekeeperService housekeeperService;
@SpyBean @MockitoSpyBean
private HousekeeperReprocessingService housekeeperReprocessingService; private HousekeeperReprocessingService housekeeperReprocessingService;
@MockitoSpyBean
private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor;
@Autowired @Autowired
private EventService eventService; private EventService eventService;
@Autowired @Autowired
@ -153,8 +155,6 @@ public class HousekeeperServiceTest extends AbstractControllerTest {
private CustomerService customerService; private CustomerService customerService;
@Autowired @Autowired
private DashboardService dashboardService; private DashboardService dashboardService;
@SpyBean
private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor;
private TenantId tenantId; private TenantId tenantId;

144
application/src/test/java/org/thingsboard/server/service/ttl/NotificationsCleanUpServiceTest.java

@ -0,0 +1,144 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.ttl;
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.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.notification.NotificationRequestDao;
import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.queue.discovery.PartitionService;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
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)
public class NotificationsCleanUpServiceTest {
@Mock
private PartitionService partitionService;
@Mock
private SqlPartitioningRepository partitioningRepository;
@Mock
private NotificationRequestDao notificationRequestDao;
@Mock
private TenantService tenantService;
private NotificationsCleanUpService cleanUpService;
private static final int BATCH_SIZE = 3;
@BeforeEach
public void setUp() {
cleanUpService = new NotificationsCleanUpService(partitionService, partitioningRepository, notificationRequestDao, tenantService);
ReflectionTestUtils.setField(cleanUpService, "ttlInSec", 2592000L);
ReflectionTestUtils.setField(cleanUpService, "partitionSizeInHours", 168);
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE);
}
@Test
public void testBatchLoopCallsDaoMultipleTimes() {
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build();
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition);
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong()))
.thenReturn(System.currentTimeMillis());
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
when(tenantService.findTenantsIds(any()))
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false));
// Sysadmin: returns 3 (full batch), then 1 (partial) -> 2 calls
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)))
.thenReturn(BATCH_SIZE)
.thenReturn(1);
// Tenant: returns 3, 3, 0 -> 3 calls
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)))
.thenReturn(BATCH_SIZE)
.thenReturn(BATCH_SIZE)
.thenReturn(0);
cleanUpService.cleanUp();
verify(notificationRequestDao, times(2))
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE));
verify(notificationRequestDao, times(3))
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE));
}
@Test
public void testSkipsTenantNotOnMyPartition() {
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build();
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build();
when(partitionService.resolve(any(), eq(TenantId.SYS_TENANT_ID), eq(TenantId.SYS_TENANT_ID)))
.thenReturn(myPartition);
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong()))
.thenReturn(System.currentTimeMillis());
// Sysadmin: no records
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)))
.thenReturn(0);
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID());
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID());
when(tenantService.findTenantsIds(any()))
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false));
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition);
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition);
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)))
.thenReturn(0);
cleanUpService.cleanUp();
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE));
verify(notificationRequestDao, never()).removeByTenantIdAndCreatedTimeBeforeBatch(eq(otherTenant), anyLong(), anyInt());
}
@Test
public void testNoPartitionsDropped_stillCleansUpRequests() {
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build();
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition);
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong()))
.thenReturn(0L);
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)))
.thenReturn(0);
when(tenantService.findTenantsIds(any()))
.thenReturn(new PageData<>(List.of(), 0, 0, false));
cleanUpService.cleanUp();
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE));
}
}

136
application/src/test/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpServiceTest.java

@ -0,0 +1,136 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.ttl.rpc;
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.data.TenantProfile;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.rpc.RpcDao;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.queue.discovery.PartitionService;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
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)
public class RpcCleanUpServiceTest {
@Mock
private PartitionService partitionService;
@Mock
private RpcDao rpcDao;
@Mock
private TenantService tenantService;
@Mock
private TbTenantProfileCache tenantProfileCache;
private RpcCleanUpService cleanUpService;
private static final int BATCH_SIZE = 3;
@BeforeEach
public void setUp() {
cleanUpService = new RpcCleanUpService(tenantService, partitionService, tenantProfileCache, rpcDao);
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE);
}
@Test
public void testBatchLoopCallsDaoMultipleTimes() {
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
setupTenant(tenantId, 7);
// Returns 3 (full batch), 3 (full batch), 1 (partial) -> 3 calls
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)))
.thenReturn(BATCH_SIZE)
.thenReturn(BATCH_SIZE)
.thenReturn(1);
cleanUpService.cleanUp();
verify(rpcDao, times(3)).deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE));
}
@Test
public void testSkipsTenantNotOnMyPartition() {
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID());
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID());
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build();
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build();
when(tenantService.findTenantsIds(any()))
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false));
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition);
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition);
setupTenantProfile(myTenant, 7);
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)))
.thenReturn(0);
cleanUpService.cleanUp();
verify(rpcDao).deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE));
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(eq(otherTenant), anyLong(), anyInt());
}
@Test
public void testSkipsTenantWithZeroTtl() {
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
setupTenant(tenantId, 0);
cleanUpService.cleanUp();
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(any(), anyLong(), anyInt());
}
private void setupTenant(TenantId tenantId, int rpcTtlDays) {
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build();
when(partitionService.resolve(any(), eq(tenantId), eq(tenantId))).thenReturn(myPartition);
when(tenantService.findTenantsIds(any()))
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false));
setupTenantProfile(tenantId, rpcTtlDays);
}
private void setupTenantProfile(TenantId tenantId, int rpcTtlDays) {
TenantProfile profile = new TenantProfile();
TenantProfileData profileData = new TenantProfileData();
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
config.setRpcTtlDays(rpcTtlDays);
profileData.setConfiguration(config);
profile.setProfileData(profileData);
when(tenantProfileCache.get(tenantId)).thenReturn(profile);
}
}

11
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java

@ -171,7 +171,14 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
if (this.leshanClient != null) { if (this.leshanClient != null) {
log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity)); log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.stop(false); try {
this.leshanClient.stop(false);
} catch (Exception stopEx) {
// Leshan may throw NPE during CoAP observe-relation cleanup when the server
// reference is null (race condition in NotificationDataStore.toKey()).
// The client is still considered stopped at this point — proceed with restart.
log.warn("Exception during LwM2M client stop, proceeding with restart: {}", stopEx.getMessage());
}
log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity)); log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.start(); this.leshanClient.start();
@ -193,7 +200,7 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
} catch (Exception e) { } catch (Exception e) {
log.error("Error during firmware update", e); log.error("Error during firmware update", e);
} }
}, 0, TimeUnit.SECONDS); // start immediately, without further delay }, 1, TimeUnit.SECONDS); // delay 1 sec to allow CoAP Execute response to be delivered before client stops
} }
protected void setLeshanClient(LeshanClient leshanClient) { protected void setLeshanClient(LeshanClient leshanClient) {

7
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java

@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper;
import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
@ -37,6 +38,12 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID
@Slf4j @Slf4j
public class RpcLwm2mIntegrationReadCollectedValueTest extends AbstractRpcLwM2MIntegrationTest { public class RpcLwm2mIntegrationReadCollectedValueTest extends AbstractRpcLwM2MIntegrationTest {
@Before
public void resetCollectedValueTimestamps() {
Lwm2mTestHelper.RESOURCE_ID_3303_12_5700_TS_0 = 0;
Lwm2mTestHelper.RESOURCE_ID_3303_12_5700_TS_1 = 0;
}
/** /**
* Read {"id":"/3303/12/5700"} * Read {"id":"/3303/12/5700"}
* Trigger a Send operation from the client with multiple values for the same resource as a payload * Trigger a Send operation from the client with multiple values for the same resource as a payload

1
application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java

@ -420,6 +420,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt
Awaitility.await() Awaitility.await()
.atMost(10, TimeUnit.SECONDS) .atMost(10, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> { .until(() -> {
List<Map<String, Object>> attributes = doGetAsyncTyped(attributeValuesUrl, new TypeReference<>() { List<Map<String, Object>> attributes = doGetAsyncTyped(attributeValuesUrl, new TypeReference<>() {
}); });

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java

@ -191,6 +191,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
AtomicReference<Device> device = new AtomicReference<>(); AtomicReference<Device> device = new AtomicReference<>();
await(alias + "find device [" + deviceName + "] after created") await(alias + "find device [" + deviceName + "] after created")
.atMost(200, TimeUnit.SECONDS) .atMost(200, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> { .until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
return device.get() != null; return device.get() != null;
@ -236,6 +237,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
AtomicReference<Device> device = new AtomicReference<>(); AtomicReference<Device> device = new AtomicReference<>();
await(alias + "find device [" + deviceName + "] after created") await(alias + "find device [" + deviceName + "] after created")
.atMost(200, TimeUnit.SECONDS) .atMost(200, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> { .until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
return device.get() != null; return device.get() != null;

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java

@ -468,6 +468,7 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra
AtomicReference<List<String>> actualKeys = new AtomicReference<>(); AtomicReference<List<String>> actualKeys = new AtomicReference<>();
await(alias + SparkplugMessageType.NBIRTH.name()) await(alias + SparkplugMessageType.NBIRTH.name())
.atMost(40, TimeUnit.SECONDS) .atMost(40, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> { .until(() -> {
actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() { actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() {
})); }));
@ -483,6 +484,7 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra
AtomicReference<List<String>> actualKeys = new AtomicReference<>(); AtomicReference<List<String>> actualKeys = new AtomicReference<>();
await(alias + SparkplugMessageType.DBIRTH.name()) await(alias + SparkplugMessageType.DBIRTH.name())
.atMost(40, TimeUnit.SECONDS) .atMost(40, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> { .until(() -> {
actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() { actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() {
})); }));

1
application/src/test/resources/application-test.properties

@ -44,6 +44,7 @@ queue.transport_api.response_poll_interval=5
queue.transport.poll_interval=5 queue.transport.poll_interval=5
queue.core.poll-interval=5 queue.core.poll-interval=5
queue.core.partitions=2 queue.core.partitions=2
queue.core.housekeeper.task-reprocessing-delay-ms=0
queue.rule-engine.poll-interval=5 queue.rule-engine.poll-interval=5
queue.rule-engine.stats.enabled=true queue.rule-engine.stats.enabled=true

2
build.sh

@ -31,7 +31,7 @@ java -version
#echo "Cleaning ui-ngx/node_modules" && rm -rf ui-ngx/node_modules #echo "Cleaning ui-ngx/node_modules" && rm -rf ui-ngx/node_modules
MAVEN_OPTS="-Xmx1024m" NODE_OPTIONS="--max_old_space_size=4096" DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=0 \ MAVEN_OPTS="-Xmx1024m" NODE_OPTIONS="--max_old_space_size=4096" DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=0 \
mvn -T2 license:format clean install -DskipTests \ mvn -T6 license:format clean install -DskipTests -Dpkg.skip=true \
$PROJECTS --also-make $PROJECTS --also-make
# \ # \
# -Dpush-docker-amd-arm-images # -Dpush-docker-amd-arm-images

2
common/actor/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/cache/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/cluster-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/coap-server/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/dao-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/data/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

4
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java

@ -23,6 +23,7 @@ import lombok.ToString;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -32,6 +33,9 @@ import java.util.UUID;
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AlarmsDeletionHousekeeperTask extends HousekeeperTask { public class AlarmsDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9214680001573764374L;
private List<UUID> alarms; private List<UUID> alarms;
public AlarmsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId) { public AlarmsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId) {

4
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java

@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.UserId;
import java.io.Serial;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -33,6 +34,9 @@ import java.util.UUID;
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AlarmsUnassignHousekeeperTask extends HousekeeperTask { public class AlarmsUnassignHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9156667024462937756L;
private String userTitle; private String userTitle;
private List<UUID> alarms; private List<UUID> alarms;

4
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java

@ -23,6 +23,7 @@ import lombok.ToString;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -32,6 +33,9 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
public class EntitiesDeletionHousekeeperTask extends HousekeeperTask { public class EntitiesDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9009068831061529286L;
private EntityType entityType; private EntityType entityType;
private List<UUID> entities; private List<UUID> entities;

4
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java

@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@ -45,6 +46,9 @@ import java.io.Serializable;
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HousekeeperTask implements Serializable { public class HousekeeperTask implements Serializable {
@Serial
private static final long serialVersionUID = -2585974110832225152L;
private TenantId tenantId; private TenantId tenantId;
private EntityId entityId; private EntityId entityId;
private HousekeeperTaskType taskType; private HousekeeperTaskType taskType;

5
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java

@ -23,12 +23,17 @@ import lombok.ToString;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
@Data @Data
@ToString(callSuper = true) @ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LatestTsDeletionHousekeeperTask extends HousekeeperTask { public class LatestTsDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 5193191938513490138L;
private String key; private String key;
public LatestTsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) { public LatestTsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) {

5
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java

@ -23,12 +23,17 @@ import lombok.ToString;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
@Data @Data
@ToString(callSuper = true) @ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@NoArgsConstructor @NoArgsConstructor
public class TenantEntitiesDeletionHousekeeperTask extends HousekeeperTask { public class TenantEntitiesDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = -8033108795318393447L;
private EntityType entityType; private EntityType entityType;
public TenantEntitiesDeletionHousekeeperTask(TenantId tenantId, EntityType entityType) { public TenantEntitiesDeletionHousekeeperTask(TenantId tenantId, EntityType entityType) {

5
common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java

@ -23,12 +23,17 @@ import lombok.ToString;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
@Data @Data
@ToString(callSuper = true) @ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TsHistoryDeletionHousekeeperTask extends HousekeeperTask { public class TsHistoryDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 4573851542705079043L;
private String key; private String key;
public TsHistoryDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) { public TsHistoryDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) {

5
common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java

@ -37,4 +37,9 @@ public interface TsKvEntry extends KvEntry, HasVersion {
return new TsValue(getTs(), getValueAsString()); return new TsValue(getTs(), getValueAsString());
} }
@JsonIgnore
default boolean isDeletedEntry() {
return getTs() == 0 && (getValue() == null || getValueAsString().isEmpty());
}
} }

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

@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.notification.rule;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo;
@ -29,7 +30,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class)
@JsonSubTypes({ @JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@ -38,6 +39,7 @@ import java.util.UUID;
public abstract class NotificationRuleRecipientsConfig implements Serializable { public abstract class NotificationRuleRecipientsConfig implements Serializable {
@NotNull @NotNull
@JsonProperty("triggerType")
private NotificationRuleTriggerType triggerType; private NotificationRuleTriggerType triggerType;
@JsonIgnore @JsonIgnore

2
common/discovery-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/edge-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/edge-api/src/main/proto/edge.proto

@ -47,8 +47,10 @@ enum EdgeVersion {
V_4_3_0 = 13; V_4_3_0 = 13;
V_4_2_1_2 = 14; V_4_2_1_2 = 14;
V_4_2_2 = 4220; V_4_2_2 = 4220;
V_4_2_2_1 = 4221;
V_4_3_0_1 = 15; V_4_3_0_1 = 15;
V_4_3_1 = 4310; V_4_3_1 = 4310;
V_4_3_1_1 = 4311;
V_LATEST = 99999; V_LATEST = 99999;
} }

2
common/edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/message/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>common</artifactId> <artifactId>common</artifactId>

2
common/proto/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/queue/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/script/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/script/remote-js-client/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>script</artifactId> <artifactId>script</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.script</groupId> <groupId>org.thingsboard.common.script</groupId>

2
common/script/script-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>script</artifactId> <artifactId>script</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.script</groupId> <groupId>org.thingsboard.common.script</groupId>

2
common/stats/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/transport/coap/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

2
common/transport/http/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

2
common/transport/lwm2m/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

18
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java

@ -323,22 +323,24 @@ public class TbLwM2mRedisRegistrationStore implements RegistrationStore, Startab
@Override @Override
public Iterator<Registration> getAllRegistrations() { public Iterator<Registration> getAllRegistrations() {
try (var connection = connectionFactory.getConnection()) { try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
Collection<Registration> list = new LinkedList<>(); Collection<Registration> list = new LinkedList<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(REG_EP + "*").build(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(REG_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>(); List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) { if (scanConnection instanceof RedisClusterConnection clusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { clusterConnection.clusterGetNodes().forEach(node ->
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); scans.add(clusterConnection.scan(node, scanOptions)));
});
} else { } else {
scans.add(connection.scan(scanOptions)); scans.add(scanConnection.scan(scanOptions));
} }
scans.forEach(scan -> { scans.forEach(scan -> {
scan.forEachRemaining(key -> { scan.forEachRemaining(key -> {
byte[] element = connection.get(key); byte[] element = getConnection.get(key);
list.add(deserializeReg(element)); if (element != null) {
list.add(deserializeReg(element));
}
}); });
}); });
return list.iterator(); return list.iterator();

14
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java

@ -61,21 +61,21 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore {
@Override @Override
public Set<LwM2mClient> getAll() { public Set<LwM2mClient> getAll() {
try (var connection = connectionFactory.getConnection()) { try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
Set<LwM2mClient> clients = new HashSet<>(); Set<LwM2mClient> clients = new HashSet<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(CLIENT_EP + "*").build(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(CLIENT_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>(); List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) { if (scanConnection instanceof RedisClusterConnection clusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { clusterConnection.clusterGetNodes().forEach(node ->
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); scans.add(clusterConnection.scan(node, scanOptions)));
});
} else { } else {
scans.add(connection.scan(scanOptions)); scans.add(scanConnection.scan(scanOptions));
} }
scans.forEach(scan -> { scans.forEach(scan -> {
scan.forEachRemaining(key -> { scan.forEachRemaining(key -> {
byte[] element = connection.get(key); byte[] element = getConnection.get(key);
if (element != null) { if (element != null) {
try { try {
clients.add(deserialize(element)); clients.add(deserialize(element));

18
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java

@ -35,22 +35,24 @@ public class TbRedisLwM2MModelConfigStore implements TbLwM2MModelConfigStore {
@Override @Override
public List<LwM2MModelConfig> getAll() { public List<LwM2MModelConfig> getAll() {
try (var connection = connectionFactory.getConnection()) { try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
List<LwM2MModelConfig> configs = new ArrayList<>(); List<LwM2MModelConfig> configs = new ArrayList<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(MODEL_EP + "*").build(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(MODEL_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>(); List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) { if (scanConnection instanceof RedisClusterConnection clusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { clusterConnection.clusterGetNodes().forEach(node ->
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); scans.add(clusterConnection.scan(node, scanOptions)));
});
} else { } else {
scans.add(connection.scan(scanOptions)); scans.add(scanConnection.scan(scanOptions));
} }
scans.forEach(scan -> { scans.forEach(scan -> {
scan.forEachRemaining(key -> { scan.forEachRemaining(key -> {
byte[] element = connection.get(key); byte[] element = getConnection.get(key);
configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class)); if (element != null) {
configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class));
}
}); });
}); });
return configs; return configs;

137
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStoreTest.java

@ -0,0 +1,137 @@
/**
* 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.store;
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.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.thingsboard.server.transport.lwm2m.server.client.LwM2MClientState;
import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.transport.lwm2m.server.store.util.LwM2MClientSerDes.serialize;
/**
* Verifies that {@link TbRedisLwM2MClientStore#getAll()} uses separate connections for
* SCAN and GET operations to prevent Jedis 5.x response-ordering corruption that occurs
* when both commands share the same connection.
*/
@ExtendWith(MockitoExtension.class)
class TbRedisLwM2MClientStoreTest {
@Mock
RedisConnectionFactory connectionFactory;
@Mock
RedisConnection scanConnection;
@Mock
RedisConnection getConnection;
TbRedisLwM2MClientStore store;
@BeforeEach
void setUp() {
// First getConnection() call → scanConnection, second → getConnection
when(connectionFactory.getConnection())
.thenReturn(scanConnection)
.thenReturn(getConnection);
store = new TbRedisLwM2MClientStore(connectionFactory);
}
@Test
void getAll_returnsSingleClient() {
LwM2mClient client = new LwM2mClient("nodeId", "testEndpoint");
client.setState(LwM2MClientState.REGISTERED);
byte[] key = "CLIENT#EP#testEndpoint".getBytes();
byte[] value = serialize(client);
// Cursor created before thenReturn to avoid Mockito unfinished-stubbing error
Cursor<byte[]> cursor = cursorOf(key);
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor);
when(getConnection.get(key)).thenReturn(value);
Set<LwM2mClient> result = store.getAll();
assertThat(result).hasSize(1);
assertThat(result.iterator().next().getEndpoint()).isEqualTo("testEndpoint");
}
@Test
void getAll_getIsNeverCalledOnScanConnection() {
Cursor<byte[]> cursor = cursorOf();
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor);
store.getAll();
verify(scanConnection, never()).get(any(byte[].class));
}
@Test
void getAll_scanIsNeverCalledOnGetConnection() {
Cursor<byte[]> cursor = cursorOf();
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor);
store.getAll();
verify(getConnection, never()).scan(any(ScanOptions.class));
}
@Test
void getAll_skipsKeyWhenValueIsNull() {
byte[] key = "CLIENT#EP#gone".getBytes();
Cursor<byte[]> cursor = cursorOf(key);
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor);
// getConnection.get(key) returns null by default — no stubbing needed
Set<LwM2mClient> result = store.getAll();
assertThat(result).isEmpty();
}
/**
* Creates a mock {@link Cursor} that iterates over the given keys via {@code forEachRemaining}.
* The cursor is created separately (not inside a {@code thenReturn()} argument) to avoid
* Mockito's "unfinished stubbing" error caused by nested {@code when()} calls.
*/
@SuppressWarnings("unchecked")
private static Cursor<byte[]> cursorOf(byte[]... keys) {
Cursor<byte[]> cursor = mock(Cursor.class);
List<byte[]> keyList = List.of(keys);
doAnswer(inv -> {
Consumer<byte[]> action = inv.getArgument(0);
keyList.forEach(action);
return null;
}).when(cursor).forEachRemaining(any(Consumer.class));
return cursor;
}
}

2
common/transport/mqtt/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

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

@ -88,20 +88,30 @@ public class MqttTransportContext extends TransportContext {
@Value("${transport.mqtt.proxy_enabled:false}") @Value("${transport.mqtt.proxy_enabled:false}")
private boolean proxyEnabled; private boolean proxyEnabled;
private final AtomicInteger connectionsCounter = new AtomicInteger(); private final AtomicInteger connectionsActiveCounterMQTT = new AtomicInteger();
private final AtomicInteger connectionsActiveCounterMQTTS = new AtomicInteger();
@PostConstruct @PostConstruct
public void init() { public void init() {
super.init(); super.init();
transportService.createGaugeStats("openConnections", connectionsCounter); transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTT, "protocol", "MQTT");
transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTTS, "protocol", "MQTTS");
} }
public void channelRegistered() { public void channelRegistered(boolean isSSL) {
connectionsCounter.incrementAndGet(); if (isSSL) {
connectionsActiveCounterMQTTS.incrementAndGet();
} else {
connectionsActiveCounterMQTT.incrementAndGet();
}
} }
public void channelUnregistered() { public void channelUnregistered(boolean isSSL) {
connectionsCounter.decrementAndGet(); if (isSSL) {
connectionsActiveCounterMQTTS.decrementAndGet();
} else {
connectionsActiveCounterMQTT.decrementAndGet();
}
} }
public boolean checkAddress(InetSocketAddress address) { public boolean checkAddress(InetSocketAddress address) {

31
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java

@ -180,16 +180,20 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
this.rpcAwaitingAck = new ConcurrentHashMap<>(); this.rpcAwaitingAck = new ConcurrentHashMap<>();
} }
boolean isSSL() {
return sslHandler != null;
}
@Override @Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception { public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
super.channelRegistered(ctx); super.channelRegistered(ctx);
context.channelRegistered(); context.channelRegistered(isSSL());
} }
@Override @Override
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
super.channelUnregistered(ctx); super.channelUnregistered(ctx);
context.channelUnregistered(); context.channelUnregistered(isSSL());
} }
@Override @Override
@ -254,6 +258,17 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
} }
} }
String getClientAddr(ChannelHandlerContext ctx) {
try {
InetSocketAddress remote = getAddress(ctx);
if (remote == null) return "unknown";
String host = remote.getAddress() != null ? remote.getAddress().getHostAddress() : remote.getHostString();
return host + ":" + remote.getPort();
} catch (Exception ignored) {
return "unknown";
}
}
InetSocketAddress getAddress(ChannelHandlerContext ctx) { InetSocketAddress getAddress(ChannelHandlerContext ctx) {
var address = ctx.channel().attr(MqttTransportService.ADDRESS).get(); var address = ctx.channel().attr(MqttTransportService.ADDRESS).get();
if (address == null) { if (address == null) {
@ -1150,19 +1165,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof IOException) { if (cause instanceof IOException) {
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[{}][{}][{}] IOException: {}", sessionId, String clientAddr = getClientAddr(ctx);
log.debug("[{}][{}][{}][{}] {}: {}", sessionId,
Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null),
Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""),
clientAddr,
cause.getClass().getSimpleName(),
cause.getMessage(), cause.getMessage(),
cause); cause);
} else if (log.isInfoEnabled()) { } else if (log.isInfoEnabled()) {
log.info("[{}][{}][{}] IOException: {}", sessionId, String clientAddr = getClientAddr(ctx);
log.info("[{}][{}][{}][{}] {}: {}", sessionId,
Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null),
Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""),
clientAddr,
cause.getClass().getSimpleName(),
cause.getMessage()); cause.getMessage());
} }
} else { } else {
log.error("[{}] Unexpected Exception", sessionId, cause); log.error("[{}][{}] Unexpected Exception", sessionId, getClientAddr(ctx), cause);
} }
closeCtx(ctx, MqttReasonCodes.Disconnect.SERVER_SHUTTING_DOWN); closeCtx(ctx, MqttReasonCodes.Disconnect.SERVER_SHUTTING_DOWN);

2
common/transport/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/transport/snmp/pom.xml

@ -21,7 +21,7 @@
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>

2
common/transport/transport-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

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

@ -161,5 +161,5 @@ public interface TransportService {
boolean hasSession(SessionInfoProto sessionInfo); boolean hasSession(SessionInfoProto sessionInfo);
void createGaugeStats(String openConnections, AtomicInteger connectionsCounter); void createGaugeStats(String statsName, AtomicInteger number, String... tags);
} }

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

@ -30,6 +30,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
@Component @Component
@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) { public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// no-op // no-op
} }
@Override
public void addBundleRegisterHandler(BiConsumer<String, SslBundle> handler) {
// no-op
}
}; };
} }

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

@ -1257,9 +1257,21 @@ public class DefaultTransportService extends TransportActivityManager implements
} }
@Override @Override
public void createGaugeStats(String statsName, AtomicInteger number) { public void createGaugeStats(String statsName, AtomicInteger number, String... tags) {
statsFactory.createGauge(StatsType.TRANSPORT + "." + statsName, number); String key = "thingsboard" + "." + StatsType.TRANSPORT.getName() + "." + statsName;
statsMap.put(statsName, number); statsFactory.createGauge(key, number, tags);
statsMap.put(statsName + tagsKey(tags), number);
}
private String tagsKey(String... tags) {
if (tags == null || tags.length < 2) return "";
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < tags.length; i += 2) {
if (i > 0) sb.append(',');
sb.append(tags[i]).append('=').append(i + 1 < tags.length ? tags[i + 1] : "");
}
sb.append(']');
return sb.toString();
} }
@Scheduled(fixedDelayString = "${transport.stats.print-interval-ms:60000}") @Scheduled(fixedDelayString = "${transport.stats.print-interval-ms:60000}")

2
common/util/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

56
common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java

@ -38,6 +38,7 @@ public class SsrfProtectionValidator {
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local"); private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local");
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY; private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY;
private static volatile AllowedHosts allowedHosts = AllowedHosts.EMPTY;
// Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal) // Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal)
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of( private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of(
@ -66,6 +67,13 @@ public class SsrfProtectionValidator {
} }
String hostLower = host.toLowerCase(); String hostLower = host.toLowerCase();
// Allow-listed hostnames bypass all hostname and IP checks
AllowedHosts currentAllowed = allowedHosts;
if (currentAllowed.hostnames.contains(hostLower)) {
return;
}
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) { if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) {
throwBlockedHost(host); throwBlockedHost(host);
} }
@ -98,7 +106,15 @@ public class SsrfProtectionValidator {
} }
} }
private static boolean isBlockedAddress(InetAddress address) { public static boolean isBlockedAddress(InetAddress address) {
// Check allow-list first: allowed addresses bypass all block checks
AllowedHosts currentAllowed = allowedHosts;
for (CidrRange cidr : currentAllowed.cidrRanges) {
if (cidr.contains(address)) {
return false;
}
}
// Covers 127.0.0.0/8 and ::1 // Covers 127.0.0.0/8 and ::1
if (address.isLoopbackAddress()) { if (address.isLoopbackAddress()) {
return true; return true;
@ -142,14 +158,37 @@ public class SsrfProtectionValidator {
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed"); throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed");
} }
public static boolean isEnabled() {
return enabled;
}
public static void setEnabled(boolean enabled) { public static void setEnabled(boolean enabled) {
SsrfProtectionValidator.enabled = enabled; SsrfProtectionValidator.enabled = enabled;
} }
public static void setAdditionalBlockedHosts(List<String> entries) { public static void setAdditionalBlockedHosts(List<String> entries) {
ParsedHostEntries parsed = parseHostEntries(entries);
additionalBlocked = new AdditionalBlockedHosts(parsed.cidrRanges, parsed.hostnames);
if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) {
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size());
}
}
public static void setAllowedHosts(List<String> entries) {
ParsedHostEntries parsed = parseHostEntries(entries);
allowedHosts = new AllowedHosts(parsed.cidrRanges, parsed.hostnames);
if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) {
log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size());
}
}
public static boolean isHostnameAllowed(String hostname) {
return allowedHosts.hostnames.contains(hostname.toLowerCase());
}
private static ParsedHostEntries parseHostEntries(List<String> entries) {
if (entries == null || entries.isEmpty()) { if (entries == null || entries.isEmpty()) {
additionalBlocked = AdditionalBlockedHosts.EMPTY; return ParsedHostEntries.EMPTY;
return;
} }
List<CidrRange> cidrRanges = new ArrayList<>(); List<CidrRange> cidrRanges = new ArrayList<>();
Set<String> hostnames = new HashSet<>(); Set<String> hostnames = new HashSet<>();
@ -168,10 +207,9 @@ public class SsrfProtectionValidator {
hostnames.add(trimmed.toLowerCase()); hostnames.add(trimmed.toLowerCase());
} }
} }
additionalBlocked = new AdditionalBlockedHosts( return new ParsedHostEntries(
Collections.unmodifiableList(cidrRanges), Collections.unmodifiableList(cidrRanges),
Collections.unmodifiableSet(hostnames)); Collections.unmodifiableSet(hostnames));
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size());
} }
private static boolean isIpLiteral(String entry) { private static boolean isIpLiteral(String entry) {
@ -179,10 +217,18 @@ public class SsrfProtectionValidator {
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":"));
} }
private record ParsedHostEntries(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final ParsedHostEntries EMPTY = new ParsedHostEntries(Collections.emptyList(), Collections.emptySet());
}
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) { record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet()); static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet());
} }
record AllowedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AllowedHosts EMPTY = new AllowedHosts(Collections.emptyList(), Collections.emptySet());
}
record CidrRange(byte[] network, int prefixLength) { record CidrRange(byte[] network, int prefixLength) {
static CidrRange of(String ip, int prefixLength) { static CidrRange of(String ip, int prefixLength) {

244
common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java

@ -20,14 +20,16 @@ import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import java.net.InetAddress;
import java.net.URI; import java.net.URI;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts @ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class SsrfProtectionValidatorTest { public class SsrfProtectionValidatorTest {
@ParameterizedTest @ParameterizedTest
@ -335,4 +337,244 @@ public class SsrfProtectionValidatorTest {
} }
} }
// --- Allow-list tests ---
@Test
void testAllowListCidrAllowsPrivateAddress() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
// 192.168.1.1 is normally blocked (site-local), but allow-listed
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Other private ranges remain blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.0.0.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListHostnameBypassesSuffixCheck() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local"));
// .local suffix is normally blocked, but allow-listed hostname passes
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true));
// Other .local hostnames remain blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://other-device.local/api"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListPrecedenceOverBlockList() {
try {
// Block 8.8.8.0/24 via additional-blocked, but allow 8.8.8.8 via allow-list
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.0/24"));
SsrfProtectionValidator.setAllowedHosts(List.of("8.8.8.8"));
// Allow-list should win
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true));
// Adjacent IP still blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsBlockedAddressPublicApi() throws Exception {
InetAddress loopback = InetAddress.getByName("127.0.0.1");
assertThat(SsrfProtectionValidator.isBlockedAddress(loopback)).isTrue();
InetAddress publicIp = InetAddress.getByName("8.8.8.8");
assertThat(SsrfProtectionValidator.isBlockedAddress(publicIp)).isFalse();
// Allow-listed private address
try {
SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8"));
InetAddress privateIp = InetAddress.getByName("10.1.2.3");
assertThat(SsrfProtectionValidator.isBlockedAddress(privateIp)).isFalse();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsEnabledAccessor() {
boolean original = SsrfProtectionValidator.isEnabled();
try {
SsrfProtectionValidator.setEnabled(true);
assertThat(SsrfProtectionValidator.isEnabled()).isTrue();
SsrfProtectionValidator.setEnabled(false);
assertThat(SsrfProtectionValidator.isEnabled()).isFalse();
} finally {
SsrfProtectionValidator.setEnabled(original);
}
}
@Test
void testSetAllowedHostsEmptyAndNull() {
// Should not throw
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
SsrfProtectionValidator.setAllowedHosts(null);
}
@Test
void testIsHostnameAllowed() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local", "Internal-Server.Corp"));
assertThat(SsrfProtectionValidator.isHostnameAllowed("my-device.local")).isTrue();
assertThat(SsrfProtectionValidator.isHostnameAllowed("MY-DEVICE.LOCAL")).isTrue(); // case-insensitive
assertThat(SsrfProtectionValidator.isHostnameAllowed("internal-server.corp")).isTrue();
assertThat(SsrfProtectionValidator.isHostnameAllowed("other-device.local")).isFalse();
assertThat(SsrfProtectionValidator.isHostnameAllowed("example.com")).isFalse();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsHostnameAllowedEmptyList() {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
assertThat(SsrfProtectionValidator.isHostnameAllowed("anything")).isFalse();
}
@Test
void testValidateUriUsesStaticEnabledFlag() {
boolean original = SsrfProtectionValidator.isEnabled();
try {
// When enabled, loopback is blocked via the public one-arg overload
SsrfProtectionValidator.setEnabled(true);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1")))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// When disabled, loopback passes
SsrfProtectionValidator.setEnabled(false);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1")));
} finally {
SsrfProtectionValidator.setEnabled(original);
}
}
@Test
void testAllowListHostnameCaseInsensitive() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("My-Device.LOCAL"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://MY-DEVICE.LOCAL/api"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListOverridesCloudMetadataRange() {
try {
// 169.254.169.254 is link-local (blocked by default), allow-list should override
SsrfProtectionValidator.setAllowedHosts(List.of("169.254.169.254"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.169.254/latest/meta-data/"), true));
// Other link-local still blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListOverridesLoopback() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("127.0.0.0/8"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"), true));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.1.2.3"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListCidrBoundary() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
// Last address in range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.255"), true));
// First address outside range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.2.0"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Different subnet entirely
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.0.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testBlockedIpv6UniqueLocal() throws Exception {
// fc00::/7 covers fc00:: through fdff::
InetAddress fc00 = InetAddress.getByName("fc00::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(fc00)).isTrue();
InetAddress fdAddr = InetAddress.getByName("fd12:3456:789a::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(fdAddr)).isTrue();
// fe00:: is NOT in fc00::/7 (it's in fe80::/10 link-local, but fe00:: without the 80 bits is different)
// 2001:db8:: is a public documentation prefix, not blocked
InetAddress publicV6 = InetAddress.getByName("2001:db8::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(publicV6)).isFalse();
}
@Test
void testParseHostEntriesWithWhitespaceAndBlanks() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of(" 192.168.1.0/24 ", "", " ", "my-host.corp"));
// Trimmed CIDR works
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Trimmed hostname works
assertThat(SsrfProtectionValidator.isHostnameAllowed("my-host.corp")).isTrue();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testSetAllowedHostsReplacePrevious() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Replace with different range
SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8"));
// Old range no longer allowed
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// New range allowed
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.1.2.3"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListHostnameBypassesBlockedHostname() {
try {
// "localhost" is in BLOCKED_HOSTNAMES; allow-listing it should let it through
SsrfProtectionValidator.setAllowedHosts(List.of("localhost"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://localhost/path"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
} }

2
common/version-control/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

3
dao/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1</version> <version>4.3.1.1-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>dao</artifactId> <artifactId>dao</artifactId>
@ -280,7 +280,6 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
<version>${jar-plugin.version}</version>
<executions> <executions>
<execution> <execution>
<goals> <goals>

10
dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.dao; package org.thingsboard.server.dao;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@ -202,4 +203,13 @@ public final class DaoUtil {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public static ConstraintViolationException extractConstraintViolation(Throwable t) {
if (t instanceof ConstraintViolationException cve) {
return cve;
} else if (t != null && t.getCause() instanceof ConstraintViolationException cve) {
return cve;
}
return null;
}
} }

2
dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java

@ -50,7 +50,7 @@ public interface NotificationRequestDao extends Dao<NotificationRequest> {
boolean existsByTenantIdAndStatusAndTemplateId(TenantId tenantId, NotificationRequestStatus status, NotificationTemplateId templateId); boolean existsByTenantIdAndStatusAndTemplateId(TenantId tenantId, NotificationRequestStatus status, NotificationTemplateId templateId);
int removeAllByCreatedTimeBefore(long ts); int removeByTenantIdAndCreatedTimeBeforeBatch(TenantId tenantId, long ts, int batchSize);
NotificationRequestInfo findInfoById(TenantId tenantId, NotificationRequestId id); NotificationRequestInfo findInfoById(TenantId tenantId, NotificationRequestId id);

58
dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java

@ -196,7 +196,9 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
log.trace("Executing deleteOtaPackage [{}]", otaPackageId); log.trace("Executing deleteOtaPackage [{}]", otaPackageId);
validateId(otaPackageId, id -> INCORRECT_OTA_PACKAGE_ID + id); validateId(otaPackageId, id -> INCORRECT_OTA_PACKAGE_ID + id);
try { try {
Long oid = getDataOidById(tenantId, otaPackageId);
otaPackageDao.removeById(tenantId, otaPackageId.getId()); otaPackageDao.removeById(tenantId, otaPackageId.getId());
unlinkDataIfPresent(tenantId, otaPackageId, oid);
publishEvictEvent(new OtaPackageCacheEvictEvent(otaPackageId)); publishEvictEvent(new OtaPackageCacheEvictEvent(otaPackageId));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(otaPackageId).build()); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(otaPackageId).build());
} catch (Exception t) { } catch (Exception t) {
@ -215,6 +217,30 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
} }
} }
private Long getDataOidById(TenantId tenantId, OtaPackageId otaPackageId) {
try {
log.trace("Executing getDataOidById tenantId [{}], otaPackageId [{}]", tenantId, otaPackageId);
return otaPackageDao.getDataOidById(otaPackageId.getId());
} catch (Exception e) {
log.warn("[{}][{}] Failed to retrieve OID before deletion", tenantId, otaPackageId, e);
}
return null;
}
private void unlinkDataIfPresent(TenantId tenantId, OtaPackageId otaPackageId, Long oid) {
if (oid == null) {
return;
}
try {
Integer result = otaPackageDao.unlinkLargeObject(oid);
if (result != 1) {
log.warn("[{}][{}] Failed to delete large object (OID: {}). Result code: {}", tenantId, otaPackageId, oid, result);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to delete large object (OID: {})", tenantId, otaPackageId, oid, e);
}
}
@Override @Override
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteOtaPackage(tenantId, (OtaPackageId) id); deleteOtaPackage(tenantId, (OtaPackageId) id);
@ -225,6 +251,11 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
return otaPackageDao.sumDataSizeByTenantId(tenantId); return otaPackageDao.sumDataSizeByTenantId(tenantId);
} }
@Override
public void deleteByTenantId(TenantId tenantId) {
deleteOtaPackagesByTenantId(tenantId);
}
@Override @Override
public void deleteOtaPackagesByTenantId(TenantId tenantId) { public void deleteOtaPackagesByTenantId(TenantId tenantId) {
log.trace("Executing deleteOtaPackagesByTenantId, tenantId [{}]", tenantId); log.trace("Executing deleteOtaPackagesByTenantId, tenantId [{}]", tenantId);
@ -232,24 +263,17 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
tenantOtaPackageRemover.removeEntities(tenantId, tenantId); tenantOtaPackageRemover.removeEntities(tenantId, tenantId);
} }
@Override private final PaginatedRemover<TenantId, OtaPackageInfo> tenantOtaPackageRemover = new PaginatedRemover<>() {
public void deleteByTenantId(TenantId tenantId) { @Override
deleteOtaPackagesByTenantId(tenantId); protected PageData<OtaPackageInfo> findEntities(TenantId tenantId, TenantId id, PageLink pageLink) {
} return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink);
}
private PaginatedRemover<TenantId, OtaPackageInfo> tenantOtaPackageRemover =
new PaginatedRemover<>() {
@Override
protected PageData<OtaPackageInfo> findEntities(TenantId tenantId, TenantId id, PageLink pageLink) {
return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink);
}
@Override @Override
protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) { protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) {
deleteOtaPackage(tenantId, entity.getId()); deleteOtaPackage(tenantId, entity.getId());
} }
}; };
@Override @Override
public Optional<HasId<?>> findEntity(TenantId tenantId, EntityId entityId) { public Optional<HasId<?>> findEntity(TenantId tenantId, EntityId entityId) {

7
dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java

@ -18,15 +18,20 @@ package org.thingsboard.server.dao.ota;
import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.OtaPackage;
import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.OtaPackageId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.ota.OtaPackageType;
import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.ExportableEntityDao;
import org.thingsboard.server.dao.TenantEntityWithDataDao; import org.thingsboard.server.dao.TenantEntityWithDataDao;
import java.util.UUID;
public interface OtaPackageDao extends Dao<OtaPackage>, TenantEntityWithDataDao, ExportableEntityDao<OtaPackageId, OtaPackage> { public interface OtaPackageDao extends Dao<OtaPackage>, TenantEntityWithDataDao, ExportableEntityDao<OtaPackageId, OtaPackage> {
Long sumDataSizeByTenantId(TenantId tenantId); Long sumDataSizeByTenantId(TenantId tenantId);
OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version);
Long getDataOidById(UUID id);
Integer unlinkLargeObject(Long dataOid);
} }

3
dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java

@ -24,12 +24,13 @@ import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.Dao;
public interface RpcDao extends Dao<Rpc> { public interface RpcDao extends Dao<Rpc> {
PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink); PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink);
PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink); PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink);
PageData<Rpc> findAllRpcByTenantId(TenantId tenantId, PageLink pageLink); PageData<Rpc> findAllRpcByTenantId(TenantId tenantId, PageLink pageLink);
int deleteOutdatedRpcByTenantId(TenantId tenantId, Long expirationTime); int deleteOutdatedRpcByTenantIdBatch(TenantId tenantId, Long expirationTime, int batchSize);
} }

8
dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.validator;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.oauth2.MapperType; import org.thingsboard.server.common.data.oauth2.MapperType;
@ -28,6 +29,8 @@ import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType;
import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.DataValidator;
import java.net.URI;
@Component @Component
@AllArgsConstructor @AllArgsConstructor
public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> { public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
@ -64,6 +67,11 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
if (StringUtils.isEmpty(customConfig.getUrl())) { if (StringUtils.isEmpty(customConfig.getUrl())) {
throw new DataValidationException("Custom mapper URL should be specified!"); throw new DataValidationException("Custom mapper URL should be specified!");
} }
try {
SsrfProtectionValidator.validateUri(new URI(customConfig.getUrl()));
} catch (Exception e) {
throw new DataValidationException("Custom mapper URL is not allowed: " + e.getMessage());
}
} }
} }
} }

4
dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java

@ -98,8 +98,8 @@ public class JpaNotificationRequestDao extends JpaAbstractDao<NotificationReques
} }
@Override @Override
public int removeAllByCreatedTimeBefore(long ts) { public int removeByTenantIdAndCreatedTimeBeforeBatch(TenantId tenantId, long ts, int batchSize) {
return notificationRequestRepository.deleteAllByCreatedTimeBefore(ts); return notificationRequestRepository.deleteByTenantIdAndCreatedTimeBeforeBatch(tenantId.getId(), ts, batchSize);
} }
@Override @Override

8
dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationRequestRepository.java

@ -71,8 +71,12 @@ public interface NotificationRequestRepository extends JpaRepository<Notificatio
@Transactional @Transactional
@Modifying @Modifying
@Query("DELETE FROM NotificationRequestEntity r WHERE r.createdTime < :ts") @Query(value = "DELETE FROM notification_request WHERE id IN " +
int deleteAllByCreatedTimeBefore(@Param("ts") long ts); "(SELECT id FROM notification_request WHERE tenant_id = :tenantId AND created_time < :ts LIMIT :batchSize)",
nativeQuery = true)
int deleteByTenantIdAndCreatedTimeBeforeBatch(@Param("tenantId") UUID tenantId,
@Param("ts") long ts,
@Param("batchSize") int batchSize);
@Transactional @Transactional
@Modifying @Modifying

10
dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java

@ -77,6 +77,16 @@ public class JpaOtaPackageDao extends JpaAbstractDao<OtaPackageEntity, OtaPackag
return DaoUtil.getData(otaPackageRepository.findByTenantIdAndExternalId(tenantId, externalId)); return DaoUtil.getData(otaPackageRepository.findByTenantIdAndExternalId(tenantId, externalId));
} }
@Override
public Long getDataOidById(UUID id) {
return otaPackageRepository.getDataOidById(id);
}
@Override
public Integer unlinkLargeObject(Long dataOid) {
return otaPackageRepository.unlinkLargeObject(dataOid);
}
@Override @Override
public OtaPackageId getExternalIdByInternal(OtaPackageId internalId) { public OtaPackageId getExternalIdByInternal(OtaPackageId internalId) {
return DaoUtil.toEntityId(otaPackageRepository.getExternalIdById(internalId.getId()), OtaPackageId::new); return DaoUtil.toEntityId(otaPackageRepository.getExternalIdById(internalId.getId()), OtaPackageId::new);

10
dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageRepository.java

@ -20,7 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.ota.OtaPackageType; import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.ExportableEntityRepository;
import org.thingsboard.server.dao.model.sql.OtaPackageEntity; import org.thingsboard.server.dao.model.sql.OtaPackageEntity;
@ -41,4 +41,12 @@ public interface OtaPackageRepository extends JpaRepository<OtaPackageEntity, UU
@Query("SELECT r.id FROM OtaPackageEntity r WHERE r.tenantId = :tenantId") @Query("SELECT r.id FROM OtaPackageEntity r WHERE r.tenantId = :tenantId")
Page<UUID> findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); Page<UUID> findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable);
// The 'data' column is of type OID (PostgreSQL large object reference), so it returns the OID as Long
@Query(value = "SELECT data FROM ota_package WHERE id = :id AND data IS NOT NULL", nativeQuery = true)
Long getDataOidById(@Param("id") UUID id);
@Transactional
@Query(value = "SELECT lo_unlink(:oid)", nativeQuery = true)
Integer unlinkLargeObject(@Param("oid") Long oid);
} }

4
dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java

@ -71,8 +71,8 @@ public class JpaRpcDao extends JpaAbstractDao<RpcEntity, Rpc> implements RpcDao,
@Transactional @Transactional
@Override @Override
public int deleteOutdatedRpcByTenantId(TenantId tenantId, Long expirationTime) { public int deleteOutdatedRpcByTenantIdBatch(TenantId tenantId, Long expirationTime, int batchSize) {
return rpcRepository.deleteOutdatedRpcByTenantId(tenantId.getId(), expirationTime); return rpcRepository.deleteOutdatedRpcByTenantIdBatch(tenantId.getId(), expirationTime, batchSize);
} }
@Override @Override

11
dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java

@ -21,20 +21,27 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.dao.model.sql.RpcEntity; import org.thingsboard.server.dao.model.sql.RpcEntity;
import java.util.UUID; import java.util.UUID;
public interface RpcRepository extends JpaRepository<RpcEntity, UUID> { public interface RpcRepository extends JpaRepository<RpcEntity, UUID> {
Page<RpcEntity> findAllByTenantIdAndDeviceId(UUID tenantId, UUID deviceId, Pageable pageable); Page<RpcEntity> findAllByTenantIdAndDeviceId(UUID tenantId, UUID deviceId, Pageable pageable);
Page<RpcEntity> findAllByTenantIdAndDeviceIdAndStatus(UUID tenantId, UUID deviceId, RpcStatus status, Pageable pageable); Page<RpcEntity> findAllByTenantIdAndDeviceIdAndStatus(UUID tenantId, UUID deviceId, RpcStatus status, Pageable pageable);
Page<RpcEntity> findAllByTenantId(UUID tenantId, Pageable pageable); Page<RpcEntity> findAllByTenantId(UUID tenantId, Pageable pageable);
@Transactional
@Modifying @Modifying
@Query(value = "DELETE FROM rpc WHERE tenant_id = :tenantId AND created_time < :expirationTime", @Query(value = "DELETE FROM rpc WHERE id IN " +
"(SELECT id FROM rpc WHERE tenant_id = :tenantId AND created_time < :expirationTime LIMIT :batchSize)",
nativeQuery = true) nativeQuery = true)
int deleteOutdatedRpcByTenantId(@Param("tenantId") UUID tenantId, @Param("expirationTime") Long expirationTime); int deleteOutdatedRpcByTenantIdBatch(@Param("tenantId") UUID tenantId,
@Param("expirationTime") Long expirationTime,
@Param("batchSize") int batchSize);
} }

52
dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java

@ -36,14 +36,14 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.ota.OtaPackageDao;
import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.exception.DataValidationException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -77,6 +77,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
@Autowired @Autowired
TenantProfileService tenantProfileService; TenantProfileService tenantProfileService;
@Autowired @Autowired
OtaPackageDao otaPackageDao;
@Autowired
TbTenantProfileCache tenantProfileCache; TbTenantProfileCache tenantProfileCache;
@Before @Before
@ -118,10 +120,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
Assert.assertEquals(1, otaPackageService.sumDataSizeByTenantId(tenantId)); Assert.assertEquals(1, otaPackageService.sumDataSizeByTenantId(tenantId));
int maxSumDataSize = 8; int maxSumDataSize = 8;
List<OtaPackage> packages = new ArrayList<>(maxSumDataSize);
for (int i = 2; i <= maxSumDataSize; i++) { for (int i = 2; i <= maxSumDataSize; i++) {
packages.add(createAndSaveFirmware(tenantId, "0." + i)); createAndSaveFirmware(tenantId, "0." + i);
Assert.assertEquals(i, otaPackageService.sumDataSizeByTenantId(tenantId)); Assert.assertEquals(i, otaPackageService.sumDataSizeByTenantId(tenantId));
} }
@ -533,6 +533,39 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
Assert.assertNull(foundFirmware); Assert.assertNull(foundFirmware);
} }
@Test
public void testDeleteOtaPackageWithoutData() {
OtaPackageInfo firmwareInfo = new OtaPackageInfo();
firmwareInfo.setTenantId(tenantId);
firmwareInfo.setDeviceProfileId(deviceProfileId);
firmwareInfo.setType(FIRMWARE);
firmwareInfo.setTitle(TITLE);
firmwareInfo.setVersion(VERSION);
OtaPackageInfo savedFirmwareInfo = otaPackageService.saveOtaPackageInfo(firmwareInfo, false);
Assert.assertNotNull(savedFirmwareInfo);
Assert.assertNotNull(savedFirmwareInfo.getId());
// Should not throw NPE when deleting package without data (OID is null)
otaPackageService.deleteOtaPackage(tenantId, savedFirmwareInfo.getId());
OtaPackageInfo foundFirmware = otaPackageService.findOtaPackageInfoById(tenantId, savedFirmwareInfo.getId());
Assert.assertNull(foundFirmware);
}
@Test
public void testDeleteOtaPackageUnlinksLargeObject() {
OtaPackage savedFirmware = createAndSaveFirmware(tenantId, VERSION);
Long oid = otaPackageDao.getDataOidById(savedFirmware.getId().getId());
Assert.assertNotNull(oid);
otaPackageService.deleteOtaPackage(tenantId, savedFirmware.getId());
// Verify the large object was unlinked - PostgreSQL throws an exception when the object doesn't exist
assertThatThrownBy(() -> otaPackageDao.unlinkLargeObject(oid)).hasMessageContaining("large object " + oid + " does not exist");
}
@Test @Test
public void testFindTenantFirmwaresByTenantId() { public void testFindTenantFirmwaresByTenantId() {
List<OtaPackageInfo> firmwares = new ArrayList<>(); List<OtaPackageInfo> firmwares = new ArrayList<>();
@ -567,8 +600,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
} }
} while (pageData.hasNext()); } while (pageData.hasNext());
Collections.sort(firmwares, idComparator); firmwares.sort(idComparator);
Collections.sort(loadedFirmwares, idComparator); loadedFirmwares.sort(idComparator);
assertThat(firmwares).isEqualTo(loadedFirmwares); assertThat(firmwares).isEqualTo(loadedFirmwares);
@ -622,8 +655,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
} }
} while (pageData.hasNext()); } while (pageData.hasNext());
Collections.sort(firmwares, idComparator); firmwares.sort(idComparator);
Collections.sort(loadedFirmwares, idComparator); loadedFirmwares.sort(idComparator);
assertThat(firmwares).isEqualTo(loadedFirmwares); assertThat(firmwares).isEqualTo(loadedFirmwares);
@ -726,4 +759,5 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
firmware.setDataSize(DATA_SIZE); firmware.setDataSize(DATA_SIZE);
return firmware; return firmware;
} }
} }

2
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java

@ -65,7 +65,7 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest {
new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}"))); new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}")));
DeviceId deviceId = new DeviceId(Uuids.timeBased()); DeviceId deviceId = new DeviceId(Uuids.timeBased());
tsService.save(tenantId, deviceId, timeseries, ttlInSec); tsService.save(tenantId, deviceId, timeseries, ttlInSec).get(MAX_TIMEOUT, TimeUnit.SECONDS);
List<TsKvEntry> fullList = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery("test", 0L, List<TsKvEntry> fullList = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery("test", 0L,
TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS);

133
dao/src/test/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDaoTest.java

@ -0,0 +1,133 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.sql.notification;
import org.junit.After;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.id.NotificationRequestId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.notification.NotificationRequestStatus;
import org.thingsboard.server.dao.AbstractJpaDaoTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
public class JpaNotificationRequestDaoTest extends AbstractJpaDaoTest {
@Autowired
JpaNotificationRequestDao notificationRequestDao;
private final List<NotificationRequest> createdRequests = new ArrayList<>();
@After
public void tearDown() {
for (NotificationRequest request : createdRequests) {
notificationRequestDao.removeById(request.getTenantId(), request.getId().getId());
}
createdRequests.clear();
}
@Test
public void testBatchDeletion() {
TenantId sysTenantId = TenantId.SYS_TENANT_ID;
long now = System.currentTimeMillis();
long oldTimestamp = now - TimeUnit.DAYS.toMillis(30);
NotificationRequest oldRequest1 = createNotificationRequest(sysTenantId, oldTimestamp);
notificationRequestDao.save(sysTenantId, oldRequest1);
NotificationRequest oldRequest2 = createNotificationRequest(sysTenantId, oldTimestamp);
notificationRequestDao.save(sysTenantId, oldRequest2);
NotificationRequest freshRequest = createNotificationRequest(sysTenantId, now);
notificationRequestDao.save(sysTenantId, freshRequest);
TenantId tenant2Id = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf"));
NotificationRequest tenant2Request = createNotificationRequest(tenant2Id, oldTimestamp);
notificationRequestDao.save(tenant2Id, tenant2Request);
int batchSize = 10_000;
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, oldTimestamp - 1, batchSize)).isEqualTo(0);
long expirationTime = now - TimeUnit.DAYS.toMillis(15);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, expirationTime, batchSize)).isEqualTo(2);
assertThat(notificationRequestDao.findById(sysTenantId, freshRequest.getId().getId())).isNotNull();
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2Id, now + 1, batchSize)).isEqualTo(1);
}
@Test
public void testBatchDeletionWithSmallBatchSize() {
TenantId tenantId = TenantId.SYS_TENANT_ID;
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30);
for (int i = 0; i < 10; i++) {
NotificationRequest request = createNotificationRequest(tenantId, oldTimestamp);
notificationRequestDao.save(tenantId, request);
}
int batchSize = 3;
long expirationTime = System.currentTimeMillis();
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(1);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(0);
}
@Test
public void testBatchDeletionIsolationBetweenTenants() {
TenantId tenant1 = TenantId.SYS_TENANT_ID;
TenantId tenant2 = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf"));
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30);
for (int i = 0; i < 5; i++) {
NotificationRequest request = createNotificationRequest(tenant1, oldTimestamp);
notificationRequestDao.save(tenant1, request);
}
for (int i = 0; i < 3; i++) {
NotificationRequest request = createNotificationRequest(tenant2, oldTimestamp);
notificationRequestDao.save(tenant2, request);
}
int batchSize = 10_000;
long expirationTime = System.currentTimeMillis();
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant1, expirationTime, batchSize)).isEqualTo(5);
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2, expirationTime, batchSize)).isEqualTo(3);
}
private NotificationRequest createNotificationRequest(TenantId tenantId, long createdTime) {
NotificationRequest request = new NotificationRequest();
request.setId(new NotificationRequestId(UUID.randomUUID()));
request.setTenantId(tenantId);
request.setCreatedTime(createdTime);
request.setTargets(List.of(UUID.randomUUID()));
request.setStatus(NotificationRequestStatus.SENT);
createdRequests.add(request);
return request;
}
}

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

Loading…
Cancel
Save