From 361e267db4aeddc9b791b69e48fe2798bed4f670 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 5 Jun 2026 15:06:55 +0300 Subject: [PATCH 01/28] Edge: prevent disconnect notification spam from flapping edges --- .../service/edge/rpc/EdgeGrpcService.java | 104 ++++++++++++++++-- .../src/main/resources/thingsboard.yml | 5 + .../edge/EdgeConnectionNotificationTest.java | 96 ++++++++++++++++ 3 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 072a4878d5..7f57101205 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -41,7 +41,6 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.EdgeId; @@ -58,6 +57,7 @@ import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg; import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import org.thingsboard.server.gen.edge.v1.RequestMsg; import org.thingsboard.server.gen.edge.v1.ResponseMsg; @@ -68,7 +68,6 @@ import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeContextComponent; -import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.io.IOException; import java.util.ArrayList; @@ -103,6 +102,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); private final Map sessionNewEvents = new HashMap<>(); private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); + private final ConcurrentMap> pendingDisconnectNotifications = new ConcurrentHashMap<>(); private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); private final List zombieSessions = new ArrayList<>(); @@ -136,6 +136,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @Value("${edges.max_high_priority_queue_size_per_session:10000}") private int maxHighPriorityQueueSizePerSession; + @Value("${edges.connectivity.disconnect_notification_delay_ms:60000}") + private long disconnectNotificationDelayMs; + @Autowired @Lazy private EdgeContextComponent ctx; @@ -239,6 +242,12 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i sessionEdgeEventChecks.remove(edgeId); } } + pendingDisconnectNotifications.values().forEach(task -> { + if (task != null && !task.isDone()) { + task.cancel(false); + } + }); + pendingDisconnectNotifications.clear(); if (edgeEventProcessingExecutorService != null) { edgeEventProcessingExecutorService.shutdownNow(); } @@ -321,6 +330,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } finally { newEventLock.unlock(); } + cancelPendingDisconnectNotification(edgeId); cancelScheduleEdgeEventsCheck(edgeId); } } @@ -383,7 +393,11 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i long lastConnectTs = System.currentTimeMillis(); save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs); edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId()); - pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); + // If the edge reconnected within the disconnect-notification delay window, suppress the pending + // "disconnected" notification - the drop was transient (debounce for flapping edges). + cancelPendingDisconnectNotification(edgeId); + pushStateEventToRuleEngine(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); + notifyEdgeConnectivity(tenantId, edge, true); cancelScheduleEdgeEventsCheck(edgeId); edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE); scheduleEdgeEventsCheck(edgeGrpcSession); @@ -545,7 +559,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i save(tenantId, edgeId, ACTIVITY_STATE, false); long lastDisconnectTs = System.currentTimeMillis(); save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs); - pushRuleEngineMessage(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT); + pushStateEventToRuleEngine(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT); + scheduleDisconnectNotification(tenantId, edge); cancelScheduleEdgeEventsCheck(edgeId); } else { log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId); @@ -617,7 +632,33 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) { + private static class AttributeSaveCallback implements FutureCallback { + + private final TenantId tenantId; + private final EdgeId edgeId; + private final String key; + private final Object value; + + AttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) { + this.tenantId = tenantId; + this.edgeId = edgeId; + this.key = key; + this.value = value; + } + + @Override + public void onSuccess(@Nullable Void result) { + log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); + } + + } + + private void pushStateEventToRuleEngine(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) { try { EdgeId edgeId = edge.getId(); ObjectNode edgeState = JacksonUtil.newObjectNode(); @@ -629,12 +670,6 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i edgeState.put(ACTIVITY_STATE, false); edgeState.put(LAST_DISCONNECT_TIME, ts); } - ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder() - .tenantId(tenantId) - .customerId(edge.getCustomerId()) - .edgeId(edgeId) - .edgeName(edge.getName()) - .connected(isConnected).build()); String data = JacksonUtil.toString(edgeState); TbMsgMetaData md = new TbMsgMetaData(); if (!persistToTelemetry) { @@ -655,6 +690,53 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } + private void notifyEdgeConnectivity(TenantId tenantId, Edge edge, boolean connected) { + try { + ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder() + .tenantId(tenantId) + .customerId(edge.getCustomerId()) + .edgeId(edge.getId()) + .edgeName(edge.getName()) + .connected(connected).build()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to process edge connectivity notification (connected={})", tenantId, edge.getId(), connected, e); + } + } + + private void scheduleDisconnectNotification(TenantId tenantId, Edge edge) { + if (disconnectNotificationDelayMs <= 0) { + notifyEdgeConnectivity(tenantId, edge, false); + return; + } + EdgeId edgeId = edge.getId(); + pendingDisconnectNotifications.compute(edgeId, (id, existing) -> { + if (existing != null && !existing.isDone()) { + existing.cancel(false); + } + return executorService.schedule(() -> fireDelayedDisconnectNotification(tenantId, edge), + disconnectNotificationDelayMs, TimeUnit.MILLISECONDS); + }); + } + + private void fireDelayedDisconnectNotification(TenantId tenantId, Edge edge) { + EdgeId edgeId = edge.getId(); + pendingDisconnectNotifications.remove(edgeId); + // Re-verify the edge is still disconnected. The cache is cluster-wide, so this also covers the case + // where the edge dropped on this node and reconnected to a different TB-Core node within the window. + if (sessions.containsKey(edgeId) || edgeIdServiceIdCache.get(edgeId) != null) { + log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", tenantId, edgeId); + return; + } + notifyEdgeConnectivity(tenantId, edge, false); + } + + private void cancelPendingDisconnectNotification(EdgeId edgeId) { + ScheduledFuture pending = pendingDisconnectNotifications.remove(edgeId); + if (pending != null && !pending.isDone()) { + pending.cancel(false); + } + } + private void cleanupZombieSessions() { try { tryToDestroyZombieSessions(getZombieSessions(sessions.values()), s -> sessions.remove(s.getEdge().getId())); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 454c59d7d7..0cb3e57ae8 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1642,6 +1642,11 @@ edges: state: # Persist state of edge (active, last connect, last disconnect) into timeseries or attributes tables. 'false' means to store edge state into attributes table persistToTelemetry: "${EDGES_PERSIST_STATE_TO_TELEMETRY:false}" + connectivity: + # Delay (ms) before sending an edge "disconnected" notification. Suppressed if the edge reconnects within + # this window - debounces flapping edges from spamming the notification center. Only the notification is + # delayed; rule-engine events and state attributes update immediately. Set to 0 to notify immediately. + disconnect_notification_delay_ms: "${TB_EDGE_DISCONNECT_NOTIFICATION_DELAY_MS:60000}" stats: # Enable or disable reporting of edge communication stats (true or false) enabled: "${EDGES_STATS_ENABLED:true}" diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java new file mode 100644 index 0000000000..f72ad51f4a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java @@ -0,0 +1,96 @@ +/** + * 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.edge; + +import org.junit.Test; +import org.mockito.ArgumentMatcher; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.controller.AbstractWebTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edge.imitator.EdgeImitator; +import org.thingsboard.server.gen.edge.v1.OAuth2ClientUpdateMsg; +import org.thingsboard.server.gen.edge.v1.OAuth2DomainUpdateMsg; + +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DaoSqlTest +@TestPropertySource(properties = { + "edges.connectivity.disconnect_notification_delay_ms=5000" +}) +public class EdgeConnectionNotificationTest extends AbstractEdgeTest { + + private static final long DELAY_MS = 5000L; + + @MockitoSpyBean + private NotificationRuleProcessor notificationRuleProcessor; + + @Test + public void givenEdgeStaysDisconnected_whenDelayElapses_thenDisconnectNotificationSent() throws Exception { + clearInvocations(notificationRuleProcessor); + + edgeImitator.disconnect(); + + // After the configured delay, the "disconnected" notification is sent exactly once. + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + verify(notificationRuleProcessor, times(1)).process(argThat(edgeConnectionTrigger(false)))); + } + + @Test + public void givenEdgeReconnectsWithinDelay_whenEdgeFlaps_thenDisconnectNotificationSuppressed() throws Exception { + clearInvocations(notificationRuleProcessor); + + // Edge drops... + edgeImitator.disconnect(); + // Ensure the server processed the disconnect (and scheduled the delayed notification) before reconnecting. + TimeUnit.SECONDS.sleep(1); + + // ...and reconnects within the delay window, which must cancel the pending "disconnected" notification. + EdgeImitator reconnected = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); + reconnected.ignoreType(OAuth2ClientUpdateMsg.class); + reconnected.ignoreType(OAuth2DomainUpdateMsg.class); + reconnected.connect(); + edgeImitator = reconnected; // let teardown clean up the live session + + // The "connected" notification still fires immediately on reconnect (we suppress the disconnect only). + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + verify(notificationRuleProcessor, atLeastOnce()).process(argThat(edgeConnectionTrigger(true)))); + + // Wait until the original disconnect-notification window has fully elapsed... + TimeUnit.MILLISECONDS.sleep(DELAY_MS + 1000); + + // ...the "disconnected" notification must have never been sent. + verify(notificationRuleProcessor, never()).process(argThat(edgeConnectionTrigger(false))); + } + + private ArgumentMatcher edgeConnectionTrigger(boolean connected) { + return trigger -> trigger instanceof EdgeConnectionTrigger edgeTrigger + && edge.getId().equals(edgeTrigger.getEdgeId()) + && edgeTrigger.isConnected() == connected; + } + +} From 4993ffc66f4749d2465730b498b9f3ea5b5a4131 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 8 Jun 2026 17:07:28 +0300 Subject: [PATCH 02/28] refactor: address PR #15732 review - cancel helper, pooled executor, test coverage --- .../service/edge/rpc/EdgeGrpcService.java | 36 +++++++---- .../src/main/resources/thingsboard.yml | 2 +- .../server/edge/AbstractEdgeTest.java | 14 +++- .../edge/EdgeConnectionNotificationTest.java | 6 +- ...geImmediateDisconnectNotificationTest.java | 64 +++++++++++++++++++ 5 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 7f57101205..a1910396dc 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -36,6 +36,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.cache.TbCacheValueWrapper; import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; @@ -242,11 +243,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i sessionEdgeEventChecks.remove(edgeId); } } - pendingDisconnectNotifications.values().forEach(task -> { - if (task != null && !task.isDone()) { - task.cancel(false); - } - }); + pendingDisconnectNotifications.values().forEach(EdgeGrpcService::cancelIfPending); pendingDisconnectNotifications.clear(); if (edgeEventProcessingExecutorService != null) { edgeEventProcessingExecutorService.shutdownNow(); @@ -330,9 +327,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } finally { newEventLock.unlock(); } - cancelPendingDisconnectNotification(edgeId); cancelScheduleEdgeEventsCheck(edgeId); } + cancelPendingDisconnectNotification(edgeId); } private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) { @@ -576,7 +573,17 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId); } } - edgeIdServiceIdCache.evict(edgeId); + evictServiceIdCacheIfOwnedByThisNode(edgeId); + } + + // Only evict if the cache still points to this node. If the edge already reconnected to a different + // TB-Core node within the keep-alive window, that node has overwritten the entry - evicting it here + // would wipe the live owner and make fireDelayedDisconnectNotification raise a false 'disconnected'. + private void evictServiceIdCacheIfOwnedByThisNode(EdgeId edgeId) { + TbCacheValueWrapper wrapper = edgeIdServiceIdCache.get(edgeId); + if (wrapper != null && serviceInfoProvider.getServiceId().equals(wrapper.get())) { + edgeIdServiceIdCache.evict(edgeId); + } } private void destroySession(EdgeGrpcSession session) { @@ -710,10 +717,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } EdgeId edgeId = edge.getId(); pendingDisconnectNotifications.compute(edgeId, (id, existing) -> { - if (existing != null && !existing.isDone()) { - existing.cancel(false); - } - return executorService.schedule(() -> fireDelayedDisconnectNotification(tenantId, edge), + cancelIfPending(existing); + return edgeEventProcessingExecutorService.schedule(() -> fireDelayedDisconnectNotification(tenantId, edge), disconnectNotificationDelayMs, TimeUnit.MILLISECONDS); }); } @@ -731,9 +736,12 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } private void cancelPendingDisconnectNotification(EdgeId edgeId) { - ScheduledFuture pending = pendingDisconnectNotifications.remove(edgeId); - if (pending != null && !pending.isDone()) { - pending.cancel(false); + cancelIfPending(pendingDisconnectNotifications.remove(edgeId)); + } + + private static void cancelIfPending(ScheduledFuture future) { + if (future != null && !future.isDone()) { + future.cancel(false); } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 0cb3e57ae8..07ea4dbf06 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1646,7 +1646,7 @@ edges: # Delay (ms) before sending an edge "disconnected" notification. Suppressed if the edge reconnects within # this window - debounces flapping edges from spamming the notification center. Only the notification is # delayed; rule-engine events and state attributes update immediately. Set to 0 to notify immediately. - disconnect_notification_delay_ms: "${TB_EDGE_DISCONNECT_NOTIFICATION_DELAY_MS:60000}" + disconnect_notification_delay_ms: "${EDGES_DISCONNECT_NOTIFICATION_DELAY_MS:60000}" stats: # Enable or disable reporting of edge communication stats (true or false) enabled: "${EDGES_STATS_ENABLED:true}" diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index d75c7b8f59..71802468ab 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -125,6 +125,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { public static final String EDGE_HOST = "localhost"; public static final int EDGE_PORT = TestSocketUtils.findAvailableTcpPort(); + @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { log.debug("edges.rpc.port = {}", EDGE_PORT); @@ -157,16 +158,23 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { //8 installation messages installation(); - edgeImitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); + edgeImitator = createEdgeImitator(); // 17 connect messages + 8 installation messages edgeImitator.expectMessageAmount(SYNC_MESSAGE_COUNT); - edgeImitator.ignoreType(OAuth2ClientUpdateMsg.class); - edgeImitator.ignoreType(OAuth2DomainUpdateMsg.class); edgeImitator.connect(); verifyEdgeConnectionAndInitialData(); } + // Creates an EdgeImitator wired with the standard ignored message types, but not yet connected. + // Callers add any expectations (e.g. expectMessageAmount) before invoking connect() themselves. + protected EdgeImitator createEdgeImitator() throws Exception { + EdgeImitator imitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); + imitator.ignoreType(OAuth2ClientUpdateMsg.class); + imitator.ignoreType(OAuth2DomainUpdateMsg.class); + return imitator; + } + @After public void teardownEdgeTest() { try { diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java index f72ad51f4a..8179e5894a 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java @@ -25,8 +25,6 @@ import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edge.imitator.EdgeImitator; -import org.thingsboard.server.gen.edge.v1.OAuth2ClientUpdateMsg; -import org.thingsboard.server.gen.edge.v1.OAuth2DomainUpdateMsg; import java.util.concurrent.TimeUnit; @@ -70,9 +68,7 @@ public class EdgeConnectionNotificationTest extends AbstractEdgeTest { TimeUnit.SECONDS.sleep(1); // ...and reconnects within the delay window, which must cancel the pending "disconnected" notification. - EdgeImitator reconnected = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); - reconnected.ignoreType(OAuth2ClientUpdateMsg.class); - reconnected.ignoreType(OAuth2DomainUpdateMsg.class); + EdgeImitator reconnected = createEdgeImitator(); reconnected.connect(); edgeImitator = reconnected; // let teardown clean up the live session diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java new file mode 100644 index 0000000000..a9d6dbe455 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import org.junit.Test; +import org.mockito.ArgumentMatcher; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.controller.AbstractWebTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +// Covers the disconnect_notification_delay_ms <= 0 branch in EdgeGrpcService.scheduleDisconnectNotification, +// where the "disconnected" notification must be sent immediately instead of being scheduled with a delay. +@DaoSqlTest +@TestPropertySource(properties = { + "edges.connectivity.disconnect_notification_delay_ms=0" +}) +public class EdgeImmediateDisconnectNotificationTest extends AbstractEdgeTest { + + @MockitoSpyBean + private NotificationRuleProcessor notificationRuleProcessor; + + @Test + public void givenZeroDelay_whenEdgeDisconnects_thenDisconnectNotificationSentImmediately() throws Exception { + clearInvocations(notificationRuleProcessor); + + edgeImitator.disconnect(); + + // With a zero delay there is no debounce window - the "disconnected" notification fires right away. + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + verify(notificationRuleProcessor, times(1)).process(argThat(edgeConnectionTrigger()))); + } + + private ArgumentMatcher edgeConnectionTrigger() { + return trigger -> trigger instanceof EdgeConnectionTrigger edgeTrigger + && edge.getId().equals(edgeTrigger.getEdgeId()) + && !edgeTrigger.isConnected(); + } + +} From f10ca1338c99c22148bad5a026435e916bb69be3 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 10 Jun 2026 16:34:02 +0300 Subject: [PATCH 03/28] removeOnCancelPolicy, cluster-path unit test, cleanups --- .../service/edge/rpc/EdgeGrpcService.java | 90 +++++--- .../server/edge/AbstractEdgeTest.java | 1 - .../edge/EdgeConnectionNotificationTest.java | 56 ++++- ...geImmediateDisconnectNotificationTest.java | 64 ------ .../service/edge/rpc/EdgeGrpcServiceTest.java | 204 ++++++++++++++++++ .../common/util/ThingsBoardExecutors.java | 10 +- 6 files changed, 317 insertions(+), 108 deletions(-) delete mode 100644 application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index a1910396dc..b875226034 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -69,6 +69,7 @@ import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.io.IOException; import java.util.ArrayList; @@ -103,7 +104,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); private final Map sessionNewEvents = new HashMap<>(); private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); - private final ConcurrentMap> pendingDisconnectNotifications = new ConcurrentHashMap<>(); + private final ConcurrentMap pendingDisconnectNotifications = new ConcurrentHashMap<>(); private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); private final List zombieSessions = new ArrayList<>(); @@ -144,6 +145,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @Lazy private EdgeContextComponent ctx; + @Autowired + private TelemetrySubscriptionService tsSubService; + @Autowired private TbClusterService clusterService; @@ -198,7 +202,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); - this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); + // removeOnCancelPolicy: cancelled delayed disconnect notifications (common with flapping edges) are dropped + // from the queue right away on reconnect, instead of piling up until their original fire time. + this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service", true); this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS); log.info("Edge RPC service initialized!"); } @@ -232,19 +238,23 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @PreDestroy public void destroy() { + // Flush already-pending disconnect notifications BEFORE shutting the server down. These are edges that + // disconnected on their own within the delay window; without this a graceful restart would silently + // swallow their "disconnected" alert. Snapshotting first means we only flush those - not edges that this + // very shutdown is about to disconnect (they rebalance to another node and shouldn't alert) - and we run + // while the scheduler and rule engine are still alive. The guard inside fireDelayedDisconnectNotification + // still suppresses any edge that reconnected elsewhere. + List pendingToFlush = new ArrayList<>(pendingDisconnectNotifications.values()); + pendingDisconnectNotifications.clear(); + pendingToFlush.forEach(pending -> { + cancelIfPending(pending.future()); + fireDelayedDisconnectNotification(pending); + }); if (server != null) { server.shutdownNow(); } - for (Map.Entry> entry : sessionEdgeEventChecks.entrySet()) { - EdgeId edgeId = entry.getKey(); - ScheduledFuture sessionEdgeEventCheck = entry.getValue(); - if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { - sessionEdgeEventCheck.cancel(true); - sessionEdgeEventChecks.remove(edgeId); - } - } - pendingDisconnectNotifications.values().forEach(EdgeGrpcService::cancelIfPending); - pendingDisconnectNotifications.clear(); + sessionEdgeEventChecks.values().forEach(task -> cancelIfPending(task, true)); + sessionEdgeEventChecks.clear(); if (edgeEventProcessingExecutorService != null) { edgeEventProcessingExecutorService.shutdownNow(); } @@ -393,6 +403,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i // If the edge reconnected within the disconnect-notification delay window, suppress the pending // "disconnected" notification - the drop was transient (debounce for flapping edges). cancelPendingDisconnectNotification(edgeId); + // Connect notifies immediately; only the disconnect notification is debounced (see scheduleDisconnectNotification). pushStateEventToRuleEngine(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); notifyEdgeConnectivity(tenantId, edge, true); cancelScheduleEdgeEventsCheck(edgeId); @@ -528,13 +539,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) { log.trace("[{}] cancelling edge event check for edge", edgeId); - if (sessionEdgeEventChecks.containsKey(edgeId)) { - ScheduledFuture sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId); - if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { - sessionEdgeEventCheck.cancel(true); - sessionEdgeEventChecks.remove(edgeId); - } - } + cancelIfPending(sessionEdgeEventChecks.remove(edgeId), true); } private void onEdgeDisconnect(Edge edge, UUID sessionId) { @@ -602,14 +607,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void save(TenantId tenantId, EdgeId edgeId, String key, long value) { log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value); if (persistToTelemetry) { - ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() + tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .entry(new LongDataEntry(key, value)) .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) .build()); } else { - ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() + tsSubService.saveAttributes(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .scope(AttributeScope.SERVER_SCOPE) @@ -622,14 +627,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) { log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value); if (persistToTelemetry) { - ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() + tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .entry(new BooleanDataEntry(key, value)) .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) .build()); } else { - ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() + tsSubService.saveAttributes(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .scope(AttributeScope.SERVER_SCOPE) @@ -717,31 +722,51 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } EdgeId edgeId = edge.getId(); pendingDisconnectNotifications.compute(edgeId, (id, existing) -> { - cancelIfPending(existing); - return edgeEventProcessingExecutorService.schedule(() -> fireDelayedDisconnectNotification(tenantId, edge), + if (existing != null) { + cancelIfPending(existing.future()); + } + // Single-element holder so the scheduled task can reference its own pending entry, which doesn't exist + // until schedule(...) returns. The task needs it for the identity-keyed remove in fireDelayedDisconnectNotification. + PendingDisconnect[] holder = {null}; + ScheduledFuture future = executorService.schedule( + () -> fireDelayedDisconnectNotification(holder[0]), disconnectNotificationDelayMs, TimeUnit.MILLISECONDS); + holder[0] = new PendingDisconnect(tenantId, edge, future); + return holder[0]; }); } - private void fireDelayedDisconnectNotification(TenantId tenantId, Edge edge) { - EdgeId edgeId = edge.getId(); - pendingDisconnectNotifications.remove(edgeId); + private void fireDelayedDisconnectNotification(PendingDisconnect pending) { + EdgeId edgeId = pending.edge().getId(); + // Identity-keyed remove: don't clobber a newer entry if a second disconnect races with this task firing. + pendingDisconnectNotifications.remove(edgeId, pending); // Re-verify the edge is still disconnected. The cache is cluster-wide, so this also covers the case // where the edge dropped on this node and reconnected to a different TB-Core node within the window. if (sessions.containsKey(edgeId) || edgeIdServiceIdCache.get(edgeId) != null) { - log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", tenantId, edgeId); + log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", pending.tenantId(), edgeId); return; } - notifyEdgeConnectivity(tenantId, edge, false); + notifyEdgeConnectivity(pending.tenantId(), pending.edge(), false); } private void cancelPendingDisconnectNotification(EdgeId edgeId) { - cancelIfPending(pendingDisconnectNotifications.remove(edgeId)); + PendingDisconnect pending = pendingDisconnectNotifications.remove(edgeId); + if (pending != null) { + cancelIfPending(pending.future()); + } } + // Carries the context the delayed task needs, so a pending notification can still be fired on shutdown + // (see destroy()), not just cancelled. Package-private for unit testing of fireDelayedDisconnectNotification. + record PendingDisconnect(TenantId tenantId, Edge edge, ScheduledFuture future) {} + private static void cancelIfPending(ScheduledFuture future) { + cancelIfPending(future, false); + } + + private static void cancelIfPending(ScheduledFuture future, boolean mayInterruptIfRunning) { if (future != null && !future.isDone()) { - future.cancel(false); + future.cancel(mayInterruptIfRunning); } } @@ -797,6 +822,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i !kafkaSession.getConsumer().getConsumer().isStopped(); } return false; + } } diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index 71802468ab..8a3c3c1ae7 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -125,7 +125,6 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { public static final String EDGE_HOST = "localhost"; public static final int EDGE_PORT = TestSocketUtils.findAvailableTcpPort(); - @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { log.debug("edges.rpc.port = {}", EDGE_PORT); diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java index 8179e5894a..c46d82437a 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java @@ -15,16 +15,20 @@ */ package org.thingsboard.server.edge; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatcher; -import org.springframework.test.context.TestPropertySource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edge.imitator.EdgeImitator; +import org.thingsboard.server.service.edge.rpc.EdgeGrpcService; import java.util.concurrent.TimeUnit; @@ -37,9 +41,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @DaoSqlTest -@TestPropertySource(properties = { - "edges.connectivity.disconnect_notification_delay_ms=5000" -}) public class EdgeConnectionNotificationTest extends AbstractEdgeTest { private static final long DELAY_MS = 5000L; @@ -47,19 +48,55 @@ public class EdgeConnectionNotificationTest extends AbstractEdgeTest { @MockitoSpyBean private NotificationRuleProcessor notificationRuleProcessor; + @Autowired + private EdgeGrpcService edgeGrpcService; + + private long originalDisconnectNotificationDelayMs; + + // Capture the bean's configured delay before each test and restore it after, so a test method that forgets + // to call setDisconnectNotificationDelayMs can't silently inherit the previous method's mutated value + // (the shared context means the mutation would otherwise persist across methods). + @Before + public void captureDisconnectNotificationDelay() { + originalDisconnectNotificationDelayMs = (long) ReflectionTestUtils.getField(edgeGrpcService, "disconnectNotificationDelayMs"); + } + + @After + public void restoreDisconnectNotificationDelay() { + setDisconnectNotificationDelayMs(originalDisconnectNotificationDelayMs); + } + + // The delay is overridden per test (rather than via a per-class @TestPropertySource) so all cases share a + // single Spring application context instead of booting a separate heavy context per delay value. + private void setDisconnectNotificationDelayMs(long delayMs) { + ReflectionTestUtils.setField(edgeGrpcService, "disconnectNotificationDelayMs", delayMs); + } + @Test public void givenEdgeStaysDisconnected_whenDelayElapses_thenDisconnectNotificationSent() throws Exception { + // After the configured delay, the "disconnected" notification is sent exactly once. + assertDisconnectNotificationSentOnce(DELAY_MS); + } + + @Test + public void givenZeroDelay_whenEdgeDisconnects_thenDisconnectNotificationSentImmediately() throws Exception { + // With a zero delay there is no debounce window - the "disconnected" notification fires right away. + assertDisconnectNotificationSentOnce(0); + } + + private void assertDisconnectNotificationSentOnce(long delayMs) throws Exception { + setDisconnectNotificationDelayMs(delayMs); clearInvocations(notificationRuleProcessor); edgeImitator.disconnect(); - // After the configured delay, the "disconnected" notification is sent exactly once. await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> verify(notificationRuleProcessor, times(1)).process(argThat(edgeConnectionTrigger(false)))); } @Test public void givenEdgeReconnectsWithinDelay_whenEdgeFlaps_thenDisconnectNotificationSuppressed() throws Exception { + setDisconnectNotificationDelayMs(DELAY_MS); clearInvocations(notificationRuleProcessor); // Edge drops... @@ -76,11 +113,10 @@ public class EdgeConnectionNotificationTest extends AbstractEdgeTest { await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> verify(notificationRuleProcessor, atLeastOnce()).process(argThat(edgeConnectionTrigger(true)))); - // Wait until the original disconnect-notification window has fully elapsed... - TimeUnit.MILLISECONDS.sleep(DELAY_MS + 1000); - - // ...the "disconnected" notification must have never been sent. - verify(notificationRuleProcessor, never()).process(argThat(edgeConnectionTrigger(false))); + // The "disconnected" notification must never be sent throughout the full delay window. + await().during(DELAY_MS + 500, TimeUnit.MILLISECONDS) + .atMost(DELAY_MS + 2000, TimeUnit.MILLISECONDS) + .untilAsserted(() -> verify(notificationRuleProcessor, never()).process(argThat(edgeConnectionTrigger(false)))); } private ArgumentMatcher edgeConnectionTrigger(boolean connected) { diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java deleted file mode 100644 index a9d6dbe455..0000000000 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeImmediateDisconnectNotificationTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/** - * 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.edge; - -import org.junit.Test; -import org.mockito.ArgumentMatcher; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; -import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; -import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; -import org.thingsboard.server.controller.AbstractWebTest; -import org.thingsboard.server.dao.service.DaoSqlTest; - -import java.util.concurrent.TimeUnit; - -import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -// Covers the disconnect_notification_delay_ms <= 0 branch in EdgeGrpcService.scheduleDisconnectNotification, -// where the "disconnected" notification must be sent immediately instead of being scheduled with a delay. -@DaoSqlTest -@TestPropertySource(properties = { - "edges.connectivity.disconnect_notification_delay_ms=0" -}) -public class EdgeImmediateDisconnectNotificationTest extends AbstractEdgeTest { - - @MockitoSpyBean - private NotificationRuleProcessor notificationRuleProcessor; - - @Test - public void givenZeroDelay_whenEdgeDisconnects_thenDisconnectNotificationSentImmediately() throws Exception { - clearInvocations(notificationRuleProcessor); - - edgeImitator.disconnect(); - - // With a zero delay there is no debounce window - the "disconnected" notification fires right away. - await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> - verify(notificationRuleProcessor, times(1)).process(argThat(edgeConnectionTrigger()))); - } - - private ArgumentMatcher edgeConnectionTrigger() { - return trigger -> trigger instanceof EdgeConnectionTrigger edgeTrigger - && edge.getId().equals(edgeTrigger.getEdgeId()) - && !edgeTrigger.isConnected(); - } - -} diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java new file mode 100644 index 0000000000..f538bd1baa --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cache.SimpleTbCacheValueWrapper; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.service.edge.EdgeContextComponent; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit coverage for the cluster-aware predicates that guard the delayed disconnect notification: + * {@code evictServiceIdCacheIfOwnedByThisNode} and the re-verify check in + * {@code fireDelayedDisconnectNotification}. These exercise the cross-node ownership logic that the + * single-node integration tests ({@code EdgeConnectionNotificationTest}) cannot reach. + */ +@ExtendWith(MockitoExtension.class) +public class EdgeGrpcServiceTest { + + private static final String THIS_NODE = "tb-core-1"; + private static final String OTHER_NODE = "tb-core-2"; + + @Mock + private TbTransactionalCache edgeIdServiceIdCache; + + @Mock + private TbServiceInfoProvider serviceInfoProvider; + + @Mock + private EdgeContextComponent ctx; + + @Mock + private NotificationRuleProcessor ruleProcessor; + + @InjectMocks + private EdgeGrpcService edgeGrpcService; + + private TenantId tenantId; + private EdgeId edgeId; + private Edge edge; + + @BeforeEach + public void setUp() { + tenantId = new TenantId(UUID.randomUUID()); + edgeId = new EdgeId(UUID.randomUUID()); + edge = new Edge(edgeId); + edge.setTenantId(tenantId); + edge.setName("test-edge"); + } + + // --- evictServiceIdCacheIfOwnedByThisNode --- + + @Test + public void givenCacheOwnedByThisNode_whenEvict_thenEntryIsEvicted() { + when(serviceInfoProvider.getServiceId()).thenReturn(THIS_NODE); + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(SimpleTbCacheValueWrapper.wrap(THIS_NODE)); + + evictServiceIdCacheIfOwnedByThisNode(); + + verify(edgeIdServiceIdCache, times(1)).evict(edgeId); + } + + @Test + public void givenCacheOwnedByAnotherNode_whenEvict_thenEntryIsKept() { + // The edge already reconnected to another node within the keep-alive window: must NOT wipe the live owner. + when(serviceInfoProvider.getServiceId()).thenReturn(THIS_NODE); + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(SimpleTbCacheValueWrapper.wrap(OTHER_NODE)); + + evictServiceIdCacheIfOwnedByThisNode(); + + verify(edgeIdServiceIdCache, never()).evict(edgeId); + } + + @Test + public void givenEmptyCache_whenEvict_thenNothingEvicted() { + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(null); + + evictServiceIdCacheIfOwnedByThisNode(); + + verify(edgeIdServiceIdCache, never()).evict(edgeId); + } + + // --- fireDelayedDisconnectNotification re-verify guard --- + + @Test + public void givenEdgeReconnectedToThisNode_whenDelayFires_thenNotificationSuppressed() { + // A live session exists again on this node - suppress the stale disconnect notification. + sessions().put(edgeId, mock(EdgeGrpcSession.class)); + + fireDelayedDisconnectNotification(); + + verify(ruleProcessor, never()).process(any()); + } + + @Test + public void givenEdgeReconnectedToAnotherNode_whenDelayFires_thenNotificationSuppressed() { + // No local session, but the cluster cache still points at some node: the edge is connected elsewhere. + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(SimpleTbCacheValueWrapper.wrap(OTHER_NODE)); + + fireDelayedDisconnectNotification(); + + verify(ruleProcessor, never()).process(any()); + } + + @Test + public void givenEdgeStaysDisconnectedClusterWide_whenDelayFires_thenNotificationSent() { + // No local session and no cache entry on any node: the edge is genuinely down - fire the notification. + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(null); + when(ctx.getRuleProcessor()).thenReturn(ruleProcessor); + + fireDelayedDisconnectNotification(); + + verify(ruleProcessor, times(1)).process(argThat(disconnectTrigger())); + } + + @Test + public void givenPendingDisconnect_whenDestroy_thenNotificationFlushed() { + // The edge is genuinely down (no session, no cache). A graceful shutdown must flush the pending + // notification rather than drop it, otherwise a restart within the delay window swallows the alert. + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(null); + when(ctx.getRuleProcessor()).thenReturn(ruleProcessor); + pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(tenantId, edge, null)); + + destroy(); + + verify(ruleProcessor, times(1)).process(argThat(disconnectTrigger())); + } + + @Test + public void givenPendingDisconnectButReconnectedElsewhere_whenDestroy_thenNotificationSuppressed() { + // The flush still honors the re-verify guard: an edge that reconnected to another node must not alert. + when(edgeIdServiceIdCache.get(edgeId)).thenReturn(SimpleTbCacheValueWrapper.wrap(OTHER_NODE)); + pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(tenantId, edge, null)); + + destroy(); + + verify(ruleProcessor, never()).process(any()); + } + + private void destroy() { + ReflectionTestUtils.invokeMethod(edgeGrpcService, "destroy"); + } + + private void evictServiceIdCacheIfOwnedByThisNode() { + ReflectionTestUtils.invokeMethod(edgeGrpcService, "evictServiceIdCacheIfOwnedByThisNode", edgeId); + } + + private void fireDelayedDisconnectNotification() { + EdgeGrpcService.PendingDisconnect pending = new EdgeGrpcService.PendingDisconnect(tenantId, edge, null); + ReflectionTestUtils.invokeMethod(edgeGrpcService, "fireDelayedDisconnectNotification", pending); + } + + @SuppressWarnings("unchecked") + private ConcurrentMap sessions() { + return (ConcurrentMap) ReflectionTestUtils.getField(edgeGrpcService, "sessions"); + } + + @SuppressWarnings("unchecked") + private ConcurrentMap pendingDisconnects() { + return (ConcurrentMap) ReflectionTestUtils.getField(edgeGrpcService, "pendingDisconnectNotifications"); + } + + private static ArgumentMatcher disconnectTrigger() { + return trigger -> trigger instanceof EdgeConnectionTrigger edgeTrigger && !edgeTrigger.isConnected(); + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardExecutors.java b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardExecutors.java index 71b4925901..47934b4df0 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardExecutors.java +++ b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardExecutors.java @@ -69,7 +69,15 @@ public class ThingsBoardExecutors { } public static ScheduledExecutorService newSingleThreadScheduledExecutor(String name) { - return Executors.unconfigurableScheduledExecutorService(new ThingsBoardScheduledThreadPoolExecutor(1, ThingsBoardThreadFactory.forName(name))); + return newSingleThreadScheduledExecutor(name, false); + } + + public static ScheduledExecutorService newSingleThreadScheduledExecutor(String name, boolean removeOnCancelPolicy) { + ThingsBoardScheduledThreadPoolExecutor executor = new ThingsBoardScheduledThreadPoolExecutor(1, ThingsBoardThreadFactory.forName(name)); + // Must be set before wrapping: unconfigurableScheduledExecutorService hides the setter. With it enabled, + // cancelled tasks are removed from the delay queue immediately instead of lingering until their fire time. + executor.setRemoveOnCancelPolicy(removeOnCancelPolicy); + return Executors.unconfigurableScheduledExecutorService(executor); } public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, String name) { From 44ea99b1e199af49a9d1947ad2dbe2e32340e93c Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 10 Jun 2026 17:20:19 +0300 Subject: [PATCH 04/28] guard cache evict, drop holder array, derive tenant from edge --- .../service/edge/rpc/EdgeGrpcService.java | 65 +++++++++++++------ .../service/edge/rpc/EdgeGrpcServiceTest.java | 6 +- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index b875226034..259f63c206 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -405,7 +405,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i cancelPendingDisconnectNotification(edgeId); // Connect notifies immediately; only the disconnect notification is debounced (see scheduleDisconnectNotification). pushStateEventToRuleEngine(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); - notifyEdgeConnectivity(tenantId, edge, true); + notifyEdgeConnectivity(edge, true); cancelScheduleEdgeEventsCheck(edgeId); edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE); scheduleEdgeEventsCheck(edgeGrpcSession); @@ -562,7 +562,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i long lastDisconnectTs = System.currentTimeMillis(); save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs); pushStateEventToRuleEngine(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT); - scheduleDisconnectNotification(tenantId, edge); + scheduleDisconnectNotification(edge); cancelScheduleEdgeEventsCheck(edgeId); } else { log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId); @@ -578,7 +578,12 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId); } } - evictServiceIdCacheIfOwnedByThisNode(edgeId); + // Don't evict while a live session for this edge still exists on this node (e.g. a stale session + // disconnecting after a newer one already replaced it) - that newer session legitimately owns the + // cache entry, and wiping it would let another node's pending task fire a false 'disconnected' notification. + if (!sessions.containsKey(edgeId)) { + evictServiceIdCacheIfOwnedByThisNode(edgeId); + } } // Only evict if the cache still points to this node. If the edge already reconnected to a different @@ -702,22 +707,22 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - private void notifyEdgeConnectivity(TenantId tenantId, Edge edge, boolean connected) { + private void notifyEdgeConnectivity(Edge edge, boolean connected) { try { ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder() - .tenantId(tenantId) + .tenantId(edge.getTenantId()) .customerId(edge.getCustomerId()) .edgeId(edge.getId()) .edgeName(edge.getName()) .connected(connected).build()); } catch (Exception e) { - log.warn("[{}][{}] Failed to process edge connectivity notification (connected={})", tenantId, edge.getId(), connected, e); + log.warn("[{}][{}] Failed to process edge connectivity notification (connected={})", edge.getTenantId(), edge.getId(), connected, e); } } - private void scheduleDisconnectNotification(TenantId tenantId, Edge edge) { + private void scheduleDisconnectNotification(Edge edge) { if (disconnectNotificationDelayMs <= 0) { - notifyEdgeConnectivity(tenantId, edge, false); + notifyEdgeConnectivity(edge, false); return; } EdgeId edgeId = edge.getId(); @@ -725,28 +730,30 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i if (existing != null) { cancelIfPending(existing.future()); } - // Single-element holder so the scheduled task can reference its own pending entry, which doesn't exist - // until schedule(...) returns. The task needs it for the identity-keyed remove in fireDelayedDisconnectNotification. - PendingDisconnect[] holder = {null}; + // Create the pending entry first so the scheduled task can reference it (for the identity-keyed + // remove in fireDelayedDisconnectNotification), then back-fill its future. Doing this inside compute() + // keeps it under the map bin lock, so the future is set before the entry becomes visible to other threads. + PendingDisconnect pending = new PendingDisconnect(edge); ScheduledFuture future = executorService.schedule( - () -> fireDelayedDisconnectNotification(holder[0]), + () -> fireDelayedDisconnectNotification(pending), disconnectNotificationDelayMs, TimeUnit.MILLISECONDS); - holder[0] = new PendingDisconnect(tenantId, edge, future); - return holder[0]; + pending.setFuture(future); + return pending; }); } private void fireDelayedDisconnectNotification(PendingDisconnect pending) { - EdgeId edgeId = pending.edge().getId(); + Edge edge = pending.edge(); + EdgeId edgeId = edge.getId(); // Identity-keyed remove: don't clobber a newer entry if a second disconnect races with this task firing. pendingDisconnectNotifications.remove(edgeId, pending); // Re-verify the edge is still disconnected. The cache is cluster-wide, so this also covers the case // where the edge dropped on this node and reconnected to a different TB-Core node within the window. if (sessions.containsKey(edgeId) || edgeIdServiceIdCache.get(edgeId) != null) { - log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", pending.tenantId(), edgeId); + log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", edge.getTenantId(), edgeId); return; } - notifyEdgeConnectivity(pending.tenantId(), pending.edge(), false); + notifyEdgeConnectivity(edge, false); } private void cancelPendingDisconnectNotification(EdgeId edgeId) { @@ -757,8 +764,28 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } // Carries the context the delayed task needs, so a pending notification can still be fired on shutdown - // (see destroy()), not just cancelled. Package-private for unit testing of fireDelayedDisconnectNotification. - record PendingDisconnect(TenantId tenantId, Edge edge, ScheduledFuture future) {} + // (see destroy()), not just cancelled. The future is back-filled right after scheduling (see + // scheduleDisconnectNotification). Package-private for unit testing of fireDelayedDisconnectNotification. + static final class PendingDisconnect { + private final Edge edge; + private ScheduledFuture future; + + PendingDisconnect(Edge edge) { + this.edge = edge; + } + + Edge edge() { + return edge; + } + + ScheduledFuture future() { + return future; + } + + void setFuture(ScheduledFuture future) { + this.future = future; + } + } private static void cancelIfPending(ScheduledFuture future) { cancelIfPending(future, false); diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java index f538bd1baa..86758cb68f 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java @@ -156,7 +156,7 @@ public class EdgeGrpcServiceTest { // notification rather than drop it, otherwise a restart within the delay window swallows the alert. when(edgeIdServiceIdCache.get(edgeId)).thenReturn(null); when(ctx.getRuleProcessor()).thenReturn(ruleProcessor); - pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(tenantId, edge, null)); + pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(edge)); destroy(); @@ -167,7 +167,7 @@ public class EdgeGrpcServiceTest { public void givenPendingDisconnectButReconnectedElsewhere_whenDestroy_thenNotificationSuppressed() { // The flush still honors the re-verify guard: an edge that reconnected to another node must not alert. when(edgeIdServiceIdCache.get(edgeId)).thenReturn(SimpleTbCacheValueWrapper.wrap(OTHER_NODE)); - pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(tenantId, edge, null)); + pendingDisconnects().put(edgeId, new EdgeGrpcService.PendingDisconnect(edge)); destroy(); @@ -183,7 +183,7 @@ public class EdgeGrpcServiceTest { } private void fireDelayedDisconnectNotification() { - EdgeGrpcService.PendingDisconnect pending = new EdgeGrpcService.PendingDisconnect(tenantId, edge, null); + EdgeGrpcService.PendingDisconnect pending = new EdgeGrpcService.PendingDisconnect(edge); ReflectionTestUtils.invokeMethod(edgeGrpcService, "fireDelayedDisconnectNotification", pending); } From 9fdb32da0568c57e2930edbf98fd1a591a4c50ea Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 10 Jun 2026 17:59:08 +0300 Subject: [PATCH 05/28] refactor: tidy edge disconnect-notification per review --- .../service/edge/rpc/EdgeGrpcService.java | 31 +++++++++---------- .../edge/EdgeConnectionNotificationTest.java | 6 ++-- .../service/edge/rpc/EdgeGrpcServiceTest.java | 12 +------ 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 259f63c206..411ccd5f05 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -238,18 +238,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @PreDestroy public void destroy() { - // Flush already-pending disconnect notifications BEFORE shutting the server down. These are edges that - // disconnected on their own within the delay window; without this a graceful restart would silently - // swallow their "disconnected" alert. Snapshotting first means we only flush those - not edges that this - // very shutdown is about to disconnect (they rebalance to another node and shouldn't alert) - and we run - // while the scheduler and rule engine are still alive. The guard inside fireDelayedDisconnectNotification - // still suppresses any edge that reconnected elsewhere. List pendingToFlush = new ArrayList<>(pendingDisconnectNotifications.values()); pendingDisconnectNotifications.clear(); - pendingToFlush.forEach(pending -> { - cancelIfPending(pending.future()); - fireDelayedDisconnectNotification(pending); - }); + pendingToFlush.forEach(this::fireDelayedDisconnectNotification); if (server != null) { server.shutdownNow(); } @@ -590,12 +581,22 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i // TB-Core node within the keep-alive window, that node has overwritten the entry - evicting it here // would wipe the live owner and make fireDelayedDisconnectNotification raise a false 'disconnected'. private void evictServiceIdCacheIfOwnedByThisNode(EdgeId edgeId) { - TbCacheValueWrapper wrapper = edgeIdServiceIdCache.get(edgeId); - if (wrapper != null && serviceInfoProvider.getServiceId().equals(wrapper.get())) { + if (isOwnedByThisNode(edgeId)) { edgeIdServiceIdCache.evict(edgeId); } } + // The edge's service-id cache entry still points at this node, i.e. this node is the recorded owner. + private boolean isOwnedByThisNode(EdgeId edgeId) { + TbCacheValueWrapper wrapper = edgeIdServiceIdCache.get(edgeId); + return wrapper != null && serviceInfoProvider.getServiceId().equals(wrapper.get()); + } + + // The edge has a live owner somewhere in the cluster (the cache is cluster-wide), regardless of which node. + private boolean isConnectedClusterWide(EdgeId edgeId) { + return edgeIdServiceIdCache.get(edgeId) != null; + } + private void destroySession(EdgeGrpcSession session) { try (session) { if (!session.destroy()) { @@ -749,7 +750,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i pendingDisconnectNotifications.remove(edgeId, pending); // Re-verify the edge is still disconnected. The cache is cluster-wide, so this also covers the case // where the edge dropped on this node and reconnected to a different TB-Core node within the window. - if (sessions.containsKey(edgeId) || edgeIdServiceIdCache.get(edgeId) != null) { + if (sessions.containsKey(edgeId) || isConnectedClusterWide(edgeId)) { log.debug("[{}][{}] Edge reconnected within the disconnect notification delay - skipping disconnect notification", edge.getTenantId(), edgeId); return; } @@ -763,9 +764,6 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - // Carries the context the delayed task needs, so a pending notification can still be fired on shutdown - // (see destroy()), not just cancelled. The future is back-filled right after scheduling (see - // scheduleDisconnectNotification). Package-private for unit testing of fireDelayedDisconnectNotification. static final class PendingDisconnect { private final Edge edge; private ScheduledFuture future; @@ -785,6 +783,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i void setFuture(ScheduledFuture future) { this.future = future; } + } private static void cancelIfPending(ScheduledFuture future) { diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java index c46d82437a..66023c7372 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java @@ -43,7 +43,7 @@ import static org.mockito.Mockito.verify; @DaoSqlTest public class EdgeConnectionNotificationTest extends AbstractEdgeTest { - private static final long DELAY_MS = 5000L; + private static final long DELAY_MS = 1500L; @MockitoSpyBean private NotificationRuleProcessor notificationRuleProcessor; @@ -115,8 +115,8 @@ public class EdgeConnectionNotificationTest extends AbstractEdgeTest { // The "disconnected" notification must never be sent throughout the full delay window. await().during(DELAY_MS + 500, TimeUnit.MILLISECONDS) - .atMost(DELAY_MS + 2000, TimeUnit.MILLISECONDS) - .untilAsserted(() -> verify(notificationRuleProcessor, never()).process(argThat(edgeConnectionTrigger(false)))); + .atMost(DELAY_MS + 2000, TimeUnit.MILLISECONDS) + .untilAsserted(() -> verify(notificationRuleProcessor, never()).process(argThat(edgeConnectionTrigger(false)))); } private ArgumentMatcher edgeConnectionTrigger(boolean connected) { diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java index 86758cb68f..61bb04a803 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcServiceTest.java @@ -45,12 +45,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** - * Unit coverage for the cluster-aware predicates that guard the delayed disconnect notification: - * {@code evictServiceIdCacheIfOwnedByThisNode} and the re-verify check in - * {@code fireDelayedDisconnectNotification}. These exercise the cross-node ownership logic that the - * single-node integration tests ({@code EdgeConnectionNotificationTest}) cannot reach. - */ @ExtendWith(MockitoExtension.class) public class EdgeGrpcServiceTest { @@ -72,21 +66,17 @@ public class EdgeGrpcServiceTest { @InjectMocks private EdgeGrpcService edgeGrpcService; - private TenantId tenantId; private EdgeId edgeId; private Edge edge; @BeforeEach public void setUp() { - tenantId = new TenantId(UUID.randomUUID()); edgeId = new EdgeId(UUID.randomUUID()); edge = new Edge(edgeId); - edge.setTenantId(tenantId); + edge.setTenantId(TenantId.fromUUID(UUID.randomUUID())); edge.setName("test-edge"); } - // --- evictServiceIdCacheIfOwnedByThisNode --- - @Test public void givenCacheOwnedByThisNode_whenEvict_thenEntryIsEvicted() { when(serviceInfoProvider.getServiceId()).thenReturn(THIS_NODE); From 668ca3b632674bc42d1616d4de58d18c5e94be26 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 11 Jun 2026 10:55:51 +0300 Subject: [PATCH 06/28] Fixes after dev-rereview --- .../service/edge/rpc/EdgeGrpcService.java | 24 +++++++++++++++---- .../edge/EdgeConnectionNotificationTest.java | 9 +++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 411ccd5f05..297a45ec2f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -84,6 +84,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -722,6 +723,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } private void scheduleDisconnectNotification(Edge edge) { + // Zero delay means "no debounce": notify immediately and skip the cluster-wide re-verify guard. if (disconnectNotificationDelayMs <= 0) { notifyEdgeConnectivity(edge, false); return; @@ -729,7 +731,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i EdgeId edgeId = edge.getId(); pendingDisconnectNotifications.compute(edgeId, (id, existing) -> { if (existing != null) { - cancelIfPending(existing.future()); + cancelIfPending(existing.getFuture()); } // Create the pending entry first so the scheduled task can reference it (for the identity-keyed // remove in fireDelayedDisconnectNotification), then back-fill its future. Doing this inside compute() @@ -744,7 +746,13 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } private void fireDelayedDisconnectNotification(PendingDisconnect pending) { - Edge edge = pending.edge(); + // Claim-once guard: the @PreDestroy destroy() flush and a concurrently-firing scheduled task can both call + // this for the same PendingDisconnect. tryClaim() ensures notifyEdgeConnectivity fires at most once without + // relying on downstream notification dedup. + if (!pending.tryClaim()) { + return; + } + Edge edge = pending.getEdge(); EdgeId edgeId = edge.getId(); // Identity-keyed remove: don't clobber a newer entry if a second disconnect races with this task firing. pendingDisconnectNotifications.remove(edgeId, pending); @@ -760,23 +768,25 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void cancelPendingDisconnectNotification(EdgeId edgeId) { PendingDisconnect pending = pendingDisconnectNotifications.remove(edgeId); if (pending != null) { - cancelIfPending(pending.future()); + cancelIfPending(pending.getFuture()); } } static final class PendingDisconnect { + private final Edge edge; + private final AtomicBoolean notified = new AtomicBoolean(false); private ScheduledFuture future; PendingDisconnect(Edge edge) { this.edge = edge; } - Edge edge() { + Edge getEdge() { return edge; } - ScheduledFuture future() { + ScheduledFuture getFuture() { return future; } @@ -784,6 +794,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i this.future = future; } + boolean tryClaim() { + return notified.compareAndSet(false, true); + } + } private static void cancelIfPending(ScheduledFuture future) { diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java index 66023c7372..4482177750 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeConnectionNotificationTest.java @@ -30,6 +30,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edge.imitator.EdgeImitator; import org.thingsboard.server.service.edge.rpc.EdgeGrpcService; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.await; @@ -101,8 +102,12 @@ public class EdgeConnectionNotificationTest extends AbstractEdgeTest { // Edge drops... edgeImitator.disconnect(); - // Ensure the server processed the disconnect (and scheduled the delayed notification) before reconnecting. - TimeUnit.SECONDS.sleep(1); + // Wait until the server has processed the disconnect and scheduled the pending notification + // (the edge id appears in the pendingDisconnectNotifications map) before reconnecting. + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS).until(() -> { + Map pending = (Map) ReflectionTestUtils.getField(edgeGrpcService, "pendingDisconnectNotifications"); + return pending != null && pending.containsKey(edge.getId()); + }); // ...and reconnects within the delay window, which must cancel the pending "disconnected" notification. EdgeImitator reconnected = createEdgeImitator(); From 31bc775035d738b1cbfa1a3f988787a741e459e4 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 7 Jul 2026 14:58:23 +0300 Subject: [PATCH 07/28] Skip subscription forwarding when tenant no longer exists A device-state attribute save can complete after its tenant was deleted. The post-save WS-update callback then resolves an evicted tenant profile via partitionService.resolve -> getRoutingInfo and throws TenantNotFoundException uncaught on the ws-callback thread. Guard resolve() in forwardToSubscriptionManagerService: a deleted tenant has no partition to route to and no subscribers to notify, so skip the forward instead of propagating. Mirrors the existing tenant-gone handling in DefaultDeviceStateService.checkStates(). --- .../AbstractSubscriptionService.java | 11 +++++++++- ...faultTelemetrySubscriptionServiceTest.java | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java index 5df3e638f5..7b5a2a0fad 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java @@ -25,6 +25,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; @@ -86,7 +87,15 @@ public abstract class AbstractSubscriptionService extends TbApplicationEventList protected void forwardToSubscriptionManagerService(TenantId tenantId, EntityId entityId, Consumer toSubscriptionManagerService, Supplier toCore) { - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + TopicPartitionInfo tpi; + try { + tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + } catch (TenantNotFoundException e) { + // The tenant was deleted (e.g. concurrently with an in-flight asynchronous save callback), + // so there is no partition to route to and no subscribers to notify. Nothing to forward. + log.debug("[{}][{}] Skipping subscription update: tenant no longer exists.", tenantId, entityId); + return; + } if (currentPartitions.contains(tpi)) { if (subscriptionManagerService.isPresent()) { toSubscriptionManagerService.accept(subscriptionManagerService.get()); diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index dcfe9bd2bd..958a41449f 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -89,6 +90,7 @@ import java.util.stream.Stream; import static com.google.common.util.concurrent.Futures.immediateFailedFuture; import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -1155,6 +1157,26 @@ class DefaultTelemetrySubscriptionServiceTest { then(deviceStateManager).shouldHaveNoInteractions(); } + /* --- Subscription forwarding --- */ + + @Test + void shouldSkipSubscriptionForwardWhenTenantWasDeleted() { + // GIVEN the tenant was deleted concurrently, so partition resolution fails + given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)) + .willThrow(new TenantNotFoundException(tenantId)); + + // WHEN forwarding a subscription update (e.g. from an in-flight async save callback) + // THEN it must not propagate the exception + assertThatNoException().isThrownBy(() -> telemetryService.forwardToSubscriptionManagerService( + tenantId, entityId, + sm -> sm.onAttributesUpdate(tenantId, entityId, AttributeScope.SERVER_SCOPE.name(), List.of(), TbCallback.EMPTY), + () -> null)); + + // AND nothing is forwarded, since there is no partition to route to and no subscribers to notify + then(subscriptionManagerService).shouldHaveNoInteractions(); + then(clusterService).shouldHaveNoInteractions(); + } + // used to emulate versions returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); From 8538b7cc022feb0029dc8b35c0adeec5aa5a3b8f Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 8 Jul 2026 17:48:12 +0300 Subject: [PATCH 08/28] Fix ENTITY_AGGREGATION CF storing numeric results as strings EntityAggregationCalculatedFieldState.toResult() serialized every metric result via ObjectNode.put(name, JacksonUtil.toString(value)), which produces a JSON *string* node even for numeric aggregation results (SUM/AVG/COUNT/...). When persisted (in particular with transport.json.type_cast_enabled=false, or for non-parsable values) the result lands in ts_kv.str_v, so server-side AVG/SUM aggregation returns no data in widgets/queries while COUNT/MIN/MAX still return. Serialize with JacksonUtil.valueToTree(...) so numeric results are emitted as numeric JSON nodes (-> dbl_v/long_v), mirroring RelatedEntitiesAggregationCalculatedFieldState. Genuine string MIN/MAX results over string telemetry are preserved as string nodes. The instanceof Number guard is retained (it only governs rounding). --- .../single/EntityAggregationCalculatedFieldState.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index c945868576..f50bc602ce 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -293,7 +293,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt Object resultValue = argumentEntry.getValue() instanceof Number number ? NumberUtils.roundResult(number.doubleValue(), precision) : argumentEntry.getValue(); - metricsNode.put(metricName, JacksonUtil.toString(resultValue)); + metricsNode.set(metricName, JacksonUtil.valueToTree(resultValue)); } } if (!metricsNode.isEmpty()) { From 80d1631bb3d0194a4fdee7692341e0d2fe111140 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 13:35:31 +0300 Subject: [PATCH 09/28] Add regression tests for ENTITY_AGGREGATION CF numeric serialization Cover the fix that makes EntityAggregationCalculatedFieldState.toResult() emit numeric aggregation results as numeric JSON nodes (dbl_v/long_v) instead of JSON strings (str_v), which had broken server-side AVG/SUM aggregation. - New unit test EntityAggregationCalculatedFieldStateTest: asserts toResult() serializes a numeric result as a numeric node and a genuine string result (lexical MIN/MAX over string telemetry) as a string node. Assertions check the node type (isNumber/isTextual) rather than asText(), since asText() coerces both node kinds identically - which is why the bug went unnoticed. - EntityAggregationCalculatedFieldTest: add a strict-types read helper (useStrictDataTypes=true) and a test asserting the stored SUM/AVG telemetry are numeric JSON nodes end-to-end. Existing asText()-based assertions are left untouched (they read via the non-strict endpoint, which stringifies every value and therefore cannot observe the storage type). --- .../EntityAggregationCalculatedFieldTest.java | 44 +++++ ...tyAggregationCalculatedFieldStateTest.java | 179 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index c23c59d137..a51ac508be 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -154,6 +154,44 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest }); } + @Test + public void testAggregationResult_isStoredAsNumericTelemetry() throws Exception { + // Regression: ENTITY_AGGREGATION must store numeric results as numbers (ts_kv.dbl_v/long_v), + // not as JSON strings (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. + // The existing .asText()-based tests cannot catch this (asText coerces both types), so this + // test reads with useStrictDataTypes=true and asserts the value node type. + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); + createConsumptionCF(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> aggregation result stored as numeric telemetry") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetryStrict(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption")).isNotNull(); + assertThat(result.get("avgConsumption")).isNotNull(); + // SUM and AVG results must be numeric JSON nodes, not strings. + assertThat(result.get("consumption").get(0).get("value").isNumber()).isTrue(); + assertThat(result.get("avgConsumption").get(0).get("value").isNumber()).isTrue(); + // Values are still correct (SUM=400, AVG=133). + assertThat(result.get("consumption").get(0).get("value").asInt()).isEqualTo(400); + assertThat(result.get("avgConsumption").get(0).get("value").asInt()).isEqualTo(133); + }); + } + @Test public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { Device device = createDevice("Device", "1234567890111"); @@ -376,4 +414,10 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } + // useStrictDataTypes=true so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). + // Without it the endpoint returns every value via getValueAsString(), masking the string-vs-number distinction. + private ObjectNode getLatestTelemetryStrict(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?useStrictDataTypes=true&keys=" + String.join(",", keys), ObjectNode.class); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..22bfcbe276 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state.aggregation.single; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicKvEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class EntityAggregationCalculatedFieldStateTest { + + private static final long INTERVAL_START_TS = 1_000L; + private static final long INTERVAL_END_TS = 2_000L; + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("80ee80ef-019f-46b1-80ba-22f3ef1b094c")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("fc83d188-9cf5-4919-a774-d5c56bba2d27")); + + private EntityAggregationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Mock + private TenantProfile tenantProfile; + @Mock + private TbTenantProfileCache tenantProfileCache; + @InjectMocks + private ActorSystemContext systemContext; + + @BeforeEach + void setUp() { + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); + ctx.init(); + state = new EntityAggregationCalculatedFieldState(DEVICE_ID); + state.setCtx(ctx, null); + state.init(false); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION); + } + + // A numeric aggregation result (SUM/AVG/COUNT/..., numeric MIN/MAX) must be serialized as a numeric + // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, spec §7) must stay a string + // node. The node type is asserted explicitly, so asText() is only used to verify the value once the type + // is already pinned - it is not relied on to distinguish the types (that blindness is what hid the bug). + @ParameterizedTest(name = "{0} (precision {2}) -> numeric={3}") + @MethodSource("toResultSerializationCases") + void toResultSerializesResultWithTypePreservingNode(String metricName, BasicKvEntry kvEntry, Integer precision, + boolean expectNumeric, String expectedText) { + JsonNode value = toResultValue(metricName, kvEntry, precision); + + assertThat(value.isNumber()).isEqualTo(expectNumeric); + assertThat(value.isTextual()).isEqualTo(!expectNumeric); + assertThat(value.asText()).isEqualTo(expectedText); + } + + private static Stream toResultSerializationCases() { + return Stream.of( + // SUM: Number result, precision 0 -> whole-number (long) node + arguments("consumption", new DoubleDataEntry("consumption", 400.0), 0, true, "400"), + // AVG: Number result, precision 2 -> half-up rounded double node + arguments("avgConsumption", new DoubleDataEntry("avgConsumption", 133.335), 2, true, "133.34"), + // MIN/MAX over string telemetry: lexical String result -> preserved as string node (spec §7) + arguments("maxCode", new StringDataEntry("maxCode", "9"), 0, false, "9") + ); + } + + private JsonNode toResultValue(String metricName, BasicKvEntry kvEntry, Integer precision) { + AggIntervalEntry interval = new AggIntervalEntry(INTERVAL_START_TS, INTERVAL_END_TS); + ArgumentEntry argumentEntry = new SingleValueArgumentEntry(INTERVAL_START_TS, kvEntry, SingleValueArgumentEntry.DEFAULT_VERSION); + Map> results = new HashMap<>(); + results.put(interval, Map.of(metricName, argumentEntry)); + + ArrayNode result = state.toResult(results, precision); + + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).get("ts").asLong()).isEqualTo(INTERVAL_START_TS); + return result.get(0).get("values").get(metricName); + } + + private CalculatedField getCalculatedField() { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION); + calculatedField.setName("Test Entity Aggregation CF"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getConfiguration()); + calculatedField.setVersion(1L); + return calculatedField; + } + + private EntityAggregationCalculatedFieldConfiguration getConfiguration() { + EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration(); + + Argument energy = new Argument(); + energy.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + configuration.setArguments(Map.of("en", energy)); + + Map metrics = new HashMap<>(); + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + metrics.put("consumption", consumption); + + AggMetric avgConsumption = new AggMetric(); + avgConsumption.setFunction(AggFunction.AVG); + avgConsumption.setInput(new AggKeyInput("en")); + metrics.put("avgConsumption", avgConsumption); + configuration.setMetrics(metrics); + + configuration.setInterval(new CustomInterval("UTC", 0L, 5L)); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + configuration.setOutput(output); + + return configuration; + } + +} From bf6f0f1ec5e6e0c38b0e9ac05a6faebb4a540ac1 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 14:53:42 +0300 Subject: [PATCH 10/28] Refine ENTITY_AGGREGATION CF numeric regression coverage in integration test Address review feedback on the integration test: - Read latest telemetry with useStrictDataTypes=true always, so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). - Assert the aggregation result is a numeric node across all existing scenarios via a shared assertNumericValue helper, instead of a separate standalone test. isNumber() is the actual regression guard; asLong()/asText() would coerce a str_v string and miss it, so the type is asserted explicitly and the value is compared numerically. - Drop the redundant standalone test that duplicated an existing scenario. --- .../EntityAggregationCalculatedFieldTest.java | 85 ++++++------------- 1 file changed, 27 insertions(+), 58 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index a51ac508be..71b89bff40 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.cf; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.After; import org.junit.Before; @@ -106,7 +107,7 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999"); + assertNumericValue(result, "consumption", 9999); assertThat(result.get("avgConsumption").get(0).get("value").isNull()).isTrue(); }); } @@ -137,8 +138,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1)); @@ -149,46 +150,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); - }); - } - - @Test - public void testAggregationResult_isStoredAsNumericTelemetry() throws Exception { - // Regression: ENTITY_AGGREGATION must store numeric results as numbers (ts_kv.dbl_v/long_v), - // not as JSON strings (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. - // The existing .asText()-based tests cannot catch this (asText coerces both types), so this - // test reads with useStrictDataTypes=true and asserts the value node type. - Device device = createDevice("Device", "1234567890111"); - - CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); - createConsumptionCF(device.getId(), customInterval, null); - - long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); - long tsInInterval_1 = currentIntervalStartTs + 1000; - long tsInInterval_2 = currentIntervalStartTs + 500; - long tsInInterval_3 = currentIntervalStartTs + 200; - postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); - postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); - postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); - - long interval = customInterval.getCurrentIntervalDurationMillis(); - - await().alias("create CF -> aggregation result stored as numeric telemetry") - .atMost(2 * interval, TimeUnit.MILLISECONDS) - .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) - .untilAsserted(() -> { - ObjectNode result = getLatestTelemetryStrict(device.getId(), "consumption", "avgConsumption"); - assertThat(result).isNotNull(); - assertThat(result.get("consumption")).isNotNull(); - assertThat(result.get("avgConsumption")).isNotNull(); - // SUM and AVG results must be numeric JSON nodes, not strings. - assertThat(result.get("consumption").get(0).get("value").isNumber()).isTrue(); - assertThat(result.get("avgConsumption").get(0).get("value").isNumber()).isTrue(); - // Values are still correct (SUM=400, AVG=133). - assertThat(result.get("consumption").get(0).get("value").asInt()).isEqualTo(400); - assertThat(result.get("avgConsumption").get(0).get("value").asInt()).isEqualTo(133); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); } @@ -219,8 +182,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1)); @@ -231,8 +194,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("200"); + assertNumericValue(result, "consumption", 600); + assertNumericValue(result, "avgConsumption", 200); }); } @@ -269,8 +232,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgConsumption", 133); }); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L)); @@ -281,9 +244,9 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500"); + assertNumericValue(result, "consumption", 500); assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); - assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500"); + assertNumericValue(result, "avgConsumption", 500); assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); }); } @@ -344,8 +307,8 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest .untilAsserted(() -> { ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgTemperature"); assertThat(result).isNotNull(); - assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); - assertThat(result.get("avgTemperature").get(0).get("value").asText()).isEqualTo("39"); + assertNumericValue(result, "consumption", 400); + assertNumericValue(result, "avgTemperature", 39); }); } @@ -410,14 +373,20 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest return saveCalculatedField(calculatedField); } - private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { - return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); - } - // useStrictDataTypes=true so the value node keeps its stored type (numeric -> JSON number, str_v -> JSON string). // Without it the endpoint returns every value via getValueAsString(), masking the string-vs-number distinction. - private ObjectNode getLatestTelemetryStrict(EntityId entityId, String... keys) throws Exception { + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?useStrictDataTypes=true&keys=" + String.join(",", keys), ObjectNode.class); } + // Regression guard: a numeric aggregation result must be stored as a numeric JSON node (ts_kv.dbl_v/long_v), + // not a JSON string (ts_kv.str_v) - otherwise server-side AVG/SUM return no data. A value-only check would + // not catch this: asLong()/asText() coerce a string node like "400" to the same value/text, so the node type + // is asserted explicitly; the numeric comparison then verifies the aggregated value. + private static void assertNumericValue(ObjectNode result, String key, long expectedValue) { + JsonNode value = result.get(key).get(0).get("value"); + assertThat(value.isNumber()).as(key + " should be stored as a numeric node").isTrue(); + assertThat(value.asLong()).isEqualTo(expectedValue); + } + } From da3f2e933a81af2551746134228b41724ca70b36 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Jul 2026 15:53:36 +0300 Subject: [PATCH 11/28] Address review comments on EntityAggregationCalculatedFieldStateTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the dangling "spec §7" reference from the test comments; inline the actual rule (a lexical MIN/MAX result over string telemetry stays a string). - Use a zero-padded code ("0009") for the string-result case - a clearer example of a genuine string that must not be coerced to a number (would lose padding). - Define the maxCode metric in the test CF configuration alongside consumption and avgConsumption, so all parameterized metric names are configured. --- ...tityAggregationCalculatedFieldStateTest.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java index 22bfcbe276..8f7cda872e 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldStateTest.java @@ -98,9 +98,10 @@ public class EntityAggregationCalculatedFieldStateTest { } // A numeric aggregation result (SUM/AVG/COUNT/..., numeric MIN/MAX) must be serialized as a numeric - // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, spec §7) must stay a string - // node. The node type is asserted explicitly, so asText() is only used to verify the value once the type - // is already pinned - it is not relied on to distinguish the types (that blindness is what hid the bug). + // JSON node; a genuine string result (lexical MIN/MAX over string telemetry, e.g. a zero-padded code) + // must stay a string node. The node type is asserted explicitly, so asText() is only used to verify the + // value once the type is already pinned - it is not relied on to distinguish the types (that blindness + // is what hid the bug). @ParameterizedTest(name = "{0} (precision {2}) -> numeric={3}") @MethodSource("toResultSerializationCases") void toResultSerializesResultWithTypePreservingNode(String metricName, BasicKvEntry kvEntry, Integer precision, @@ -118,8 +119,9 @@ public class EntityAggregationCalculatedFieldStateTest { arguments("consumption", new DoubleDataEntry("consumption", 400.0), 0, true, "400"), // AVG: Number result, precision 2 -> half-up rounded double node arguments("avgConsumption", new DoubleDataEntry("avgConsumption", 133.335), 2, true, "133.34"), - // MIN/MAX over string telemetry: lexical String result -> preserved as string node (spec §7) - arguments("maxCode", new StringDataEntry("maxCode", "9"), 0, false, "9") + // MAX over string telemetry: a zero-padded code is a genuine String result and must stay a + // string node - as a number it would lose its padding ("0009" -> 9). + arguments("maxCode", new StringDataEntry("maxCode", "0009"), 0, false, "0009") ); } @@ -165,6 +167,11 @@ public class EntityAggregationCalculatedFieldStateTest { avgConsumption.setFunction(AggFunction.AVG); avgConsumption.setInput(new AggKeyInput("en")); metrics.put("avgConsumption", avgConsumption); + + AggMetric maxCode = new AggMetric(); + maxCode.setFunction(AggFunction.MAX); + maxCode.setInput(new AggKeyInput("en")); + metrics.put("maxCode", maxCode); configuration.setMetrics(metrics); configuration.setInterval(new CustomInterval("UTC", 0L, 5L)); From 8237be6c2bd0e31ded4d2c663d41fc19225a16e7 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Mon, 10 Aug 2026 17:13:57 +0300 Subject: [PATCH 12/28] Fix uplink stall and off-heap memory growth after cloud disconnect --- .../thingsboard/edge/rpc/EdgeGrpcClient.java | 57 ++++++ .../thingsboard/edge/rpc/EdgeRpcClient.java | 2 + .../edge/rpc/EdgeGrpcClientLeakTest.java | 168 ++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeGrpcClientLeakTest.java diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 7e58a2cb9f..e78ca440a6 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -19,8 +19,17 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.ManagedChannel; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.channel.Channel; +import io.grpc.netty.shaded.io.netty.channel.EventLoopGroup; +import io.grpc.netty.shaded.io.netty.channel.epoll.Epoll; +import io.grpc.netty.shaded.io.netty.channel.epoll.EpollEventLoopGroup; +import io.grpc.netty.shaded.io.netty.channel.epoll.EpollSocketChannel; +import io.grpc.netty.shaded.io.netty.channel.nio.NioEventLoopGroup; +import io.grpc.netty.shaded.io.netty.channel.socket.nio.NioSocketChannel; import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import io.grpc.netty.shaded.io.netty.util.concurrent.DefaultThreadFactory; import io.grpc.stub.StreamObserver; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -84,8 +93,12 @@ public class EdgeGrpcClient implements EdgeRpcClient { private ManagedChannel channel; + private EventLoopGroup workerGroup; + private StreamObserver inputStream; + private volatile boolean connected; + private static final ReentrantLock uplinkMsgLock = new ReentrantLock(); @Override @@ -95,7 +108,13 @@ public class EdgeGrpcClient implements EdgeRpcClient { Consumer onEdgeUpdate, Consumer onDownlink, Consumer onError) { + connected = false; + if (workerGroup == null) { + workerGroup = createWorkerGroup(); + } NettyChannelBuilder builder = NettyChannelBuilder.forAddress(rpcHost, rpcPort) + .eventLoopGroup(workerGroup) + .channelType(channelType()) .maxInboundMessageSize(maxInboundMessageSize) .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) @@ -146,6 +165,22 @@ public class EdgeGrpcClient implements EdgeRpcClient { return EdgeVersionComparator.getNewestEdgeVersion(); } + private static EventLoopGroup createWorkerGroup() { + DefaultThreadFactory threadFactory = new DefaultThreadFactory("edge-grpc-worker", true); + return Epoll.isAvailable() ? new EpollEventLoopGroup(1, threadFactory) : new NioEventLoopGroup(1, threadFactory); + } + + private static Class channelType() { + return Epoll.isAvailable() ? EpollSocketChannel.class : NioSocketChannel.class; + } + + @PreDestroy + public void destroy() { + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + } + private StreamObserver initOutputStream(String edgeKey, Consumer onUplinkResponse, Consumer onEdgeUpdate, @@ -162,8 +197,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { serverMaxInboundMessageSize = connectResponseMsg.getMaxInboundMessageSize(); } log.info("[{}] Configuration received: {}", edgeKey, connectResponseMsg.getConfiguration()); + connected = true; onEdgeUpdate.accept(connectResponseMsg.getConfiguration()); } else { + connected = false; log.error("[{}] Failed to establish the connection! Code: {}. Error message: {}.", edgeKey, connectResponseMsg.getResponseCode(), connectResponseMsg.getErrorMsg()); try { EdgeGrpcClient.this.disconnect(true); @@ -186,6 +223,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void onError(Throwable t) { + connected = false; log.warn("[{}] Stream was terminated due to error:", edgeKey, t); try { EdgeGrpcClient.this.disconnect(true); @@ -197,6 +235,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void onCompleted() { + connected = false; log.info("[{}] Stream was closed and completed successfully!", edgeKey); } }; @@ -204,6 +243,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void disconnect(boolean onError) throws InterruptedException { + connected = false; if (!onError) { try { if (inputStream != null) { @@ -236,10 +276,19 @@ public class EdgeGrpcClient implements EdgeRpcClient { } } + @Override + public boolean isConnected() { + return connected; + } + @Override public void sendUplinkMsg(UplinkMsg msg) { uplinkMsgLock.lock(); try { + if (!connected) { + log.debug("Uplink msg is skipped, the cloud session is not established: {}", msg); + return; + } this.inputStream.onNext(RequestMsg.newBuilder() .setMsgType(RequestMsgType.UPLINK_RPC_MESSAGE) .setUplinkMsg(msg) @@ -253,6 +302,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { public void sendSyncRequestMsg(boolean fullSyncRequired) { uplinkMsgLock.lock(); try { + if (!connected) { + log.debug("Sync request msg is skipped, the cloud session is not established"); + return; + } SyncRequestMsg syncRequestMsg = SyncRequestMsg.newBuilder() .setFullSync(fullSyncRequired) .build(); @@ -269,6 +322,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { public void sendDownlinkResponseMsg(DownlinkResponseMsg downlinkResponseMsg) { uplinkMsgLock.lock(); try { + if (!connected) { + log.debug("Downlink response msg is skipped, the cloud session is not established: {}", downlinkResponseMsg); + return; + } this.inputStream.onNext(RequestMsg.newBuilder() .setMsgType(RequestMsgType.UPLINK_RPC_MESSAGE) .setDownlinkResponseMsg(downlinkResponseMsg) diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java index 267fd2b3f2..d9a05ab2ce 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java @@ -34,6 +34,8 @@ public interface EdgeRpcClient { void disconnect(boolean onError) throws InterruptedException; + boolean isConnected(); + void sendSyncRequestMsg(boolean fullSyncRequired); void sendUplinkMsg(UplinkMsg uplinkMsg); diff --git a/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeGrpcClientLeakTest.java b/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeGrpcClientLeakTest.java new file mode 100644 index 0000000000..9fc66ed33e --- /dev/null +++ b/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeGrpcClientLeakTest.java @@ -0,0 +1,168 @@ +/** + * 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.edge.rpc; + +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.buffer.PooledByteBufAllocator; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; +import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsgType; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.fail; + +class EdgeGrpcClientLeakTest { + + // The window in which the default shared event loop group would be destroyed after the channel + // terminates (SharedResourceHolder delays destruction by 1 second). If EdgeGrpcClient ever goes + // back to the shared group, writes after this window hit a terminated executor and every buffer + // committed to grpc-netty's WriteQueue is pinned forever (4112 bytes per message, silent after + // the first RejectedExecutionException). + private static final long SHARED_GROUP_DEATH_WINDOW_MS = 3000; + private static final int MSG_COUNT = 50; + private static final long AWAIT_TIMEOUT_MS = 15_000; + + private Server server; + private EdgeGrpcClient client; + + @Test + void uplinksSentAfterTransportDeathDoNotPinPooledBuffers() throws Exception { + server = NettyServerBuilder.forPort(0) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() { + @Override + public StreamObserver handleMsgs(StreamObserver outputStream) { + return new StreamObserver<>() { + @Override + public void onNext(RequestMsg requestMsg) { + if (requestMsg.hasConnectRequestMsg()) { + outputStream.onNext(ResponseMsg.newBuilder() + .setConnectResponseMsg(ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.ACCEPTED) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }) + .build() + .start(); + + client = new EdgeGrpcClient(); + ReflectionTestUtils.setField(client, "rpcHost", "localhost"); + ReflectionTestUtils.setField(client, "rpcPort", server.getPort()); + ReflectionTestUtils.setField(client, "timeoutSecs", 1); + ReflectionTestUtils.setField(client, "keepAliveTimeSec", 10); + ReflectionTestUtils.setField(client, "keepAliveTimeoutSec", 5); + ReflectionTestUtils.setField(client, "maxInboundMessageSize", 4194304); + + client.connect("leakTest", "leakTest", msg -> {}, cfg -> {}, msg -> {}, e -> {}); + await("client to connect", () -> client.isConnected()); + + server.shutdownNow(); + server.awaitTermination(10, TimeUnit.SECONDS); + await("client to observe the transport death", () -> !client.isConnected()); + Thread.sleep(SHARED_GROUP_DEATH_WINDOW_MS); + + long baseline = pinnedBytes(); + @SuppressWarnings("unchecked") + StreamObserver inputStream = (StreamObserver) ReflectionTestUtils.getField(client, "inputStream"); + RequestMsg uplink = RequestMsg.newBuilder() + .setMsgType(RequestMsgType.UPLINK_RPC_MESSAGE) + .setUplinkMsg(UplinkMsg.newBuilder().setUplinkMsgId(1).build()) + .build(); + // Bypasses the connected gate on purpose: this models the check-then-act straggler (and the + // pre-gate retry loop) writing to a stream whose transport is already gone. Exceptions are + // swallowed the same way the production retry loop survives them. + for (int i = 0; i < MSG_COUNT; i++) { + try { + inputStream.onNext(uplink); + } catch (RuntimeException ignored) { + } + } + + long deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS; + while (pinnedBytes() > baseline) { + if (System.currentTimeMillis() > deadline) { + fail("Pinned pooled memory did not return to baseline: " + (pinnedBytes() - baseline) + + " bytes retained after " + MSG_COUNT + " uplinks to a dead stream"); + } + Thread.sleep(50); + } + } + + @AfterEach + void tearDown() throws Exception { + if (client != null) { + client.disconnect(true); + client.destroy(); + } + if (server != null) { + server.shutdownNow(); + } + } + + private void await(String what, BooleanSupplier condition) throws InterruptedException { + long deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + fail("Timed out waiting for " + what); + } + Thread.sleep(50); + } + } + + // grpc-netty builds its own PooledByteBufAllocator instances instead of using + // PooledByteBufAllocator.DEFAULT, and the factory that owns them is package private - so they + // have to be pulled out reflectively. Both variants are checked so the assertion holds no matter + // which one the transport picks on this platform/version. + private static long pinnedBytes() { + try { + Class utils = Class.forName("io.grpc.netty.shaded.io.grpc.netty.Utils"); + Method getByteBufAllocator = utils.getDeclaredMethod("getByteBufAllocator", boolean.class); + getByteBufAllocator.setAccessible(true); + long total = 0; + for (boolean forceHeapBuffer : new boolean[]{false, true}) { + PooledByteBufAllocator pooled = (PooledByteBufAllocator) getByteBufAllocator.invoke(null, forceHeapBuffer); + total += pooled.pinnedDirectMemory() + pooled.pinnedHeapMemory(); + } + return total; + } catch (Exception e) { + throw new IllegalStateException("Failed to read the gRPC allocator metrics", e); + } + } + +} From f92065693c1018dfe807cf28cbfa4d11680708aa Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Mon, 10 Aug 2026 17:40:33 +0300 Subject: [PATCH 13/28] Create the gRPC worker event loop group eagerly as a final field --- .../java/org/thingsboard/edge/rpc/EdgeGrpcClient.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index e78ca440a6..141cca2250 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -93,7 +93,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { private ManagedChannel channel; - private EventLoopGroup workerGroup; + private final EventLoopGroup workerGroup = createWorkerGroup(); private StreamObserver inputStream; @@ -109,9 +109,6 @@ public class EdgeGrpcClient implements EdgeRpcClient { Consumer onDownlink, Consumer onError) { connected = false; - if (workerGroup == null) { - workerGroup = createWorkerGroup(); - } NettyChannelBuilder builder = NettyChannelBuilder.forAddress(rpcHost, rpcPort) .eventLoopGroup(workerGroup) .channelType(channelType()) @@ -176,9 +173,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @PreDestroy public void destroy() { - if (workerGroup != null) { - workerGroup.shutdownGracefully(); - } + workerGroup.shutdownGracefully(); } private StreamObserver initOutputStream(String edgeKey, From 0ac6ddb50d361af6ed0a5eb114c98c8fe98be3ab Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Tue, 11 Aug 2026 10:39:28 +0300 Subject: [PATCH 14/28] Gate uplink sends on a live stream instead of a completed handshake --- .../org/thingsboard/edge/rpc/EdgeGrpcClient.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 141cca2250..2237e06f43 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -99,6 +99,8 @@ public class EdgeGrpcClient implements EdgeRpcClient { private volatile boolean connected; + private volatile boolean streamActive; + private static final ReentrantLock uplinkMsgLock = new ReentrantLock(); @Override @@ -109,6 +111,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { Consumer onDownlink, Consumer onError) { connected = false; + streamActive = false; NettyChannelBuilder builder = NettyChannelBuilder.forAddress(rpcHost, rpcPort) .eventLoopGroup(workerGroup) .channelType(channelType()) @@ -147,6 +150,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { EdgeRpcServiceGrpc.EdgeRpcServiceStub stub = EdgeRpcServiceGrpc.newStub(channel); log.info("[{}] Sending a connect request to the TB!", edgeKey); this.inputStream = stub.withCompression("gzip").handleMsgs(initOutputStream(edgeKey, onUplinkResponse, onEdgeUpdate, onDownlink, onError)); + streamActive = true; this.inputStream.onNext(RequestMsg.newBuilder() .setMsgType(RequestMsgType.CONNECT_RPC_MESSAGE) .setConnectRequestMsg(ConnectRequestMsg.newBuilder() @@ -219,6 +223,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void onError(Throwable t) { connected = false; + streamActive = false; log.warn("[{}] Stream was terminated due to error:", edgeKey, t); try { EdgeGrpcClient.this.disconnect(true); @@ -231,6 +236,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void onCompleted() { connected = false; + streamActive = false; log.info("[{}] Stream was closed and completed successfully!", edgeKey); } }; @@ -239,6 +245,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { @Override public void disconnect(boolean onError) throws InterruptedException { connected = false; + streamActive = false; if (!onError) { try { if (inputStream != null) { @@ -280,7 +287,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { public void sendUplinkMsg(UplinkMsg msg) { uplinkMsgLock.lock(); try { - if (!connected) { + if (!streamActive) { log.debug("Uplink msg is skipped, the cloud session is not established: {}", msg); return; } @@ -297,7 +304,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { public void sendSyncRequestMsg(boolean fullSyncRequired) { uplinkMsgLock.lock(); try { - if (!connected) { + if (!streamActive) { log.debug("Sync request msg is skipped, the cloud session is not established"); return; } @@ -317,7 +324,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { public void sendDownlinkResponseMsg(DownlinkResponseMsg downlinkResponseMsg) { uplinkMsgLock.lock(); try { - if (!connected) { + if (!streamActive) { log.debug("Downlink response msg is skipped, the cloud session is not established: {}", downlinkResponseMsg); return; } From 175d78d5518c50d3e9d0a689fb9b9a959627b612 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Mon, 17 Aug 2026 14:15:37 +0300 Subject: [PATCH 15/28] Removed outdated migrator tool --- .../tools/migrator/DictionaryParser.java | 74 ----- .../client/tools/migrator/MigratorTool.java | 102 ------- .../client/tools/migrator/PgCaMigrator.java | 282 ------------------ .../client/tools/migrator/README.md | 92 ------ .../tools/migrator/RelatedEntitiesParser.java | 88 ------ .../client/tools/migrator/WriterBuilder.java | 86 ------ 6 files changed, 724 deletions(-) delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/DictionaryParser.java delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaMigrator.java delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/README.md delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/RelatedEntitiesParser.java delete mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/DictionaryParser.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/DictionaryParser.java deleted file mode 100644 index d7f38b388e..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/DictionaryParser.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 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.client.tools.migrator; - -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.LineIterator; -import org.thingsboard.server.common.data.StringUtils; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class DictionaryParser { - private Map dictionaryParsed = new HashMap<>(); - - public DictionaryParser(File sourceFile) throws IOException { - parseDictionaryDump(FileUtils.lineIterator(sourceFile)); - } - - public String getKeyByKeyId(String keyId) { - return dictionaryParsed.get(keyId); - } - - private boolean isBlockFinished(String line) { - return StringUtils.isBlank(line) || line.equals("\\."); - } - - private boolean isBlockStarted(String line) { - return line.startsWith("COPY public.key_dictionary ("); - } - - private void parseDictionaryDump(LineIterator iterator) throws IOException { - try { - String tempLine; - while (iterator.hasNext()) { - tempLine = iterator.nextLine(); - - if (isBlockStarted(tempLine)) { - processBlock(iterator); - } - } - } finally { - iterator.close(); - } - } - - private void processBlock(LineIterator lineIterator) { - String tempLine; - String[] lineSplited; - while(lineIterator.hasNext()) { - tempLine = lineIterator.nextLine(); - if(isBlockFinished(tempLine)) { - return; - } - - lineSplited = tempLine.split("\t"); - dictionaryParsed.put(lineSplited[1], lineSplited[0]); - } - } -} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java deleted file mode 100644 index 8d73c9cdbc..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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.client.tools.migrator; - -import org.apache.commons.cli.BasicParser; -import org.apache.commons.cli.CommandLine; -import org.apache.commons.cli.CommandLineParser; -import org.apache.commons.cli.HelpFormatter; -import org.apache.commons.cli.Option; -import org.apache.commons.cli.Options; -import org.apache.commons.cli.ParseException; - -import java.io.File; - -public class MigratorTool { - - public static void main(String[] args) { - CommandLine cmd = parseArgs(args); - - try { - boolean castEnable = Boolean.parseBoolean(cmd.getOptionValue("castEnable")); - File allTelemetrySource = new File(cmd.getOptionValue("telemetryFrom")); - File tsSaveDir = null; - File partitionsSaveDir = null; - File latestSaveDir = null; - - RelatedEntitiesParser allEntityIdsAndTypes = - new RelatedEntitiesParser(new File(cmd.getOptionValue("relatedEntities"))); - DictionaryParser dictionaryParser = new DictionaryParser(allTelemetrySource); - - if(cmd.getOptionValue("latestTelemetryOut") != null) { - latestSaveDir = new File(cmd.getOptionValue("latestTelemetryOut")); - } - if(cmd.getOptionValue("telemetryOut") != null) { - tsSaveDir = new File(cmd.getOptionValue("telemetryOut")); - partitionsSaveDir = new File(cmd.getOptionValue("partitionsOut")); - } - - new PgCaMigrator(allTelemetrySource, tsSaveDir, partitionsSaveDir, latestSaveDir, allEntityIdsAndTypes, dictionaryParser, castEnable).migrate(); - - } catch (Throwable th) { - th.printStackTrace(); - throw new IllegalStateException("failed", th); - } - - } - - private static CommandLine parseArgs(String[] args) { - Options options = new Options(); - - Option telemetryAllFrom = new Option("telemetryFrom", "telemetryFrom", true, "telemetry source file"); - telemetryAllFrom.setRequired(true); - options.addOption(telemetryAllFrom); - - Option latestTsOutOpt = new Option("latestOut", "latestTelemetryOut", true, "latest telemetry save dir"); - latestTsOutOpt.setRequired(false); - options.addOption(latestTsOutOpt); - - Option tsOutOpt = new Option("tsOut", "telemetryOut", true, "sstable save dir"); - tsOutOpt.setRequired(false); - options.addOption(tsOutOpt); - - Option partitionOutOpt = new Option("partitionsOut", "partitionsOut", true, "partitions save dir"); - partitionOutOpt.setRequired(false); - options.addOption(partitionOutOpt); - - Option castOpt = new Option("castEnable", "castEnable", true, "cast String to Double if possible"); - castOpt.setRequired(true); - options.addOption(castOpt); - - Option relatedOpt = new Option("relatedEntities", "relatedEntities", true, "related entities source file path"); - relatedOpt.setRequired(true); - options.addOption(relatedOpt); - - HelpFormatter formatter = new HelpFormatter(); - CommandLineParser parser = new BasicParser(); - - try { - return parser.parse(options, args); - } catch (ParseException e) { - System.out.println(e.getMessage()); - formatter.printHelp("utility-name", options); - - System.exit(1); - } - return null; - } - -} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaMigrator.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaMigrator.java deleted file mode 100644 index 2ef202dc9f..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaMigrator.java +++ /dev/null @@ -1,282 +0,0 @@ -/** - * 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.client.tools.migrator; - -import com.google.common.collect.Lists; -import org.apache.cassandra.io.sstable.CQLSSTableWriter; -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.LineIterator; -import org.apache.commons.lang3.math.NumberUtils; -import org.thingsboard.server.common.data.StringUtils; - -import java.io.File; -import java.io.IOException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class PgCaMigrator { - - private final long LOG_BATCH = 1000000; - private final long rowPerFile = 1000000; - - private long linesTsMigrated = 0; - private long linesLatestMigrated = 0; - private long castErrors = 0; - private long castedOk = 0; - - private long currentWriterCount = 1; - - private final File sourceFile; - private final boolean castStringIfPossible; - - private final RelatedEntitiesParser entityIdsAndTypes; - private final DictionaryParser keyParser; - private CQLSSTableWriter currentTsWriter; - private CQLSSTableWriter currentPartitionsWriter; - private CQLSSTableWriter currentTsLatestWriter; - private final Set partitions = new HashSet<>(); - - private File outTsDir; - private File outTsLatestDir; - - public PgCaMigrator(File sourceFile, - File ourTsDir, - File outTsPartitionDir, - File outTsLatestDir, - RelatedEntitiesParser allEntityIdsAndTypes, - DictionaryParser dictionaryParser, - boolean castStringsIfPossible) { - this.sourceFile = sourceFile; - this.entityIdsAndTypes = allEntityIdsAndTypes; - this.keyParser = dictionaryParser; - this.castStringIfPossible = castStringsIfPossible; - if(outTsLatestDir != null) { - this.currentTsLatestWriter = WriterBuilder.getLatestWriter(outTsLatestDir); - this.outTsLatestDir = outTsLatestDir; - } - if(ourTsDir != null) { - this.currentTsWriter = WriterBuilder.getTsWriter(ourTsDir); - this.currentPartitionsWriter = WriterBuilder.getPartitionWriter(outTsPartitionDir); - this.outTsDir = ourTsDir; - } - } - - public void migrate() throws IOException { - boolean isTsDone = false; - boolean isLatestDone = false; - String line; - LineIterator iterator = FileUtils.lineIterator(this.sourceFile); - - try { - while(iterator.hasNext()) { - line = iterator.nextLine(); - if(!isLatestDone && isBlockLatestStarted(line)) { - System.out.println("START TO MIGRATE LATEST"); - long start = System.currentTimeMillis(); - processBlock(iterator, currentTsLatestWriter, outTsLatestDir, this::toValuesLatest); - System.out.println("TOTAL LINES MIGRATED: " + linesLatestMigrated + ", FORMING OF SSL FOR LATEST TS FINISHED WITH TIME: " + (System.currentTimeMillis() - start) + " ms."); - isLatestDone = true; - } - - if(!isTsDone && isBlockTsStarted(line)) { - System.out.println("START TO MIGRATE TS"); - long start = System.currentTimeMillis(); - processBlock(iterator, currentTsWriter, outTsDir, this::toValuesTs); - System.out.println("TOTAL LINES MIGRATED: " + linesTsMigrated + ", FORMING OF SSL FOR TS FINISHED WITH TIME: " + (System.currentTimeMillis() - start) + " ms."); - isTsDone = true; - } - } - - System.out.println("Partitions collected " + partitions.size()); - long startTs = System.currentTimeMillis(); - for (String partition : partitions) { - String[] split = partition.split("\\|"); - List values = Lists.newArrayList(); - values.add(split[0]); - values.add(UUID.fromString(split[1])); - values.add(split[2]); - values.add(Long.parseLong(split[3])); - currentPartitionsWriter.addRow(values); - } - - System.out.println(new Date() + " Migrated partitions " + partitions.size() + " in " + (System.currentTimeMillis() - startTs)); - - System.out.println(); - System.out.println("Finished migrate Telemetry"); - - } finally { - iterator.close(); - currentTsLatestWriter.close(); - currentTsWriter.close(); - currentPartitionsWriter.close(); - } - } - - private void logLinesProcessed(long lines) { - if (lines % LOG_BATCH == 0) { - System.out.println(new Date() + " lines processed = " + lines + " in, castOk " + castedOk + " castErr " + castErrors); - } - } - - private void logLinesMigrated(long lines) { - if(lines % LOG_BATCH == 0) { - System.out.println(new Date() + " lines migrated = " + lines + " in, castOk " + castedOk + " castErr " + castErrors); - } - } - - private void addTypeIdKey(List result, List raw) { - result.add(entityIdsAndTypes.getEntityType(raw.get(0))); - result.add(UUID.fromString(raw.get(0))); - result.add(keyParser.getKeyByKeyId(raw.get(1))); - } - - private void addPartitions(List result, List raw) { - long ts = Long.parseLong(raw.get(2)); - long partition = toPartitionTs(ts); - result.add(partition); - result.add(ts); - } - - private void addTimeseries(List result, List raw) { - result.add(Long.parseLong(raw.get(2))); - } - - private void addValues(List result, List raw) { - result.add(raw.get(3).equals("\\N") ? null : raw.get(3).equals("t") ? Boolean.TRUE : Boolean.FALSE); - result.add(raw.get(4).equals("\\N") ? null : raw.get(4)); - result.add(raw.get(5).equals("\\N") ? null : Long.parseLong(raw.get(5))); - result.add(raw.get(6).equals("\\N") ? null : Double.parseDouble(raw.get(6))); - result.add(raw.get(7).equals("\\N") ? null : raw.get(7)); - } - - private List toValuesTs(List raw) { - - logLinesMigrated(linesTsMigrated++); - - List result = new ArrayList<>(); - - addTypeIdKey(result, raw); - addPartitions(result, raw); - addValues(result, raw); - - processPartitions(result); - - return result; - } - - private List toValuesLatest(List raw) { - logLinesMigrated(linesLatestMigrated++); - List result = new ArrayList<>(); - - addTypeIdKey(result, raw); - addTimeseries(result, raw); - addValues(result, raw); - - return result; - } - - private long toPartitionTs(long ts) { - LocalDateTime time = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneOffset.UTC); - return time.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).toInstant(ZoneOffset.UTC).toEpochMilli(); - } - - private void processPartitions(List values) { - String key = values.get(0) + "|" + values.get(1) + "|" + values.get(2) + "|" + values.get(3); - partitions.add(key); - } - - private void processBlock(LineIterator iterator, CQLSSTableWriter writer, File outDir, Function, List> function) { - String currentLine; - long linesProcessed = 0; - while(iterator.hasNext()) { - logLinesProcessed(linesProcessed++); - currentLine = iterator.nextLine(); - if(isBlockFinished(currentLine)) { - return; - } - - try { - List raw = Arrays.stream(currentLine.trim().split("\t")) - .map(String::trim) - .collect(Collectors.toList()); - List values = function.apply(raw); - - if (this.currentWriterCount == 0) { - System.out.println(new Date() + " close writer " + new Date()); - writer.close(); - writer = WriterBuilder.getLatestWriter(outDir); - } - - if (this.castStringIfPossible) { - writer.addRow(castToNumericIfPossible(values)); - } else { - writer.addRow(values); - } - - currentWriterCount++; - if (currentWriterCount >= rowPerFile) { - currentWriterCount = 0; - } - } catch (Exception ex) { - System.out.println(ex.getMessage() + " -> " + currentLine); - } - } - } - - private List castToNumericIfPossible(List values) { - try { - if (values.get(6) != null && NumberUtils.isCreatable(values.get(6).toString())) { - Double casted = NumberUtils.createDouble(values.get(6).toString()); - List numeric = Lists.newArrayList(); - numeric.addAll(values); - numeric.set(6, null); - numeric.set(8, casted); - castedOk++; - return numeric; - } - } catch (Throwable th) { - castErrors++; - } - - processPartitions(values); - - return values; - } - - private boolean isBlockFinished(String line) { - return StringUtils.isBlank(line) || line.equals("\\."); - } - - private boolean isBlockTsStarted(String line) { - return line.startsWith("COPY public.ts_kv ("); - } - - private boolean isBlockLatestStarted(String line) { - return line.startsWith("COPY public.ts_kv_latest ("); - } - -} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md b/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md deleted file mode 100644 index 6995632514..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Description: -This tool used for migrating ThingsBoard into hybrid mode from Postgres. - -Performance of this tool depends on disk type and instance type (mostly on CPU resources). -But in general here are few benchmarks: -1. Creating Dump of the postgres ts_kv table -> 100GB = 90 minutes -2. If postgres table has size 100GB then dump file will be about 30GB -3. Generation SSTables from dump -> 100GB = 3 hours -4. 100GB Dump file will be converted into SSTable with size about 18GB - -# Tool build Instruction: -Switch to `tools` module in Command Line and execute - - mvn clean compile assembly:single - -It will generate single jar file with all required dependencies inside `target dir` -> `tools-2.4.1-SNAPSHOT-jar-with-dependencies.jar`. - - -# Prepare requred files and run Tool: - -#### Dump data from the source Postgres Database -*Do not use compression if possible because Tool can only work with uncompressed file - -1. Dump related tables that need to correct save telemetry - - `pg_dump -h localhost -U postgres -d thingsboard -T admin_settings -T attribute_kv -T audit_log -T component_discriptor -T device_credentials -T event -T oauth2_client_registration -T oauth2_client_registration_info -T oauth2_client_registration_template -T relation -T rule_node_state tb_schema_settings -T user_credentials > related_entities.dmp` - -2. Dump `ts_kv` and child: - - `pg_dump -h localhost -U postgres -d thingsboard --load-via-partition-root --data-only -t ts_kv* > ts_kv_all.dmp` - -3. [Optional] Move table dumps to the instance where cassandra will be hosted - -#### Prepare directory structure for SSTables -Tool use 3 different directories for saving SSTables - `ts_kv_cf`, `ts_kv_latest_cf`, `ts_kv_partitions_cf` - -Create 3 empty directories. For example: - - /home/user/migration/ts - /home/user/migration/ts_latest - /home/user/migration/ts_partition - -#### Run tool - -**If you want to migrate just `ts_kv` without `ts_kv_latest` or vice versa don't use arguments (paths) for output files* - -**Note: if you run this tool on remote instance - don't forget to execute this command in `screen` to avoid unexpected termination* - -``` -java -jar ./tools-3.2.2-SNAPSHOT-jar-with-dependencies.jar - -telemetryFrom /home/user/dump/ts_kv_all.dmp - -relatedEntities /home/user/dump/related_entities.dmp - -latestOut /home/user/migration/ts_latest - -tsOut /home/user/migration/ts - -partitionsOut /home/user/migration/ts_partition - -castEnable false -``` -*Use your paths for program arguments* - -Tool execution time depends on DB size, CPU resources and Disk throughput - -## Adding SSTables into Cassandra -* Note that this this part works only for single node Cassandra Cluster. If you have more nodes - it is better to use `sstableloader` tool. - -1. [Optional] install Cassandra on the instance -2. [Optional] Using `cqlsh` create `thingsboard` keyspace and requred tables from this files `schema-keyspace.cql`, `schema-ts.cql` and `schema-ts-latest.cql` using `source` command -3. Stop Cassandra -4. Look at `/var/lib/cassandra/data/thingsboard` and check for names of data folders -5. Copy generated SSTable files into cassandra data dir using next command: - -``` - sudo find /home/user/migration/ts -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_cf-0e9aaf00ee5511e9a5fa7d6f489ffd13/ \; - sudo find /home/user/migration/ts_latest -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_latest_cf-161449d0ee5511e9a5fa7d6f489ffd13/ \; - sudo find /home/user/migration/ts_partition -name '*.*' -exec mv {} /var/lib/cassandra/data/thingsboard/ts_kv_partitions_cf-12e8fa80ee5511e9a5fa7d6f489ffd13/ \; -``` - *Pay attention! Data folders have similar name `ts_kv_cf-0e9aaf00ee5511e9a5fa7d6f489ffd13`, but you have to use own* -6. Start Cassandra service and trigger compaction - - Trigger compactions: `nodetool compact thingsboard` - - Check compaction status: `nodetool compactionstats` - - -## Switch Thignsboard into Hybrid Mode - -Modify Thingsboard properites file `thingsboard.yml` - - - DATABASE_TS_TYPE = cassandra - - TS_KV_PARTITIONING = MONTHS - -# Final steps -Start Thingsboard and verify migration \ No newline at end of file diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/RelatedEntitiesParser.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/RelatedEntitiesParser.java deleted file mode 100644 index 59d28f09db..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/RelatedEntitiesParser.java +++ /dev/null @@ -1,88 +0,0 @@ -/** - * 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.client.tools.migrator; - -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.LineIterator; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.StringUtils; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class RelatedEntitiesParser { - private final Map allEntityIdsAndTypes = new HashMap<>(); - - private final Map tableNameAndEntityType = Map.ofEntries( - Map.entry("COPY public.alarm ", EntityType.ALARM), - Map.entry("COPY public.asset ", EntityType.ASSET), - Map.entry("COPY public.customer ", EntityType.CUSTOMER), - Map.entry("COPY public.dashboard ", EntityType.DASHBOARD), - Map.entry("COPY public.device ", EntityType.DEVICE), - Map.entry("COPY public.rule_chain ", EntityType.RULE_CHAIN), - Map.entry("COPY public.rule_node ", EntityType.RULE_NODE), - Map.entry("COPY public.tenant ", EntityType.TENANT), - Map.entry("COPY public.tb_user ", EntityType.USER), - Map.entry("COPY public.entity_view ", EntityType.ENTITY_VIEW), - Map.entry("COPY public.widgets_bundle ", EntityType.WIDGETS_BUNDLE), - Map.entry("COPY public.widget_type ", EntityType.WIDGET_TYPE), - Map.entry("COPY public.tenant_profile ", EntityType.TENANT_PROFILE), - Map.entry("COPY public.device_profile ", EntityType.DEVICE_PROFILE), - Map.entry("COPY public.asset_profile ", EntityType.ASSET_PROFILE), - Map.entry("COPY public.api_usage_state ", EntityType.API_USAGE_STATE) - ); - - public RelatedEntitiesParser(File source) throws IOException { - processAllTables(FileUtils.lineIterator(source)); - } - - public String getEntityType(String uuid) { - return this.allEntityIdsAndTypes.get(uuid); - } - - private boolean isBlockFinished(String line) { - return StringUtils.isBlank(line) || line.equals("\\."); - } - - private void processAllTables(LineIterator lineIterator) throws IOException { - String currentLine; - try { - while (lineIterator.hasNext()) { - currentLine = lineIterator.nextLine(); - for(Map.Entry entry : tableNameAndEntityType.entrySet()) { - if(currentLine.startsWith(entry.getKey())) { - processBlock(lineIterator, entry.getValue()); - } - } - } - } finally { - lineIterator.close(); - } - } - - private void processBlock(LineIterator lineIterator, EntityType entityType) { - String currentLine; - while(lineIterator.hasNext()) { - currentLine = lineIterator.nextLine(); - if(isBlockFinished(currentLine)) { - return; - } - allEntityIdsAndTypes.put(currentLine.split("\t")[0], entityType.name()); - } - } -} diff --git a/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java b/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java deleted file mode 100644 index e2d1571ade..0000000000 --- a/tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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.client.tools.migrator; - -import org.apache.cassandra.io.sstable.CQLSSTableWriter; - -import java.io.File; - -public class WriterBuilder { - - private static final String tsSchema = "CREATE TABLE thingsboard.ts_kv_cf (\n" + - " entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" + - " entity_id timeuuid,\n" + - " key text,\n" + - " partition bigint,\n" + - " ts bigint,\n" + - " bool_v boolean,\n" + - " str_v text,\n" + - " long_v bigint,\n" + - " dbl_v double,\n" + - " json_v text,\n" + - " PRIMARY KEY (( entity_type, entity_id, key, partition ), ts)\n" + - ");"; - - private static final String latestSchema = "CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf (\n" + - " entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" + - " entity_id timeuuid,\n" + - " key text,\n" + - " ts bigint,\n" + - " bool_v boolean,\n" + - " str_v text,\n" + - " long_v bigint,\n" + - " dbl_v double,\n" + - " json_v text,\n" + - " PRIMARY KEY (( entity_type, entity_id ), key)\n" + - ") WITH compaction = { 'class' : 'LeveledCompactionStrategy' };"; - - private static final String partitionSchema = "CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf (\n" + - " entity_type text, // (DEVICE, CUSTOMER, TENANT)\n" + - " entity_id timeuuid,\n" + - " key text,\n" + - " partition bigint,\n" + - " PRIMARY KEY (( entity_type, entity_id, key ), partition)\n" + - ") WITH CLUSTERING ORDER BY ( partition ASC )\n" + - " AND compaction = { 'class' : 'LeveledCompactionStrategy' };"; - - public static CQLSSTableWriter getTsWriter(File dir) { - return CQLSSTableWriter.builder() - .inDirectory(dir.getAbsolutePath()) - .forTable(tsSchema) - .using("INSERT INTO thingsboard.ts_kv_cf (entity_type, entity_id, key, partition, ts, bool_v, str_v, long_v, dbl_v, json_v) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - .build(); - } - - public static CQLSSTableWriter getLatestWriter(File dir) { - return CQLSSTableWriter.builder() - .inDirectory(dir.getAbsolutePath()) - .forTable(latestSchema) - .using("INSERT INTO thingsboard.ts_kv_latest_cf (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") - .build(); - } - - public static CQLSSTableWriter getPartitionWriter(File dir) { - return CQLSSTableWriter.builder() - .inDirectory(dir.getAbsolutePath()) - .forTable(partitionSchema) - .using("INSERT INTO thingsboard.ts_kv_partitions_cf (entity_type, entity_id, key, partition) " + - "VALUES (?, ?, ?, ?)") - .build(); - } -} From bad121985105903340506ab693cc02231cfd3bc4 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 19 Aug 2026 12:13:46 +0300 Subject: [PATCH 16/28] Compare edge customer before session state is replaced in onConfigurationUpdate --- .../thingsboard/server/service/edge/rpc/EdgeGrpcSession.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 43a4417323..43dc153c91 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -243,8 +244,9 @@ public abstract class EdgeGrpcSession implements Closeable { public void onConfigurationUpdate(Edge edge) { log.debug("[{}] onConfigurationUpdate [{}]", sessionId, edge); this.tenantId = edge.getTenantId(); + CustomerId stateCustomerId = this.edge != null ? this.edge.getCustomerId() : null; this.edge = edge; - if (!this.edge.getCustomerId().equals(edge.getCustomerId())) { + if (stateCustomerId != null && !stateCustomerId.equals(edge.getCustomerId())) { // do not send edge configuration message on customer update // message send by separate flow from assign_to or unassing_from customer return; From 1ad0c481dd74fb8e8fca212d4377abe8159fb26a Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 19 Aug 2026 13:05:35 +0300 Subject: [PATCH 17/28] Do not strand edge sync when an event fetcher fails --- .../server/service/edge/rpc/EdgeGrpcSession.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 1e9a0ade87..aeab041c7f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -269,7 +269,12 @@ public abstract class EdgeGrpcSession implements Closeable { @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Exception during sync process", tenantId, edge.getId(), t); + log.error("[{}][{}] Exception during sync process, skipping fetcher {} and continuing", + tenantId, edge.getId(), next.getClass().getSimpleName(), t); + // Keep walking the cursor: returning here leaves syncInProgress set for the life of the + // session, so the edge never receives SyncCompletedMsg and both general downlink delivery + // and uplink processing stay gated until the session is re-established. + doSync(cursor); } }, ctx.getGrpcCallbackExecutorService()); } else { From 966aea28bb90291438a3fb8567296f26fde553d4 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 19 Aug 2026 13:05:35 +0300 Subject: [PATCH 18/28] Do not strand edge sync when an event fetcher fails --- .../server/service/edge/rpc/EdgeGrpcSession.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 43a4417323..d1b21f7110 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -282,7 +282,12 @@ public abstract class EdgeGrpcSession implements Closeable { @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Exception during sync process", tenantId, edge.getId(), t); + log.error("[{}][{}] Exception during sync process, skipping fetcher {} and continuing", + tenantId, edge.getId(), next.getClass().getSimpleName(), t); + // Keep walking the cursor: returning here leaves syncInProgress set for the life of the + // session, so the edge never receives SyncCompletedMsg and both general downlink delivery + // and uplink processing stay gated until the session is re-established. + doSync(cursor); } }, ctx.getGrpcCallbackExecutorService()); } else { From 0f42f092872e9e945cc606652955ff99ef60c299 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 28 Aug 2026 11:54:23 +0300 Subject: [PATCH 19/28] Version set to 4.2.2.5-SNAPSHOT --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edge-api/src/main/proto/edge.proto | 1 + common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/package.json | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/package.json | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/package.json | 2 +- ui-ngx/pom.xml | 2 +- 64 files changed, 65 insertions(+), 64 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 9a46bbdc3a..9c6d05d31f 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 7d6aba753b..05dbdae924 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 4db50b2380..3413e7c22f 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 48d931bb53..29736b874e 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index d6d1d1d69b..f889c6bcaa 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index a0f9a8be78..987d753d2c 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index a2b9dce687..527bc39881 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 38774d93f7..df8f86335f 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 9141a164d8..2cbf7a2672 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 376a4d8c5b..8650087f74 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -50,6 +50,7 @@ enum EdgeVersion { V_4_2_2_2 = 4222; V_4_2_2_3 = 4223; V_4_2_2_4 = 4224; + V_4_2_2_5 = 4225; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index ae62259f79..f17be58106 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index d338f57946..d92d489c84 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 1eb2dc44b2..665367ddc3 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index c2f898bb38..eb10742275 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index e714fc749b..13a5ac837c 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index 46a97d2ece..b2f1011c79 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 0f9191e3cc..7f285986ca 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 6752190015..826392ce6a 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index ae013f938c..e9340252e5 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 79c1e4c06b..35a7b96c41 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 801fb49117..5cbccd7ede 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index a4892f9f80..9a7e8ddad5 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index a6c0686b4b..6664d14d1d 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index dc12d0d7b5..ec420af200 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index b1b2ed7343..ccabdf75af 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index a11c5b17d1..723b95a74f 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index db60495e95..c6584ec7b2 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 28a7e6e332..4c67a2c476 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 19c089dee1..83bc3ccf85 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index bdae55999d..92f2541039 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 2026a40f70..efd6dab9a3 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index a78bea8969..bab206418b 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 74a6e58ad9..247e1116b1 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index de6339b518..e88f5da2eb 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.2.2.4", + "version": "4.2.2.5", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index f6f7961d0e..ca09388c19 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 64157d6197..451150aafc 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 0847add29e..edaecf7223 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 9be443d031..70c12c90c1 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index ba18c5279b..7cdda058e3 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index f78e2f0f13..02b8a665b2 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index ac25949c82..322e40f366 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 89e99f79e2..018afb6f23 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index eb69201745..0eac380740 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 399d84763c..726d5fdb13 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 330e205394..55e1f03df4 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index fc8779250a..0c4c528f1a 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 4bb404284a..c09b0dbb7f 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index e5acd2a6fa..4b1e489783 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.2.2.4", + "version": "4.2.2.5", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 738118d4f8..79996bfdd4 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 9cc1f94bbe..cdcd9ef078 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard netty-mqtt - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 680da381d9..ecc5870817 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index ceadf1d118..3e07db05d5 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 75bd4e06ed..d2a7afffd9 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index f45cd15c34..3112a8ba8c 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index c6c2429a4f..9bce009263 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index 86a17be7a5..35de3d7584 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index cf4374ad9e..65b9af146d 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index d5ff0be961..2bbe2277cf 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 1d0fe86220..8229e4d7d5 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index e1c9512d05..3f84fd44d0 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 16052972cd..b3279b32ed 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index cc18e3a815..d6f763a8a5 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 023ede7bd1..5358528a6b 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.2.2.4", + "version": "4.2.2.5", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 099850fb80..9d4ec3f656 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.4-SNAPSHOT + 4.2.2.5-SNAPSHOT thingsboard org.thingsboard From be29e95d8468d93f7f25098fceb88a7993be7ef7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 28 Aug 2026 11:55:06 +0300 Subject: [PATCH 20/28] Version set to 4.3.1.5-SNAPSHOT --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edge-api/src/main/proto/edge.proto | 1 + common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/package.json | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/package.json | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/package.json | 2 +- ui-ngx/pom.xml | 2 +- 64 files changed, 65 insertions(+), 64 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 33aed0edaa..1bebbbc03f 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 6b2e880b17..6d1f7b0980 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 178aef0362..5d571ca11c 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 9caeac75a7..6ce8d94ad5 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index 2c1d3717ff..c70f370661 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 99fb31db63..bb09a6a0cc 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index 3028c351c3..36529d9530 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 644eb064b4..8acc807b2b 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index d8493b17e3..c34242eecd 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index c9d96db37a..e8ef08343b 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -58,6 +58,7 @@ enum EdgeVersion { V_4_3_1_2 = 4312; V_4_3_1_3 = 4313; V_4_3_1_4 = 4314; + V_4_3_1_5 = 4315; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 9183c1bd40..8dd1986adc 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 20d19e15f2..05d0028de4 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index b8938a59fc..7a9526d060 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 741b023009..854554b550 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index b423a7ac95..c8446eca89 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index 5abb0714c2..9796c6fb34 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 1f4c46d174..3f3f81602d 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index a4142c1bac..8978aef6d8 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index f2501e49fa..0db1ee0325 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 2f194f707d..737d3bfc86 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index c98263eec8..eeb9cdc606 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 46017d7702..36288d6d92 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 7c423d8ed3..6c1b8fd5f3 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index c3849a4a3d..6cfb17ccec 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 274d91e4fd..5b813ce8a8 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index cdd962e6e2..284281ef08 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index 0ef686dac0..b901605509 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 9266f168fe..7475de1017 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 3f4d9f50e8..9db9e5ef86 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index 2a53420e3a..93b9669468 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 4d9d3115c9..5e4eb6762e 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index ac6d7aac4c..9a96d37f59 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 895433d9e2..b182991f03 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 86e77e3b69..7f1fd39925 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.3.1.4", + "version": "4.3.1.5", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 1d76932680..fa2dcf5843 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index e3fef8d09a..8f55f5d7ee 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 89fa1c2279..62e6cf0d12 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 030c5d3ea6..f39f4899fe 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 19865342a9..0b429e94fc 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index f32bf41134..8bd310632a 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index a700056963..e5f87122ab 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 6201b569f2..6f0cd5fa22 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 5b9eb76e8a..d6641b9e6a 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 97f9088663..6120eb28fc 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index d4a2855810..08b32f968d 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 1f4b82e073..fbd9784157 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 0c147da7f3..80bee0ce8c 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index 364b7a030d..29768bc12c 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.3.1.4", + "version": "4.3.1.5", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 7564a77749..49ddd262a4 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 849650dc84..0f08d0dc4b 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard netty-mqtt - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index f1f6dff89d..74c805dbbf 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 625aed3b3d..b47ba6bf97 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index d351b9a97e..db7a2d64a4 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 429d634861..b25c4fb340 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index bd3af40c67..73c7942b97 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index f7e6fa62cc..6809d431f6 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 6c9e243a5c..fbcf8c778c 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 62c5228c48..5ee5a5c694 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index e849e43ac2..489743da5b 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 9639fd4175..ed090c6c50 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 3eb181d923..4b3058770c 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 2f44afc17f..350cfc6594 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 8a14e254f9..eb775db908 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.3.1.4", + "version": "4.3.1.5", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 8b2fc64669..b93e491a38 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.4-SNAPSHOT + 4.3.1.5-SNAPSHOT thingsboard org.thingsboard From 93b4433e6bc991c0cd362690c06da6eee35f4178 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 11:22:57 +0300 Subject: [PATCH 21/28] Validate prevUri redirect parameter --- .../server/controller/AdminController.java | 8 +- ...eOAuth2AuthorizationRequestRepository.java | 5 +- .../Oauth2AuthenticationSuccessHandler.java | 30 ++++-- .../auth/oauth2/PrevUriValidator.java | 47 ++++++++ .../controller/AdminControllerTest.java | 23 ++++ ...th2AuthorizationRequestRepositoryTest.java | 66 ++++++++++++ ...thenticationSuccessHandlerBaseUrlTest.java | 102 ++++++++++++++++++ ...auth2AuthenticationSuccessHandlerTest.java | 5 + .../auth/oauth2/PrevUriValidatorTest.java | 74 +++++++++++++ 9 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 0b04a6939f..cbe1114864 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -76,6 +76,7 @@ import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; +import org.thingsboard.server.service.security.auth.oauth2.PrevUriValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.permission.Operation; @@ -419,8 +420,9 @@ public class AdminController extends BaseController { @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); - if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { - CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180); + String prevUriParam = request.getParameter(PREV_URI_PATH_PARAMETER); + if (PrevUriValidator.isValid(prevUriParam)) { + CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, prevUriParam, 180); } CookieUtils.addCookie(response, STATE_COOKIE_NAME, state, 180); @@ -449,7 +451,7 @@ public class AdminController extends BaseController { Optional cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME); String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); - String prevUri = baseUrl + (prevUrlOpt.isPresent() ? prevUrlOpt.get().getValue() : "/settings/outgoing-mail"); + String prevUri = baseUrl + prevUrlOpt.map(Cookie::getValue).filter(PrevUriValidator::isValid).orElse("/settings/outgoing-mail"); if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) { CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java index b908a6c650..2b40e548e6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -43,8 +43,9 @@ public class HttpCookieOAuth2AuthorizationRequestRepository implements Authoriza CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); return; } - if (request.getParameter(PREV_URI_PARAMETER) != null) { - CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PARAMETER), cookieExpireSeconds); + String prevUri = request.getParameter(PREV_URI_PARAMETER); + if (PrevUriValidator.isValid(prevUri)) { + CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, prevUri, cookieExpireSeconds); } CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index c22ceb944a..f3fb49f45b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -83,17 +83,7 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS Authentication authentication) throws IOException { OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); String callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); - String baseUrl; - if (!StringUtils.isEmpty(callbackUrlScheme)) { - baseUrl = callbackUrlScheme + ":"; - } else { - baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); - Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); - if (prevUrlOpt.isPresent()) { - baseUrl += prevUrlOpt.get().getValue(); - CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); - } - } + String baseUrl = getBaseUrl(request, response, callbackUrlScheme); try { OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; @@ -125,6 +115,22 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS } } + String getBaseUrl(HttpServletRequest request, HttpServletResponse response, String callbackUrlScheme) { + if (!StringUtils.isEmpty(callbackUrlScheme)) { + return callbackUrlScheme + ":"; + } + String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); + if (prevUrlOpt.isPresent()) { + String prevUri = prevUrlOpt.get().getValue(); + if (PrevUriValidator.isValid(prevUri)) { + baseUrl += prevUri; + } + CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); + } + return baseUrl; + } + protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { super.clearAuthenticationAttributes(request); httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); @@ -133,6 +139,8 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS String getRedirectUrl(String baseUrl, JwtPair tokenPair) { if (baseUrl.indexOf("?") > 0) { baseUrl += "&"; + } else if (baseUrl.endsWith("/")) { + baseUrl += "?"; } else { baseUrl += "/?"; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java new file mode 100644 index 0000000000..005979408c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.auth.oauth2; + +import org.thingsboard.server.common.data.StringUtils; + +import java.util.Locale; + +public class PrevUriValidator { + + private static final int MAX_LENGTH = 2048; + + /** + * prevUri is appended to the platform base URL, which ends right after the authority, so the single leading '/' + * is what keeps the redirect on this host - it closes the authority before any of the value is read. The rest + * keeps an accepted value usable: it has to survive the cookie round trip (RFC 6265 allows neither control + * characters nor '"', ',', ';', '\' or non-ASCII) and to pass StrictHttpFirewall, which rejects '//', '%2f' + * and '%5c' in the path; a fragment would swallow the access token. + */ + public static boolean isValid(String prevUri) { + if (StringUtils.isEmpty(prevUri) || prevUri.length() > MAX_LENGTH || prevUri.charAt(0) != '/') { + return false; + } + for (int i = 0; i < prevUri.length(); i++) { + char c = prevUri.charAt(i); + if (c <= ' ' || c >= 127 || c == '"' || c == ',' || c == ';' || c == '\\' || c == '#') { + return false; + } + } + String path = StringUtils.substringBefore(prevUri, "?").toLowerCase(Locale.ROOT); + return !path.contains("//") && !path.contains("%2f") && !path.contains("%5c"); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index 21a8b3b2e9..e0866a3d86 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.Cookie; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.junit.Test; @@ -111,6 +112,28 @@ public class AdminControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString("is prohibited"))); } + @Test + public void testMailOAuth2AuthorizationStoresOnlyInAppPrevUri() throws Exception { + loginSysAdmin(); + AdminSettings mailSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + ObjectNode jsonValue = JacksonUtil.fromString(mailSettings.getJsonValue().toString(), ObjectNode.class); + jsonValue.put("clientId", "clientId"); + jsonValue.put("authUri", "https://accounts.google.com/o/oauth2/v2/auth"); + jsonValue.put("redirectUri", "https://thingsboard.io/api/admin/mail/oauth2/code"); + jsonValue.set("scope", JacksonUtil.newArrayNode().add("https://mail.google.com/")); + mailSettings.setJsonValue(jsonValue); + doPost("/api/admin/settings", mailSettings, AdminSettings.class); + + Cookie prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?prevUri=@evil.com/") + .andExpect(status().isOk()).andReturn().getResponse().getCookie("prev_uri"); + assertThat(prevUriCookie).isNull(); + + prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?prevUri=/settings/outgoing-mail") + .andExpect(status().isOk()).andReturn().getResponse().getCookie("prev_uri"); + assertThat(prevUriCookie).isNotNull(); + assertThat(prevUriCookie.getValue()).isEqualTo("/settings/outgoing-mail"); + } + @Test public void testSendTestMail() throws Exception { Mockito.doNothing().when(mailService).sendTestMail(any(), anyString()); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java new file mode 100644 index 0000000000..92aa8f18df --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java @@ -0,0 +1,66 @@ +/** + * 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.security.auth.oauth2; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER; + +public class HttpCookieOAuth2AuthorizationRequestRepositoryTest { + + private final HttpCookieOAuth2AuthorizationRequestRepository repository = new HttpCookieOAuth2AuthorizationRequestRepository(); + + @Test + public void testPrevUriSavedForInAppPath() { + assertThat(savePrevUri("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState")) + .isEqualTo("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + } + + @Test + public void testPrevUriNotSavedForExternalUri() { + assertThat(savePrevUri("@evil.com/")).isNull(); + assertThat(savePrevUri("https://evil.com")).isNull(); + assertThat(savePrevUri("//evil.com")).isNull(); + } + + private String savePrevUri(String prevUri) { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + when(request.getParameter(PREV_URI_PARAMETER)).thenReturn(prevUri); + + repository.saveAuthorizationRequest(OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("testUri").clientId("testId").build(), request, response); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, atLeastOnce()).addCookie(cookieCaptor.capture()); + return cookieCaptor.getAllValues().stream() + .filter(cookie -> PREV_URI_COOKIE_NAME.equals(cookie.getName())) + .map(Cookie::getValue) + .findAny().orElse(null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java new file mode 100644 index 0000000000..5d85c742f1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java @@ -0,0 +1,102 @@ +/** + * 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.security.auth.oauth2; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.oauth2.OAuth2ClientService; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; + +public class Oauth2AuthenticationSuccessHandlerBaseUrlTest { + + private static final String BASE_URL = "https://thingsboard.example.com"; + + private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class); + private final Oauth2AuthenticationSuccessHandler successHandler = new Oauth2AuthenticationSuccessHandler( + mock(JwtTokenFactory.class), mock(OAuth2ClientMapperProvider.class), mock(OAuth2ClientService.class), + mock(OAuth2AuthorizedClientService.class), mock(HttpCookieOAuth2AuthorizationRequestRepository.class), + systemSecurityService); + + private HttpServletRequest request; + private HttpServletResponse response; + + @BeforeEach + public void before() { + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); + } + + @Test + public void testInAppPathIsAppendedToBaseUrl() { + givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + assertThat(successHandler.getBaseUrl(request, response, null)) + .isEqualTo(BASE_URL + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + } + + @ParameterizedTest + @ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/dashboards#fragment"}) + public void testForgedPrevUriCookieIsIgnored(String prevUri) { + givenPrevUriCookie(prevUri); + assertThat(successHandler.getBaseUrl(request, response, null)).isEqualTo(BASE_URL); + } + + @Test + public void testForgedPrevUriCookieIsDeleted() { + givenPrevUriCookie("@evil.com/"); + + successHandler.getBaseUrl(request, response, null); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + assertThat(cookieCaptor.getValue().getName()).isEqualTo(PREV_URI_COOKIE_NAME); + assertThat(cookieCaptor.getValue().getMaxAge()).isZero(); + } + + @Test + public void testBaseUrlWithoutPrevUriCookie() { + when(request.getCookies()).thenReturn(null); + assertThat(successHandler.getBaseUrl(request, response, null)).isEqualTo(BASE_URL); + } + + @Test + public void testCallbackUrlSchemeIgnoresPrevUri() { + givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e"); + assertThat(successHandler.getBaseUrl(request, response, "tbmobile")).isEqualTo("tbmobile:"); + } + + private void givenPrevUriCookie(String prevUri) { + when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)}); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java index 7e4c2645c7..e3ec321e8a 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java @@ -64,5 +64,10 @@ public class Oauth2AuthenticationSuccessHandlerTest extends AbstractControllerTe redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithParams, jwtPair); expectedUrl = urlWithParams + "&accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); assertEquals(expectedUrl, redirectUrl); + + String urlWithTrailingSlash = "http://localhost:8080/"; + redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithTrailingSlash, jwtPair); + expectedUrl = urlWithTrailingSlash + "?accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); + assertEquals(expectedUrl, redirectUrl); } } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java new file mode 100644 index 0000000000..487abffc24 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java @@ -0,0 +1,74 @@ +/** + * 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.security.auth.oauth2; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PrevUriValidatorTest { + + @ParameterizedTest + @ValueSource(strings = { + "/", + "/login", + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e", + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1", + "/settings/outgoing-mail", + "/some%20path?q=a+b", + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=W3siaWQiOiJhL2IifV0%3D" + }) + public void testValidPrevUri(String prevUri) { + assertThat(PrevUriValidator.isValid(prevUri)).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + "@evil.com/", + "evil.com", + "https://evil.com", + "//evil.com", + "/\\evil.com", + "/\tevil.com", + "/ evil.com", + "/dashboards\\..\\evil.com", + "/login\nLocation: https://evil.com", + "/login\r\nSet-Cookie: a=b", + "/dashboards//evil.com", + "/dashboards%2Fevil.com", + "/dashboards%5cevil.com", + "/dashboards;jsessionid=1", + "/dashboards?title=a,b", + "/dashboards,list", + "/dashboards\"list", + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e#fragment", + "/панель" + }) + public void testInvalidPrevUri(String prevUri) { + assertThat(PrevUriValidator.isValid(prevUri)).isFalse(); + } + + @Test + public void testPrevUriLengthLimit() { + assertThat(PrevUriValidator.isValid("/" + "a".repeat(2047))).isTrue(); + assertThat(PrevUriValidator.isValid("/" + "a".repeat(2048))).isFalse(); + } + +} From ee64fa1938ff8e9324c0f2ae96a13d0c9b0006f4 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 12:14:30 +0300 Subject: [PATCH 22/28] Validate mobile app callback url scheme --- .../model/token/OAuth2AppTokenFactory.java | 12 +++ .../token/OAuth2AppTokenFactoryTest.java | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java index a353aac86b..4a9b18cafd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -29,7 +29,10 @@ import org.thingsboard.server.common.data.StringUtils; import java.util.Base64; import java.util.Date; +import java.util.Locale; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; @Component @Slf4j @@ -39,6 +42,9 @@ public class OAuth2AppTokenFactory { private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5); + private static final Pattern CALLBACK_URL_SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*"); + private static final Set FORBIDDEN_CALLBACK_URL_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript"); + public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) { Jws jwsClaims; try { @@ -65,6 +71,12 @@ public class OAuth2AppTokenFactory { if (StringUtils.isEmpty(callbackUrlScheme)) { throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme"); } + // the redirect carrying the access token is built as callbackUrlScheme + ":", so only a mobile app scheme + // may pass: a web scheme would send the token to whatever host follows it + if (!CALLBACK_URL_SCHEME_PATTERN.matcher(callbackUrlScheme).matches() + || FORBIDDEN_CALLBACK_URL_SCHEMES.contains(callbackUrlScheme.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Application token has invalid callbackUrlScheme"); + } return callbackUrlScheme; } diff --git a/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java new file mode 100644 index 0000000000..ec4da607f8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java @@ -0,0 +1,76 @@ +/** + * 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.security.model.token; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class OAuth2AppTokenFactoryTest { + + private static final String APP_PACKAGE = "org.thingsboard.demo.app"; + private static final byte[] KEY_BYTES = "yjNyylzT1TmiVE2jV3YTnUpZzwLLLdPDJKmhLNyXDPnLtVCLcJIjIGmDPKHNoDMK".getBytes(); + + private final OAuth2AppTokenFactory tokenFactory = new OAuth2AppTokenFactory(); + + @Test + public void testMobileAppSchemeIsAccepted() { + assertThat(validate("tb-mobile.app1")).isEqualTo("tb-mobile.app1"); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://evil.com", + "http://evil.com", + "https", + "HTTPS", + "javascript", + "data", + "//evil.com", + "tbmobile/evil.com", + "tbmobile:evil.com", + "tbmobile evil", + "1tbmobile", + "tbmobile@evil.com" + }) + public void testInvalidCallbackUrlSchemeIsRejected(String callbackUrlScheme) { + assertThatThrownBy(() -> validate(callbackUrlScheme)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("callbackUrlScheme"); + } + + private String validate(String callbackUrlScheme) { + SecretKey key = Keys.hmacShaKeyFor(KEY_BYTES); + String appToken = Jwts.builder() + .issuer(APP_PACKAGE) + .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) + .claim("callbackUrlScheme", callbackUrlScheme) + .signWith(key) + .compact(); + return tokenFactory.validateTokenAndGetCallbackUrlScheme(APP_PACKAGE, appToken, Base64.getEncoder().encodeToString(KEY_BYTES)); + } + +} From 1d60693835c0f7cc5ecb98f6eb7ca9cd58083e53 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 14:42:14 +0300 Subject: [PATCH 23/28] Validate the callback url scheme where the redirect is built The scheme is restored from the oauth2_auth_request cookie, which the client can replace, so checking it only while the authorization request is built still let a forged cookie point the token redirect at any host. Both handlers now re-check it on read, sharing the rule with OAuth2AppTokenFactory. The success handler keeps prevUri out of the base URL as well, so the error redirect no longer appends /login to an in-app path. --- .../oauth2/CallbackUrlSchemeValidator.java | 65 ++++++++++ .../Oauth2AuthenticationFailureHandler.java | 5 +- .../Oauth2AuthenticationSuccessHandler.java | 36 ++++-- .../model/token/OAuth2AppTokenFactory.java | 14 +- .../CallbackUrlSchemeValidatorTest.java | 81 ++++++++++++ ...thenticationSuccessHandlerBaseUrlTest.java | 102 --------------- ...auth2AuthenticationSuccessHandlerTest.java | 121 ++++++++++++------ .../token/OAuth2AppTokenFactoryTest.java | 50 ++++---- 8 files changed, 279 insertions(+), 195 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java delete mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java new file mode 100644 index 0000000000..a75af8dd5e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java @@ -0,0 +1,65 @@ +/** + * 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.security.auth.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.thingsboard.server.common.data.StringUtils; + +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +@Slf4j +public class CallbackUrlSchemeValidator { + + private static final Pattern SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*"); + private static final Set FORBIDDEN_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript"); + private static final int MAX_LOGGED_LENGTH = 128; + + /** + * The redirect carrying the access token is built as callbackUrlScheme + ':', so only a mobile app scheme may + * pass: a web scheme would send the token to whatever host follows it. + */ + public static boolean isValid(String callbackUrlScheme) { + return !StringUtils.isEmpty(callbackUrlScheme) + && SCHEME_PATTERN.matcher(callbackUrlScheme).matches() + && !FORBIDDEN_SCHEMES.contains(callbackUrlScheme.toLowerCase(Locale.ROOT)); + } + + /** + * The attribute is restored from the oauth2_auth_request cookie, which the client can replace, so the scheme is + * checked again on read and not only when the authorization request is built. + */ + public static String getCallbackUrlScheme(OAuth2AuthorizationRequest authorizationRequest) { + String callbackUrlScheme = authorizationRequest != null ? + authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME) : null; + if (StringUtils.isEmpty(callbackUrlScheme)) { + return null; + } + if (!isValid(callbackUrlScheme)) { + log.warn("Ignoring invalid callback url scheme: [{}]", forLog(callbackUrlScheme)); + return null; + } + return callbackUrlScheme; + } + + // a rejected value is attacker-controlled: it must not be able to forge log lines + private static String forLog(String value) { + return value.substring(0, Math.min(value.length(), MAX_LOGGED_LENGTH)).replaceAll("[^\\x20-\\x7E]", "?"); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java index 3b9d325c38..421be12c00 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java @@ -54,11 +54,8 @@ public class Oauth2AuthenticationFailureHandler extends SimpleUrlAuthenticationF throws IOException, ServletException { String baseUrl; String errorPrefix; - String callbackUrlScheme = null; OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); - if (authorizationRequest != null) { - callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); - } + String callbackUrlScheme = CallbackUrlSchemeValidator.getCallbackUrlScheme(authorizationRequest); if (!StringUtils.isEmpty(callbackUrlScheme)) { baseUrl = callbackUrlScheme + ":"; errorPrefix = "/?error="; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index f3fb49f45b..18717cc9ab 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -82,8 +82,9 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS HttpServletResponse response, Authentication authentication) throws IOException { OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); - String callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); - String baseUrl = getBaseUrl(request, response, callbackUrlScheme); + String callbackUrlScheme = CallbackUrlSchemeValidator.getCallbackUrlScheme(authorizationRequest); + String baseUrl = getBaseUrl(request, callbackUrlScheme); + String prevUri = getPrevUri(request, response, callbackUrlScheme); try { OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; @@ -98,7 +99,7 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS clearAuthenticationAttributes(request, response); JwtPair tokenPair = tokenFactory.createTokenPair(securityUser); - getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(baseUrl, tokenPair)); + getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(baseUrl + prevUri, tokenPair)); systemSecurityService.logLoginAction(securityUser, new RestAuthenticationDetails(request), ActionType.LOGIN, oauth2Client.getName(), null); } catch (Exception e) { log.debug("Error occurred during processing authentication success result. " + @@ -115,20 +116,29 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS } } - String getBaseUrl(HttpServletRequest request, HttpServletResponse response, String callbackUrlScheme) { + String getBaseUrl(HttpServletRequest request, String callbackUrlScheme) { if (!StringUtils.isEmpty(callbackUrlScheme)) { return callbackUrlScheme + ":"; } - String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); - Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); - if (prevUrlOpt.isPresent()) { - String prevUri = prevUrlOpt.get().getValue(); - if (PrevUriValidator.isValid(prevUri)) { - baseUrl += prevUri; - } - CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); + return this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + } + + /** + * The in-app path the user was on before the login, or an empty string. The cookie is dropped either way - it is + * only meant to survive a single login round trip. It is kept out of the base URL so that the error redirect, + * which appends its own path, stays routable. + */ + String getPrevUri(HttpServletRequest request, HttpServletResponse response, String callbackUrlScheme) { + if (!StringUtils.isEmpty(callbackUrlScheme)) { + return ""; + } + Optional prevUriOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); + if (prevUriOpt.isEmpty()) { + return ""; } - return baseUrl; + String prevUri = prevUriOpt.get().getValue(); + CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); + return PrevUriValidator.isValid(prevUri) ? prevUri : ""; } protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java index 4a9b18cafd..9e6b525364 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -26,13 +26,11 @@ import io.jsonwebtoken.security.SignatureException; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.service.security.auth.oauth2.CallbackUrlSchemeValidator; import java.util.Base64; import java.util.Date; -import java.util.Locale; -import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; @Component @Slf4j @@ -42,9 +40,6 @@ public class OAuth2AppTokenFactory { private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5); - private static final Pattern CALLBACK_URL_SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*"); - private static final Set FORBIDDEN_CALLBACK_URL_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript"); - public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) { Jws jwsClaims; try { @@ -64,17 +59,14 @@ public class OAuth2AppTokenFactory { if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) { throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes"); } - if (!claims.getIssuer().equals(appPackage)) { + if (!appPackage.equals(claims.getIssuer())) { throw new IllegalArgumentException("Application token issuer doesn't match application package"); } String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class); if (StringUtils.isEmpty(callbackUrlScheme)) { throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme"); } - // the redirect carrying the access token is built as callbackUrlScheme + ":", so only a mobile app scheme - // may pass: a web scheme would send the token to whatever host follows it - if (!CALLBACK_URL_SCHEME_PATTERN.matcher(callbackUrlScheme).matches() - || FORBIDDEN_CALLBACK_URL_SCHEMES.contains(callbackUrlScheme.toLowerCase(Locale.ROOT))) { + if (!CallbackUrlSchemeValidator.isValid(callbackUrlScheme)) { throw new IllegalArgumentException("Application token has invalid callbackUrlScheme"); } return callbackUrlScheme; diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java new file mode 100644 index 0000000000..2ba11324ce --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java @@ -0,0 +1,81 @@ +/** + * 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.security.auth.oauth2; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CallbackUrlSchemeValidatorTest { + + @ParameterizedTest + @ValueSource(strings = {"tbmobile", "tb-mobile.app1", "TbMobile+1"}) + public void testMobileAppSchemeIsValid(String callbackUrlScheme) { + assertThat(CallbackUrlSchemeValidator.isValid(callbackUrlScheme)).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + "https://evil.com", + "http://evil.com", + "https", + "HTTPS", + "javascript", + "data", + "file", + "vbscript", + "//evil.com", + "tbmobile/evil.com", + "tbmobile:evil.com", + "tbmobile evil", + "1tbmobile", + "tbmobile@evil.com" + }) + public void testInvalidSchemeIsRejected(String callbackUrlScheme) { + assertThat(CallbackUrlSchemeValidator.isValid(callbackUrlScheme)).isFalse(); + } + + @Test + public void testValidSchemeIsTakenFromAuthorizationRequest() { + assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest("tbmobile"))).isEqualTo("tbmobile"); + } + + @Test + public void testForgedSchemeFromAuthorizationRequestIsIgnored() { + assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest("https://evil.com"))).isNull(); + } + + @Test + public void testAuthorizationRequestWithoutScheme() { + assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest(null))).isNull(); + assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(null)).isNull(); + } + + private OAuth2AuthorizationRequest givenAuthorizationRequest(String callbackUrlScheme) { + OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("testUri").clientId("testId"); + if (callbackUrlScheme != null) { + builder.attributes(attributes -> attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme)); + } + return builder.build(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java deleted file mode 100644 index 5d85c742f1..0000000000 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerBaseUrlTest.java +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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.security.auth.oauth2; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.oauth2.OAuth2ClientService; -import org.thingsboard.server.service.security.model.token.JwtTokenFactory; -import org.thingsboard.server.service.security.system.SystemSecurityService; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; - -public class Oauth2AuthenticationSuccessHandlerBaseUrlTest { - - private static final String BASE_URL = "https://thingsboard.example.com"; - - private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class); - private final Oauth2AuthenticationSuccessHandler successHandler = new Oauth2AuthenticationSuccessHandler( - mock(JwtTokenFactory.class), mock(OAuth2ClientMapperProvider.class), mock(OAuth2ClientService.class), - mock(OAuth2AuthorizedClientService.class), mock(HttpCookieOAuth2AuthorizationRequestRepository.class), - systemSecurityService); - - private HttpServletRequest request; - private HttpServletResponse response; - - @BeforeEach - public void before() { - request = mock(HttpServletRequest.class); - response = mock(HttpServletResponse.class); - when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); - } - - @Test - public void testInAppPathIsAppendedToBaseUrl() { - givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); - assertThat(successHandler.getBaseUrl(request, response, null)) - .isEqualTo(BASE_URL + "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); - } - - @ParameterizedTest - @ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/dashboards#fragment"}) - public void testForgedPrevUriCookieIsIgnored(String prevUri) { - givenPrevUriCookie(prevUri); - assertThat(successHandler.getBaseUrl(request, response, null)).isEqualTo(BASE_URL); - } - - @Test - public void testForgedPrevUriCookieIsDeleted() { - givenPrevUriCookie("@evil.com/"); - - successHandler.getBaseUrl(request, response, null); - - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response).addCookie(cookieCaptor.capture()); - assertThat(cookieCaptor.getValue().getName()).isEqualTo(PREV_URI_COOKIE_NAME); - assertThat(cookieCaptor.getValue().getMaxAge()).isZero(); - } - - @Test - public void testBaseUrlWithoutPrevUriCookie() { - when(request.getCookies()).thenReturn(null); - assertThat(successHandler.getBaseUrl(request, response, null)).isEqualTo(BASE_URL); - } - - @Test - public void testCallbackUrlSchemeIgnoresPrevUri() { - givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e"); - assertThat(successHandler.getBaseUrl(request, response, "tbmobile")).isEqualTo("tbmobile:"); - } - - private void givenPrevUriCookie(String prevUri) { - when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)}); - } - -} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java index e3ec321e8a..dc014a9091 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java @@ -15,59 +15,104 @@ */ package org.thingsboard.server.service.security.auth.oauth2; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.server.common.data.id.UserId; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.model.JwtPair; -import org.thingsboard.server.controller.AbstractControllerTest; -import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; -import java.util.UUID; - -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.eq; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; -@DaoSqlTest -public class Oauth2AuthenticationSuccessHandlerTest extends AbstractControllerTest { +public class Oauth2AuthenticationSuccessHandlerTest { - @Autowired - private Oauth2AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler; + private static final String BASE_URL = "https://thingsboard.example.com"; - @Mock - private JwtTokenFactory jwtTokenFactory; + private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class); + private final Oauth2AuthenticationSuccessHandler successHandler = new Oauth2AuthenticationSuccessHandler( + mock(JwtTokenFactory.class), mock(OAuth2ClientMapperProvider.class), mock(OAuth2ClientService.class), + mock(OAuth2AuthorizedClientService.class), mock(HttpCookieOAuth2AuthorizationRequestRepository.class), + systemSecurityService); - private SecurityUser securityUser; + private HttpServletRequest request; + private HttpServletResponse response; - @Before + @BeforeEach public void before() { - UserId userId = new UserId(UUID.randomUUID()); - securityUser = new SecurityUser(userId); - when(jwtTokenFactory.createTokenPair(eq(securityUser))).thenReturn(new JwtPair("testAccessToken", "testRefreshToken")); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); } @Test - public void testGetRedirectUrl() { - JwtPair jwtPair = jwtTokenFactory.createTokenPair(securityUser); + public void testInAppPathIsTakenFromPrevUriCookie() { + givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + assertThat(successHandler.getBaseUrl(request, null)).isEqualTo(BASE_URL); + assertThat(successHandler.getPrevUri(request, response, null)) + .isEqualTo("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + } + + @ParameterizedTest + @ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/dashboards#fragment"}) + public void testForgedPrevUriCookieIsIgnored(String prevUri) { + givenPrevUriCookie(prevUri); + assertThat(successHandler.getPrevUri(request, response, null)).isEmpty(); + } - String urlWithoutParams = "http://localhost:8080/dashboardGroups/3fa13530-6597-11ed-bd76-8bd591f0ec3e"; - String urlWithParams = "http://localhost:8080/dashboardGroups/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1"; + @Test + public void testForgedPrevUriCookieIsDeleted() { + givenPrevUriCookie("@evil.com/"); - String redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithoutParams, jwtPair); - String expectedUrl = urlWithoutParams + "/?accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); - assertEquals(expectedUrl, redirectUrl); + successHandler.getPrevUri(request, response, null); - redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithParams, jwtPair); - expectedUrl = urlWithParams + "&accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); - assertEquals(expectedUrl, redirectUrl); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + assertThat(cookieCaptor.getValue().getName()).isEqualTo(PREV_URI_COOKIE_NAME); + assertThat(cookieCaptor.getValue().getMaxAge()).isZero(); + } - String urlWithTrailingSlash = "http://localhost:8080/"; - redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithTrailingSlash, jwtPair); - expectedUrl = urlWithTrailingSlash + "?accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); - assertEquals(expectedUrl, redirectUrl); + @Test + public void testBaseUrlWithoutPrevUriCookie() { + when(request.getCookies()).thenReturn(null); + assertThat(successHandler.getBaseUrl(request, null)).isEqualTo(BASE_URL); + assertThat(successHandler.getPrevUri(request, response, null)).isEmpty(); } -} \ No newline at end of file + + @Test + public void testCallbackUrlSchemeIgnoresPrevUri() { + givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e"); + assertThat(successHandler.getBaseUrl(request, "tbmobile")).isEqualTo("tbmobile:"); + assertThat(successHandler.getPrevUri(request, response, "tbmobile")).isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e, https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e/?", + "https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1, https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1&", + "https://thingsboard.example.com/, https://thingsboard.example.com/?" + }) + public void testGetRedirectUrl(String baseUrl, String expectedPrefix) { + assertThat(successHandler.getRedirectUrl(baseUrl, new JwtPair("testAccessToken", "testRefreshToken"))) + .isEqualTo(expectedPrefix + "accessToken=testAccessToken&refreshToken=testRefreshToken"); + } + + private void givenPrevUriCookie(String prevUri) { + when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)}); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java index ec4da607f8..0130ddfff7 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java @@ -15,11 +15,10 @@ */ package org.thingsboard.server.service.security.model.token; +import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import javax.crypto.SecretKey; import java.util.Base64; @@ -38,38 +37,35 @@ public class OAuth2AppTokenFactoryTest { @Test public void testMobileAppSchemeIsAccepted() { - assertThat(validate("tb-mobile.app1")).isEqualTo("tb-mobile.app1"); + assertThat(validate(appToken("tb-mobile.app1", APP_PACKAGE))).isEqualTo("tb-mobile.app1"); } - @ParameterizedTest - @ValueSource(strings = { - "https://evil.com", - "http://evil.com", - "https", - "HTTPS", - "javascript", - "data", - "//evil.com", - "tbmobile/evil.com", - "tbmobile:evil.com", - "tbmobile evil", - "1tbmobile", - "tbmobile@evil.com" - }) - public void testInvalidCallbackUrlSchemeIsRejected(String callbackUrlScheme) { - assertThatThrownBy(() -> validate(callbackUrlScheme)) + @Test + public void testInvalidCallbackUrlSchemeIsRejected() { + assertThatThrownBy(() -> validate(appToken("https://evil.com", APP_PACKAGE))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("callbackUrlScheme"); } - private String validate(String callbackUrlScheme) { - SecretKey key = Keys.hmacShaKeyFor(KEY_BYTES); - String appToken = Jwts.builder() - .issuer(APP_PACKAGE) + @Test + public void testTokenWithoutIssuerIsRejected() { + assertThatThrownBy(() -> validate(appToken("tbmobile", null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("issuer"); + } + + private String appToken(String callbackUrlScheme, String issuer) { + JwtBuilder builder = Jwts.builder() .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) - .claim("callbackUrlScheme", callbackUrlScheme) - .signWith(key) - .compact(); + .claim("callbackUrlScheme", callbackUrlScheme); + if (issuer != null) { + builder.issuer(issuer); + } + SecretKey key = Keys.hmacShaKeyFor(KEY_BYTES); + return builder.signWith(key).compact(); + } + + private String validate(String appToken) { return tokenFactory.validateTokenAndGetCallbackUrlScheme(APP_PACKAGE, appToken, Base64.getEncoder().encodeToString(KEY_BYTES)); } From e6ab6871e98a9cb60a993e873d9c3b7c98a63af6 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 14:42:14 +0300 Subject: [PATCH 24/28] Log rejected prevUri values and share the mail flow constants A rejected deep link is otherwise invisible to support. AdminController reuses the prevUri parameter and cookie names instead of its own copies, and its redirect target moves into a helper so the read side is covered by a test. --- .../server/controller/AdminController.java | 23 ++++-- .../auth/oauth2/PrevUriValidator.java | 23 +++++- .../AdminControllerMailOAuth2Test.java | 81 +++++++++++++++++++ .../controller/AdminControllerTest.java | 42 ++++++---- ...th2AuthorizationRequestRepositoryTest.java | 13 +-- 5 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index cbe1114864..8e8f4c4de3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -93,6 +93,8 @@ import java.util.Optional; import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER; @RestController @TbCoreComponent @@ -101,8 +103,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO @RequiredArgsConstructor public class AdminController extends BaseController { - private static final String PREV_URI_PATH_PARAMETER = "prevUri"; - private static final String PREV_URI_COOKIE_NAME = "prev_uri"; + private static final String DEFAULT_PREV_URI = "/settings/outgoing-mail"; private static final String STATE_COOKIE_NAME = "state"; private static final String MAIL_SETTINGS_KEY = "mail"; @@ -420,7 +421,7 @@ public class AdminController extends BaseController { @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); - String prevUriParam = request.getParameter(PREV_URI_PATH_PARAMETER); + String prevUriParam = request.getParameter(PREV_URI_PARAMETER); if (PrevUriValidator.isValid(prevUriParam)) { CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, prevUriParam, 180); } @@ -447,12 +448,9 @@ public class AdminController extends BaseController { public void codeProcessingUrl( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { - Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); + String redirectUrl = getMailOAuth2RedirectUrl(request); Optional cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME); - String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); - String prevUri = baseUrl + prevUrlOpt.map(Cookie::getValue).filter(PrevUriValidator::isValid).orElse("/settings/outgoing-mail"); - if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) { CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME); throw new ThingsboardException("Refresh token was not generated, invalid state param", ThingsboardErrorCode.BAD_REQUEST_PARAMS); @@ -482,7 +480,16 @@ public class AdminController extends BaseController { ((ObjectNode) jsonValue).put("tokenGenerated", true); adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); - response.sendRedirect(prevUri); + response.sendRedirect(redirectUrl); + } + + String getMailOAuth2RedirectUrl(HttpServletRequest request) { + String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + String prevUri = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME) + .map(Cookie::getValue) + .filter(PrevUriValidator::isValid) + .orElse(DEFAULT_PREV_URI); + return baseUrl + prevUri; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java index 005979408c..14855ecda3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java @@ -15,13 +15,27 @@ */ package org.thingsboard.server.service.security.auth.oauth2; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.StringUtils; import java.util.Locale; +@Slf4j public class PrevUriValidator { private static final int MAX_LENGTH = 2048; + private static final int MAX_LOGGED_LENGTH = 128; + + public static boolean isValid(String prevUri) { + if (StringUtils.isEmpty(prevUri)) { + return false; + } + if (!isInAppPath(prevUri)) { + log.debug("Ignoring prevUri that is not an in-app path: [{}]", forLog(prevUri)); + return false; + } + return true; + } /** * prevUri is appended to the platform base URL, which ends right after the authority, so the single leading '/' @@ -30,8 +44,8 @@ public class PrevUriValidator { * characters nor '"', ',', ';', '\' or non-ASCII) and to pass StrictHttpFirewall, which rejects '//', '%2f' * and '%5c' in the path; a fragment would swallow the access token. */ - public static boolean isValid(String prevUri) { - if (StringUtils.isEmpty(prevUri) || prevUri.length() > MAX_LENGTH || prevUri.charAt(0) != '/') { + private static boolean isInAppPath(String prevUri) { + if (prevUri.length() > MAX_LENGTH || prevUri.charAt(0) != '/') { return false; } for (int i = 0; i < prevUri.length(); i++) { @@ -44,4 +58,9 @@ public class PrevUriValidator { return !path.contains("//") && !path.contains("%2f") && !path.contains("%5c"); } + // a rejected value is attacker-controlled: it must not be able to forge log lines + private static String forLog(String prevUri) { + return prevUri.substring(0, Math.min(prevUri.length(), MAX_LOGGED_LENGTH)).replaceAll("[^\\x20-\\x7E]", "?"); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java new file mode 100644 index 0000000000..b74acea73a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java @@ -0,0 +1,81 @@ +/** + * 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.controller; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; + +@ExtendWith(MockitoExtension.class) +public class AdminControllerMailOAuth2Test { + + private static final String BASE_URL = "https://thingsboard.example.com"; + private static final String DEFAULT_PREV_URI = "/settings/outgoing-mail"; + + @Mock + private SystemSecurityService systemSecurityService; + + @InjectMocks + private AdminController adminController; + + private HttpServletRequest request; + + @BeforeEach + public void before() { + request = mock(HttpServletRequest.class); + when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); + } + + @Test + public void testInAppPathIsTakenFromPrevUriCookie() { + givenPrevUriCookie("/settings/notifications?tab=1"); + assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + "/settings/notifications?tab=1"); + } + + @ParameterizedTest + @ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/settings#fragment"}) + public void testForgedPrevUriCookieIsIgnored(String prevUri) { + givenPrevUriCookie(prevUri); + assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + DEFAULT_PREV_URI); + } + + @Test + public void testRedirectUrlWithoutPrevUriCookie() { + when(request.getCookies()).thenReturn(null); + assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + DEFAULT_PREV_URI); + } + + private void givenPrevUriCookie(String prevUri) { + when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)}); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index e0866a3d86..bbdd324d82 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -39,6 +39,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER; @Slf4j @DaoSqlTest @@ -116,22 +118,30 @@ public class AdminControllerTest extends AbstractControllerTest { public void testMailOAuth2AuthorizationStoresOnlyInAppPrevUri() throws Exception { loginSysAdmin(); AdminSettings mailSettings = doGet("/api/admin/settings/mail", AdminSettings.class); - ObjectNode jsonValue = JacksonUtil.fromString(mailSettings.getJsonValue().toString(), ObjectNode.class); - jsonValue.put("clientId", "clientId"); - jsonValue.put("authUri", "https://accounts.google.com/o/oauth2/v2/auth"); - jsonValue.put("redirectUri", "https://thingsboard.io/api/admin/mail/oauth2/code"); - jsonValue.set("scope", JacksonUtil.newArrayNode().add("https://mail.google.com/")); - mailSettings.setJsonValue(jsonValue); - doPost("/api/admin/settings", mailSettings, AdminSettings.class); - - Cookie prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?prevUri=@evil.com/") - .andExpect(status().isOk()).andReturn().getResponse().getCookie("prev_uri"); - assertThat(prevUriCookie).isNull(); - - prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?prevUri=/settings/outgoing-mail") - .andExpect(status().isOk()).andReturn().getResponse().getCookie("prev_uri"); - assertThat(prevUriCookie).isNotNull(); - assertThat(prevUriCookie.getValue()).isEqualTo("/settings/outgoing-mail"); + JsonNode originalJsonValue = mailSettings.getJsonValue(); + try { + ObjectNode jsonValue = JacksonUtil.fromString(originalJsonValue.toString(), ObjectNode.class); + jsonValue.put("clientId", "clientId"); + jsonValue.put("authUri", "https://accounts.google.com/o/oauth2/v2/auth"); + jsonValue.put("redirectUri", "https://thingsboard.io/api/admin/mail/oauth2/code"); + jsonValue.set("scope", JacksonUtil.newArrayNode().add("https://mail.google.com/")); + mailSettings.setJsonValue(jsonValue); + doPost("/api/admin/settings", mailSettings, AdminSettings.class); + + Cookie prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?" + PREV_URI_PARAMETER + "=@evil.com/") + .andExpect(status().isOk()).andReturn().getResponse().getCookie(PREV_URI_COOKIE_NAME); + assertThat(prevUriCookie).isNull(); + + prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?" + PREV_URI_PARAMETER + "=/settings/outgoing-mail") + .andExpect(status().isOk()).andReturn().getResponse().getCookie(PREV_URI_COOKIE_NAME); + assertThat(prevUriCookie).isNotNull(); + assertThat(prevUriCookie.getValue()).isEqualTo("/settings/outgoing-mail"); + } finally { + // the mail settings are shared by the whole test context + AdminSettings currentSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + currentSettings.setJsonValue(originalJsonValue); + doPost("/api/admin/settings", currentSettings, AdminSettings.class); + } } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java index 92aa8f18df..194b5d7939 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java @@ -19,6 +19,9 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; @@ -40,11 +43,11 @@ public class HttpCookieOAuth2AuthorizationRequestRepositoryTest { .isEqualTo("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); } - @Test - public void testPrevUriNotSavedForExternalUri() { - assertThat(savePrevUri("@evil.com/")).isNull(); - assertThat(savePrevUri("https://evil.com")).isNull(); - assertThat(savePrevUri("//evil.com")).isNull(); + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"@evil.com/"}) + public void testPrevUriNotSavedForExternalUri(String prevUri) { + assertThat(savePrevUri(prevUri)).isNull(); } private String savePrevUri(String prevUri) { From 0730387640ff99195fcda5ee2af981a006cace82 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 15:27:47 +0300 Subject: [PATCH 25/28] Resolve OAuth2 user within the tenant of the OAuth2 client The user was looked up by email across the whole platform, so a client belonging to one tenant could resolve a user of another. New users were also placed in the tenant named by the provider response rather than in the tenant that owns the client. Both now follow the client's own tenant. System clients keep the platform-wide behaviour they rely on. --- .../oauth2/AbstractOAuth2ClientMapper.java | 28 ++++- .../auth/oauth2/OAuth2ClientMapperTest.java | 115 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java index 9ac31a8813..5af6eed34f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java @@ -95,7 +95,7 @@ public abstract class AbstractOAuth2ClientMapper { UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, oauth2User.getEmail()); - User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail()); + User user = findUserForClient(oauth2User.getEmail(), oAuth2Client); if (user == null && !config.isAllowUserCreation()) { throw new UsernameNotFoundException("User not found: " + oauth2User.getEmail()); @@ -104,7 +104,7 @@ public abstract class AbstractOAuth2ClientMapper { if (user == null) { userCreationLock.lock(); try { - user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail()); + user = findUserForClient(oauth2User.getEmail(), oAuth2Client); if (user == null) { user = new User(); if (oauth2User.getCustomerId() == null && StringUtils.isEmpty(oauth2User.getCustomerName())) { @@ -112,7 +112,12 @@ public abstract class AbstractOAuth2ClientMapper { } else { user.setAuthority(Authority.CUSTOMER_USER); } - TenantId tenantId = oauth2User.getTenantId() != null ? oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName()); + TenantId tenantId; + if (oAuth2Client.getTenantId().isSysTenantId()) { + tenantId = oauth2User.getTenantId() != null ? oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName()); + } else { + tenantId = oAuth2Client.getTenantId(); + } user.setTenantId(tenantId); CustomerId customerId = oauth2User.getCustomerId() != null ? oauth2User.getCustomerId() : getCustomerId(user.getTenantId(), oauth2User.getCustomerName()); @@ -164,6 +169,23 @@ public abstract class AbstractOAuth2ClientMapper { } } + /** + * The user is matched by email alone, and the email is whatever the provider chose to send. A client registered by a + * tenant must therefore only ever resolve users of that same tenant; a system client is platform-wide by design. + */ + private User findUserForClient(String email, OAuth2Client oAuth2Client) { + User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, email); + if (user == null || oAuth2Client.getTenantId().isSysTenantId()) { + return user; + } + if (user.getTenantId().equals(oAuth2Client.getTenantId())) { + return user; + } + log.warn("OAuth2 client [{}] of tenant [{}] attempted to log in as user [{}] of tenant [{}]", + oAuth2Client.getId(), oAuth2Client.getTenantId(), user.getId(), user.getTenantId()); + throw new UsernameNotFoundException("User not found: " + email); + } + private TenantId getTenantId(String name) throws Exception { Tenant tenant = tenantService.findTenantByName(name); if (tenant != null) { diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperTest.java new file mode 100644 index 0000000000..775ed5945e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperTest.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.service.security.auth.oauth2; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.oauth2.OAuth2Client; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DaoSqlTest +public class OAuth2ClientMapperTest extends AbstractControllerTest { + + @Autowired + private BasicOAuth2ClientMapper basicOAuth2ClientMapper; + @Autowired + private UserService userService; + + @Test + public void testShouldFindUserOfOwnTenant() throws Exception { + loginTenantAdmin(); + OAuth2Client tenantClient = doPost("/api/oauth2/client", createOauth2Client(tenantId, "tenant client"), OAuth2Client.class); + + OAuth2User oAuth2User = new OAuth2User(); + oAuth2User.setEmail(TENANT_ADMIN_EMAIL); + + SecurityUser securityUser = basicOAuth2ClientMapper.getOrCreateSecurityUserFromOAuth2User(oAuth2User, tenantClient); + assertThat(securityUser.getTenantId()).isEqualTo(tenantId); + assertThat(securityUser.getAuthority()).isEqualTo(Authority.TENANT_ADMIN); + } + + @Test + public void testShouldNotFindUserOfAnotherTenant() throws Exception { + loginDifferentTenant(); + loginTenantAdmin(); + OAuth2Client tenantClient = doPost("/api/oauth2/client", createOauth2Client(tenantId, "tenant client"), OAuth2Client.class); + + // the email attribute is controlled by the identity provider behind the client + OAuth2User oAuth2User = new OAuth2User(); + oAuth2User.setEmail(DIFFERENT_TENANT_ADMIN_EMAIL); + + UsernameNotFoundException exception = assertThrows( + UsernameNotFoundException.class, + () -> basicOAuth2ClientMapper.getOrCreateSecurityUserFromOAuth2User(oAuth2User, tenantClient)); + assertThat(exception.getMessage()).isEqualTo("User not found: " + DIFFERENT_TENANT_ADMIN_EMAIL); + + User differentTenantAdmin = userService.findUserByEmail(TenantId.SYS_TENANT_ID, DIFFERENT_TENANT_ADMIN_EMAIL); + assertThat(differentTenantAdmin.getTenantId()).isEqualTo(differentTenantId); + + loginSysAdmin(); + deleteDifferentTenant(); + } + + @Test + public void testShouldNotFindSysAdmin() throws Exception { + loginTenantAdmin(); + OAuth2Client tenantClient = doPost("/api/oauth2/client", createOauth2Client(tenantId, "tenant client"), OAuth2Client.class); + + OAuth2User oAuth2User = new OAuth2User(); + oAuth2User.setEmail(SYS_ADMIN_EMAIL); + + UsernameNotFoundException exception = assertThrows( + UsernameNotFoundException.class, + () -> basicOAuth2ClientMapper.getOrCreateSecurityUserFromOAuth2User(oAuth2User, tenantClient)); + assertThat(exception.getMessage()).isEqualTo("User not found: " + SYS_ADMIN_EMAIL); + + User sysAdmin = userService.findUserByEmail(TenantId.SYS_TENANT_ID, SYS_ADMIN_EMAIL); + assertThat(sysAdmin.getAuthority()).isEqualTo(Authority.SYS_ADMIN); + } + + @Test + public void testShouldCreateUserInClientTenant() throws Exception { + loginDifferentTenant(); + loginTenantAdmin(); + OAuth2Client tenantClient = doPost("/api/oauth2/client", createOauth2Client(tenantId, "tenant client"), OAuth2Client.class); + + // a custom mapper endpoint may return any tenant id; the client's own tenant must win + String email = "userA@corporation.gmail.com"; + OAuth2User oAuth2User = new OAuth2User(); + oAuth2User.setEmail(email); + oAuth2User.setTenantId(differentTenantId); + + basicOAuth2ClientMapper.getOrCreateSecurityUserFromOAuth2User(oAuth2User, tenantClient); + + User created = userService.findUserByEmail(TenantId.SYS_TENANT_ID, email); + assertThat(created.getTenantId()).isEqualTo(tenantId); + + loginSysAdmin(); + deleteDifferentTenant(); + } + +} From a6c28e79b90b647c606b78660e68f82fea6b6be9 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 15:37:07 +0300 Subject: [PATCH 26/28] Allow an underscore in the mobile app callback url scheme Mobile apps derive the scheme from their package name, which may contain an underscore. It cannot introduce an authority, so accepting it keeps the rule as strong as the RFC 3986 grammar. --- .../security/auth/oauth2/CallbackUrlSchemeValidator.java | 3 ++- .../security/auth/oauth2/CallbackUrlSchemeValidatorTest.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java index a75af8dd5e..bdb4a6d4ab 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java @@ -26,7 +26,8 @@ import java.util.regex.Pattern; @Slf4j public class CallbackUrlSchemeValidator { - private static final Pattern SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*"); + // RFC 3986 scheme grammar, plus '_': mobile apps derive the scheme from their package name, which may contain one + private static final Pattern SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.\\-_]*"); private static final Set FORBIDDEN_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript"); private static final int MAX_LOGGED_LENGTH = 128; diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java index 2ba11324ce..aa2eeb7982 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java @@ -26,7 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; public class CallbackUrlSchemeValidatorTest { @ParameterizedTest - @ValueSource(strings = {"tbmobile", "tb-mobile.app1", "TbMobile+1"}) + @ValueSource(strings = {"tbmobile", "tb-mobile.app1", "TbMobile+1", "org.mycompany.myapp.auth", "com.my_company.app.auth"}) public void testMobileAppSchemeIsValid(String callbackUrlScheme) { assertThat(CallbackUrlSchemeValidator.isValid(callbackUrlScheme)).isTrue(); } From b8e17822b17c36eb705ea0a392c44ca7b7505760 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 15:49:57 +0300 Subject: [PATCH 27/28] Include the email in the OAuth2 user resolution log message --- .../security/auth/oauth2/AbstractOAuth2ClientMapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java index 5af6eed34f..4559c53862 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java @@ -181,8 +181,8 @@ public abstract class AbstractOAuth2ClientMapper { if (user.getTenantId().equals(oAuth2Client.getTenantId())) { return user; } - log.warn("OAuth2 client [{}] of tenant [{}] attempted to log in as user [{}] of tenant [{}]", - oAuth2Client.getId(), oAuth2Client.getTenantId(), user.getId(), user.getTenantId()); + log.warn("OAuth2 client [{}] of tenant [{}] cannot resolve user [{}] [{}] of tenant [{}]: outside of the client tenant", + oAuth2Client.getId(), oAuth2Client.getTenantId(), user.getId(), email, user.getTenantId()); throw new UsernameNotFoundException("User not found: " + email); } From 1655cf9296674e465be6af68450f8b35cdaf46ae Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 1 Sep 2026 15:55:53 +0300 Subject: [PATCH 28/28] Assert the redirect URLs sent by the OAuth2 handlers --- .../Oauth2AuthenticationSuccessHandler.java | 6 +- ...th2AuthorizationRequestRepositoryTest.java | 2 +- ...auth2AuthenticationFailureHandlerTest.java | 103 ++++++++++++++++++ ...auth2AuthenticationSuccessHandlerTest.java | 90 +++++++++++++-- 4 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index 18717cc9ab..c99fb9378e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -124,9 +124,9 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS } /** - * The in-app path the user was on before the login, or an empty string. The cookie is dropped either way - it is - * only meant to survive a single login round trip. It is kept out of the base URL so that the error redirect, - * which appends its own path, stays routable. + * The in-app path the user was on before the login, or an empty string. A present cookie is dropped whether or + * not its value passes validation - it is only meant to survive a single login round trip. The path is kept out + * of the base URL so that the error redirect, which appends its own path, stays routable. */ String getPrevUri(HttpServletRequest request, HttpServletResponse response, String callbackUrlScheme) { if (!StringUtils.isEmpty(callbackUrlScheme)) { diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java index 194b5d7939..5cada8477f 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java @@ -46,7 +46,7 @@ public class HttpCookieOAuth2AuthorizationRequestRepositoryTest { @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {"@evil.com/"}) - public void testPrevUriNotSavedForExternalUri(String prevUri) { + public void testPrevUriNotSavedForInvalidValue(String prevUri) { assertThat(savePrevUri(prevUri)).isNull(); } diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java new file mode 100644 index 0000000000..15110aa46d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java @@ -0,0 +1,103 @@ +/** + * 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.security.auth.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Oauth2AuthenticationFailureHandlerTest { + + private static final String BASE_URL = "https://thingsboard.example.com"; + + private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository = + mock(HttpCookieOAuth2AuthorizationRequestRepository.class); + private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class); + private final Oauth2AuthenticationFailureHandler failureHandler = + new Oauth2AuthenticationFailureHandler(authorizationRequestRepository, systemSecurityService); + + private HttpServletRequest request; + private HttpServletResponse response; + + @BeforeEach + public void before() { + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + when(request.getContextPath()).thenReturn(""); + when(response.encodeRedirectURL(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); + } + + @Test + public void testErrorIsSentToMobileAppScheme() throws Exception { + givenCallbackUrlScheme("tbmobile"); + assertThat(sendFailure()).isEqualTo("tbmobile:/?error=someError"); + } + + /** + * The scheme is restored from the oauth2_auth_request cookie, so a forged one must not turn the error redirect + * into a link to another host. + */ + @ParameterizedTest + @ValueSource(strings = {"https://evil.com", "javascript"}) + public void testForgedCallbackUrlSchemeFallsBackToLoginPage(String callbackUrlScheme) throws Exception { + givenCallbackUrlScheme(callbackUrlScheme); + assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError"); + } + + @Test + public void testErrorIsSentToLoginPageWithoutCallbackUrlScheme() throws Exception { + givenCallbackUrlScheme(null); + assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError"); + } + + @Test + public void testErrorIsSentToLoginPageWithoutAuthorizationRequest() throws Exception { + assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError"); + } + + private void givenCallbackUrlScheme(String callbackUrlScheme) { + when(authorizationRequestRepository.loadAuthorizationRequest(request)).thenReturn( + OAuth2AuthorizationRequest.authorizationCode().authorizationUri("testUri").clientId("testId") + .attributes(attributes -> attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme)) + .build()); + } + + private String sendFailure() throws Exception { + failureHandler.onAuthenticationFailure(request, response, new AuthenticationServiceException("someError")); + + ArgumentCaptor redirectCaptor = ArgumentCaptor.forClass(String.class); + verify(response).sendRedirect(redirectCaptor.capture()); + return redirectCaptor.getValue(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java index dc014a9091..eae9441711 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java @@ -24,16 +24,28 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.oauth2.MapperType; +import org.thingsboard.server.common.data.oauth2.OAuth2Client; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.dao.oauth2.OAuth2ClientService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; +import java.time.Instant; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,12 +54,17 @@ import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAut public class Oauth2AuthenticationSuccessHandlerTest { private static final String BASE_URL = "https://thingsboard.example.com"; + private static final String PREV_URI = "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e"; + private static final JwtPair TOKEN_PAIR = new JwtPair("testAccessToken", "testRefreshToken"); + private final JwtTokenFactory tokenFactory = mock(JwtTokenFactory.class); + private final OAuth2ClientMapperProvider oauth2ClientMapperProvider = mock(OAuth2ClientMapperProvider.class); + private final OAuth2ClientService oAuth2ClientService = mock(OAuth2ClientService.class); + private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService = mock(OAuth2AuthorizedClientService.class); private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class); private final Oauth2AuthenticationSuccessHandler successHandler = new Oauth2AuthenticationSuccessHandler( - mock(JwtTokenFactory.class), mock(OAuth2ClientMapperProvider.class), mock(OAuth2ClientService.class), - mock(OAuth2AuthorizedClientService.class), mock(HttpCookieOAuth2AuthorizationRequestRepository.class), - systemSecurityService); + tokenFactory, oauth2ClientMapperProvider, oAuth2ClientService, oAuth2AuthorizedClientService, + mock(HttpCookieOAuth2AuthorizationRequestRepository.class), systemSecurityService); private HttpServletRequest request; private HttpServletResponse response; @@ -57,14 +74,14 @@ public class Oauth2AuthenticationSuccessHandlerTest { request = mock(HttpServletRequest.class); response = mock(HttpServletResponse.class); when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL); + when(response.encodeRedirectURL(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); } @Test public void testInAppPathIsTakenFromPrevUriCookie() { - givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + givenPrevUriCookie(PREV_URI + "?state=someState"); assertThat(successHandler.getBaseUrl(request, null)).isEqualTo(BASE_URL); - assertThat(successHandler.getPrevUri(request, response, null)) - .isEqualTo("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"); + assertThat(successHandler.getPrevUri(request, response, null)).isEqualTo(PREV_URI + "?state=someState"); } @ParameterizedTest @@ -95,11 +112,36 @@ public class Oauth2AuthenticationSuccessHandlerTest { @Test public void testCallbackUrlSchemeIgnoresPrevUri() { - givenPrevUriCookie("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e"); + givenPrevUriCookie(PREV_URI); assertThat(successHandler.getBaseUrl(request, "tbmobile")).isEqualTo("tbmobile:"); assertThat(successHandler.getPrevUri(request, response, "tbmobile")).isEmpty(); } + @Test + public void testSuccessRedirectCarriesTokensToPrevUri() throws Exception { + givenPrevUriCookie(PREV_URI); + givenSuccessfulLogin(); + + successHandler.onAuthenticationSuccess(request, response, givenAuthentication()); + + assertThat(captureRedirect()).isEqualTo(BASE_URL + PREV_URI + + "/?accessToken=testAccessToken&refreshToken=testRefreshToken"); + } + + /** + * The error redirect appends its own path, so it must be built from the base URL alone - with prevUri in it the + * result would be an unroutable https://host/dashboards/x/login?loginError=... + */ + @Test + public void testErrorRedirectDropsPrevUri() throws Exception { + givenPrevUriCookie(PREV_URI); + when(oAuth2ClientService.findOAuth2ClientById(any(), any())).thenThrow(new RuntimeException("someError")); + + successHandler.onAuthenticationSuccess(request, response, givenAuthentication()); + + assertThat(captureRedirect()).isEqualTo(BASE_URL + "/login?loginError=someError"); + } + @ParameterizedTest @CsvSource({ "https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e, https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e/?", @@ -107,7 +149,7 @@ public class Oauth2AuthenticationSuccessHandlerTest { "https://thingsboard.example.com/, https://thingsboard.example.com/?" }) public void testGetRedirectUrl(String baseUrl, String expectedPrefix) { - assertThat(successHandler.getRedirectUrl(baseUrl, new JwtPair("testAccessToken", "testRefreshToken"))) + assertThat(successHandler.getRedirectUrl(baseUrl, TOKEN_PAIR)) .isEqualTo(expectedPrefix + "accessToken=testAccessToken&refreshToken=testRefreshToken"); } @@ -115,4 +157,36 @@ public class Oauth2AuthenticationSuccessHandlerTest { when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)}); } + private OAuth2AuthenticationToken givenAuthentication() { + OAuth2User principal = mock(OAuth2User.class); + when(principal.getName()).thenReturn("testUser"); + OAuth2AuthenticationToken token = mock(OAuth2AuthenticationToken.class); + when(token.getAuthorizedClientRegistrationId()).thenReturn(UUID.randomUUID().toString()); + when(token.getPrincipal()).thenReturn(principal); + return token; + } + + private void givenSuccessfulLogin() { + OAuth2Client oauth2Client = new OAuth2Client(); + oauth2Client.setMapperConfig(OAuth2MapperConfig.builder().type(MapperType.BASIC).build()); + when(oAuth2ClientService.findOAuth2ClientById(any(), any())).thenReturn(oauth2Client); + + OAuth2AuthorizedClient authorizedClient = mock(OAuth2AuthorizedClient.class); + when(authorizedClient.getAccessToken()).thenReturn(new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "testProviderAccessToken", Instant.now(), Instant.now().plusSeconds(60))); + when(oAuth2AuthorizedClientService.loadAuthorizedClient(anyString(), anyString())).thenReturn(authorizedClient); + + SecurityUser securityUser = mock(SecurityUser.class); + OAuth2ClientMapper mapper = mock(OAuth2ClientMapper.class); + when(mapper.getOrCreateUserByClientPrincipal(any(), any(), anyString(), any())).thenReturn(securityUser); + when(oauth2ClientMapperProvider.getOAuth2ClientMapperByType(any())).thenReturn(mapper); + when(tokenFactory.createTokenPair(securityUser)).thenReturn(TOKEN_PAIR); + } + + private String captureRedirect() throws Exception { + ArgumentCaptor redirectCaptor = ArgumentCaptor.forClass(String.class); + verify(response).sendRedirect(redirectCaptor.capture()); + return redirectCaptor.getValue(); + } + }