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
.claude/
.claude
.planning

16
TEST_FAST.md

@ -6,7 +6,9 @@ export MAVEN_OPTS="-Xmx1024m"
export NODE_OPTIONS="--max_old_space_size=4096"
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 dao -Dparallel=packages -DforkCount=4
@ -31,6 +33,18 @@ mvn test -pl application -Dtest='
' -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
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>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>application</artifactId>
@ -35,7 +35,6 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/..</main.dir>
<pkg.type>java</pkg.type>
<pkg.disabled>false</pkg.disabled>
<pkg.process-resources.phase>process-resources</pkg.process-resources.phase>
<pkg.package.phase>package</pkg.package.phase>
<pkg.name>thingsboard</pkg.name>
@ -493,10 +492,6 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<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:}")
private List<String> ssrfAdditionalBlockedHosts;
@Value("${actors.rule.external.ssrf_allowed_hosts:}")
private List<String> ssrfAllowedHosts;
@PostConstruct
public void init() {
this.localCacheType = "caffeine".equals(cacheType);
SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled);
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}")

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

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

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

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.exception;
import jakarta.persistence.PersistenceException;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
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.msg.tools.MaxPayloadSizeExceededException;
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.JwtExpiredTokenException;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
@ -162,8 +164,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
handleAuthenticationException(authenticationException, response);
} else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException);
} else if (exception instanceof DataAccessException e) {
handleDatabaseException(e, response);
} else if (exception instanceof DataAccessException || exception instanceof PersistenceException) {
handleDatabaseException(exception, response);
} else {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
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 {
ThingsboardErrorResponse errorResponse;
if (databaseException instanceof ConstraintViolationException) {
errorResponse = ThingsboardErrorResponse.of(ExceptionUtils.getRootCause(databaseException).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST);
ConstraintViolationException constraintViolationException = DaoUtil.extractConstraintViolation(databaseException);
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 {
log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException));
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);
Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType);
Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType);
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
public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType) {
public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType) {
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()));
}

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) {
return true;
}
List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION)
List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(tenantId, CalculatedFieldType.RELATED_ENTITIES_AGGREGATION)
.filter(relatedEntityFilter)
.toList();
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.Futures;
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.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy;
@ -37,7 +40,8 @@ import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
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.EdgeEvent;
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 java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@ -112,6 +115,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
@ -176,9 +181,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
.addService(this);
if (sslEnabled) {
try {
InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource);
InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource);
builder.useTransportSecurity(certFileIs, privateKeyFileIs);
setupSsl(builder);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), 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!");
}
/**
* 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
public void destroy() {
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.web.client.RestTemplate;
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.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.info.NotificationInfo;
@ -109,10 +110,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
adaptiveCard.getActions().add(actionOpenUrl);
}
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
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 {
@ -139,10 +143,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
teamsMessageCard.setPotentialAction(List.of(actionCard));
}
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
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 {

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.web.client.RestTemplate;
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.oauth2.OAuth2CustomMapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.dao.oauth2.OAuth2User;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.net.URI;
import org.thingsboard.server.service.security.model.SecurityUser;
@Service(value = "customOAuth2ClientMapper")
@ -64,6 +67,7 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme
throw new RuntimeException("Can't convert principal to JSON string", e);
}
try {
SsrfProtectionValidator.validateUri(new URI(custom.getUrl()));
return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody();
} catch (Exception 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.ThingsBoardThreadFactory;
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.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
@ -242,7 +243,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
@Override
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);
}
@ -258,7 +262,18 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
handleLatestCmd(ctx, cmd.getLatestCmd());
}
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 {
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) {
if (!theCtx.isInitialDataSent()) {
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()) {
for (TsKvEntry kv : data) {
Long stateTs = keyStates.get(kv.getKey());
if (stateTs == null || kv.getTs() >= stateTs) {
if (stateTs == null || kv.getTs() >= stateTs || kv.isDeletedEntry()) {
if (updateData == null) {
updateData = new ArrayList<>();
}
@ -362,7 +362,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer
for (TsKvEntry kv : data) {
Long stateTs = keyStates.get(kv.getKey());
if (stateTs != null) {
if (!sub.isLatestValues() || kv.getTs() >= stateTs) {
if (!sub.isLatestValues() || kv.getTs() >= stateTs || kv.isDeletedEntry()) {
if (updateData == null) {
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();
}
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.scheduling.annotation.Scheduled;
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.page.PageDataIterable;
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.time.Instant;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.dao.model.ModelConstants.NOTIFICATION_TABLE_NAME;
@Slf4j
@Service
@ConditionalOnExpression("${sql.ttl.notifications.enabled:true} && ${sql.ttl.notifications.ttl:0} > 0")
@Slf4j
public class NotificationsCleanUpService extends AbstractCleanUpService {
private final SqlPartitioningRepository partitioningRepository;
private final NotificationRequestDao notificationRequestDao;
private final TenantService tenantService;
@Value("${sql.ttl.notifications.ttl:2592000}")
private long ttlInSec;
@Value("${sql.notifications.partition_size:168}")
private int partitionSizeInHours;
@Value("${sql.ttl.notifications.removal_batch_size:10000}")
private int removalBatchSize;
public NotificationsCleanUpService(PartitionService partitionService, SqlPartitioningRepository partitioningRepository,
NotificationRequestDao notificationRequestDao) {
NotificationRequestDao notificationRequestDao, TenantService tenantService) {
super(partitionService);
this.partitioningRepository = partitioningRepository;
this.notificationRequestDao = notificationRequestDao;
this.tenantService = tenantService;
}
@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() {
long expTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttlInSec);
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);
return;
}
long lastRemovedNotificationTs = partitioningRepository.dropPartitionsBefore(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs);
if (lastRemovedNotificationTs > 0) {
long gap = TimeUnit.MINUTES.toMillis(10);
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);
long gap = TimeUnit.MINUTES.toMillis(10);
long requestExpTime = expTime - TimeUnit.SECONDS.toMillis(NotificationRequestConfig.MAX_SENDING_DELAY) - gap;
cleanUpNotificationRequests(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;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.PageDataIterable;
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.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.queue.discovery.PartitionService;
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.concurrent.TimeUnit;
@TbCoreComponent
@Service
@Slf4j
@RequiredArgsConstructor
public class RpcCleanUpService {
@Value("${sql.ttl.rpc.enabled}")
private boolean ttlTaskExecutionEnabled;
@Service
@TbCoreComponent
@ConditionalOnExpression("${sql.ttl.rpc.enabled:true}")
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 PartitionService partitionService;
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}")
public void cleanUp() {
if (ttlTaskExecutionEnabled) {
PageLink tenantsBatchRequest = new PageLink(10_000, 0);
PageData<TenantId> tenantsIds;
do {
tenantsIds = tenantService.findTenantsIds(tenantsBatchRequest);
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));
}
PageDataIterable<TenantId> tenants = new PageDataIterable<>(tenantService::findTenantsIds, 10_000);
for (TenantId tenantId : tenants) {
try {
if (!isTenantPartitionMine(tenantId)) {
continue;
}
tenantsBatchRequest = tenantsBatchRequest.nextPageLink();
} while (tenantsIds.hasNext());
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 = 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}"
# The password of the cacerts keystore file
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:
@ -430,10 +484,11 @@ sql:
edge_events_ttl: "${SQL_TTL_EDGE_EVENTS_TTL:2628000}" # Number of seconds. The current value corresponds to one month
alarms:
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:
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
removal_batch_size: "${SQL_RPC_TTL_REMOVAL_BATCH_SIZE:10000}" # Batch size for records removal
audit_logs:
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
@ -442,6 +497,7 @@ sql:
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
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:
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
@ -519,6 +575,10 @@ actors:
# Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames).
# Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata"
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:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@ -804,21 +864,28 @@ updates:
# Enable/disable checks for the new version
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:
mappings:
# Intercept path
"[/api/**]":
#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.
allowed-methods: "*"
allowed-methods: "${TB_CORS_ALLOWED_METHODS:*}"
#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.
max-age: "1800"
max-age: "${TB_CORS_MAX_AGE:1800}"
#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
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:
# Enable/disable SSL support
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}"
# 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}"
# 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
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.

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 {
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();
deleteTenant(tenantId);
deleteDifferentTenant();
@ -453,6 +457,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
.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 {
List<Tenant> loadedTenants = new ArrayList<>();
PageLink pageLink = new PageLink(10);
@ -1141,7 +1150,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
assertThat(findRelationsByTo(entityTo)).hasSize(1);
doDelete(urlDelete)
.andExpect(status().isInternalServerError());
.andExpect(status().isBadRequest());
assertThat(findRelationsByTo(entityTo)).hasSize(1);
} 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.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.util.TestSocketUtils;
import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.ThingsBoardExecutors;
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.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
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 = {
"transport.mqtt.enabled=true",
@ -101,6 +100,15 @@ import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.
@ContextConfiguration(classes = {EntityViewControllerTest.Config.class})
@DaoSqlTest
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
static void props(DynamicPropertyRegistry registry) {
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.id.EntityId;
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.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@ -936,6 +937,26 @@ public class TenantControllerTest extends AbstractControllerTest {
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() {
Mockito.verify(tbClusterService, never()).onTenantChange(Mockito.any(Tenant.class),
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.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
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.TsValue;
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.timeseries.TimeseriesService;
import org.thingsboard.server.service.subscription.SubscriptionErrorCode;
import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
@ -95,6 +100,9 @@ public class WebsocketApiTest extends AbstractControllerTest {
@Autowired
private TelemetrySubscriptionService tsService;
@SpyBean
private TimeseriesService timeseriesService;
Device device;
DeviceTypeFilter dtf;
@ -726,6 +734,41 @@ public class WebsocketApiTest extends AbstractControllerTest {
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
public void testEntityDataLatestAttrWsCmd() throws Exception {
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 {
CountDownLatch latch = new CountDownLatch(1);
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.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -1749,13 +1751,13 @@ public class EntityServiceTest extends AbstractControllerTest {
}
@Test
public void testFindTenantTelemetry() {
public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin
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);
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.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.Mockito;
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.bean.override.mockito.MockitoSpyBean;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil;
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 {
@SpyBean
@MockitoSpyBean
private HousekeeperService housekeeperService;
@SpyBean
@MockitoSpyBean
private HousekeeperReprocessingService housekeeperReprocessingService;
@MockitoSpyBean
private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor;
@Autowired
private EventService eventService;
@Autowired
@ -153,8 +155,6 @@ public class HousekeeperServiceTest extends AbstractControllerTest {
private CustomerService customerService;
@Autowired
private DashboardService dashboardService;
@SpyBean
private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor;
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) {
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));
this.leshanClient.start();
@ -193,7 +200,7 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
} catch (Exception 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) {

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 org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper;
import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.TimeUnit.SECONDS;
@ -37,6 +38,12 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID
@Slf4j
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"}
* 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()
.atMost(10, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> {
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<>();
await(alias + "find device [" + deviceName + "] after created")
.atMost(200, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
return device.get() != null;
@ -236,6 +237,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
AtomicReference<Device> device = new AtomicReference<>();
await(alias + "find device [" + deviceName + "] after created")
.atMost(200, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
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<>();
await(alias + SparkplugMessageType.NBIRTH.name())
.atMost(40, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> {
actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() {
}));
@ -483,6 +484,7 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra
AtomicReference<List<String>> actualKeys = new AtomicReference<>();
await(alias + SparkplugMessageType.DBIRTH.name())
.atMost(40, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> {
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.core.poll-interval=5
queue.core.partitions=2
queue.core.housekeeper.task-reprocessing-delay-ms=0
queue.rule-engine.poll-interval=5
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
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
# \
# -Dpush-docker-amd-arm-images

2
common/actor/pom.xml

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

2
common/cache/pom.xml

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

2
common/cluster-api/pom.xml

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

2
common/coap-server/pom.xml

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

2
common/dao-api/pom.xml

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

2
common/data/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<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.TenantId;
import java.io.Serial;
import java.util.List;
import java.util.UUID;
@ -32,6 +33,9 @@ import java.util.UUID;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AlarmsDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9214680001573764374L;
private List<UUID> alarms;
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.UserId;
import java.io.Serial;
import java.util.List;
import java.util.UUID;
@ -33,6 +34,9 @@ import java.util.UUID;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AlarmsUnassignHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9156667024462937756L;
private String userTitle;
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.id.TenantId;
import java.io.Serial;
import java.util.List;
import java.util.UUID;
@ -32,6 +33,9 @@ import java.util.UUID;
@NoArgsConstructor
public class EntitiesDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 9009068831061529286L;
private EntityType entityType;
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.TenantId;
import java.io.Serial;
import java.io.Serializable;
@JsonIgnoreProperties(ignoreUnknown = true)
@ -45,6 +46,9 @@ import java.io.Serializable;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HousekeeperTask implements Serializable {
@Serial
private static final long serialVersionUID = -2585974110832225152L;
private TenantId tenantId;
private EntityId entityId;
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.TenantId;
import java.io.Serial;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LatestTsDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 5193191938513490138L;
private 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.id.TenantId;
import java.io.Serial;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class TenantEntitiesDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = -8033108795318393447L;
private 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.TenantId;
import java.io.Serial;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TsHistoryDeletionHousekeeperTask extends HousekeeperTask {
@Serial
private static final long serialVersionUID = 4573851542705079043L;
private 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());
}
@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.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@ -29,7 +30,7 @@ import java.util.List;
import java.util.Map;
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)
@JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@ -38,6 +39,7 @@ import java.util.UUID;
public abstract class NotificationRuleRecipientsConfig implements Serializable {
@NotNull
@JsonProperty("triggerType")
private NotificationRuleTriggerType triggerType;
@JsonIgnore

2
common/discovery-api/pom.xml

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

2
common/edge-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<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_2_1_2 = 14;
V_4_2_2 = 4220;
V_4_2_2_1 = 4221;
V_4_3_0_1 = 15;
V_4_3_1 = 4310;
V_4_3_1_1 = 4311;
V_LATEST = 99999;
}

2
common/edqs/pom.xml

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

2
common/message/pom.xml

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

2
common/pom.xml

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

2
common/proto/pom.xml

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

2
common/queue/pom.xml

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

2
common/script/pom.xml

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

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

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

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

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

2
common/stats/pom.xml

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

2
common/transport/coap/pom.xml

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

2
common/transport/http/pom.xml

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

2
common/transport/lwm2m/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<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
public Iterator<Registration> getAllRegistrations() {
try (var connection = connectionFactory.getConnection()) {
try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
Collection<Registration> list = new LinkedList<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(REG_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> {
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions));
});
if (scanConnection instanceof RedisClusterConnection clusterConnection) {
clusterConnection.clusterGetNodes().forEach(node ->
scans.add(clusterConnection.scan(node, scanOptions)));
} else {
scans.add(connection.scan(scanOptions));
scans.add(scanConnection.scan(scanOptions));
}
scans.forEach(scan -> {
scan.forEachRemaining(key -> {
byte[] element = connection.get(key);
list.add(deserializeReg(element));
byte[] element = getConnection.get(key);
if (element != null) {
list.add(deserializeReg(element));
}
});
});
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
public Set<LwM2mClient> getAll() {
try (var connection = connectionFactory.getConnection()) {
try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
Set<LwM2mClient> clients = new HashSet<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(CLIENT_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> {
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions));
});
if (scanConnection instanceof RedisClusterConnection clusterConnection) {
clusterConnection.clusterGetNodes().forEach(node ->
scans.add(clusterConnection.scan(node, scanOptions)));
} else {
scans.add(connection.scan(scanOptions));
scans.add(scanConnection.scan(scanOptions));
}
scans.forEach(scan -> {
scan.forEachRemaining(key -> {
byte[] element = connection.get(key);
byte[] element = getConnection.get(key);
if (element != null) {
try {
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
public List<LwM2MModelConfig> getAll() {
try (var connection = connectionFactory.getConnection()) {
try (var scanConnection = connectionFactory.getConnection();
var getConnection = connectionFactory.getConnection()) {
List<LwM2MModelConfig> configs = new ArrayList<>();
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(MODEL_EP + "*").build();
List<Cursor<byte[]>> scans = new ArrayList<>();
if (connection instanceof RedisClusterConnection) {
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> {
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions));
});
if (scanConnection instanceof RedisClusterConnection clusterConnection) {
clusterConnection.clusterGetNodes().forEach(node ->
scans.add(clusterConnection.scan(node, scanOptions)));
} else {
scans.add(connection.scan(scanOptions));
scans.add(scanConnection.scan(scanOptions));
}
scans.forEach(scan -> {
scan.forEachRemaining(key -> {
byte[] element = connection.get(key);
configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class));
byte[] element = getConnection.get(key);
if (element != null) {
configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class));
}
});
});
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>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<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}")
private boolean proxyEnabled;
private final AtomicInteger connectionsCounter = new AtomicInteger();
private final AtomicInteger connectionsActiveCounterMQTT = new AtomicInteger();
private final AtomicInteger connectionsActiveCounterMQTTS = new AtomicInteger();
@PostConstruct
public void init() {
super.init();
transportService.createGaugeStats("openConnections", connectionsCounter);
transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTT, "protocol", "MQTT");
transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTTS, "protocol", "MQTTS");
}
public void channelRegistered() {
connectionsCounter.incrementAndGet();
public void channelRegistered(boolean isSSL) {
if (isSSL) {
connectionsActiveCounterMQTTS.incrementAndGet();
} else {
connectionsActiveCounterMQTT.incrementAndGet();
}
}
public void channelUnregistered() {
connectionsCounter.decrementAndGet();
public void channelUnregistered(boolean isSSL) {
if (isSSL) {
connectionsActiveCounterMQTTS.decrementAndGet();
} else {
connectionsActiveCounterMQTT.decrementAndGet();
}
}
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<>();
}
boolean isSSL() {
return sslHandler != null;
}
@Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
super.channelRegistered(ctx);
context.channelRegistered();
context.channelRegistered(isSSL());
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
super.channelUnregistered(ctx);
context.channelUnregistered();
context.channelUnregistered(isSSL());
}
@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) {
var address = ctx.channel().attr(MqttTransportService.ADDRESS).get();
if (address == null) {
@ -1150,19 +1165,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof IOException) {
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::getDeviceName).orElse(""),
clientAddr,
cause.getClass().getSimpleName(),
cause.getMessage(),
cause);
} 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::getDeviceName).orElse(""),
clientAddr,
cause.getClass().getSimpleName(),
cause.getMessage());
}
} else {
log.error("[{}] Unexpected Exception", sessionId, cause);
log.error("[{}][{}] Unexpected Exception", sessionId, getClientAddr(ctx), cause);
}
closeCtx(ctx, MqttReasonCodes.Disconnect.SERVER_SHUTTING_DOWN);

2
common/transport/pom.xml

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

2
common/transport/snmp/pom.xml

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

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

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<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);
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 java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Component
@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// 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
public void createGaugeStats(String statsName, AtomicInteger number) {
statsFactory.createGauge(StatsType.TRANSPORT + "." + statsName, number);
statsMap.put(statsName, number);
public void createGaugeStats(String statsName, AtomicInteger number, String... tags) {
String key = "thingsboard" + "." + StatsType.TRANSPORT.getName() + "." + statsName;
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}")

2
common/util/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<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 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)
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of(
@ -66,6 +67,13 @@ public class SsrfProtectionValidator {
}
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)) {
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
if (address.isLoopbackAddress()) {
return true;
@ -142,14 +158,37 @@ public class SsrfProtectionValidator {
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed");
}
public static boolean isEnabled() {
return enabled;
}
public static void setEnabled(boolean enabled) {
SsrfProtectionValidator.enabled = enabled;
}
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()) {
additionalBlocked = AdditionalBlockedHosts.EMPTY;
return;
return ParsedHostEntries.EMPTY;
}
List<CidrRange> cidrRanges = new ArrayList<>();
Set<String> hostnames = new HashSet<>();
@ -168,10 +207,9 @@ public class SsrfProtectionValidator {
hostnames.add(trimmed.toLowerCase());
}
}
additionalBlocked = new AdditionalBlockedHosts(
return new ParsedHostEntries(
Collections.unmodifiableList(cidrRanges),
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) {
@ -179,10 +217,18 @@ public class SsrfProtectionValidator {
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) {
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) {
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.provider.ValueSource;
import java.net.InetAddress;
import java.net.URI;
import java.util.Collections;
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.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 {
@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>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.3.1</version>
<version>4.3.1.1-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

3
dao/pom.xml

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

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@ -202,4 +203,13 @@ public final class DaoUtil {
.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);
int removeAllByCreatedTimeBefore(long ts);
int removeByTenantIdAndCreatedTimeBeforeBatch(TenantId tenantId, long ts, int batchSize);
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);
validateId(otaPackageId, id -> INCORRECT_OTA_PACKAGE_ID + id);
try {
Long oid = getDataOidById(tenantId, otaPackageId);
otaPackageDao.removeById(tenantId, otaPackageId.getId());
unlinkDataIfPresent(tenantId, otaPackageId, oid);
publishEvictEvent(new OtaPackageCacheEvictEvent(otaPackageId));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(otaPackageId).build());
} 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
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteOtaPackage(tenantId, (OtaPackageId) id);
@ -225,6 +251,11 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
return otaPackageDao.sumDataSizeByTenantId(tenantId);
}
@Override
public void deleteByTenantId(TenantId tenantId) {
deleteOtaPackagesByTenantId(tenantId);
}
@Override
public void deleteOtaPackagesByTenantId(TenantId tenantId) {
log.trace("Executing deleteOtaPackagesByTenantId, tenantId [{}]", tenantId);
@ -232,24 +263,17 @@ public class BaseOtaPackageService extends AbstractCachedEntityService<OtaPackag
tenantOtaPackageRemover.removeEntities(tenantId, tenantId);
}
@Override
public void deleteByTenantId(TenantId tenantId) {
deleteOtaPackagesByTenantId(tenantId);
}
private PaginatedRemover<TenantId, OtaPackageInfo> tenantOtaPackageRemover =
new PaginatedRemover<>() {
@Override
protected PageData<OtaPackageInfo> findEntities(TenantId tenantId, TenantId id, PageLink pageLink) {
return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink);
}
private final PaginatedRemover<TenantId, OtaPackageInfo> tenantOtaPackageRemover = new PaginatedRemover<>() {
@Override
protected PageData<OtaPackageInfo> findEntities(TenantId tenantId, TenantId id, PageLink pageLink) {
return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink);
}
@Override
protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) {
deleteOtaPackage(tenantId, entity.getId());
}
};
@Override
protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) {
deleteOtaPackage(tenantId, entity.getId());
}
};
@Override
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.id.OtaPackageId;
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.ExportableEntityDao;
import org.thingsboard.server.dao.TenantEntityWithDataDao;
import java.util.UUID;
public interface OtaPackageDao extends Dao<OtaPackage>, TenantEntityWithDataDao, ExportableEntityDao<OtaPackageId, OtaPackage> {
Long sumDataSizeByTenantId(TenantId tenantId);
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;
public interface RpcDao extends Dao<Rpc> {
PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink);
PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, 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 org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.TenantId;
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.dao.service.DataValidator;
import java.net.URI;
@Component
@AllArgsConstructor
public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
@ -64,6 +67,11 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
if (StringUtils.isEmpty(customConfig.getUrl())) {
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
public int removeAllByCreatedTimeBefore(long ts) {
return notificationRequestRepository.deleteAllByCreatedTimeBefore(ts);
public int removeByTenantIdAndCreatedTimeBeforeBatch(TenantId tenantId, long ts, int batchSize) {
return notificationRequestRepository.deleteByTenantIdAndCreatedTimeBeforeBatch(tenantId.getId(), ts, batchSize);
}
@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
@Modifying
@Query("DELETE FROM NotificationRequestEntity r WHERE r.createdTime < :ts")
int deleteAllByCreatedTimeBefore(@Param("ts") long ts);
@Query(value = "DELETE FROM notification_request WHERE id IN " +
"(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
@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));
}
@Override
public Long getDataOidById(UUID id) {
return otaPackageRepository.getDataOidById(id);
}
@Override
public Integer unlinkLargeObject(Long dataOid) {
return otaPackageRepository.unlinkLargeObject(dataOid);
}
@Override
public OtaPackageId getExternalIdByInternal(OtaPackageId internalId) {
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.Query;
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.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")
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
@Override
public int deleteOutdatedRpcByTenantId(TenantId tenantId, Long expirationTime) {
return rpcRepository.deleteOutdatedRpcByTenantId(tenantId.getId(), expirationTime);
public int deleteOutdatedRpcByTenantIdBatch(TenantId tenantId, Long expirationTime, int batchSize) {
return rpcRepository.deleteOutdatedRpcByTenantIdBatch(tenantId.getId(), expirationTime, batchSize);
}
@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.Query;
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.dao.model.sql.RpcEntity;
import java.util.UUID;
public interface RpcRepository extends JpaRepository<RpcEntity, UUID> {
Page<RpcEntity> findAllByTenantIdAndDeviceId(UUID tenantId, UUID deviceId, Pageable pageable);
Page<RpcEntity> findAllByTenantIdAndDeviceIdAndStatus(UUID tenantId, UUID deviceId, RpcStatus status, Pageable pageable);
Page<RpcEntity> findAllByTenantId(UUID tenantId, Pageable pageable);
@Transactional
@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)
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.dao.device.DeviceProfileService;
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.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.exception.DataValidationException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -77,6 +77,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
@Autowired
TenantProfileService tenantProfileService;
@Autowired
OtaPackageDao otaPackageDao;
@Autowired
TbTenantProfileCache tenantProfileCache;
@Before
@ -118,10 +120,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
Assert.assertEquals(1, otaPackageService.sumDataSizeByTenantId(tenantId));
int maxSumDataSize = 8;
List<OtaPackage> packages = new ArrayList<>(maxSumDataSize);
for (int i = 2; i <= maxSumDataSize; i++) {
packages.add(createAndSaveFirmware(tenantId, "0." + i));
createAndSaveFirmware(tenantId, "0." + i);
Assert.assertEquals(i, otaPackageService.sumDataSizeByTenantId(tenantId));
}
@ -533,6 +533,39 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
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
public void testFindTenantFirmwaresByTenantId() {
List<OtaPackageInfo> firmwares = new ArrayList<>();
@ -567,8 +600,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
}
} while (pageData.hasNext());
Collections.sort(firmwares, idComparator);
Collections.sort(loadedFirmwares, idComparator);
firmwares.sort(idComparator);
loadedFirmwares.sort(idComparator);
assertThat(firmwares).isEqualTo(loadedFirmwares);
@ -622,8 +655,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
}
} while (pageData.hasNext());
Collections.sort(firmwares, idComparator);
Collections.sort(loadedFirmwares, idComparator);
firmwares.sort(idComparator);
loadedFirmwares.sort(idComparator);
assertThat(firmwares).isEqualTo(loadedFirmwares);
@ -726,4 +759,5 @@ public class OtaPackageServiceTest extends AbstractServiceTest {
firmware.setDataSize(DATA_SIZE);
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\"}")));
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,
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