From 222360905daac2a4d4dd396d005213c63ce51a53 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 30 Apr 2026 15:57:41 +0300 Subject: [PATCH 01/18] fixed gateway docker-compose.yml YAML Injection, enhanced device credential validation --- .../DeviceCredentialsDataValidator.java | 22 +++ .../dao/util/DeviceConnectivityUtil.java | 13 +- .../DeviceCredentialsDataValidatorTest.java | 128 ++++++++++++++++++ .../dao/util/DeviceConnectivityUtilTest.java | 90 ++++++++++++ 4 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 7035fcfd9b..c8762a5b75 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -18,18 +18,25 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.service.DataValidator; +import java.util.regex.Pattern; + @Component public class DeviceCredentialsDataValidator extends DataValidator { + private static final Pattern CONTROL_CHARS = Pattern.compile("[\\r\\n\\t\\x00-\\x1F\\x7F]"); + @Autowired private DeviceCredentialsDao deviceCredentialsDao; @@ -69,9 +76,24 @@ public class DeviceCredentialsDataValidator extends DataValidator validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("credentialsId") + .hasMessageContaining("control characters"); + } + + @Test + void rejectsCarriageReturnInAccessToken() { + DeviceCredentials creds = accessToken("token\rprivileged: true"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("control characters"); + } + + @Test + void rejectsNewlineInMqttClientId() { + DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("clientId"); + } + + @Test + void rejectsNewlineInMqttUserName() { + DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("userName"); + } + + @Test + void rejectsNewlineInMqttPassword() { + DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("password"); + } + + @Test + void acceptsValidCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = accessToken("safe_token_123"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + private DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java index d4c93a1d5f..b69fefeea0 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java @@ -16,6 +16,13 @@ package org.thingsboard.server.dao.util; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +36,87 @@ class DeviceConnectivityUtilTest { assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); } + @Test + void validAccessTokenIsRenderedAsIs() throws Exception { + String yaml = renderCompose(accessToken("safe_token_123")); + + assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n"); + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInAccessTokenIsSanitized() throws Exception { + String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]"; + + String yaml = renderCompose(accessToken(malicious)); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void carriageReturnInAccessTokenIsSanitized() throws Exception { + String yaml = renderCompose(accessToken("token\rprivileged: true")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttClientIdIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttUserNameIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttPasswordIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]")); + + assertNoInjectedSiblingKeys(yaml); + } + + private static String renderCompose(DeviceCredentials credentials) throws Exception { + var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( + "host.docker.internal", "3.8-stable", credentials); + try (var in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private static DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + + private static void assertNoInjectedSiblingKeys(String yaml) throws IOException { + for (String line : yaml.split("\n")) { + String trimmed = line.replaceFirst("^\\s+", ""); + assertThat(trimmed) + .as("unexpected sibling key — possible YAML injection: %s", line) + .doesNotStartWith("entrypoint:") + .doesNotStartWith("privileged:") + .doesNotStartWith("command:"); + } + } + } From 157c773fcdc447795e56c0443a810df8ef23820c Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 30 Apr 2026 17:19:19 +0300 Subject: [PATCH 02/18] smplified CONTROL_CHARS regex --- .../validator/DeviceCredentialsDataValidator.java | 2 +- .../server/dao/util/DeviceConnectivityUtil.java | 2 +- .../DeviceCredentialsDataValidatorTest.java | 15 ++++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index c8762a5b75..96ffa613c7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -35,7 +35,7 @@ import java.util.regex.Pattern; @Component public class DeviceCredentialsDataValidator extends DataValidator { - private static final Pattern CONTROL_CHARS = Pattern.compile("[\\r\\n\\t\\x00-\\x1F\\x7F]"); + private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); @Autowired private DeviceCredentialsDao deviceCredentialsDao; diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index 707be1d8c2..a546023f42 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -50,7 +50,7 @@ public class DeviceConnectivityUtil { public static final String MQTT_IMAGE = "thingsboard/mosquitto-clients "; public static final String COAP_IMAGE = "thingsboard/coap-clients "; private final static Pattern VALID_URL_PATTERN = Pattern.compile("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); - private final static Pattern CONTROL_CHARS = Pattern.compile("[\\r\\n\\t\\x00-\\x1F\\x7F]"); + private final static Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); private static String sanitize(String value) { return value == null ? null : CONTROL_CHARS.matcher(value).replaceAll("_"); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java index c1b65fab76..2e43f9d102 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java @@ -16,9 +16,10 @@ package org.thingsboard.server.dao.service.validator; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; @@ -36,14 +37,14 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.willReturn; -@SpringBootTest(classes = DeviceCredentialsDataValidator.class) +@ExtendWith(MockitoExtension.class) class DeviceCredentialsDataValidatorTest { - @MockBean + @Mock DeviceCredentialsDao deviceCredentialsDao; - @MockBean + @Mock DeviceService deviceService; - @SpyBean + @InjectMocks DeviceCredentialsDataValidator validator; final TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); From ff780fc6e9a12e137bbaed6420c2115e48d336a3 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 18 May 2026 13:14:50 +0200 Subject: [PATCH 03/18] Release Californium resources when CoAP server init fails --- .../coapserver/DefaultCoapServerService.java | 56 ++++++++----- .../DefaultCoapServerServiceTest.java | 79 +++++++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 3b7248ee72..081f1a9db5 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -106,26 +106,46 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial private CoapServer createCoapServer() throws UnknownHostException { Configuration networkConfig = createNetworkConfiguration(); server = new CoapServer(networkConfig); + try { + CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); + InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); + noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + + noSecCoapEndpointBuilder.setConfiguration(networkConfig); + CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); + server.addEndpoint(noSecCoapEndpoint); + if (isDtlsEnabled()) { + createDtlsEndpoint(networkConfig); + dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); + dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + } + Resource root = server.getRoot(); + TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); + server.setMessageDeliverer(messageDeliverer); - CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); - InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); - InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); - noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); - - noSecCoapEndpointBuilder.setConfiguration(networkConfig); - CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); - server.addEndpoint(noSecCoapEndpoint); - if (isDtlsEnabled()) { - createDtlsEndpoint(networkConfig); - dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); - dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + server.start(); + return server; + } catch (RuntimeException | UnknownHostException e) { + log.error("Failed to start CoAP server, releasing resources", e); + try { + if (dtlsSessionsExecutor != null) { + dtlsSessionsExecutor.shutdownNow(); + } + if (server != null) { + server.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + server = null; + dtlsSessionsExecutor = null; + dtlsConnector = null; + dtlsCoapEndpoint = null; + tbDtlsCertificateVerifier = null; + } + throw e; } - Resource root = server.getRoot(); - TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); - server.setMessageDeliverer(messageDeliverer); - - server.start(); - return server; } private boolean isDtlsEnabled() { diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java new file mode 100644 index 0000000000..5606fe18d7 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCoapServerServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private CoapServerContext mockCoapServerContext; + + private DefaultCoapServerService service; + private DatagramSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new DefaultCoapServerService(); + ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn(HOST); + when(mockCoapServerContext.getPort()).thenReturn(occupiedPort); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + } + + @AfterEach + public void tearDown() { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + +} From 4718fb75566f8f3d647fd8abd680898430da6bb1 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 18 May 2026 16:11:02 +0200 Subject: [PATCH 04/18] Release Californium resources when LwM2M bootstrap server init fails --- .../LwM2MTransportBootstrapService.java | 22 +++- .../LwM2MTransportBootstrapServiceTest.java | 115 ++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 9b370d0b71..bf2b48dd61 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -82,13 +82,29 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); - this.server = getLhBootstrapServer(); - this.server.start(); - log.info("Started LwM2M transport bootstrap server."); + LeshanBootstrapServer bootstrapServer = getLhBootstrapServer(); + try { + this.server = bootstrapServer; + bootstrapServer.start(); + log.info("Started LwM2M transport bootstrap server."); + } catch (RuntimeException e) { + log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e); + try { + bootstrapServer.destroy(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + this.server = null; + } + throw e; + } } @PreDestroy public void shutdown() { + if (server == null) { + return; + } try { log.info("Stopping LwM2M transport bootstrap server!"); server.destroy(); diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java new file mode 100644 index 0000000000..67e8265361 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.bootstrap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2MTransportBootstrapServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private LwM2MTransportServerConfig serverConfig; + + @Mock + private LwM2MTransportBootstrapConfig bootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; + + @Mock + private TransportService transportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; + + private LwM2MTransportBootstrapService service; + private DatagramSocket occupiedPlain; + private DatagramSocket occupiedSecure; + + @BeforeEach + public void setUp() throws Exception { + occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + + when(bootstrapConfig.getHost()).thenReturn(HOST); + when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort()); + when(bootstrapConfig.getSecureHost()).thenReturn(HOST); + when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort()); + when(bootstrapConfig.getSslCredentials()).thenReturn(null); + + when(serverConfig.isRecommendedCiphers()).thenReturn(false); + when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false); + when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + when(serverConfig.getDtlsCidLength()).thenReturn(null); + + service = new LwM2MTransportBootstrapService( + serverConfig, + bootstrapConfig, + lwM2MBootstrapSecurityStore, + lwM2MInMemoryBootstrapConfigStore, + transportService, + certificateVerifier + ); + } + + @AfterEach + public void tearDown() { + if (occupiedPlain != null && !occupiedPlain.isClosed()) { + occupiedPlain.close(); + } + if (occupiedSecure != null && !occupiedSecure.isClosed()) { + occupiedSecure.close(); + } + } + + @Test + public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + } + +} From 674263dd9e5e9313eee923afea4516e60bb149e8 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 19 May 2026 15:40:49 +0300 Subject: [PATCH 05/18] fixed FromDeviceRPCResponseProto.response handling in case of null --- .../service/queue/DefaultTbCoreConsumerService.java | 4 ++-- .../queue/DefaultTbRuleEngineConsumerService.java | 4 ++-- .../thingsboard/server/common/util/ProtoUtils.java | 7 ++++--- common/proto/src/main/proto/queue.proto | 2 +- .../server/common/util/ProtoUtilsTest.java | 11 +++++++++++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index de2e65723e..6edcd31b16 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -466,9 +466,9 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = proto.getError() >= 0 ? RpcError.values()[proto.getError()] : null; FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index 22e2f05cb6..b4be90b1db 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -180,9 +180,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo callback.onSuccess(); } else if (nfMsg.hasFromDeviceRpcResponse()) { TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); - RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = proto.getError() >= 0 ? RpcError.values()[proto.getError()] : null; FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbDeviceRpcService.processRpcResponseFromDevice(response); callback.onSuccess(); } else if (nfMsg.getQueueUpdateMsgsCount() > 0) { diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 4e0604239f..213b61ad05 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -583,10 +583,11 @@ public class ProtoUtils { } private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { + TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse(); FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( - new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), - proto.getRpcResponse().getResponse(), - proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); + new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()), + rpcResponse.hasResponse() ? rpcResponse.getResponse() : null, + rpcResponse.getError() >= 0 ? RpcError.values()[rpcResponse.getError()] : null); return new FromDeviceRpcResponseActorMsg( proto.getRequestId(), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 7ad4d2883d..1f45ab7293 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1238,7 +1238,7 @@ message LocalSubscriptionServiceMsgProto { message FromDeviceRPCResponseProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - string response = 3; + optional string response = 3; int32 error = 4; } diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index e779632088..a438c62dc7 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -226,6 +226,17 @@ class ProtoUtilsTest { assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); } + @Test + void protoFromDeviceRpcResponseOnewaySerialization() { + // Oneway RPC success: response and error are both null. Relies on the proto + // 'optional string response' presence bit so the receiver round-trips null + // rather than seeing the proto3 default "". + FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null)); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + @Test void protoRemoveRpcActorSerialization() { RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); From 2810edca613e662caa836e52c651a842cbe9bda2 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 1 Jun 2026 11:37:26 +0300 Subject: [PATCH 06/18] fixed alarm comment permission bug --- .../controller/AlarmCommentController.java | 10 +++++ .../AlarmCommentControllerTest.java | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 8a113fb424..90a20880f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); + checkUserCommentOwnership(alarmComment, Operation.WRITE); alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); @@ -93,6 +95,7 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); + checkUserCommentOwnership(alarmComment, Operation.DELETE); tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); } @@ -120,4 +123,11 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation) throws ThingsboardException { + if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(getCurrentUser().getId())) { + throw new ThingsboardException("User is not allowed to " + operation.name().toLowerCase() + " other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index ba86d60852..f2599d3adf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -160,6 +160,26 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); } + @Test + public void testUpdateOthersAlarmCommentByTenantAdmin() throws Exception { + // Tenant admins are NOT exempt from the ownership rule — even with full tenant-level + // privileges they cannot rewrite a comment authored by a different user. + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + loginTenantAdmin(); + Mockito.reset(tbClusterService, auditLogService); + + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Tenant rewrite attempt")); + alarmComment.setComment(newComment); + + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to write other user's comment"))); + + testNotifyEntityNever(alarm.getId(), alarmComment); + } + @Test public void testUpdateAlarmViaDifferentTenant() throws Exception { loginTenantAdmin(); @@ -215,6 +235,23 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } + @Test + public void testDeleteOthersAlarmCommentByTenantAdmin() throws Exception { + // Tenant admins are NOT exempt from the ownership rule on delete either — even with full + // tenant-level privileges they cannot delete a comment authored by a different user. + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + loginTenantAdmin(); + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + + testNotifyEntityNever(alarm.getId(), alarmComment); + } + @Test public void testDeleteAlarmViaTenant() throws Exception { loginTenantAdmin(); From ae2fdf85f2651792ce2db09e7a2066ecefb59629 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 1 Jun 2026 12:28:21 +0300 Subject: [PATCH 07/18] fixed alarm comment update flow validation --- .../server/controller/AlarmCommentController.java | 12 ++++++++++-- .../controller/AlarmCommentControllerTest.java | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 90a20880f6..c9d2b6fef7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -78,7 +78,10 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); - checkUserCommentOwnership(alarmComment, Operation.WRITE); + if (alarmComment.getId() != null) { + AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); + checkUserCommentOwnership(existingAlarmComment, Operation.WRITE); + } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); @@ -125,7 +128,12 @@ public class AlarmCommentController extends BaseController { private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation) throws ThingsboardException { if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(getCurrentUser().getId())) { - throw new ThingsboardException("User is not allowed to " + operation.name().toLowerCase() + " other user's comment", + String action = switch (operation) { + case WRITE -> "edit"; + case DELETE -> "delete"; + default -> "perform this operation with"; + }; + throw new ThingsboardException("User is not allowed to " + action + " other user's comment", ThingsboardErrorCode.PERMISSION_DENIED); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index f2599d3adf..e8e9412dc6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -172,10 +172,13 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Tenant rewrite attempt")); alarmComment.setComment(newComment); + // Simulate the real attack: the attacker controls the request body and would omit (or spoof) + // the userId. Ownership must be enforced against the persisted comment, not the body. + alarmComment.setUserId(null); doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) .andExpect(status().isForbidden()) - .andExpect(statusReason(containsString("User is not allowed to write other user's comment"))); + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); testNotifyEntityNever(alarm.getId(), alarmComment); } From 5c796117a01a3efcaf8855dd3a094f8f51531386 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 1 Jun 2026 12:46:09 +0300 Subject: [PATCH 08/18] minor refactoring --- .../controller/AlarmCommentController.java | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index c9d2b6fef7..3091d6ac44 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION; @@ -78,13 +79,14 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); + SecurityUser currentUser = getCurrentUser(); if (alarmComment.getId() != null) { AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); - checkUserCommentOwnership(existingAlarmComment, Operation.WRITE); + checkUserCommentOwnership(existingAlarmComment, Operation.WRITE, currentUser); } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); - return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); + return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", @@ -98,8 +100,9 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); - checkUserCommentOwnership(alarmComment, Operation.DELETE); - tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); + SecurityUser currentUser = getCurrentUser(); + checkUserCommentOwnership(alarmComment, Operation.DELETE, currentUser); + tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Get Alarm comments (getAlarmComments)", @@ -126,14 +129,9 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } - private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation) throws ThingsboardException { - if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(getCurrentUser().getId())) { - String action = switch (operation) { - case WRITE -> "edit"; - case DELETE -> "delete"; - default -> "perform this operation with"; - }; - throw new ThingsboardException("User is not allowed to " + action + " other user's comment", + private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation, SecurityUser securityUser) throws ThingsboardException { + if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(securityUser.getId())) { + throw new ThingsboardException("User is not allowed to " + (operation == Operation.DELETE ? "delete" : "edit") + " other user's comment", ThingsboardErrorCode.PERMISSION_DENIED); } } From 93df85a78c2583527788e7ce5600f663ab734ce7 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 5 Jun 2026 14:05:01 +0300 Subject: [PATCH 09/18] fixed pr comments --- .../queue/DefaultTbCoreConsumerService.java | 4 +- .../DefaultTbRuleEngineConsumerService.java | 2 +- .../DefaultTbCoreConsumerServiceTest.java | 32 ++++++++ ...efaultTbRuleEngineConsumerServiceTest.java | 78 +++++++++++++++++++ .../server/common/data/rpc/RpcError.java | 12 +++ .../server/common/util/ProtoUtils.java | 2 +- 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6edcd31b16..9eacd83e62 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -465,8 +465,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService= 0 ? RpcError.values()[proto.getError()] : null; + void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) , proto.hasResponse() ? proto.getResponse() : null, error); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index b4be90b1db..01ac9a3665 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -180,7 +180,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo callback.onSuccess(); } else if (nfMsg.hasFromDeviceRpcResponse()) { TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); - RpcError error = proto.getError() >= 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) , proto.hasResponse() ? proto.getResponse() : null, error); tbDeviceRpcService.processRpcResponseFromDevice(response); diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index 86b5ae2cf8..53c855aa4d 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.state.DeviceStateService; @@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest { private TbCoreConsumerStats statsMock; @Mock private RuleEngineCallService ruleEngineCallServiceMock; + @Mock + private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock; @Mock private TbCallback tbCallbackMock; @@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest { then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); } + @Test + public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java new file mode 100644 index 0000000000..1cf41eca0c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java @@ -0,0 +1,78 @@ +/** + * 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.queue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doCallRealMethod; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbRuleEngineConsumerServiceTest { + + @Mock + private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock; + @Mock + private TbCallback tbCallbackMock; + + @Mock + private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock; + + @Test + public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build(); + var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg); + doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock); + + // WHEN + defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java index 03cd1d69cb..c5575b53be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java @@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc; */ public enum RpcError { NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL; + + private static final RpcError[] VALUES = values(); + + /** + * Resolves an {@link RpcError} from the proto {@code error} ordinal. + * Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals + * that a newer node in a mixed-version cluster might emit, so callers never hit an + * {@link ArrayIndexOutOfBoundsException}. + */ + public static RpcError fromProtoErrorCode(int errorCode) { + return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null; + } } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 213b61ad05..05c0a7d980 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -587,7 +587,7 @@ public class ProtoUtils { FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()), rpcResponse.hasResponse() ? rpcResponse.getResponse() : null, - rpcResponse.getError() >= 0 ? RpcError.values()[rpcResponse.getError()] : null); + RpcError.fromProtoErrorCode(rpcResponse.getError())); return new FromDeviceRpcResponseActorMsg( proto.getRequestId(), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), From ad88850eb93909d9fa262286c28b8b88b7738956 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 5 Jun 2026 17:42:19 +0300 Subject: [PATCH 10/18] restored tenant admin permissions to moderate user comments --- .../controller/AlarmCommentController.java | 3 ++ .../AlarmCommentControllerTest.java | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 3091d6ac44..0bcce4be83 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -130,6 +130,9 @@ public class AlarmCommentController extends BaseController { } private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation, SecurityUser securityUser) throws ThingsboardException { + if (securityUser.isTenantAdmin()) { + return; + } if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(securityUser.getId())) { throw new ThingsboardException("User is not allowed to " + (operation == Operation.DELETE ? "delete" : "edit") + " other user's comment", ThingsboardErrorCode.PERMISSION_DENIED); diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index e8e9412dc6..74b3a1122d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -162,25 +162,24 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { @Test public void testUpdateOthersAlarmCommentByTenantAdmin() throws Exception { - // Tenant admins are NOT exempt from the ownership rule — even with full tenant-level - // privileges they cannot rewrite a comment authored by a different user. + // Tenant admins may moderate comments authored by other users — the ownership rule + // applies only to non-admin users, so a tenant admin can edit someone else's comment. loginCustomerUser(); AlarmComment alarmComment = createAlarmComment(alarm.getId()); loginTenantAdmin(); Mockito.reset(tbClusterService, auditLogService); - JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Tenant rewrite attempt")); + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Tenant rewrite")); alarmComment.setComment(newComment); - // Simulate the real attack: the attacker controls the request body and would omit (or spoof) - // the userId. Ownership must be enforced against the persisted comment, not the body. - alarmComment.setUserId(null); + AlarmComment updatedAlarmComment = saveAlarmComment(alarm.getId(), alarmComment); - doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) - .andExpect(status().isForbidden()) - .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + Assert.assertNotNull(updatedAlarmComment); + Assert.assertEquals(newComment.get("text"), updatedAlarmComment.getComment().get("text")); + Assert.assertEquals("true", updatedAlarmComment.getComment().get("edited").asText()); + Assert.assertNotNull(updatedAlarmComment.getComment().get("editedOn")); - testNotifyEntityNever(alarm.getId(), alarmComment); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); } @Test @@ -240,8 +239,8 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { @Test public void testDeleteOthersAlarmCommentByTenantAdmin() throws Exception { - // Tenant admins are NOT exempt from the ownership rule on delete either — even with full - // tenant-level privileges they cannot delete a comment authored by a different user. + // Tenant admins may moderate comments authored by other users — the ownership rule + // applies only to non-admin users, so a tenant admin can delete someone else's comment. loginCustomerUser(); AlarmComment alarmComment = createAlarmComment(alarm.getId()); @@ -249,10 +248,15 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService); doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) - .andExpect(status().isForbidden()) - .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + .andExpect(status().isOk()); - testNotifyEntityNever(alarm.getId(), alarmComment); + AlarmComment expectedAlarmComment = AlarmComment.builder() + .alarmId(alarm.getId()) + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", + TENANT_ADMIN_EMAIL))) + .build(); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @Test From 88180f9009fd68b08249654e87eb674256f895cb Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 5 Jun 2026 17:58:37 +0300 Subject: [PATCH 11/18] minor refactoring --- .../server/controller/AlarmCommentController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 0bcce4be83..aae7670d20 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -82,7 +82,7 @@ public class AlarmCommentController extends BaseController { SecurityUser currentUser = getCurrentUser(); if (alarmComment.getId() != null) { AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); - checkUserCommentOwnership(existingAlarmComment, Operation.WRITE, currentUser); + checkUserCommentOwnership(existingAlarmComment, "edit", currentUser); } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); @@ -101,7 +101,7 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); SecurityUser currentUser = getCurrentUser(); - checkUserCommentOwnership(alarmComment, Operation.DELETE, currentUser); + checkUserCommentOwnership(alarmComment, "delete", currentUser); tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @@ -129,12 +129,12 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } - private void checkUserCommentOwnership(AlarmComment alarmComment, Operation operation, SecurityUser securityUser) throws ThingsboardException { + private void checkUserCommentOwnership(AlarmComment alarmComment, String action, SecurityUser securityUser) throws ThingsboardException { if (securityUser.isTenantAdmin()) { return; } if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(securityUser.getId())) { - throw new ThingsboardException("User is not allowed to " + (operation == Operation.DELETE ? "delete" : "edit") + " other user's comment", + throw new ThingsboardException("User is not allowed to " + action + " other user's comment", ThingsboardErrorCode.PERMISSION_DENIED); } } From 4ea25be79b7c366282750865a0373cafeb5b2dda Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 8 Jun 2026 15:41:27 +0300 Subject: [PATCH 12/18] fixed pr comments --- .../server/common/data/StringUtils.java | 7 ++++ .../DeviceCredentialsDataValidator.java | 6 +-- .../dao/util/DeviceConnectivityUtil.java | 41 ++++++++++++------- .../DeviceCredentialsDataValidatorTest.java | 9 ++++ .../dao/util/DeviceConnectivityUtilTest.java | 33 +++++++++++++++ 5 files changed, 77 insertions(+), 19 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 37242dfa1d..48b0efe16b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -24,6 +24,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.function.Function; +import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.repeat; @@ -37,6 +38,12 @@ public class StringUtils { public static final int INDEX_NOT_FOUND = -1; + public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + + public static boolean containsControlChars(String source) { + return source != null && CONTROL_CHARS.matcher(source).find(); + } + public static boolean isEmpty(String source) { return source == null || source.isEmpty(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 96ffa613c7..c053f36d5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -30,13 +30,9 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.service.DataValidator; -import java.util.regex.Pattern; - @Component public class DeviceCredentialsDataValidator extends DataValidator { - private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); - @Autowired private DeviceCredentialsDao deviceCredentialsDao; @@ -92,7 +88,7 @@ public class DeviceCredentialsDataValidator extends DataValidator validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + private DeviceCredentials accessToken(String token) { DeviceCredentials c = new DeviceCredentials(); c.setDeviceId(deviceId); diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java index b69fefeea0..2c6fee2fcf 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java @@ -81,6 +81,39 @@ class DeviceConnectivityUtilTest { assertNoInjectedSiblingKeys(yaml); } + @Test + void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("cid", "u\";touch pwned;echo \"", "pwd")); + + // the double quote must be backslash-escaped so it cannot terminate the -u "..." argument + assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\""); + assertThat(command).doesNotContain("-u \"u\";"); + } + + @Test + void controlCharsInMqttClientIdAreStrippedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("c\nid", "user", "pwd")); + + assertThat(command).doesNotContain("\n"); + assertThat(command).contains("-i \"c_id\""); + } + + @Test + void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() { + DeviceCredentials creds = accessToken("tok\nen"); + + assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + } + private static String renderCompose(DeviceCredentials credentials) throws Exception { var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( "host.docker.internal", "3.8-stable", credentials); From 761cba79e436ac971050dd145bcd9729f632a5bd Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 8 Jun 2026 15:56:41 +0300 Subject: [PATCH 13/18] Fix transport tenant-profile lock convoy under cold-cache reconnect storm The defective code lives in common/transport/transport-api and is shared by all transports (MQTT, HTTP, CoAP, LwM2M, SNMP); the production incident happened to surface on MQTT. On a cold tenant-profile cache (e.g. after a cache clear + restart), a device reconnect storm could serialize the whole transport instance behind tenant-profile resolution, saturating the callback pool and stalling the node for ~15 minutes. Two compounding causes are addressed: - DefaultTransportTenantProfileCache held a single process-wide ReentrantLock across the synchronous cross-service getEntityProfile round-trip, so every tenant-profile cache miss in the whole process was serialized one-at-a-time. Replace it with a bounded set of per-tenant locks (Guava Striped) so different tenants resolve concurrently while concurrent misses for the same tenant are still de-duplicated. - DefaultTransportRateLimitService performed that blocking fetch inside ConcurrentHashMap.computeIfAbsent's mapping function, holding a CHM bin lock across the remote round-trip. Pre-fetch the tenant profile before computeIfAbsent so no bin lock is held across I/O. Also de-duplicate the four near-identical getXRateLimits methods into one generic helper, move the per-type rate-limit getters onto the TransportLimitsType enum, and avoid fetching the tenant profile four times in update(TenantId). --- .../DefaultTransportRateLimitService.java | 102 ++++++------- .../transport/limits/TransportLimitsType.java | 35 ++++- .../DefaultTransportTenantProfileCache.java | 72 ++++++---- .../DefaultTransportRateLimitServiceTest.java | 94 ++++++++++++ ...efaultTransportTenantProfileCacheTest.java | 136 ++++++++++++++++++ 5 files changed, 348 insertions(+), 91 deletions(-) create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index e6c3cab1ba..30bedf4eb6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -119,11 +119,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); + TenantProfile profile = tenantProfileCache.get(tenantId); + update(tenantId, + createRateLimits(profile, TENANT_LIMITS), + createRateLimits(profile, DEVICE_LIMITS), + createRateLimits(profile, GATEWAY_LIMITS), + createRateLimits(profile, GATEWAY_DEVICE_LIMITS) + ); } private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype, @@ -231,25 +233,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi BiConsumer putFunction) { EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId); if (oldRateLimits == null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] New rate limits: {}", entityId, newRateLimits); - } else { - log.debug("[{}] New rate limits: {}", entityId, newRateLimits); - } + logLimits(entityId, "New", newRateLimits); putFunction.accept(entityId, newRateLimits); } else { EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits); if (updated != null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] Updated rate limits: {}", entityId, updated); - } else { - log.debug("[{}] Updated rate limits: {}", entityId, updated); - } + logLimits(entityId, "Updated", updated); putFunction.accept(entityId, updated); } } } + private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) { + // Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG. + if (EntityType.TENANT.equals(entityId.getEntityType())) { + log.info("[{}] {} rate limits: {}", entityId, action, limits); + } else { + log.debug("[{}] {} rate limits: {}", entityId, action, limits); + } + } + private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) { boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration()); boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration()); @@ -269,36 +272,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); - } else { - TransportRateLimit regularMsgRateLimit; - TransportRateLimit telemetryMsgRateLimit; - TransportRateLimit telemetryDpRateLimit; - switch (limitsType) { - case TENANT_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); - } - case DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); - } - case GATEWAY_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); - } - case GATEWAY_DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit()); - } - default -> throw new IllegalStateException("Unknown limits type: " + limitsType); - } - - return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } + return new EntityTransportRateLimits( + newLimit(limitsType.getRegularMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile)) + ); } private static TransportRateLimit newLimit(String config) { @@ -306,31 +285,34 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); + return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { - return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - getTenantDevices(tenantId).add(deviceId); - return limits; - }); + return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId)); } private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - getTenantGateways(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId)); } private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - getTenantGatewayDevices(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId)); + } + + private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId, + T entityId, TransportLimitsType limitsType, Runnable onCreate) { + EntityTransportRateLimits limits = limitsMap.get(entityId); + if (limits == null) { + // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may + // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function. + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType)); + if (onCreate != null) { + onCreate.run(); + } + } + return limits; } private Set getTenantDevices(TenantId tenantId) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java index 29077877a8..3a124da2b6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -15,6 +15,39 @@ */ package org.thingsboard.server.common.transport.limits; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.function.Function; + +@Getter +@RequiredArgsConstructor public enum TransportLimitsType { - TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS + + TENANT_LIMITS( + DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit + ), + DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit + ), + GATEWAY_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit + ), + GATEWAY_DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit + ); + + private final Function regularMsgRateLimit; + private final Function telemetryMsgRateLimit; + private final Function telemetryDataPointsRateLimit; + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java index 4923bcd9a2..8eb8e795c1 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.service; +import com.google.common.util.concurrent.Striped; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -37,14 +38,15 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; @Component @TbTransportComponent @Slf4j public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { - private final Lock tenantProfileFetchLock = new ReentrantLock(); + // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while + // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size). + private final Striped tenantProfileFetchLocks = Striped.lock(1024); private final ConcurrentMap profiles = new ConcurrentHashMap<>(); private final ConcurrentMap tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>(); @@ -103,43 +105,53 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil } private TenantProfile getTenantProfile(TenantId tenantId) { - TenantProfile profile = null; - TenantProfileId tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + TenantProfile profile = lookupCached(tenantId); if (profile == null) { - tenantProfileFetchLock.lock(); + // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing + // different tenants to resolve their profiles concurrently. A single global lock here + // serializes the synchronous cross-service fetch below across the entire process. + Lock lock = tenantProfileFetchLocks.get(tenantId); + lock.lock(); try { - tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + profile = lookupCached(tenantId); if (profile == null) { - TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() - .setEntityType(EntityType.TENANT.name()) - .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) - .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) - .build(); - TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); - profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); - TenantProfile existingProfile = profiles.get(profile.getId()); - if (existingProfile != null) { - profile = existingProfile; - } else { - profiles.put(profile.getId(), profile); - } - tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); - tenantIds.put(tenantId, profile.getId()); - ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); - rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + profile = fetchAndCacheTenantProfile(tenantId); } } finally { - tenantProfileFetchLock.unlock(); + lock.unlock(); } } return profile; } + private TenantProfile lookupCached(TenantId tenantId) { + TenantProfileId tenantProfileId = tenantIds.get(tenantId); + if (tenantProfileId != null) { + return profiles.get(tenantProfileId); + } + return null; + } + + private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) { + TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) + .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) + .build(); + TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); + TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); + TenantProfile existingProfile = profiles.get(profile.getId()); + if (existingProfile != null) { + profile = existingProfile; + } else { + profiles.put(profile.getId(), profile); + } + tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); + tenantIds.put(tenantId, profile.getId()); + ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); + rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + return profile; + } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java new file mode 100644 index 0000000000..0ca00ed1e4 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.limits; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.transport.TransportTenantProfileCache; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTransportRateLimitServiceTest { + + private TransportTenantProfileCache tenantProfileCache; + private ExecutorService executor; + + private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID()); + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TransportTenantProfileCache.class); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception { + // Two concurrent rate-limit checks for the SAME tenant must both be able to reach + // the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside + // ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation + // node and never reaches the fetch -> the latch never reaches zero. + CountDownLatch bothCallersReachedFetch = new CountDownLatch(2); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> { + bothCallersReachedFetch.countDown(); + releaseFetch.await(5, TimeUnit.SECONDS); + return tenantProfile(); + }); + + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + Runnable check = () -> service.checkLimits(tenant, null, null, 1, false); + executor.submit(check); + executor.submit(check); + + boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS); + releaseFetch.countDown(); + + assertThat(bothReached) + .as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)") + .isTrue(); + } + + private TenantProfile tenantProfile() { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("test-profile"); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(new DefaultTenantProfileConfiguration()); + profile.setProfileData(profileData); + return profile; + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java new file mode 100644 index 0000000000..4da7767330 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.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.common.transport.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTransportTenantProfileCacheTest { + + private DefaultTransportTenantProfileCache cache; + private TransportService transportService; + private TransportRateLimitService rateLimitService; + private ExecutorService executor; + + private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID()); + private final TenantId tenantB = TenantId.fromUUID(UUID.randomUUID()); + + @BeforeEach + void setUp() { + cache = new DefaultTransportTenantProfileCache(); + transportService = mock(TransportService.class); + rateLimitService = mock(TransportRateLimitService.class); + doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean()); + cache.setTransportService(transportService); + cache.setRateLimitService(rateLimitService); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception { + CountDownLatch tenantAFetchStarted = new CountDownLatch(1); + CountDownLatch releaseTenantA = new CountDownLatch(1); + + GetEntityProfileResponseMsg responseA = responseFor(tenantA); + GetEntityProfileResponseMsg responseB = responseFor(tenantB); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + GetEntityProfileRequestMsg msg = invocation.getArgument(0); + TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + if (requested.equals(tenantA)) { + tenantAFetchStarted.countDown(); + releaseTenantA.await(5, TimeUnit.SECONDS); + return responseA; + } + return responseB; + }); + + // T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip. + Future tenantAResult = executor.submit(() -> cache.get(tenantA)); + assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS)) + .as("tenantA fetch should have started").isTrue(); + + // T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch. + // Fails today (single global lock); passes once locking is per-tenant. + TenantProfile tenantBProfile = CompletableFuture + .supplyAsync(() -> cache.get(tenantB), executor) + .get(2, TimeUnit.SECONDS); + assertThat(tenantBProfile).isNotNull(); + + releaseTenantA.countDown(); + assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull(); + } + + private GetEntityProfileResponseMsg responseFor(TenantId tenantId) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("profile-" + tenantId.getId()); + return GetEntityProfileResponseMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setTenantProfile(ProtoUtils.toProto(profile)) + .setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId))) + .build(); + } + + private ApiUsageState enabledApiUsageState(TenantId tenantId) { + ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + state.setTenantId(tenantId); + state.setEntityId(tenantId); + state.setTransportState(ApiUsageStateValue.ENABLED); + state.setDbStorageState(ApiUsageStateValue.ENABLED); + state.setReExecState(ApiUsageStateValue.ENABLED); + state.setJsExecState(ApiUsageStateValue.ENABLED); + state.setTbelExecState(ApiUsageStateValue.ENABLED); + state.setEmailExecState(ApiUsageStateValue.ENABLED); + state.setSmsExecState(ApiUsageStateValue.ENABLED); + state.setAlarmExecState(ApiUsageStateValue.ENABLED); + state.setVersion(1L); + return state; + } + +} From 0276d66d7238ba83bd14b4245d46197bc2a96b3d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 8 Jun 2026 16:53:28 +0300 Subject: [PATCH 14/18] update alarm comment moderation logic: delete is allowed for all users with alarm WRITE permission, edit - only for authors --- .../controller/AlarmCommentController.java | 16 ++------ .../alarm/DefaultTbAlarmCommentService.java | 2 +- .../server/controller/AbstractWebTest.java | 23 ++++++++++- .../AlarmCommentControllerTest.java | 41 ++++++++----------- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index aae7670d20..adc29df05f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -82,7 +82,10 @@ public class AlarmCommentController extends BaseController { SecurityUser currentUser = getCurrentUser(); if (alarmComment.getId() != null) { AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); - checkUserCommentOwnership(existingAlarmComment, "edit", currentUser); + if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { + throw new ThingsboardException("User is not allowed to edit other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); @@ -101,7 +104,6 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); SecurityUser currentUser = getCurrentUser(); - checkUserCommentOwnership(alarmComment, "delete", currentUser); tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @@ -129,14 +131,4 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } - private void checkUserCommentOwnership(AlarmComment alarmComment, String action, SecurityUser securityUser) throws ThingsboardException { - if (securityUser.isTenantAdmin()) { - return; - } - if (alarmComment.getUserId() != null && !alarmComment.getUserId().equals(securityUser.getId())) { - throw new ThingsboardException("User is not allowed to " + action + " other user's comment", - ThingsboardErrorCode.PERMISSION_DENIED); - } - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java index 0fb43a4511..276ba1bedc 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java @@ -60,7 +60,7 @@ public class DefaultTbAlarmCommentService extends AbstractTbEntityService implem alarmComment.setType(AlarmCommentType.SYSTEM); alarmComment.setUserId(null); alarmComment.setComment(JacksonUtil.newObjectNode().put("text", - String.format("User %s deleted his comment", + String.format("Comment was deleted by user %s", (user.getFirstName() == null || user.getLastName() == null) ? user.getName() : user.getFirstName() + " " + user.getLastName()))); AlarmComment savedAlarmComment = checkNotNull(alarmCommentService.saveAlarmComment(alarm.getTenantId(), alarmComment)); logEntityActionService.logEntityAction(alarm.getTenantId(), alarm.getId(), alarm, alarm.getCustomerId(), ActionType.DELETED_COMMENT, user, savedAlarmComment); diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index bbf3a3467e..2b70e04a85 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -210,6 +210,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; + protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; @@ -247,6 +248,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected CustomerId differentTenantCustomerId; protected UserId customerUserId; + protected UserId secondCustomerUserId; protected UserId differentCustomerUserId; protected UserId differentTenantCustomerUserId; @@ -372,9 +374,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { customerUser.setCustomerId(savedCustomer.getId()); customerUser.setEmail(CUSTOMER_USER_EMAIL); - customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); + customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD); customerUserId = customerUser.getId(); + User secondCustomerUser = new User(); + secondCustomerUser.setAuthority(Authority.CUSTOMER_USER); + secondCustomerUser.setTenantId(tenantId); + secondCustomerUser.setCustomerId(customerId); + secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL); + secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD); + secondCustomerUserId = secondCustomerUser.getId(); + resetTokens(); log.debug("Executed web test setup"); @@ -472,6 +482,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); } + protected void loginSecondCustomerUser() throws Exception { + login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); + } + protected void loginUser(String userName, String password) throws Exception { login(userName, password); } @@ -586,6 +600,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return savedUser; } + protected User createUserAndActivate(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + JsonNode activateRequest = getActivateRequest(password); + doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk()); + return savedUser; + } + protected User createUser(User user, String password) throws Exception { User savedUser = doPost("/api/user", user, User.class); JsonNode activateRequest = getActivateRequest(password); diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 74b3a1122d..9b997cd8ad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -161,25 +161,22 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { } @Test - public void testUpdateOthersAlarmCommentByTenantAdmin() throws Exception { - // Tenant admins may moderate comments authored by other users — the ownership rule - // applies only to non-admin users, so a tenant admin can edit someone else's comment. + public void testEditOthersAlarmCommentIsProhibited() throws Exception { loginCustomerUser(); AlarmComment alarmComment = createAlarmComment(alarm.getId()); - loginTenantAdmin(); - Mockito.reset(tbClusterService, auditLogService); - - JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Tenant rewrite")); + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite")); alarmComment.setComment(newComment); - AlarmComment updatedAlarmComment = saveAlarmComment(alarm.getId(), alarmComment); - Assert.assertNotNull(updatedAlarmComment); - Assert.assertEquals(newComment.get("text"), updatedAlarmComment.getComment().get("text")); - Assert.assertEquals("true", updatedAlarmComment.getComment().get("edited").asText()); - Assert.assertNotNull(updatedAlarmComment.getComment().get("editedOn")); + loginSecondCustomerUser(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); - testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); + loginTenantAdmin(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); } @Test @@ -231,20 +228,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", CUSTOMER_USER_EMAIL))) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @Test - public void testDeleteOthersAlarmCommentByTenantAdmin() throws Exception { - // Tenant admins may moderate comments authored by other users — the ownership rule - // applies only to non-admin users, so a tenant admin can delete someone else's comment. + public void testDeleteOthersAlarmCommentIsAllowedForUserWithAlarmWritePermission() throws Exception { loginCustomerUser(); AlarmComment alarmComment = createAlarmComment(alarm.getId()); - loginTenantAdmin(); + loginSecondCustomerUser(); Mockito.reset(tbClusterService, auditLogService); doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) @@ -253,10 +248,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - TENANT_ADMIN_EMAIL))) + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", + SECOND_CUSTOMER_USER_EMAIL))) .build(); - testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, secondCustomerUserId, SECOND_CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @Test @@ -278,13 +273,13 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); - assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s", TENANT_ADMIN_EMAIL)); AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", + .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", TENANT_ADMIN_EMAIL))) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); From 8082d60ffe163a987f25ad7ab9950dc645eb9193 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 8 Jun 2026 17:48:35 +0300 Subject: [PATCH 15/18] update alarm comment moderation logic: delete is allowed for author or tenant admin only --- .../controller/AlarmCommentController.java | 17 ++++++++++++----- .../controller/AlarmCommentControllerTest.java | 12 ++++++++---- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index adc29df05f..998ca1cfa5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -81,11 +81,7 @@ public class AlarmCommentController extends BaseController { Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); SecurityUser currentUser = getCurrentUser(); if (alarmComment.getId() != null) { - AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); - if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { - throw new ThingsboardException("User is not allowed to edit other user's comment", - ThingsboardErrorCode.PERMISSION_DENIED); - } + checkUserPermission(alarmComment, alarmId, "edit", currentUser); } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); @@ -104,6 +100,9 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); SecurityUser currentUser = getCurrentUser(); + if (!currentUser.isTenantAdmin()) { + checkUserPermission(alarmComment, alarmId, "delete", currentUser); + } tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @@ -131,4 +130,12 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException { + AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); + if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { + throw new ThingsboardException("User is not allowed to " + operation + " other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 9b997cd8ad..bebfe832e8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -235,7 +235,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { } @Test - public void testDeleteOthersAlarmCommentIsAllowedForUserWithAlarmWritePermission() throws Exception { + public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception { loginCustomerUser(); AlarmComment alarmComment = createAlarmComment(alarm.getId()); @@ -243,15 +243,19 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService, auditLogService); doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) - .andExpect(status().isOk()); + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + loginTenantAdmin(); + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isOk()); AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) .comment(JacksonUtil.newObjectNode().put("text", String.format("Comment was deleted by user %s", - SECOND_CUSTOMER_USER_EMAIL))) + TENANT_ADMIN_EMAIL))) .build(); - testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, secondCustomerUserId, SECOND_CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @Test From a0f5b3bc5b6496dcab4248b15a038a39766b45ae Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 9 Jun 2026 09:53:57 +0200 Subject: [PATCH 16/18] Move Californium server construction into init try/catch and guard CoAP shutdown Construct CoapServer and the LwM2M bootstrap server inside the init try block so a failure in the constructor or build() is cleaned up by the existing catch. Guard CoAP shutdown() against a null server. Add a DTLS-enabled CoAP test that covers the dtlsSessionsExecutor shutdown branch. --- .../coapserver/DefaultCoapServerService.java | 6 +- .../DefaultCoapServerServiceTest.java | 66 +++++++++++++++++++ .../LwM2MTransportBootstrapService.java | 7 +- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 081f1a9db5..8ffa489f7b 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsSessionsExecutor.shutdownNow(); } log.info("Stopping CoAP server!"); - server.destroy(); + if (server != null) { + server.destroy(); + } log.info("CoAP server stopped!"); } @@ -105,8 +107,8 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial private CoapServer createCoapServer() throws UnknownHostException { Configuration networkConfig = createNetworkConfiguration(); - server = new CoapServer(networkConfig); try { + server = new CoapServer(networkConfig); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java index 5606fe18d7..0c9e9fc29d 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java @@ -15,20 +15,36 @@ */ package org.thingsboard.server.coapserver; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.Resource; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.ThingsBoardExecutors; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.util.concurrent.ScheduledExecutorService; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -76,4 +92,54 @@ public class DefaultCoapServerServiceTest { assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); } + @Test + public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception { + // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start(). + // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not. + TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1)); + TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class); + Resource mockRoot = mock(Resource.class); + + try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class); + MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> { + when(server.getRoot()).thenReturn(mockRoot); + doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start(); + }); + MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> { + when(builder.setInetSocketAddress(any())).thenReturn(builder); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + when(builder.build()).thenReturn(mock(CoapEndpoint.class)); + })) { + + executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + // DTLS branch was actually entered and the executor was created... + verify(mockDtlsSettings).dtlsConnectorConfig(any()); + // ...and the cleanup branch shut it down and destroyed the server. + verify(mockExecutor).shutdownNow(); + verify(serverMock.constructed().get(0)).destroy(); + } + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index bf2b48dd61..639e0bf74a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -82,15 +82,18 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); - LeshanBootstrapServer bootstrapServer = getLhBootstrapServer(); + LeshanBootstrapServer bootstrapServer = null; try { + bootstrapServer = getLhBootstrapServer(); this.server = bootstrapServer; bootstrapServer.start(); log.info("Started LwM2M transport bootstrap server."); } catch (RuntimeException e) { log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e); try { - bootstrapServer.destroy(); + if (bootstrapServer != null) { + bootstrapServer.destroy(); + } } catch (Exception suppressed) { e.addSuppressed(suppressed); } finally { From a1bf69cd2767a282a5031d6442aba70abab9303d Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 9 Jun 2026 14:30:36 +0300 Subject: [PATCH 17/18] Address PR review feedback - Extract the tenant-profile fetch lock stripe count to a named constant (PR #15744) - Trim the stale global-lock sentence from the per-tenant lock comment (PR #15744) - Rename the rate-limit onCreate callback to onMiss and document its idempotency requirement (PR #15744) - Reuse a single tenant profile local in update(TenantProfileUpdateResult) (PR #15744) - Make the transport callback thread pool size configurable via transport.callback_thread_pool_size (PR #15744) - Add a parameterized test locking the TransportLimitsType enum-to-profile-field mapping (PR #15744) - Add device/gateway rate-limit coverage asserting update(tenantId) reaches tracked entities (PR #15744) - Add a same-tenant fetch-dedup test and pin the cross-tenant test to distinct stripes (PR #15744) --- .../src/main/resources/thingsboard.yml | 2 + .../DefaultTransportRateLimitService.java | 19 +-- .../service/DefaultTransportService.java | 4 +- .../DefaultTransportTenantProfileCache.java | 10 +- .../DefaultTransportRateLimitServiceTest.java | 110 +++++++++++++++++- ...efaultTransportTenantProfileCacheTest.java | 57 ++++++++- .../src/main/resources/tb-coap-transport.yml | 2 + .../src/main/resources/tb-http-transport.yml | 2 + .../src/main/resources/tb-lwm2m-transport.yml | 2 + .../src/main/resources/tb-mqtt-transport.yml | 2 + .../src/main/resources/tb-snmp-transport.yml | 2 + 11 files changed, 198 insertions(+), 14 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2c24caa1f2..22f52aff03 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1138,6 +1138,8 @@ transport: timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" # Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property) api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index 30bedf4eb6..e7ef634c72 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { - log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS); + TenantProfile profile = update.getProfile(); + log.info("Received tenant profile update: {}", profile); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS); + EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); } @@ -301,15 +302,17 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId, - T entityId, TransportLimitsType limitsType, Runnable onCreate) { + T entityId, TransportLimitsType limitsType, Runnable onMiss) { EntityTransportRateLimits limits = limitsMap.get(entityId); if (limits == null) { // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function. TenantProfile tenantProfile = tenantProfileCache.get(tenantId); limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType)); - if (onCreate != null) { - onCreate.run(); + // Runs on every observed miss, including callers that lost the computeIfAbsent race and got an + // existing value back - NOT only on actual creation, so the callback must be idempotent. + if (onMiss != null) { + onMiss.run(); } } return limits; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 240de91424..80980beeb7 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements private int notificationsPollDuration; @Value("${transport.stats.enabled:false}") private boolean statsEnabled; + @Value("${transport.callback_thread_pool_size:20}") + private int callbackThreadPoolSize; @Autowired @Lazy @@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer"); this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer"); this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer"); - this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); + this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass()); this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS); transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate(); transportApiRequestTemplate.setMessagesStats(transportApiStats); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java index 8eb8e795c1..ac2fd4c28a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java @@ -44,9 +44,14 @@ import java.util.concurrent.locks.Lock; @Slf4j public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { + // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache + // misses (cached tenants never take the lock), and concurrent fetches are already bounded by the + // transport callback pool, so this comfortably over-provisions the realistic concurrency. + private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024; + // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size). - private final Striped tenantProfileFetchLocks = Striped.lock(1024); + private final Striped tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES); private final ConcurrentMap profiles = new ConcurrentHashMap<>(); private final ConcurrentMap tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>(); @@ -108,8 +113,7 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil TenantProfile profile = lookupCached(tenantId); if (profile == null) { // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing - // different tenants to resolve their profiles concurrently. A single global lock here - // serializes the synchronous cross-service fetch below across the entire process. + // different tenants to resolve their profiles concurrently. Lock lock = tenantProfileFetchLocks.get(tenantId); lock.lock(); try { diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java index 0ca00ed1e4..b7b6a81abf 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java @@ -18,13 +18,18 @@ package org.thingsboard.server.common.transport.limits; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.transport.TransportTenantProfileCache; +import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -82,13 +87,116 @@ class DefaultTransportRateLimitServiceTest { .isTrue(); } + @ParameterizedTest + @EnumSource(TransportLimitsType.class) + void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) { + // Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS + // wired to the plain gateway getters) resolves to the wrong value and fails the assertion. + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setTransportTenantMsgRateLimit("tenant-msg"); + config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg"); + config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp"); + config.setTransportDeviceMsgRateLimit("device-msg"); + config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg"); + config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp"); + config.setTransportGatewayMsgRateLimit("gateway-msg"); + config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg"); + config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp"); + config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg"); + config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg"); + config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp"); + + String prefix = switch (type) { + case TENANT_LIMITS -> "tenant"; + case DEVICE_LIMITS -> "device"; + case GATEWAY_LIMITS -> "gateway"; + case GATEWAY_DEVICE_LIMITS -> "gateway-device"; + }; + + assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg"); + assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg"); + assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp"); + } + + @ParameterizedTest + @EnumSource(EntityLevel.class) + void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) { + DeviceId entity = new DeviceId(UUID.randomUUID()); + when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600")); + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + // First check resolves the (permissive) limit and must register the entity into the per-tenant + // tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it. + assertThat(level.check(service, tenant, entity)) + .as("permissive limit should allow the first %s check", level).isNull(); + + // Tighten the limit to a single message and push a profile update for this tenant. + service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant))); + + // The freshly merged "1:600" bucket allows exactly one message... + assertThat(level.check(service, tenant, entity)).isNull(); + // ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity. + assertThat(level.check(service, tenant, entity)) + .as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull(); + } + private TenantProfile tenantProfile() { + return profileWith(new DefaultTenantProfileConfiguration()); + } + + private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) { + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + level.setRegularMsgRateLimit(config, regularMsgRateLimit); + return profileWith(config); + } + + private TenantProfile profileWith(DefaultTenantProfileConfiguration config) { TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); profile.setName("test-profile"); TenantProfileData profileData = new TenantProfileData(); - profileData.setConfiguration(new DefaultTenantProfileConfiguration()); + profileData.setConfiguration(config); profile.setProfileData(profileData); return profile; } + private enum EntityLevel { + DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, false); + } + }, + GATEWAY { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, entityId, null, 0, false); + } + }, + GATEWAY_DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, true); + } + }; + + abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value); + + abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId); + } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java index 4da7767330..d2b4544f53 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.service; +import com.google.common.util.concurrent.Striped; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -31,6 +32,8 @@ import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -38,12 +41,15 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class DefaultTransportTenantProfileCacheTest { @@ -53,8 +59,22 @@ class DefaultTransportTenantProfileCacheTest { private TransportRateLimitService rateLimitService; private ExecutorService executor; + // Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES. + private static final int STRIPE_COUNT = 1024; + private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID()); - private final TenantId tenantB = TenantId.fromUUID(UUID.randomUUID()); + // Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant + // test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe. + private final TenantId tenantB = differentStripeFrom(tenantA); + + private static TenantId differentStripeFrom(TenantId other) { + Striped probe = Striped.lock(STRIPE_COUNT); + TenantId candidate = TenantId.fromUUID(UUID.randomUUID()); + while (probe.get(candidate) == probe.get(other)) { + candidate = TenantId.fromUUID(UUID.randomUUID()); + } + return candidate; + } @BeforeEach void setUp() { @@ -107,6 +127,41 @@ class DefaultTransportTenantProfileCacheTest { assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull(); } + @Test + void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception { + // The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse + // into a single cross-service fetch (the rest are served from cache). Assert that contract directly. + int callers = 8; + CountDownLatch fetchStarted = new CountDownLatch(1); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + fetchStarted.countDown(); + // Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock. + releaseFetch.await(5, TimeUnit.SECONDS); + return responseFor(tenantA); + }); + + CountDownLatch allSubmitted = new CountDownLatch(callers); + List> results = new ArrayList<>(); + for (int i = 0; i < callers; i++) { + results.add(executor.submit(() -> { + allSubmitted.countDown(); + return cache.get(tenantA); + })); + } + + assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue(); + assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue(); + releaseFetch.countDown(); + + for (Future result : results) { + assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull(); + } + // All 8 callers resolved the same tenant, but only one of them hit the backend. + verify(transportService, times(1)).getEntityProfile(any()); + } + private GetEntityProfileResponseMsg responseFor(TenantId tenantId) { TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); profile.setName("profile-" + tenantId.getId()); diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 1554b3fc24..97d53384ba 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -133,6 +133,8 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" # Local CoAP transport parameters coap: # CoaP processing timeout in milliseconds diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index c878887a01..bf8d5e1542 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -167,6 +167,8 @@ redis: # HTTP server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" http: # HTTP request processing timeout in milliseconds request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 51b1ad0a2b..1c14202efd 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -134,6 +134,8 @@ redis: # LWM2M server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. # The parameter value is in milliseconds. diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index dfe35db29c..0c011e207e 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -135,6 +135,8 @@ redis: # MQTT server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" mqtt: # MQTT bind-address bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index d021030a91..b5ec777af6 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -134,6 +134,8 @@ redis: # Snmp server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" snmp: # Enable/disable SNMP transport protocol enabled: "${SNMP_ENABLED:true}" From 4007b85ce46a9330a9d3925a16f23aadba4478dd Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 9 Jun 2026 15:07:21 +0300 Subject: [PATCH 18/18] test fixes --- .../org/thingsboard/server/controller/HomePageApiTest.java | 4 ++-- .../org/thingsboard/server/controller/UserControllerTest.java | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index ee2aefc81a..d1621b29ec 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest { Assert.assertEquals(1, usageInfo.getCustomers()); Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers()); - Assert.assertEquals(2, usageInfo.getUsers()); + Assert.assertEquals(3, usageInfo.getUsers()); Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers()); Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards()); @@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest { } usageInfo = doGet("/api/usage", UsageInfo.class); - Assert.assertEquals(users.size() + 2, usageInfo.getUsers()); + Assert.assertEquals(users.size() + 3, usageInfo.getUsers()); List dashboards = new ArrayList<>(); for (int i = 0; i < 97; i++) { diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index 7d7131df96..cae55287b6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -682,6 +682,7 @@ public class UserControllerTest extends AbstractControllerTest { String email = "testEmail1"; List expectedCustomerUserIds = new ArrayList<>(); expectedCustomerUserIds.add(customerUserId); + expectedCustomerUserIds.add(secondCustomerUserId); for (int i = 0; i < 45; i++) { User customerUser = createCustomerUser(customerId); customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org");