From f651f0a721c39fc6e1be59425aeff0fa206ccfa1 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 17:27:32 +0200 Subject: [PATCH 01/59] fixed cache cleanup on tenant/entity deletion for DefaultCalculatedFieldCache, DefaultTbAssetProfileCache, DefaultTbDeviceProfileCache --- .../cf/DefaultCalculatedFieldCache.java | 47 +++++++++++++++++++ .../profile/DefaultTbAssetProfileCache.java | 30 ++++++++++++ .../profile/DefaultTbDeviceProfileCache.java | 30 ++++++++++++ 3 files changed, 107 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 1eba2c4549..57ce166014 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -19,11 +19,14 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -35,6 +38,7 @@ import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -183,6 +187,49 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (event.getEvent() != ComponentLifecycleEvent.DELETED) { + return; + } + switch (event.getEntityId().getEntityType()) { + case TENANT: + TenantId tenantId = event.getTenantId(); + var removedCfIds = new HashSet(); + calculatedFields.forEach((cfId, cf) -> { + if (cf.getTenantId().equals(tenantId)) { + calculatedFields.remove(cfId); + calculatedFieldLinks.remove(cfId); + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } + }); + entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + break; + case DEVICE: + case ASSET: + case DEVICE_PROFILE: + case ASSET_PROFILE: + EntityId entityId = event.getEntityId(); + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + } + entityIdCalculatedFieldLinks.remove(entityId); + break; + } + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index 28fa68d803..a2795e6929 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,9 +24,12 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + assetProfilesMap.forEach((assetProfileId, assetProfile) -> { + if (assetProfile.getTenantId().equals(tenantId)) { + assetProfilesMap.remove(assetProfileId); + removedProfileIds.add(assetProfileId); + log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + } + }); + assetsMap.forEach((assetId, assetProfileId) -> { + if (removedProfileIds.contains(assetProfileId)) { + assetsMap.remove(assetId); + } + }); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 6b356adf94..93072a72c7 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,9 +24,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { + if (deviceProfile.getTenantId().equals(tenantId)) { + deviceProfilesMap.remove(deviceProfileId); + removedProfileIds.add(deviceProfileId); + log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + } + }); + devicesMap.forEach((deviceId, deviceProfileId) -> { + if (removedProfileIds.contains(deviceProfileId)) { + devicesMap.remove(deviceId); + } + }); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { From 262565a411dcfe305768798d00fafb17090b0acf Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 19:56:07 +0200 Subject: [PATCH 02/59] refactoring --- .../cf/DefaultCalculatedFieldCache.java | 35 ++++++++++++++++--- .../profile/DefaultTbAssetProfileCache.java | 21 +++++------ .../profile/DefaultTbDeviceProfileCache.java | 21 +++++------ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 57ce166014..05c60e8f9a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -40,6 +40,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -196,17 +197,42 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { case TENANT: TenantId tenantId = event.getTenantId(); var removedCfIds = new HashSet(); - calculatedFields.forEach((cfId, cf) -> { + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + for (Map.Entry entry : calculatedFields.entrySet()) { + CalculatedFieldId cfId = entry.getKey(); + CalculatedField cf = entry.getValue(); if (cf.getTenantId().equals(tenantId)) { calculatedFields.remove(cfId); - calculatedFieldLinks.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); + } calculatedFieldsCtx.remove(cfId); removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } }); - entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; case DEVICE: case ASSET: @@ -224,6 +250,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); }); entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + cfIds.forEach(calculatedFieldFetchLocks::remove); } entityIdCalculatedFieldLinks.remove(entityId); break; diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index a2795e6929..a0bae27b68 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -30,6 +30,7 @@ import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; import java.util.HashSet; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - assetProfilesMap.forEach((assetProfileId, assetProfile) -> { - if (assetProfile.getTenantId().equals(tenantId)) { - assetProfilesMap.remove(assetProfileId); - removedProfileIds.add(assetProfileId); - log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + for (Map.Entry entry : assetProfilesMap.entrySet()) { + if (entry.getValue().getTenantId().equals(tenantId)) { + assetProfilesMap.remove(entry.getKey()); + removedProfileIds.add(entry.getKey()); + log.debug("[{}] evict asset profile from cache: {}", entry.getKey(), entry.getValue()); } - }); - assetsMap.forEach((assetId, assetProfileId) -> { - if (removedProfileIds.contains(assetProfileId)) { - assetsMap.remove(assetId); + } + for (Map.Entry entry : assetsMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + assetsMap.remove(entry.getKey()); } - }); + } profileListeners.remove(tenantId); assetProfileListeners.remove(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 93072a72c7..34b5f365f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -30,6 +30,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; import java.util.HashSet; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { - if (deviceProfile.getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(deviceProfileId); - removedProfileIds.add(deviceProfileId); - log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + for (Map.Entry entry : deviceProfilesMap.entrySet()) { + if (entry.getValue().getTenantId().equals(tenantId)) { + deviceProfilesMap.remove(entry.getKey()); + removedProfileIds.add(entry.getKey()); + log.debug("[{}] evict device profile from cache: {}", entry.getKey(), entry.getValue()); } - }); - devicesMap.forEach((deviceId, deviceProfileId) -> { - if (removedProfileIds.contains(deviceProfileId)) { - devicesMap.remove(deviceId); + } + for (Map.Entry entry : devicesMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + devicesMap.remove(entry.getKey()); } - }); + } profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } From f0af6882821d149458f2df5b36588c188aa9dafb Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 20 Mar 2026 16:57:41 +0200 Subject: [PATCH 03/59] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 127 +++++---- ...faultTbCalculatedFieldConsumerService.java | 3 +- .../queue/DefaultTbCoreConsumerService.java | 3 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 5 +- .../processing/AbstractConsumerService.java | 13 +- ...AbstractPartitionBasedConsumerService.java | 3 +- .../cf/DefaultCalculatedFieldCacheTest.java | 260 ++++++++++++++++++ .../DefaultTbAssetProfileCacheTest.java | 160 +++++++++++ .../DefaultTbDeviceProfileCacheTest.java | 160 +++++++++++ 10 files changed, 656 insertions(+), 80 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 05c60e8f9a..5922551a29 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -190,71 +190,82 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @EventListener(ComponentLifecycleMsg.class) public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { - if (event.getEvent() != ComponentLifecycleEvent.DELETED) { - return; - } switch (event.getEntityId().getEntityType()) { case TENANT: - TenantId tenantId = event.getTenantId(); - var removedCfIds = new HashSet(); - var removedCfEntityIds = new HashSet(); - var removedLinkEntityIds = new HashSet(); - for (Map.Entry entry : calculatedFields.entrySet()) { - CalculatedFieldId cfId = entry.getKey(); - CalculatedField cf = entry.getValue(); - if (cf.getTenantId().equals(tenantId)) { - calculatedFields.remove(cfId); - List links = calculatedFieldLinks.remove(cfId); - if (links != null) { - links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); - } + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictTenantCfs(event.getTenantId()); } - removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); - } - } - }); - removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); - } - } - }); - removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; - case DEVICE: - case ASSET: - case DEVICE_PROFILE: - case ASSET_PROFILE: - EntityId entityId = event.getEntityId(); - List cfs = entityIdCalculatedFields.remove(entityId); - if (cfs != null) { - var cfIds = new HashSet(); - cfs.forEach(cf -> { - calculatedFields.remove(cf.getId()); - calculatedFieldLinks.remove(cf.getId()); - calculatedFieldsCtx.remove(cf.getId()); - cfIds.add(cf.getId()); - log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); - }); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); - cfIds.forEach(calculatedFieldFetchLocks::remove); + case DEVICE, ASSET, DEVICE_PROFILE, ASSET_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictEntityCfs(event.getEntityId()); } - entityIdCalculatedFieldLinks.remove(entityId); break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evict((CalculatedFieldId) event.getEntityId()); + } + break; + } + } + + private void evictTenantCfs(TenantId tenantId) { + var removedCfIds = new HashSet(); + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + for (Map.Entry entry : calculatedFields.entrySet()) { + CalculatedFieldId cfId = entry.getKey(); + CalculatedField cf = entry.getValue(); + if (cf.getTenantId().equals(tenantId)) { + calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); + } + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } + }); + } + + private void evictEntityCfs(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); } + entityIdCalculatedFieldLinks.remove(entityId); } private Lock getFetchLock(CalculatedFieldId id) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 60e60b5444..ba4ab74f37 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -90,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6399e55e03..de2e65723e 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -179,9 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} From c033f4b5b3a248a27e7d871dfc13df04f1e83eb2 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:08:28 +0200 Subject: [PATCH 04/59] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 56 +++++---- .../profile/DefaultTbAssetProfileCache.java | 23 ++-- .../profile/DefaultTbDeviceProfileCache.java | 23 ++-- .../cf/DefaultCalculatedFieldCacheTest.java | 108 ++++++++++++++++++ 4 files changed, 151 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 5922551a29..66282135cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -25,13 +25,13 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; -import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.queue.util.AfterStartUp; @@ -46,6 +46,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; @Service @Slf4j @@ -214,41 +215,38 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } private void evictTenantCfs(TenantId tenantId) { - var removedCfIds = new HashSet(); var removedCfEntityIds = new HashSet(); var removedLinkEntityIds = new HashSet(); - for (Map.Entry entry : calculatedFields.entrySet()) { - CalculatedFieldId cfId = entry.getKey(); - CalculatedField cf = entry.getValue(); - if (cf.getTenantId().equals(tenantId)) { - calculatedFields.remove(cfId); - List links = calculatedFieldLinks.remove(cfId); - if (links != null) { - links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + var toRemove = calculatedFields.entrySet().stream() + .filter(e -> e.getValue().getTenantId().equals(tenantId)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + toRemove.forEach(cfId -> { + CalculatedField cf = calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } - } + calculatedFieldsCtx.remove(cfId); + removedCfEntityIds.add(cf.getEntityId()); + }); removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); + entityIdCalculatedFields.compute(entityId, (k, cfs) -> { + if (cfs != null) { + cfs.removeIf(cf -> toRemove.contains(cf.getId())); + return cfs.isEmpty() ? null : cfs; } - } + return null; + }); }); removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); + entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> { + if (links != null) { + links.removeIf(link -> toRemove.contains(link.getCalculatedFieldId())); + return links.isEmpty() ? null : links; } - } + return null; + })); }); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index a0bae27b68..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : assetProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - assetProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict asset profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : assetsMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - assetsMap.remove(entry.getKey()); - } - } + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); assetProfileListeners.remove(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 34b5f365f5..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : deviceProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict device profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : devicesMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - devicesMap.remove(entry.getKey()); - } - } + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index df0acef244..cde66dce65 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -26,9 +26,11 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -151,6 +153,112 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty(); } + // --- DeviceProfile/AssetProfile deletion tests --- + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId linkedAsset = new AssetId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedAsset); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + // --- CalculatedField lifecycle tests --- @Test From 7cce88f330e06c14752ba4e979fdfaf5be27428d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:17:49 +0200 Subject: [PATCH 05/59] fixed potential NPE, code cleanup --- .../cf/DefaultCalculatedFieldCache.java | 4 +++- .../cf/DefaultCalculatedFieldCacheTest.java | 19 ------------------- .../DefaultTbAssetProfileCacheTest.java | 1 - 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 66282135cd..fc54b4b0db 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -228,7 +228,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } calculatedFieldsCtx.remove(cfId); - removedCfEntityIds.add(cf.getEntityId()); + if (cf != null) { + removedCfEntityIds.add(cf.getEntityId()); + } }); removedCfEntityIds.forEach(entityId -> { entityIdCalculatedFields.compute(entityId, (k, cfs) -> { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index cde66dce65..ee83f9df64 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -302,25 +302,6 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf); } - // --- Helpers --- - - private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) { - Device device = new Device(); - device.setId(deviceId); - device.setTenantId(tenantId); - if (ownerId instanceof CustomerId customerId) { - device.setCustomerId(customerId); - } - // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId - when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); - // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly) - when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData()); - when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData()); - if (ownerId instanceof TenantId) { - when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); - } - } - private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) { CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig()); diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java index 6d1a66e27b..f9b8d428d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -70,7 +70,6 @@ public class DefaultTbAssetProfileCacheTest { // After deletion tenant1 profile should be reloaded from service on next get when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); assertThat(cache.get(tenant1, profileId1)).isNull(); - // tenant2 profile should still be served from cache (no extra service call) verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); } From 8be7a23b15b6028a5e7071c0de1d242f02101839 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:17:42 +0200 Subject: [PATCH 06/59] Added automatic SSL/TLS certificate reload for transports without service restart --- .../src/main/resources/thingsboard.yml | 9 + .../coapserver/DefaultCoapServerService.java | 131 +++++- .../server/coapserver/TbCoapDtlsSettings.java | 6 +- .../CoapDtlsCertificateReloadTest.java | 235 +++++++++++ .../coapserver/TbCoapDtlsSettingsTest.java | 8 +- .../server/common/data/ResourceUtils.java | 19 +- .../server/common/data/ResourceUtilsTest.java | 39 ++ .../transport/http/DeviceApiController.java | 4 - .../transport/http/HttpTransportContext.java | 4 +- .../LwM2MTransportBootstrapService.java | 43 +- .../config/LwM2MTransportBootstrapConfig.java | 29 ++ .../config/LwM2MTransportServerConfig.java | 32 ++ .../server/DefaultLwM2mTransportService.java | 65 ++- .../LwM2mBootstrapCertificateReloadTest.java | 192 +++++++++ .../LwM2mServerCertificateReloadTest.java | 188 +++++++++ .../mqtt/MqttSslHandlerProvider.java | 35 +- .../transport/mqtt/MqttTransportContext.java | 3 - .../mqtt/MqttSslHandlerProviderTest.java | 197 +++++++++ .../common/transport/DeviceDeletedEvent.java | 5 + .../common/transport/SessionMsgListener.java | 3 - .../common/transport/TransportContext.java | 5 - .../common/transport/TransportService.java | 4 +- .../transport/TransportServiceCallback.java | 3 - .../config/ssl/AbstractSslCredentials.java | 93 +++-- .../config/ssl/KeystoreSslCredentials.java | 16 + .../config/ssl/PemSslCredentials.java | 49 ++- .../transport/config/ssl/SslCredentials.java | 7 + .../config/ssl/SslCredentialsConfig.java | 27 ++ .../SslCredentialsWebServerCustomizer.java | 119 ++++-- .../service/CertificateReloadManager.java | 272 +++++++++++++ .../service/DefaultTransportService.java | 16 +- .../transport/service/SessionMetaData.java | 8 +- .../service/ToRuleEngineMsgEncoder.java | 3 - .../ToTransportMsgResponseDecoder.java | 3 - .../service/TransportApiRequestEncoder.java | 3 - .../service/TransportApiResponseDecoder.java | 3 - .../session/DeviceAwareSessionContext.java | 3 - .../transport/session/SessionContext.java | 1 + .../common/transport/util/JsonUtils.java | 16 +- .../server/common/transport/util/SslUtil.java | 7 +- .../config/ssl/SslCredentialsConfigTest.java | 182 +++++++++ ...SslCredentialsWebServerCustomizerTest.java | 277 +++++++++++++ .../service/CertificateReloadManagerTest.java | 383 ++++++++++++++++++ .../src/main/resources/tb-coap-transport.yml | 9 + .../src/main/resources/tb-http-transport.yml | 9 + .../src/main/resources/tb-lwm2m-transport.yml | 9 + .../src/main/resources/tb-mqtt-transport.yml | 9 + 47 files changed, 2586 insertions(+), 197 deletions(-) create mode 100644 common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 81b56aff28..419e6d3dfd 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1394,6 +1394,15 @@ transport: branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # CoAP server parameters coap: diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 271866d23c..7882125906 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardExecutors; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; @@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S @Slf4j @Component @TbCoapServerComponent -public class DefaultCoapServerService implements CoapServerService { +public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton { @Autowired private CoapServerContext coapServerContext; private CoapServer server; - private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; + private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; private ScheduledExecutorService dtlsSessionsExecutor; + private volatile DTLSConnector dtlsConnector; + + private volatile CoapEndpoint dtlsCoapEndpoint; + @PostConstruct public void init() throws UnknownHostException { createCoapServer(); } + @Override + public void afterSingletonsInstantiated() { + if (isDtlsEnabled()) { + coapServerContext.getDtlsSettings().registerReloadCallback(() -> { + try { + log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint..."); + recreateDtlsEndpoint(); + log.info("CoAP DTLS endpoint recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e); + } + }); + } + } + @PreDestroy public void shutdown() { if (dtlsSessionsExecutor != null) { @@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService { } private CoapServer createCoapServer() throws UnknownHostException { - Configuration networkConfig = new Configuration(); - networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); - networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); - networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); - networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); - networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); - networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); - networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + Configuration networkConfig = createNetworkConfiguration(); server = new CoapServer(networkConfig); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); @@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService { CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); server.addEndpoint(noSecCoapEndpoint); if (isDtlsEnabled()) { - CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); - TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); - DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); - networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); - dtlsCoapEndpointBuilder.setConfiguration(networkConfig); - DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig); - dtlsCoapEndpointBuilder.setConnector(connector); - CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build(); - server.addEndpoint(dtlsCoapEndpoint); - tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + createDtlsEndpoint(networkConfig); dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); } @@ -137,4 +140,88 @@ public class DefaultCoapServerService implements CoapServerService { return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout(); } + private Configuration createNetworkConfiguration() { + Configuration networkConfig = new Configuration(); + networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); + networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); + networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); + networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); + networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); + networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + return networkConfig; + } + + DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); + DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); + networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); + return dtlsConnectorConfig; + } + + CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); + dtlsCoapEndpointBuilder.setConfiguration(networkConfig); + dtlsCoapEndpointBuilder.setConnector(connector); + return dtlsCoapEndpointBuilder.build(); + } + + private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException { + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + server.addEndpoint(newEndpoint); + + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + } + + DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + return new DTLSConnector(config); + } + + private synchronized void recreateDtlsEndpoint() throws IOException { + CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint; + DTLSConnector oldDtlsConnector = dtlsConnector; + + Configuration networkConfig = createNetworkConfiguration(); + + log.info("Creating new DTLS endpoint with updated certificates..."); + + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + + server.addEndpoint(newEndpoint); + try { + newEndpoint.start(); + } catch (IOException e) { + log.error("Failed to start new DTLS endpoint, cleaning up", e); + server.getEndpoints().remove(newEndpoint); + newEndpoint.destroy(); + newConnector.destroy(); + throw e; + } + log.info("New DTLS endpoint started successfully."); + + // Only swap instance fields after a successful start + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint..."); + oldDtlsEndpoint.stop(); + if (oldDtlsConnector != null) { + oldDtlsConnector.destroy(); + } + oldDtlsEndpoint.destroy(); + server.getEndpoints().remove(oldDtlsEndpoint); + log.info("Old DTLS endpoint stopped and destroyed."); + } + } + } diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java index 672de6bacd..c6e90d1351 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java @@ -100,6 +100,10 @@ public class TbCoapDtlsSettings { @Autowired(required = false) private TbServiceInfoProvider serviceInfoProvider; + public void registerReloadCallback(Runnable callback) { + coapDtlsCredentialsConfig.registerReloadCallback(callback); + } + public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException { DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration); configBuilder.setAddress(getInetSocketAddress()); @@ -154,5 +158,5 @@ public class TbCoapDtlsSettings { } return null; } -} +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java new file mode 100644 index 0000000000..642f2e0be9 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -0,0 +1,235 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.network.Endpoint; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class CoapDtlsCertificateReloadTest { + + @Mock + private CoapServerContext mockCoapServerContext; + + @Mock + private TbCoapDtlsSettings mockDtlsSettings; + + @Mock + private CoapServer mockCoapServer; + + @Mock + private CoapEndpoint mockDtlsEndpoint; + + @Mock + private DTLSConnector mockDtlsConnector; + + private DefaultCoapServerService coapServerService; + + @BeforeEach + public void setup() { + coapServerService = new DefaultCoapServerService(); + ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + doAnswer(invocation -> { + invocation.getArgument(0); + return null; + }).when(mockDtlsSettings).registerReloadCallback(any()); + } + + @Test + public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings, never()).registerReloadCallback(any()); + } + + @Test + public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw. + // The old endpoint should not be stopped/destroyed when creation of the new one fails. + reloadCallback.run(); + + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsConnector, never()).destroy(); + } + + @Test + public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); + } + + @Test + public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + assertThat(reloadCallback).isNotNull(); + } + + @Test + public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + DTLSConnector mockNewConnector = mock(DTLSConnector.class); + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + + DefaultCoapServerService spyService = Mockito.spy(coapServerService); + ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); + ReflectionTestUtils.setField(spyService, "server", mockCoapServer); + ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); + + doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); + doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); + doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + // WHEN + ReflectionTestUtils.invokeMethod(spyService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } + + @Test + public void givenReloadCallback_whenStartFails_thenNewResourcesCleaned() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + DTLSConnector mockNewConnector = mock(DTLSConnector.class); + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); + + DefaultCoapServerService spyService = Mockito.spy(coapServerService); + ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); + ReflectionTestUtils.setField(spyService, "server", mockCoapServer); + ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); + + doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); + doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); + doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + + List endpointsList = new CopyOnWriteArrayList<>(); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + // WHEN - the callback catches the IOException internally + spyService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + verify(mockNewEndpoint).destroy(); + verify(mockNewConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); + // Old endpoint not touched + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsEndpoint, never()).destroy(); + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java index c75348d97e..9538670e25 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java @@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest { @Autowired TbCoapDtlsSettings coapDtlsSettings; - @MockBean + @MockitoBean SslCredentialsConfig sslCredentialsConfig; - @MockBean + @MockitoBean private TransportService transportService; - @MockBean + @MockitoBean private TbServiceInfoProvider serviceInfoProvider; @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 725aa7921c..626d0cd372 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -51,11 +51,9 @@ public class ResourceUtils { return true; } else { try { - URL url = Resources.getResource(path); - if (url != null) { - return true; - } - } catch (IllegalArgumentException e) {} + Resources.getResource(path); + return true; + } catch (IllegalArgumentException ignored) {} } return false; } @@ -93,9 +91,9 @@ public class ResourceUtils { } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } } throw new RuntimeException("Unable to find resource: " + filePath); @@ -113,15 +111,16 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); - return url.toURI().toString(); + return url != null ? url.toURI().toString() : null; } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } throw new RuntimeException("Unable to find resource: " + filePath); } } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java new file mode 100644 index 0000000000..8c91762068 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceUtilsTest { + + @Test + public void givenNonExistentResource_whenGetUri_thenReturnsNull() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"); + + assertThat(result).isNull(); + } + + @Test + public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); + + assertThat(result).isNotNull(); + assertThat(result).contains("ResourceUtilsTest"); + } + +} diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 9a7cfe43ff..73d77e2ddb 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -76,10 +76,6 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ @RestController @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 830e081dfd..db72ba747c 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @Component @@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext { } }; } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 412036677a..78f292d69c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -29,6 +29,7 @@ import org.eclipse.leshan.server.californium.bootstrap.LwM2mBootstrapPskStore; import org.eclipse.leshan.server.californium.bootstrap.endpoint.CaliforniumBootstrapServerEndpointsProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coap.CoapBootstrapServerProtocolProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coaps.CoapsBootstrapServerProtocolProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; @@ -55,7 +56,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @Component @TbLwM2mBootstrapTransportComponent @RequiredArgsConstructor -public class LwM2MTransportBootstrapService { +public class LwM2MTransportBootstrapService implements SmartInitializingSingleton { private final LwM2MTransportServerConfig serverConfig; private final LwM2MTransportBootstrapConfig bootstrapConfig; @@ -65,6 +66,19 @@ public class LwM2MTransportBootstrapService { private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; private LeshanBootstrapServer server; + @Override + public void afterSingletonsInstantiated() { + bootstrapConfig.registerServerReloadCallback(() -> { + try { + log.info("LwM2M Bootstrap certificates reloaded. Recreating bootstrap server..."); + recreateBootstrapServer(); + log.info("LwM2M Bootstrap server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M Bootstrap server after certificate reload", e); + } + }); + } + @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); @@ -110,7 +124,7 @@ public class LwM2MTransportBootstrapService { // Create Californium Configuration Configuration serverCoapConfig = endpointsBuilder.createDefaultConfiguration(); - getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(),serverConfig); + getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(), serverConfig); serverCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY); serverCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, serverConfig.isRecommendedCiphers()); serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH); @@ -119,7 +133,7 @@ public class LwM2MTransportBootstrapService { serverCoapConfig.set(DTLS_RETRANSMISSION_TIMEOUT, serverConfig.getDtlsRetransmissionTimeout(), MILLISECONDS); if (serverConfig.getDtlsCidLength() != null) { - setDtlsConnectorConfigCidLength( serverCoapConfig, serverConfig.getDtlsCidLength()); + setDtlsConnectorConfigCidLength(serverCoapConfig, serverConfig.getDtlsCidLength()); } /* Create DTLS Config */ @@ -164,4 +178,27 @@ public class LwM2MTransportBootstrapService { builder.setTrustedCertificates(new X509Certificate[0]); } } + + private synchronized void recreateBootstrapServer() { + LeshanBootstrapServer oldServer = this.server; + + log.info("Creating new LwM2M Bootstrap server with updated certificates..."); + LeshanBootstrapServer newServer = getLhBootstrapServer(); + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M Bootstrap server, rolling back", e); + newServer.destroy(); + throw e; + } + this.server = newServer; + log.info("New LwM2M Bootstrap server started successfully."); + + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server..."); + oldServer.destroy(); + log.info("Old LwM2M Bootstrap server stopped."); + } + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java index b15a757ae3..d3bf9e33fe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,9 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Component @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") @@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mBootstrapCredentials") private SslCredentialsConfig credentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Bootstrap DTLS certificates reloaded. Triggering bootstrap server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M bootstrap server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index d2b24fe9a8..6034219c4d 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -31,6 +32,7 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; @Slf4j @Component @@ -134,6 +136,35 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mTrustCredentials") private SslCredentialsConfig trustCredentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Server DTLS certificates reloaded. Triggering server reload..."); + notifyServerReload(); + }); + + trustCredentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Trust certificates reloaded. Triggering server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); @@ -142,4 +173,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { public SslCredentials getTrustSslCredentials() { return this.trustCredentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index c4fb504864..21fdc07277 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -33,6 +33,7 @@ import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpoints import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider; import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider; import org.eclipse.leshan.server.registration.RegistrationStore; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -68,7 +69,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @DependsOn({"lwM2mDownlinkMsgHandler", "lwM2mUplinkMsgHandler"}) @TbLwM2mTransportComponent @RequiredArgsConstructor -public class DefaultLwM2mTransportService implements LwM2MTransportService { +public class DefaultLwM2mTransportService implements LwM2MTransportService, SmartInitializingSingleton { public static final CipherSuite[] RPK_OR_X509_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256}; public static final CipherSuite[] PSK_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256}; @@ -84,6 +85,20 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private final LwM2mVersionedModelProvider modelProvider; private LeshanServer server; + private LwM2mServerListener serverListener; + + @Override + public void afterSingletonsInstantiated() { + config.registerServerReloadCallback(() -> { + try { + log.info("LwM2M certificates reloaded. Recreating LwM2M server..."); + recreateLwM2mServer(); + log.info("LwM2M server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M server after certificate reload", e); + } + }); + } @AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE) public void init() { @@ -95,11 +110,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private void startLhServer() { log.info("Starting LwM2M transport server..."); this.server.start(); - LwM2mServerListener lhServerCertListener = new LwM2mServerListener(handler); - this.server.getRegistrationService().addListener(lhServerCertListener.registrationListener); - this.server.getPresenceService().addListener(lhServerCertListener.presenceListener); - this.server.getObservationService().addListener(lhServerCertListener.observationListener); - this.server.getSendService().addListener(lhServerCertListener.sendListener); + serverListener = new LwM2mServerListener(handler); + this.server.getRegistrationService().addListener(serverListener.registrationListener); + this.server.getPresenceService().addListener(serverListener.presenceListener); + this.server.getObservationService().addListener(serverListener.observationListener); + this.server.getSendService().addListener(serverListener.sendListener); log.info("Started LwM2M transport server."); } @@ -214,6 +229,44 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { } } + private synchronized void recreateLwM2mServer() { + LeshanServer oldServer = this.server; + LwM2mServerListener oldListener = this.serverListener; + + log.info("Creating new LwM2M server with updated certificates..."); + LeshanServer newServer = getLhServer(); + newServer.start(); + + try { + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + } catch (Exception e) { + log.error("Failed to register listeners on new LwM2M server, rolling back", e); + newServer.destroy(); + throw e; + } + log.info("New LwM2M server started successfully."); + + if (oldServer != null) { + log.info("Stopping old LwM2M server..."); + if (oldListener != null) { + oldServer.getRegistrationService().removeListener(oldListener.registrationListener); + oldServer.getPresenceService().removeListener(oldListener.presenceListener); + oldServer.getObservationService().removeListener(oldListener.observationListener); + oldServer.getSendService().removeListener(oldListener.sendListener); + } + oldServer.destroy(); + log.info("Old LwM2M server stopped."); + } + } + @Override public String getName() { return DataConstants.LWM2M_TRANSPORT_NAME; diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java new file mode 100644 index 0000000000..51d37ca739 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -0,0 +1,192 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.bootstrap; + +import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mBootstrapCertificateReloadTest { + + @Mock + private LwM2MTransportServerConfig mockServerConfig; + + @Mock + private LwM2MTransportBootstrapConfig mockBootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore mockSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore mockConfigStore; + + @Mock + private TransportService mockTransportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; + + @Mock + private LeshanBootstrapServer mockBootstrapServer; + + @Mock + private SslCredentials mockSslCredentials; + + private LwM2MTransportBootstrapService bootstrapService; + + @BeforeEach + public void setup() { + bootstrapService = new LwM2MTransportBootstrapService( + mockServerConfig, + mockBootstrapConfig, + mockSecurityStore, + mockConfigStore, + mockTransportService, + mockCertificateVerifier + ); + + when(mockBootstrapConfig.getHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getPort()).thenReturn(5687); + when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); + when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); + when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + } + + @Test + public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + bootstrapService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + // Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockBootstrapConfig.getHost()).thenReturn(null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // getLhBootstrapServer() will fail due to null host. + // With create-then-swap, the old server should NOT be destroyed. + reloadCallback.run(); + + verify(mockBootstrapServer, never()).destroy(); + assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); + } + + @Test + public void givenNullServer_whenRecreate_thenShouldNotThrow() { + ReflectionTestUtils.setField(bootstrapService, "server", null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // Should not throw — callback catches exceptions internally + reloadCallback.run(); + } + + @Test + public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { + SslCredentials oldCredentials = mockSslCredentials; + SslCredentials newCredentials = mock(SslCredentials.class); + + when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); + + SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); + assertThat(firstCall).isEqualTo(oldCredentials); + + SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); + assertThat(secondCall).isEqualTo(newCredentials); + + verify(mockBootstrapConfig, times(2)).getSslCredentials(); + } + + @Test + public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { + bootstrapService.afterSingletonsInstantiated(); + + verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndOldPreserved() { + // GIVEN + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); + doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + spyService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // WHEN + reloadCallback.run(); + + // THEN + verify(mockNewServer).destroy(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); + verify(mockBootstrapServer, never()).destroy(); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java new file mode 100644 index 0000000000..15e4d07622 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -0,0 +1,188 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server; + +import org.eclipse.leshan.server.LeshanServer; +import org.eclipse.leshan.server.observation.ObservationService; +import org.eclipse.leshan.server.registration.RegistrationService; +import org.eclipse.leshan.server.registration.RegistrationStore; +import org.eclipse.leshan.server.send.SendService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; +import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mServerCertificateReloadTest { + + @Mock + private LwM2mTransportContext mockContext; + + @Mock + private LwM2MTransportServerConfig mockConfig; + + @Mock + private OtaPackageDataCache mockOtaCache; + + @Mock + private LwM2mUplinkMsgHandler mockHandler; + + @Mock + private RegistrationStore mockRegistrationStore; + + @Mock + private TbSecurityStore mockSecurityStore; + + @Mock + private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; + + @Mock + private TbLwM2MAuthorizer mockAuthorizer; + + @Mock + private LwM2mVersionedModelProvider mockModelProvider; + + @Mock + private LeshanServer mockLeshanServer; + + @Mock + private RegistrationService mockRegistrationService; + + @Mock + private ObservationService mockObservationService; + + @Mock + private SendService mockSendService; + + @Mock + private SslCredentials mockSslCredentials; + + private DefaultLwM2mTransportService lwm2mTransportService; + + @BeforeEach + public void setup() { + lwm2mTransportService = new DefaultLwM2mTransportService( + mockContext, + mockConfig, + mockOtaCache, + mockHandler, + mockRegistrationStore, + mockSecurityStore, + mockCertificateVerifier, + mockAuthorizer, + mockModelProvider + ); + + when(mockConfig.getHost()).thenReturn("localhost"); + when(mockConfig.getPort()).thenReturn(5683); + when(mockConfig.getSecureHost()).thenReturn("localhost"); + when(mockConfig.getSecurePort()).thenReturn(5684); + when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); + + when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); + when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); + when(mockLeshanServer.getSendService()).thenReturn(mockSendService); + } + + @Test + public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockConfig.getHost()).thenReturn(null); + + // With create-then-swap, the old server should NOT be destroyed if the new one fails. + reloadCallback.run(); + + verify(mockLeshanServer, never()).destroy(); + // Old server should still be the active one + assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); + } + + @Test + public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); + ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + + // Invoke the callback — new server creation will fail, old listeners should stay + callbackCaptor.getValue().run(); + + verify(mockRegistrationService, never()).removeListener(any()); + } + + @Test + public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { + lwm2mTransportService.afterSingletonsInstantiated(); + + verify(mockConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", null); + + // Should not throw - callback catches exceptions internally + callbackCaptor.getValue().run(); + } + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 1551ef9023..fcb58bc20c 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -48,7 +49,7 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component("MqttSslHandlerProvider") @TbMqttSslTransportComponent -public class MqttSslHandlerProvider { +public class MqttSslHandlerProvider implements SmartInitializingSingleton { @Value("${transport.mqtt.ssl.protocol}") private String sslProtocol; @@ -66,13 +67,29 @@ public class MqttSslHandlerProvider { @Qualifier("mqttSslCredentials") private SslCredentialsConfig mqttSslCredentialsConfig; - private SSLContext sslContext; + private volatile SSLContext sslContext; + + @Override + public void afterSingletonsInstantiated() { + mqttSslCredentialsConfig.registerReloadCallback(() -> { + log.info("MQTT SSL certificates reloaded. Invalidating SSL context..."); + sslContext = null; + log.info("MQTT SSL context invalidated. Will be recreated on next connection."); + }); + } public SslHandler getSslHandler() { - if (sslContext == null) { - sslContext = createSslContext(); + SSLContext ctx = sslContext; + if (ctx == null) { + synchronized (this) { + ctx = sslContext; + if (ctx == null) { + ctx = createSslContext(); + sslContext = ctx; + } + } } - SSLEngine sslEngine = sslContext.createSSLEngine(); + SSLEngine sslEngine = ctx.createSSLEngine(); sslEngine.setUseClientMode(false); sslEngine.setNeedClientAuth(false); sslEngine.setWantClientAuth(true); @@ -98,7 +115,7 @@ public class MqttSslHandlerProvider { sslContext.init(km, tm, null); return sslContext; } catch (Exception e) { - log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + log.error("Unable to set up SSL context. Reason: {}", e.getMessage(), e); throw new RuntimeException("Failed to get SSL context", e); } } @@ -106,8 +123,8 @@ public class MqttSslHandlerProvider { private TrustManager getX509TrustManager(TrustManagerFactory tmf) throws Exception { X509TrustManager x509Tm = null; for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - x509Tm = (X509TrustManager) tm; + if (tm instanceof X509TrustManager x509TrustManager) { + x509Tm = x509TrustManager; break; } } @@ -191,5 +208,7 @@ public class MqttSslHandlerProvider { return false; } } + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 3d05e999e0..166a67368c 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -32,9 +32,6 @@ import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @Component @TbMqttTransportComponent diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java new file mode 100644 index 0000000000..96183c2934 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -0,0 +1,197 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import io.netty.handler.ssl.SslHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MqttSslHandlerProviderTest { + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private TransportService mockTransportService; + + private MqttSslHandlerProvider sslHandlerProvider; + + @BeforeEach + public void setup() throws Exception { + sslHandlerProvider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); + ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); + ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); + + KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); + TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); + X509TrustManager mockTrustManager = mock(X509TrustManager.class); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); + when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); + when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); + when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); + } + + @Test + public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + + assertThat(handler1).isNotNull(); + assertThat(handler2).isNotNull(); + assertThat(handler1).isNotSameAs(handler2); + + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenCertificatesReloaded_whenGetSslHandler_thenShouldRecreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + reloadCallback.run(); + + assertThat(handler1).isNotNull(); + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNull(); + + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + SSLContext newContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + + assertThat(handler2).isNotNull(); + assertThat(newContext).isNotNull(); + assertThat(newContext).isNotSameAs(initialContext); + } + + @Test + public void givenConcurrentGetSslHandlerCalls_whenSSLContextNull_thenShouldCreateOnlyOnce() throws Exception { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + SslHandler handler = sslHandlerProvider.getSslHandler(); + assertThat(handler).isNotNull(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldInvalidateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNull(); + } + + @Test + public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext context1; + SSLContext context2; + SSLContext context3; + + sslHandlerProvider.getSslHandler(); + context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context1).isNotNull(); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context2).isNotNull(); + assertThat(context2).isNotSameAs(context1); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context3).isNotNull(); + assertThat(context3).isNotSameAs(context2); + assertThat(context3).isNotSameAs(context1); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java index df30e2879b..c595e3ca83 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java @@ -19,9 +19,13 @@ import lombok.Getter; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.queue.discovery.event.TbApplicationEvent; +import java.io.Serial; + public final class DeviceDeletedEvent extends TbApplicationEvent { + @Serial private static final long serialVersionUID = -7453664970966733857L; + @Getter private final DeviceId deviceId; @@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent { super(new Object()); this.deviceId = deviceId; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 1e03156ec8..12857e3208 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMs import java.util.Optional; import java.util.UUID; -/** - * Created by ashvayka on 04.10.18. - */ public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index 9317652719..afd7bd2fed 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent; import java.util.concurrent.ExecutorService; -/** - * Created by ashvayka on 15.10.18. - */ @Slf4j @Data public abstract class TransportContext { @@ -77,6 +74,4 @@ public abstract class TransportContext { return serviceInfoProvider.getServiceId(); } - - } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 5c7d552855..939f2278ce 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -66,9 +66,6 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportService { GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg); @@ -162,4 +159,5 @@ public interface TransportService { boolean hasSession(SessionInfoProto sessionInfo); void createGaugeStats(String openConnections, AtomicInteger connectionsCounter); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index 41cb57da04..82849b5bdd 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.transport; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportServiceCallback { TransportServiceCallback EMPTY = new TransportServiceCallback() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index f15fc42364..9ffa4f7aac 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -37,41 +37,56 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; public abstract class AbstractSslCredentials implements SslCredentials { - private char[] keyPasswordArray; + private record SslState( + char[] keyPasswordArray, + KeyStore keyStore, + PrivateKey privateKey, + PublicKey publicKey, + X509Certificate[] chain, + X509Certificate[] trusts + ) {} - private KeyStore keyStore; - - private PrivateKey privateKey; - - private PublicKey publicKey; - - private X509Certificate[] chain; - - private X509Certificate[] trusts; + private final AtomicReference state = new AtomicReference<>(); @Override public void init(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + @Override + public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { String keyPassword = getKeyPassword(); + char[] keyPasswordArray; if (StringUtils.isEmpty(keyPassword)) { - this.keyPasswordArray = new char[0]; + keyPasswordArray = new char[0]; } else { - this.keyPasswordArray = keyPassword.toCharArray(); + keyPasswordArray = keyPassword.toCharArray(); } - this.keyStore = this.loadKeyStore(trustsOnly, this.keyPasswordArray); - Set trustedCerts = getTrustedCerts(this.keyStore, trustsOnly); - this.trusts = trustedCerts.toArray(new X509Certificate[0]); + KeyStore keyStore = this.loadKeyStore(trustsOnly, keyPasswordArray); + Set trustedCerts = getTrustedCerts(keyStore, trustsOnly); + X509Certificate[] trusts = trustedCerts.toArray(new X509Certificate[0]); + PrivateKey privateKey = null; + PublicKey publicKey = null; + X509Certificate[] chain = null; if (!trustsOnly) { PrivateKeyEntry privateKeyEntry = null; String keyAlias = this.getKeyAlias(); if (!StringUtils.isEmpty(keyAlias)) { - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, keyAlias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, keyAlias, keyPasswordArray); } else { - for (Enumeration e = this.keyStore.aliases(); e.hasMoreElements(); ) { + for (Enumeration e = keyStore.aliases(); e.hasMoreElements(); ) { String alias = e.nextElement(); - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, alias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, alias, keyPasswordArray); if (privateKeyEntry != null) { this.updateKeyAlias(alias); break; @@ -82,50 +97,61 @@ public abstract class AbstractSslCredentials implements SslCredentials { throw new IllegalArgumentException("Failed to get private key from the keystore or pem files. " + "Please check if the private key exists in the keystore or pem files and if the provided private key password is valid."); } - this.chain = asX509Certificates(privateKeyEntry.getCertificateChain()); - this.privateKey = privateKeyEntry.getPrivateKey(); - if (this.chain.length > 0) { - this.publicKey = this.chain[0].getPublicKey(); + chain = asX509Certificates(privateKeyEntry.getCertificateChain()); + privateKey = privateKeyEntry.getPrivateKey(); + if (chain.length > 0) { + publicKey = chain[0].getPublicKey(); } } + return new SslState(keyPasswordArray, keyStore, privateKey, publicKey, chain, trusts); + } + + private SslState getState() { + SslState s = state.get(); + if (s == null) { + throw new IllegalStateException("SSL credentials not initialized. Call init() first."); + } + return s; } @Override public KeyStore getKeyStore() { - return this.keyStore; + return getState().keyStore; } @Override public PrivateKey getPrivateKey() { - return this.privateKey; + return getState().privateKey; } @Override public PublicKey getPublicKey() { - return this.publicKey; + return getState().publicKey; } @Override public X509Certificate[] getCertificateChain() { - return this.chain; + return getState().chain; } @Override public X509Certificate[] getTrustedCertificates() { - return this.trusts; + return getState().trusts; } @Override public TrustManagerFactory createTrustManagerFactory() throws NoSuchAlgorithmException, KeyStoreException { + SslState s = getState(); TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmFactory.init(this.keyStore); + tmFactory.init(s.keyStore); return tmFactory; } @Override public KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException { + SslState s = getState(); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(this.keyStore, this.keyPasswordArray); + kmf.init(s.keyStore, s.keyPasswordArray); return kmf; } @@ -133,7 +159,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { public String getValueFromSubjectNameByKey(String subjectName, String key) { String[] dns = subjectName.split(","); Optional cn = (Arrays.stream(dns).filter(dn -> dn.contains(key + "="))).findFirst(); - String value = cn.isPresent() ? cn.get().replace(key + "=", "") : null; + String value = cn.map(s -> s.replace(key + "=", "")).orElse(null); return StringUtils.isNotEmpty(value) ? value : null; } @@ -189,7 +215,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (cert instanceof X509Certificate) { if (trustsOnly) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } else { @@ -203,12 +229,12 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (trustsOnly) { for (Certificate cert : certs) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } } else { - set.add((X509Certificate)certs[0]); + set.add((X509Certificate) certs[0]); } } } @@ -216,4 +242,5 @@ public abstract class AbstractSslCredentials implements SslCredentials { } catch (KeyStoreException ignored) {} return Collections.unmodifiableSet(set); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 33851f50b1..7a2fb1a545 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -20,10 +20,14 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.Collections; +import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @@ -54,4 +58,16 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { protected void updateKeyAlias(String keyAlias) { this.keyAlias = keyAlias; } + + @Override + public List getCertificateFilePaths() { + if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File storeFileObj = new File(storeFile); + if (storeFileObj.exists()) { + return Collections.singletonList(storeFileObj.toPath().toAbsolutePath()); + } + } + return Collections.emptyList(); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index cb2c9ba97b..72ad7af9c5 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -30,9 +30,11 @@ import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; @@ -76,13 +78,13 @@ public class PemSslCredentials extends AbstractSslCredentials { if (object instanceof X509CertificateHolder) { X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object); certificates.add(x509Cert); - } else if (object instanceof PEMEncryptedKeyPair) { + } else if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -93,15 +95,15 @@ public class PemSslCredentials extends AbstractSslCredentials { try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) { Object object; while ((object = pemParser.readObject()) != null) { - if (object instanceof PEMEncryptedKeyPair) { + if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); break; - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); break; - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -138,6 +140,27 @@ public class PemSslCredentials extends AbstractSslCredentials { } @Override - protected void updateKeyAlias(String keyAlias) { + protected void updateKeyAlias(String keyAlias) {} + + @Override + public List getCertificateFilePaths() { + List paths = new ArrayList<>(); + + if (!StringUtils.isEmpty(certFile) && !certFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File certFileObj = new File(certFile); + if (certFileObj.exists()) { + paths.add(certFileObj.toPath().toAbsolutePath()); + } + } + + if (!StringUtils.isEmpty(keyFile) && !keyFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File keyFileObj = new File(keyFile); + if (keyFileObj.exists()) { + paths.add(keyFileObj.toPath().toAbsolutePath()); + } + } + + return paths; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java index 89d4540b77..89dccac18e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.transport.config.ssl; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import java.io.IOException; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -26,11 +27,14 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.X509Certificate; +import java.util.List; public interface SslCredentials { void init(boolean trustsOnly) throws IOException, GeneralSecurityException; + void reload(boolean trustsOnly) throws IOException, GeneralSecurityException; + KeyStore getKeyStore(); String getKeyPassword(); @@ -50,4 +54,7 @@ public interface SslCredentials { KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException; String getValueFromSubjectNameByKey(String subjectName, String key); + + List getCertificateFilePaths(); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index 0df22a33cb..3646c4f37d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -19,6 +19,9 @@ import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Data public class SslCredentialsConfig { @@ -33,6 +36,8 @@ public class SslCredentialsConfig { private final String name; private final boolean trustsOnly; + private final List reloadCallbacks = new CopyOnWriteArrayList<>(); + public SslCredentialsConfig(String name, boolean trustsOnly) { this.name = name; this.trustsOnly = trustsOnly; @@ -62,4 +67,26 @@ public class SslCredentialsConfig { } } + public void onCertificateFileChanged() { + try { + log.info("{}: Certificate file changed. Reloading SSL credentials...", name); + this.credentials.reload(this.trustsOnly); + log.info("{}: SSL credentials reloaded successfully.", name); + + for (Runnable callback : reloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("{}: Error executing reload callback", name, e); + } + } + } catch (Exception e) { + log.error("{}: Failed to reload SSL credentials", name, e); + } + } + + public void registerReloadCallback(Runnable callback) { + this.reloadCallbacks.add(callback); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 34cc1151c4..147daefa7e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.transport.config.ssl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -30,71 +32,124 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.function.Consumer; +@Slf4j @Component @ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'") -public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer { +public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer, SmartInitializingSingleton { - @Bean - @ConfigurationProperties(prefix = "server.ssl.credentials") - public SslCredentialsConfig httpServerSslCredentials() { - return new SslCredentialsConfig("HTTP Server SSL Credentials", false); - } + private static final String DEFAULT_BUNDLE_NAME = "default"; + + private final ServerProperties serverProperties; + private final List> updateHandlers = new CopyOnWriteArrayList<>(); @Autowired @Qualifier("httpServerSslCredentials") private SslCredentialsConfig httpServerSslCredentialsConfig; @Autowired - SslBundles sslBundles; - - private final ServerProperties serverProperties; + private SslBundles sslBundles; public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) { this.serverProperties = serverProperties; } + @Bean + @ConfigurationProperties(prefix = "server.ssl.credentials") + public SslCredentialsConfig httpServerSslCredentials() { + return new SslCredentialsConfig("HTTP Server SSL Credentials", false); + } + + @Bean + public SslBundles sslBundles() { + return new DynamicSslBundles(); + } + @Override public void customize(ConfigurableServletWebServerFactory factory) { - SslCredentials sslCredentials = this.httpServerSslCredentialsConfig.getCredentials(); + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + Ssl ssl = serverProperties.getSsl(); - ssl.setBundle("default"); - ssl.setKeyAlias(sslCredentials.getKeyAlias()); - ssl.setKeyPassword(sslCredentials.getKeyPassword()); + ssl.setBundle(DEFAULT_BUNDLE_NAME); + ssl.setKeyAlias(credentials.getKeyAlias()); + ssl.setKeyPassword(credentials.getKeyPassword()); + factory.setSsl(ssl); factory.setSslBundles(sslBundles); } - @Bean - public SslBundles sslBundles() { + @Override + public void afterSingletonsInstantiated() { + httpServerSslCredentialsConfig.registerReloadCallback(this::reloadSslCertificates); + } + + private void reloadSslCertificates() { + try { + log.info("Reloading HTTP Server SSL certificates..."); + + SslBundle newBundle = createSslBundle(); + notifyUpdateHandlers(newBundle); + + log.info("HTTP Server SSL certificates reloaded successfully"); + } catch (Exception e) { + log.error("Failed to reload HTTP Server SSL certificates", e); + } + } + + private SslBundle createSslBundle() { + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + SslStoreBundle storeBundle = SslStoreBundle.of( - httpServerSslCredentialsConfig.getCredentials().getKeyStore(), - httpServerSslCredentialsConfig.getCredentials().getKeyPassword(), + credentials.getKeyStore(), + credentials.getKeyPassword(), null ); - return new SslBundles() { - @Override - public SslBundle getBundle(String name) { - return SslBundle.of(storeBundle); - } + return SslBundle.of(storeBundle); + } - @Override - public List getBundleNames() { - return List.of("default"); + private void notifyUpdateHandlers(SslBundle newBundle) { + for (Consumer handler : updateHandlers) { + try { + handler.accept(newBundle); + } catch (Exception e) { + log.error("Failed to notify SSL bundle update handler", e); } + } + } + + private class DynamicSslBundles implements SslBundles { - @Override - public void addBundleUpdateHandler(String name, Consumer handler) { - // no-op + @Override + public SslBundle getBundle(String name) { + if (!DEFAULT_BUNDLE_NAME.equals(name)) { + throw new IllegalArgumentException("Unknown SSL bundle: " + name); } + return createSslBundle(); + } - @Override - public void addBundleRegisterHandler(BiConsumer handler) { - // no-op + @Override + public List getBundleNames() { + return List.of(DEFAULT_BUNDLE_NAME); + } + + @Override + public void addBundleUpdateHandler(String name, Consumer handler) { + if (DEFAULT_BUNDLE_NAME.equals(name)) { + updateHandlers.add(handler); + log.debug("Registered SSL bundle update handler for bundle: {}", name); + } else { + log.warn("Attempted to register update handler for unknown bundle: {}", name); } - }; + } + + @Override + public void addBundleRegisterHandler(BiConsumer registerHandler) { + + } + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java new file mode 100644 index 0000000000..19fc545d0b --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -0,0 +1,272 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.queue.util.TbTransportComponent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@TbTransportComponent +public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { + + private static final int MAX_CONSECUTIVE_FAILURES = 10; + + @Value("${transport.ssl.certificate.reload.enabled:true}") + private boolean reloadEnabled; + + @Value("${transport.ssl.certificate.reload.check_interval:60}") + private long checkIntervalInSeconds; + + @Autowired + protected ApplicationContext applicationContext; + + private final Map watchers = new ConcurrentHashMap<>(); + private volatile ScheduledExecutorService scheduler; + + public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { + registerWatcher(name, List.of(certPath), reloadCallback); + } + + public void registerWatcher(String name, List certPaths, Runnable reloadCallback) { + watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); + log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); + } + + private void checkCertificates() { + watchers.forEach((name, watcher) -> { + try { + watcher.checkAndReload(name); + } catch (Exception e) { + log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); + } + }); + } + + private void discoverAndRegisterSslCredentials() { + try { + Map sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); + + log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); + + for (Map.Entry entry : sslConfigBeans.entrySet()) { + String beanName = entry.getKey(); + SslCredentialsConfig config = entry.getValue(); + + try { + if (!config.isEnabled()) { + log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); + continue; + } + + SslCredentials credentials = config.getCredentials(); + if (credentials == null) { + log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); + continue; + } + + List filePaths = credentials.getCertificateFilePaths(); + if (filePaths == null || filePaths.isEmpty()) { + log.debug("No certificate files to watch for: {} ({})", config.getName(), beanName); + continue; + } + + List existingPaths = filePaths.stream() + .filter(p -> p != null && Files.exists(p)) + .toList(); + + for (Path filePath : filePaths) { + if (filePath == null || !Files.exists(filePath)) { + log.warn("Certificate file does not exist: {} (from {})", filePath, config.getName()); + } + } + + if (!existingPaths.isEmpty()) { + registerWatcher(config.getName(), existingPaths, config::onCertificateFileChanged); + log.info("Registered certificate watcher: {} -> {}", config.getName(), existingPaths); + } + + } catch (Exception e) { + log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); + } + } + + } catch (Exception e) { + log.error("Error discovering SSL credentials configs", e); + } + } + + @Override + public void destroy() throws Exception { + if (scheduler != null) { + scheduler.shutdown(); + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } + } + + @Override + public void afterSingletonsInstantiated() { + if (!reloadEnabled) { + log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); + return; + } + log.info("Initializing Certificate Reload Manager..."); + + discoverAndRegisterSslCredentials(); + + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); + scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); + } + + static class CertificateWatcher { + private final List paths; + private final Runnable reloadCallback; + private final Map lastModifiedMap; + private final Map lastChecksumMap; + private int consecutiveFailures; + private String failedCombinedChecksum; + + CertificateWatcher(List paths, Runnable reloadCallback) { + this.paths = paths; + this.reloadCallback = reloadCallback; + this.lastModifiedMap = new HashMap<>(); + this.lastChecksumMap = new HashMap<>(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, calculateChecksum(path)); + } + this.consecutiveFailures = 0; + } + + synchronized void checkAndReload(String name) { + boolean anyModifiedChanged = false; + for (Path path : paths) { + long currentModified = getLastModifiedTime(path); + Long lastModified = lastModifiedMap.getOrDefault(path, 0L); + if (currentModified != lastModified) { + anyModifiedChanged = true; + break; + } + } + if (!anyModifiedChanged) { + return; + } + + // Compute combined checksum of all files + Map currentChecksums = new HashMap<>(); + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + String checksum = calculateChecksum(path); + currentChecksums.put(path, checksum); + combined.append(checksum); + } + String combinedChecksum = combined.toString(); + + // Build old combined checksum for comparison + StringBuilder oldCombined = new StringBuilder(); + for (Path path : paths) { + oldCombined.append(lastChecksumMap.getOrDefault(path, "")); + } + String oldCombinedChecksum = oldCombined.toString(); + + if (combinedChecksum.equals(oldCombinedChecksum)) { + // Content unchanged, just update modification times + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + } + return; + } + + if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { + // File content changed since last failure — reset and retry + consecutiveFailures = 0; + failedCombinedChecksum = null; + } + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + return; + } + + try { + log.info("Certificate change detected for: {}. Triggering reload...", name); + reloadCallback.run(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, currentChecksums.get(path)); + } + consecutiveFailures = 0; + failedCombinedChecksum = null; + } catch (Exception e) { + consecutiveFailures++; + failedCombinedChecksum = combinedChecksum; + log.error("Failed to reload certificate for {} (attempt {}/{}): {}", + name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); + } + } + + private long getLastModifiedTime(Path path) { + try { + if (!Files.exists(path)) { + return 0; + } + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return 0; + } + } + + private String calculateChecksum(Path path) { + try { + if (!Files.exists(path)) { + return ""; + } + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] bytes = Files.readAllBytes(path); + byte[] hash = md.digest(bytes); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + log.warn("Failed to calculate checksum for certificate file: {}", path, e); + return ""; + } + } + + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 260957c990..240de91424 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -/** - * Created by ashvayka on 17.10.18. - */ @Slf4j @Service @TbTransportComponent @@ -789,7 +786,7 @@ public class DefaultTransportService extends TransportActivityManager implements TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build(); - ScheduledFuture executorFuture = scheduler.schedule(() -> { + ScheduledFuture executorFuture = scheduler.schedule(() -> { listener.onRemoteSessionCloseCommand(sessionId, notification); deregisterSession(sessionInfo); }, timeout, TimeUnit.MILLISECONDS); @@ -1169,6 +1166,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private static class StatsCallback implements TbQueueCallback { @@ -1183,16 +1181,19 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void onSuccess(TbQueueMsgMetadata metadata) { stats.incrementSuccessful(); - if (callback != null) + if (callback != null) { callback.onSuccess(metadata); + } } @Override public void onFailure(Throwable t) { stats.incrementFailed(); - if (callback != null) + if (callback != null) { callback.onFailure(t); + } } + } private class MsgPackCallback implements TbQueueCallback { @@ -1215,6 +1216,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private class ApiStatsProxyCallback implements TransportServiceCallback { @@ -1244,6 +1246,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onError(Throwable e) { callback.onError(e); } + } @Override @@ -1270,4 +1273,5 @@ public class DefaultTransportService extends TransportActivityManager implements log.info("Transport Stats: {}", values); } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index 245f167ef8..612871f22a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -21,9 +21,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.concurrent.ScheduledFuture; -/** - * Created by ashvayka on 15.10.18. - */ @Data public class SessionMetaData { @@ -47,11 +44,8 @@ public class SessionMetaData { this.scheduledFuture = scheduledFuture; } - public ScheduledFuture getScheduledFuture() { - return scheduledFuture; - } - public boolean hasScheduledFuture() { return null != this.scheduledFuture; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index a353cf94e4..fd144e0110 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { @Override public byte[] encode(ToRuleEngineMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index 2e1d292a63..13a99686ad 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java index 2de10a70fd..4a907c836a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiRequestEncoder implements TbKafkaEncoder { @Override public byte[] encode(TransportApiRequestMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java index cfb7168e66..563d1078c9 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index cd0efe2210..535baf921d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; import java.util.UUID; -/** - * @author Andrew Shvayka - */ @Data public abstract class DeviceAwareSessionContext implements SessionContext { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java index ee0786aeb1..df28bb3390 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java @@ -31,4 +31,5 @@ public interface SessionContext { void onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile); void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional deviceProfileOpt); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java index ecdfc479cb..4d5451d4ca 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java @@ -27,8 +27,7 @@ import java.util.regex.Pattern; public class JsonUtils { - private static final Pattern BASE64_PATTERN = - Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); + private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); public static JsonObject getJsonObject(List tsKv) { JsonObject json = new JsonObject(); @@ -68,12 +67,12 @@ public class JsonUtils { } return JsonParser.parseString((String) value); } - } else if (value instanceof Boolean) { - return new JsonPrimitive((Boolean) value); - } else if (value instanceof Double) { - return new JsonPrimitive((Double) value); - } else if (value instanceof Float) { - return new JsonPrimitive((Float) value); + } else if (value instanceof Boolean booleanValue) { + return new JsonPrimitive(booleanValue); + } else if (value instanceof Double doubleValue) { + return new JsonPrimitive(doubleValue); + } else if (value instanceof Float floatValue) { + return new JsonPrimitive(floatValue); } else { throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName()); } @@ -91,4 +90,5 @@ public class JsonUtils { public static boolean isBase64(String value) { return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches(); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 30598925d5..0158e23d93 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -31,10 +31,6 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Base64; - -/** - * @author Valerii Sosliuk - */ @Slf4j public class SslUtil { @@ -51,7 +47,7 @@ public class SslUtil { String begin = "-----BEGIN CERTIFICATE-----"; String end = "-----END CERTIFICATE-----"; StringBuilder stringBuilder = new StringBuilder(); - for (Certificate cert: chain) { + for (Certificate cert : chain) { stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n"); } return stringBuilder.toString(); @@ -85,4 +81,5 @@ public class SslUtil { RDN cn = x500name.getRDNs(BCStyle.CN)[0]; return IETFUtils.valueToString(cn.getFirst().getValue()); } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java new file mode 100644 index 0000000000..6e16b2dc83 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java @@ -0,0 +1,182 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class SslCredentialsConfigTest { + + @Mock + private SslCredentials mockCredentials; + + private SslCredentialsConfig config; + + @BeforeEach + public void setup() { + config = new SslCredentialsConfig("Test SSL Config", false); + } + + @Test + public void givenConfig_whenCreated_thenShouldHaveCorrectName() { + assertThat(config.getName()).isEqualTo("Test SSL Config"); + assertThat(config.isTrustsOnly()).isFalse(); + } + + @Test + public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); + } + + @Test + public void givenCallback_whenRegistered_thenShouldBeStoredInList() { + AtomicInteger callCount = new AtomicInteger(0); + + config.registerReloadCallback(callCount::incrementAndGet); + config.setCredentials(mockCredentials); + + try { + doNothing().when(mockCredentials).reload(false); + } catch (Exception e) { + throw new RuntimeException(e); + } + + config.onCertificateFileChanged(); + + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + AtomicInteger callback3Count = new AtomicInteger(0); + + config.registerReloadCallback(callback1Count::incrementAndGet); + config.registerReloadCallback(callback2Count::incrementAndGet); + config.registerReloadCallback(callback3Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + assertThat(callback3Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + + config.registerReloadCallback(() -> { + callback1Count.incrementAndGet(); + throw new RuntimeException("Simulated callback failure"); + }); + config.registerReloadCallback(callback2Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + } + + @Test + public void givenCredentialsReloadFails_whenCertificateChanged_thenCallbacksShouldNotBeCalled() throws Exception { + AtomicInteger callbackCount = new AtomicInteger(0); + + config.registerReloadCallback(callbackCount::incrementAndGet); + config.setCredentials(mockCredentials); + + doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callbackCount.get()).isEqualTo(0); + } + + @Test + public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + verify(mockCredentials).reload(false); + } + + @Test + public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + trustsOnlyConfig.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(true); + + trustsOnlyConfig.onCertificateFileChanged(); + + verify(mockCredentials).reload(true); + } + + @Test + public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { + AtomicInteger totalCallbacks = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(10); + + for (int i = 0; i < 10; i++) { + new Thread(() -> { + try { + startLatch.await(); + config.registerReloadCallback(totalCallbacks::incrementAndGet); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(totalCallbacks.get()).isEqualTo(10); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java new file mode 100644 index 0000000000..b03e1f2edc --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java @@ -0,0 +1,277 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SslCredentialsWebServerCustomizerTest { + + @Mock + private ServerProperties mockServerProperties; + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private KeyStore mockKeyStore; + + private SslCredentialsWebServerCustomizer customizer; + + @BeforeEach + public void setup() throws Exception { + customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); + ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); + when(mockCredentials.getKeyPassword()).thenReturn("password"); + when(mockCredentials.getKeyAlias()).thenReturn("server"); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); + when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); + } + + @Test + public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + + verify(mockCredentialsConfig, times(1)).getCredentials(); + } + + @Test + public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle = sslBundles.getBundle("default"); + + assertThat(bundle).isNotNull(); + } + + @Test + public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { + SslBundles sslBundles = customizer.sslBundles(); + + List bundleNames = sslBundles.getBundleNames(); + + assertThat(bundleNames).containsExactly("default"); + } + + @Test + public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("default", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(1); + } + + @Test + public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("wrong-bundle", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + AtomicInteger handler3CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + assertThat(handler3CallCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + reloadCallback.run(); + reloadCallback.run(); + + assertThat(handlerCallCount.get()).isEqualTo(3); + } + + @Test + public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> { + handler1CallCount.incrementAndGet(); + throw new RuntimeException("Handler 1 failed"); + }); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + reloadCallback.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlerCallCount.get()).isEqualTo(5); + } + + @Test + public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { + when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + } + + @Test + public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle1 = sslBundles.getBundle("default"); + SslBundle bundle2 = sslBundles.getBundle("default"); + + assertThat(bundle1).isNotNull(); + assertThat(bundle2).isNotNull(); + } + + @Test + public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { + SslCredentialsConfig config = customizer.httpServerSslCredentials(); + + assertThat(config).isNotNull(); + assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); + assertThat(config.isTrustsOnly()).isFalse(); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java new file mode 100644 index 0000000000..0c9f1fc5b1 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -0,0 +1,383 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CertificateReloadManagerTest { + + @TempDir + Path tempDir; + + private CertificateReloadManager certificateReloadManager; + private Path certFile; + + @BeforeEach + public void setup() throws IOException { + certificateReloadManager = new CertificateReloadManager(); + + certFile = tempDir.resolve("test-cert.pem"); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); + } + + @AfterEach + public void teardown() throws Exception { + if (certificateReloadManager != null) { + certificateReloadManager.destroy(); + } + } + + @Test + public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + CountDownLatch reloadLatch = new CountDownLatch(1); + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadCount.incrementAndGet(); + reloadLatch.countDown(); + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(reloadTriggered).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + Thread.sleep(100); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.delete(certFile); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + Thread.sleep(100); + + // File deletion changes checksum from real hash to "", so reload is triggered + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { + certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); + + certificateReloadManager.destroy(); + + assertThat(certificateReloadManager).isNotNull(); + } + + @Test + public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { + Path keyFile = tempDir.resolve("test-key.pem"); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); + + CountDownLatch certReloadLatch = new CountDownLatch(1); + CountDownLatch keyReloadLatch = new CountDownLatch(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); + + Thread.sleep(100); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(keyReloaded).isTrue(); + assertThat(certReloadLatch.getCount()).isEqualTo(1); + } + + @Test + public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload1Count = new AtomicInteger(0); + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reload1Count.get()).isEqualTo(1); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { + throw new RuntimeException("Simulated reload failure"); + }); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.delete(certFile); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reloadCount.get()).isEqualTo(2); + } + + @Test + public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { + CountDownLatch reloadLatch = new CountDownLatch(1); + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadCount.incrementAndGet(); + reloadLatch.countDown(); + }); + + Thread.sleep(100); + + for (int i = 0; i < 5; i++) { + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); + } + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(reloadTriggered).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + // With atomic checkAndReload, exactly one reload should happen + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + String originalContent = Files.readString(certFile); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.writeString(certFile, originalContent); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + throw new RuntimeException("Persistent failure"); + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + + assertThat(reloadAttempts.get()).isEqualTo(10); + } + + @Test + public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Transient failure"); + } + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // First attempt fails + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(1); + + // Fix the callback and change the file to new content + shouldFail.set(0); + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Should reset failure counter and succeed because file content changed + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(2); + } + + @Test + public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Persistent failure"); + } + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Exhaust all retries + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + assertThat(reloadAttempts.get()).isEqualTo(10); + + // Fix callback and change file to new content + shouldFail.set(0); + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Should detect new content, reset counter, and succeed + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(11); + } + +} diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 9ab06ca996..c7283b9c0c 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -170,6 +170,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # CoAP server parameters coap: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index f869534088..d9ca77d8af 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -201,6 +201,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration parameters queue: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index d22bd34505..12dbf24298 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -301,6 +301,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration properties queue: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index ccbd3901ce..7be3879efe 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -234,6 +234,15 @@ transport: max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" # Timeout to expire block IP addresses ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration parameters queue: From 1d6594161b1a4f6069b5204b87376eb3663414a8 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:28:28 +0200 Subject: [PATCH 07/59] fix: force getLhServer() failure in LwM2m listener preservation test --- .../lwm2m/server/LwM2mServerCertificateReloadTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java index 15e4d07622..c8b7f0d060 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -159,6 +159,9 @@ public class LwM2mServerCertificateReloadTest { LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + // Force getLhServer() to fail by returning null host + when(mockConfig.getHost()).thenReturn(null); + // Invoke the callback — new server creation will fail, old listeners should stay callbackCaptor.getValue().run(); From 255bb38dc2c0a3265376ce1a49dd1c5a0d905804 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:44:10 +0200 Subject: [PATCH 08/59] Fimprove SSL certificate reload robustness and config clarity --- application/src/main/resources/thingsboard.yml | 2 +- .../lwm2m/server/DefaultLwM2mTransportService.java | 8 +++++++- .../ssl/SslCredentialsWebServerCustomizer.java | 2 +- .../service/CertificateReloadManager.java | 14 ++++++++++---- .../coap/src/main/resources/tb-coap-transport.yml | 2 +- .../http/src/main/resources/tb-http-transport.yml | 2 +- .../src/main/resources/tb-lwm2m-transport.yml | 2 +- .../mqtt/src/main/resources/tb-mqtt-transport.yml | 2 +- 8 files changed, 23 insertions(+), 11 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 419e6d3dfd..3001ea4187 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1402,7 +1402,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters coap: diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 21fdc07277..21b90f8d0c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -235,7 +235,13 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - newServer.start(); + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server, rolling back", e); + newServer.destroy(); + throw e; + } try { LwM2mServerListener newListener = new LwM2mServerListener(handler); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 147daefa7e..d213d9dc86 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -147,7 +147,7 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom @Override public void addBundleRegisterHandler(BiConsumer registerHandler) { - + log.debug("addBundleRegisterHandler is not supported for dynamic SSL bundles"); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 19fc545d0b..9940046b7d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.util.TbTransportComponent; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; @@ -50,7 +51,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis @Value("${transport.ssl.certificate.reload.enabled:true}") private boolean reloadEnabled; - @Value("${transport.ssl.certificate.reload.check_interval:60}") + @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") private long checkIntervalInSeconds; @Autowired @@ -258,9 +259,14 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis return ""; } MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] bytes = Files.readAllBytes(path); - byte[] hash = md.digest(bytes); - return Base64.getEncoder().encodeToString(hash); + byte[] buf = new byte[8192]; + try (InputStream is = Files.newInputStream(path)) { + int bytesRead; + while ((bytesRead = is.read(buf)) != -1) { + md.update(buf, 0, bytesRead); + } + } + return Base64.getEncoder().encodeToString(md.digest()); } catch (Exception e) { log.warn("Failed to calculate checksum for certificate file: {}", path, e); return ""; diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index c7283b9c0c..3c1ef94f09 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -178,7 +178,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters coap: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index d9ca77d8af..1b221d1fd9 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -209,7 +209,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters queue: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 12dbf24298..6140122062 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -309,7 +309,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration properties queue: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 7be3879efe..ac02fa396b 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -242,7 +242,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters queue: From c2a8f79edd42c421a86567c46443a7872c6bb362 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:54:02 +0200 Subject: [PATCH 09/59] Minor changing for CertificateReloadManager --- .../server/coapserver/DefaultCoapServerService.java | 2 +- .../server/common/data/ResourceUtils.java | 5 ++++- .../server/common/data/ResourceUtilsTest.java | 9 +++++---- .../bootstrap/LwM2MTransportBootstrapService.java | 2 +- .../lwm2m/server/DefaultLwM2mTransportService.java | 4 ++-- .../ssl/SslCredentialsWebServerCustomizer.java | 3 ++- .../transport/service/CertificateReloadManager.java | 12 +++++++++--- 7 files changed, 24 insertions(+), 13 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 7882125906..b073e34202 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -214,12 +214,12 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint..."); + server.getEndpoints().remove(oldDtlsEndpoint); oldDtlsEndpoint.stop(); if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); } oldDtlsEndpoint.destroy(); - server.getEndpoints().remove(oldDtlsEndpoint); log.info("Old DTLS endpoint stopped and destroyed."); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 626d0cd372..13c75feed0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -111,7 +111,10 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); - return url != null ? url.toURI().toString() : null; + if (url == null) { + throw new RuntimeException("Unable to find resource: " + filePath); + } + return url.toURI().toString(); } } catch (Exception e) { if (e instanceof NullPointerException) { diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java index 8c91762068..8fb6214dac 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -18,14 +18,15 @@ package org.thingsboard.server.common.data; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class ResourceUtilsTest { @Test - public void givenNonExistentResource_whenGetUri_thenReturnsNull() { - String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"); - - assertThat(result).isNull(); + public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { + assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to find resource"); } @Test diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 78f292d69c..6de8acde90 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -64,7 +64,7 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto private final LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; private final TransportService transportService; private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; - private LeshanBootstrapServer server; + private volatile LeshanBootstrapServer server; @Override public void afterSingletonsInstantiated() { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 21b90f8d0c..91b3fb072f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -84,8 +84,8 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar private final TbLwM2MAuthorizer authorizer; private final LwM2mVersionedModelProvider modelProvider; - private LeshanServer server; - private LwM2mServerListener serverListener; + private volatile LeshanServer server; + private volatile LwM2mServerListener serverListener; @Override public void afterSingletonsInstantiated() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index d213d9dc86..2a291b3888 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -22,6 +22,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslStoreBundle; @@ -125,7 +126,7 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom @Override public SslBundle getBundle(String name) { if (!DEFAULT_BUNDLE_NAME.equals(name)) { - throw new IllegalArgumentException("Unknown SSL bundle: " + name); + throw new NoSuchSslBundleException(name, "Unknown SSL bundle: " + name); } return createSslBundle(); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 9940046b7d..a514b110cb 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -196,14 +196,20 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis for (Path path : paths) { String checksum = calculateChecksum(path); currentChecksums.put(path, checksum); - combined.append(checksum); + if (!combined.isEmpty()) { + combined.append("|"); + } + combined.append(path).append("=").append(checksum); } String combinedChecksum = combined.toString(); // Build old combined checksum for comparison StringBuilder oldCombined = new StringBuilder(); for (Path path : paths) { - oldCombined.append(lastChecksumMap.getOrDefault(path, "")); + if (!oldCombined.isEmpty()) { + oldCombined.append("|"); + } + oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); } String oldCombinedChecksum = oldCombined.toString(); @@ -216,7 +222,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { - // File content changed since last failure — reset and retry + // File content has changed since the last failure - reset and retry consecutiveFailures = 0; failedCombinedChecksum = null; } From edcf3a9d2295d9a8d425fc8750de4a7bd3f8f3e6 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:01:34 +0200 Subject: [PATCH 10/59] Fix SSL certificate reload: port conflict, redundant reload(), and watcher polling --- .../coapserver/DefaultCoapServerService.java | 25 +++++++++++++++---- .../CoapDtlsCertificateReloadTest.java | 9 ++++--- .../config/ssl/AbstractSslCredentials.java | 3 +-- .../service/CertificateReloadManager.java | 4 +++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index b073e34202..34e7e2cfde 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -195,14 +195,31 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + // Stop old endpoint first to release the port before starting the new one + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint to release the port..."); + server.getEndpoints().remove(oldDtlsEndpoint); + oldDtlsEndpoint.stop(); + } + server.addEndpoint(newEndpoint); try { newEndpoint.start(); } catch (IOException e) { - log.error("Failed to start new DTLS endpoint, cleaning up", e); + log.error("Failed to start new DTLS endpoint, restoring old endpoint", e); server.getEndpoints().remove(newEndpoint); newEndpoint.destroy(); newConnector.destroy(); + // Attempt to restore the old endpoint + if (oldDtlsEndpoint != null) { + try { + server.addEndpoint(oldDtlsEndpoint); + oldDtlsEndpoint.start(); + log.info("Old DTLS endpoint restored successfully."); + } catch (IOException restoreEx) { + log.error("Failed to restore old DTLS endpoint", restoreEx); + } + } throw e; } log.info("New DTLS endpoint started successfully."); @@ -212,15 +229,13 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsCoapEndpoint = newEndpoint; tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + // Destroy old resources after successful swap if (oldDtlsEndpoint != null) { - log.info("Stopping old DTLS endpoint..."); - server.getEndpoints().remove(oldDtlsEndpoint); - oldDtlsEndpoint.stop(); if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); } oldDtlsEndpoint.destroy(); - log.info("Old DTLS endpoint stopped and destroyed."); + log.info("Old DTLS endpoint destroyed."); } } diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java index 642f2e0be9..a7413bdadd 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -189,7 +189,7 @@ public class CoapDtlsCertificateReloadTest { } @Test - public void givenReloadCallback_whenStartFails_thenNewResourcesCleaned() throws Exception { + public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); @@ -210,6 +210,7 @@ public class CoapDtlsCertificateReloadTest { doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); // WHEN - the callback catches the IOException internally @@ -224,12 +225,12 @@ public class CoapDtlsCertificateReloadTest { verify(mockNewEndpoint).destroy(); verify(mockNewConnector).destroy(); assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); // Old fields preserved assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); - // Old endpoint not touched - verify(mockDtlsEndpoint, never()).stop(); - verify(mockDtlsEndpoint, never()).destroy(); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index 9ffa4f7aac..55e6f63e5d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -60,8 +60,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { @Override public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { - SslState newState = buildState(trustsOnly); - state.set(newState); + init(trustsOnly); } private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index a514b110cb..0dd1fae724 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -228,6 +228,10 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + } return; } From b431e4c0e605d288d954e8237973506b8fc48a13 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:11:50 +0200 Subject: [PATCH 11/59] Fix LwM2M server recreation: stop old server before starting new to avoid port conflict --- .../LwM2MTransportBootstrapService.java | 26 ++++++-- .../server/DefaultLwM2mTransportService.java | 63 ++++++++++-------- .../LwM2mBootstrapCertificateReloadTest.java | 19 ++++-- .../service/CertificateReloadManagerTest.java | 64 +++++++++---------- 4 files changed, 101 insertions(+), 71 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 6de8acde90..a9a4e364b5 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -184,21 +184,33 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto log.info("Creating new LwM2M Bootstrap server with updated certificates..."); LeshanBootstrapServer newServer = getLhBootstrapServer(); + + // Stop the old server first to release the ports before starting the new one + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server to release ports..."); + oldServer.destroy(); + } + try { newServer.start(); } catch (Exception e) { - log.error("Failed to start new LwM2M Bootstrap server, rolling back", e); + log.error("Failed to start new LwM2M Bootstrap server", e); newServer.destroy(); + // Attempt to restore the old server + if (oldServer != null) { + try { + LeshanBootstrapServer restoredServer = getLhBootstrapServer(); + restoredServer.start(); + this.server = restoredServer; + log.info("Restored LwM2M Bootstrap server with previous configuration."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); + } + } throw e; } this.server = newServer; log.info("New LwM2M Bootstrap server started successfully."); - - if (oldServer != null) { - log.info("Stopping old LwM2M Bootstrap server..."); - oldServer.destroy(); - log.info("Old LwM2M Bootstrap server stopped."); - } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 91b3fb072f..b0d25729b3 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -235,33 +235,10 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - try { - newServer.start(); - } catch (Exception e) { - log.error("Failed to start new LwM2M server, rolling back", e); - newServer.destroy(); - throw e; - } - - try { - LwM2mServerListener newListener = new LwM2mServerListener(handler); - newServer.getRegistrationService().addListener(newListener.registrationListener); - newServer.getPresenceService().addListener(newListener.presenceListener); - newServer.getObservationService().addListener(newListener.observationListener); - newServer.getSendService().addListener(newListener.sendListener); - - this.server = newServer; - this.context.setServer(newServer); - this.serverListener = newListener; - } catch (Exception e) { - log.error("Failed to register listeners on new LwM2M server, rolling back", e); - newServer.destroy(); - throw e; - } - log.info("New LwM2M server started successfully."); + // Stop old server first to release the ports before starting the new one if (oldServer != null) { - log.info("Stopping old LwM2M server..."); + log.info("Stopping old LwM2M server to release ports..."); if (oldListener != null) { oldServer.getRegistrationService().removeListener(oldListener.registrationListener); oldServer.getPresenceService().removeListener(oldListener.presenceListener); @@ -269,8 +246,42 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar oldServer.getSendService().removeListener(oldListener.sendListener); } oldServer.destroy(); - log.info("Old LwM2M server stopped."); } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server", e); + newServer.destroy(); + // Attempt to restore the old server + try { + LeshanServer restoredServer = getLhServer(); + restoredServer.start(); + LwM2mServerListener restoredListener = new LwM2mServerListener(handler); + restoredServer.getRegistrationService().addListener(restoredListener.registrationListener); + restoredServer.getPresenceService().addListener(restoredListener.presenceListener); + restoredServer.getObservationService().addListener(restoredListener.observationListener); + restoredServer.getSendService().addListener(restoredListener.sendListener); + this.server = restoredServer; + this.context.setServer(restoredServer); + this.serverListener = restoredListener; + log.info("Restored LwM2M server with previous configuration."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M server", restoreEx); + } + throw e; + } + + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + log.info("New LwM2M server started successfully."); } @Override diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java index 51d37ca739..84bc44b03b 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -118,8 +118,8 @@ public class LwM2mBootstrapCertificateReloadTest { Runnable reloadCallback = callbackCaptor.getValue(); - // getLhBootstrapServer() will fail due to null host. - // With create-then-swap, the old server should NOT be destroyed. + // getLhBootstrapServer() will fail due to null host before old server is stopped. + // The old server should NOT be destroyed since the new server was never created. reloadCallback.run(); verify(mockBootstrapServer, never()).destroy(); @@ -164,15 +164,18 @@ public class LwM2mBootstrapCertificateReloadTest { } @Test - public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndOldPreserved() { + public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndRestorationAttempted() { // GIVEN ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + LeshanBootstrapServer mockRestoredServer = mock(LeshanBootstrapServer.class); + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); - doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + // First call returns the failing server, second call returns the restoration server + doReturn(mockNewServer).doReturn(mockRestoredServer).when(spyService).getLhBootstrapServer(); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); spyService.afterSingletonsInstantiated(); @@ -184,9 +187,13 @@ public class LwM2mBootstrapCertificateReloadTest { reloadCallback.run(); // THEN + // Old server is destroyed to release ports + verify(mockBootstrapServer).destroy(); + // New server fails to start and is destroyed verify(mockNewServer).destroy(); - assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); - verify(mockBootstrapServer, never()).destroy(); + // Restoration server is started and becomes the active server + verify(mockRestoredServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockRestoredServer); } } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index 0c9f1fc5b1..ba3aa66c70 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -63,7 +63,7 @@ public class CertificateReloadManagerTest { reloadLatch.countDown(); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -80,9 +80,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -94,7 +94,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -107,13 +107,13 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.delete(certFile); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // File deletion changes checksum from real hash to "", so reload is triggered assertThat(reloadCount.get()).isEqualTo(1); @@ -139,9 +139,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -162,10 +162,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -186,10 +186,10 @@ public class CertificateReloadManagerTest { }); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -203,15 +203,15 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.delete(certFile); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -229,12 +229,12 @@ public class CertificateReloadManagerTest { reloadLatch.countDown(); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -252,9 +252,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -284,10 +284,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, originalContent); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -304,9 +304,9 @@ public class CertificateReloadManagerTest { throw new RuntimeException("Persistent failure"); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops for (int i = 0; i < 15; i++) { @@ -328,9 +328,9 @@ public class CertificateReloadManagerTest { } }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // First attempt fails ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -338,9 +338,9 @@ public class CertificateReloadManagerTest { // Fix the callback and change the file to new content shouldFail.set(0); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Should reset failure counter and succeed because file content changed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -359,9 +359,9 @@ public class CertificateReloadManagerTest { } }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Exhaust all retries for (int i = 0; i < 15; i++) { @@ -371,9 +371,9 @@ public class CertificateReloadManagerTest { // Fix callback and change file to new content shouldFail.set(0); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Should detect new content, reset counter, and succeed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); From 22d9506206b8b588fe342f3a38dcd81faa590c39 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:28:18 +0200 Subject: [PATCH 12/59] Improve SSL certificate reload: rollback safety, defaults, and test encapsulation --- .../src/main/resources/thingsboard.yml | 2 +- .../coapserver/DefaultCoapServerService.java | 10 +- .../CoapDtlsCertificateReloadTest.java | 120 ++++++++++-------- .../LwM2MTransportBootstrapService.java | 17 ++- .../server/DefaultLwM2mTransportService.java | 38 +++--- .../LwM2mBootstrapCertificateReloadTest.java | 21 ++- .../LwM2mServerCertificateReloadTest.java | 3 +- .../service/CertificateReloadManager.java | 4 +- .../src/main/resources/tb-coap-transport.yml | 2 +- .../src/main/resources/tb-http-transport.yml | 2 +- .../src/main/resources/tb-lwm2m-transport.yml | 2 +- .../src/main/resources/tb-mqtt-transport.yml | 2 +- 12 files changed, 120 insertions(+), 103 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 3001ea4187..e4bd13cdb3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1400,7 +1400,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 34e7e2cfde..087f8137c7 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -154,14 +154,14 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial return networkConfig; } - DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); return dtlsConnectorConfig; } - CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); dtlsCoapEndpointBuilder.setConfiguration(networkConfig); dtlsCoapEndpointBuilder.setConnector(connector); @@ -179,7 +179,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); } - DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { return new DTLSConnector(config); } @@ -195,7 +195,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); - // Stop old endpoint first to release the port before starting the new one + // Stop the old endpoint first to release the port before starting the new one if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint to release the port..."); server.getEndpoints().remove(oldDtlsEndpoint); @@ -229,7 +229,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsCoapEndpoint = newEndpoint; tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); - // Destroy old resources after successful swap + // Destroy old resources after a successful swap if (oldDtlsEndpoint != null) { if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java index a7413bdadd..f22aaf5c7e 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -18,7 +18,6 @@ package org.thingsboard.server.coapserver; import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.network.CoapEndpoint; import org.eclipse.californium.core.network.Endpoint; -import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; import org.junit.jupiter.api.BeforeEach; @@ -26,22 +25,23 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.Mockito; +import org.mockito.MockedConstruction; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.test.util.ReflectionTestUtils; import java.io.IOException; +import java.net.InetSocketAddress; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -155,37 +155,42 @@ public class CoapDtlsCertificateReloadTest { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); - CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); - DTLSConnector mockNewConnector = mock(DTLSConnector.class); DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); - DefaultCoapServerService spyService = Mockito.spy(coapServerService); - ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); - ReflectionTestUtils.setField(spyService, "server", mockCoapServer); - ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); - ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); - - doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); - doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); - doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); List endpointsList = new CopyOnWriteArrayList<>(); endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); - // WHEN - ReflectionTestUtils.invokeMethod(spyService, "recreateDtlsEndpoint"); - - // THEN - assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); - verify(mockDtlsEndpoint).stop(); - verify(mockDtlsEndpoint).destroy(); - verify(mockDtlsConnector).destroy(); - verify(mockCoapServer).addEndpoint(mockNewEndpoint); - verify(mockNewEndpoint).start(); - assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } } @Test @@ -193,44 +198,49 @@ public class CoapDtlsCertificateReloadTest { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); - CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); - DTLSConnector mockNewConnector = mock(DTLSConnector.class); DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); - doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); - - DefaultCoapServerService spyService = Mockito.spy(coapServerService); - ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); - ReflectionTestUtils.setField(spyService, "server", mockCoapServer); - ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); - ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); - - doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); - doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); - doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); List endpointsList = new CopyOnWriteArrayList<>(); endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); - // WHEN - the callback catches the IOException internally - spyService.afterSingletonsInstantiated(); - - ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); - verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); - Runnable reloadCallback = callbackCaptor.getValue(); - reloadCallback.run(); + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); - // THEN - new resources cleaned up - verify(mockNewEndpoint).destroy(); - verify(mockNewConnector).destroy(); - assertThat(endpointsList).doesNotContain(mockNewEndpoint); - // Old endpoint was stopped to release port, then restored after new one failed - verify(mockDtlsEndpoint).stop(); - verify(mockDtlsEndpoint).start(); - // Old fields preserved - assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); - assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + coapServerService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + DTLSConnector constructedConnector = dtlsMock.constructed().get(0); + verify(mockNewEndpoint).destroy(); + verify(constructedConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); + } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index a9a4e364b5..9b370d0b71 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -185,10 +185,10 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto log.info("Creating new LwM2M Bootstrap server with updated certificates..."); LeshanBootstrapServer newServer = getLhBootstrapServer(); - // Stop the old server first to release the ports before starting the new one + // Stop (not destroy) the old server to release ports but keep it restartable for rollback if (oldServer != null) { log.info("Stopping old LwM2M Bootstrap server to release ports..."); - oldServer.destroy(); + oldServer.stop(); } try { @@ -196,13 +196,11 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto } catch (Exception e) { log.error("Failed to start new LwM2M Bootstrap server", e); newServer.destroy(); - // Attempt to restore the old server + // Attempt to restart the old server (only stopped, not destroyed) if (oldServer != null) { try { - LeshanBootstrapServer restoredServer = getLhBootstrapServer(); - restoredServer.start(); - this.server = restoredServer; - log.info("Restored LwM2M Bootstrap server with previous configuration."); + oldServer.start(); + log.info("Restored old LwM2M Bootstrap server successfully."); } catch (Exception restoreEx) { log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); } @@ -211,6 +209,11 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto } this.server = newServer; log.info("New LwM2M Bootstrap server started successfully."); + + // Destroy the old server only after a successful swap + if (oldServer != null) { + oldServer.destroy(); + } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index b0d25729b3..5815a47555 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -236,7 +236,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - // Stop old server first to release the ports before starting the new one + // Stop (not destroy) old server to release ports but keep it restartable for rollback if (oldServer != null) { log.info("Stopping old LwM2M server to release ports..."); if (oldListener != null) { @@ -245,7 +245,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar oldServer.getObservationService().removeListener(oldListener.observationListener); oldServer.getSendService().removeListener(oldListener.sendListener); } - oldServer.destroy(); + oldServer.stop(); } try { @@ -253,21 +253,20 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar } catch (Exception e) { log.error("Failed to start new LwM2M server", e); newServer.destroy(); - // Attempt to restore the old server - try { - LeshanServer restoredServer = getLhServer(); - restoredServer.start(); - LwM2mServerListener restoredListener = new LwM2mServerListener(handler); - restoredServer.getRegistrationService().addListener(restoredListener.registrationListener); - restoredServer.getPresenceService().addListener(restoredListener.presenceListener); - restoredServer.getObservationService().addListener(restoredListener.observationListener); - restoredServer.getSendService().addListener(restoredListener.sendListener); - this.server = restoredServer; - this.context.setServer(restoredServer); - this.serverListener = restoredListener; - log.info("Restored LwM2M server with previous configuration."); - } catch (Exception restoreEx) { - log.error("Failed to restore old LwM2M server", restoreEx); + // Attempt to restart the old server (only stopped, not destroyed) + if (oldServer != null) { + try { + oldServer.start(); + if (oldListener != null) { + oldServer.getRegistrationService().addListener(oldListener.registrationListener); + oldServer.getPresenceService().addListener(oldListener.presenceListener); + oldServer.getObservationService().addListener(oldListener.observationListener); + oldServer.getSendService().addListener(oldListener.sendListener); + } + log.info("Restored old LwM2M server successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M server", restoreEx); + } } throw e; } @@ -282,6 +281,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar this.context.setServer(newServer); this.serverListener = newListener; log.info("New LwM2M server started successfully."); + + // Destroy old server only after successful swap + if (oldServer != null) { + oldServer.destroy(); + } } @Override diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java index 84bc44b03b..bbcbc921f6 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -122,6 +122,7 @@ public class LwM2mBootstrapCertificateReloadTest { // The old server should NOT be destroyed since the new server was never created. reloadCallback.run(); + verify(mockBootstrapServer, never()).stop(); verify(mockBootstrapServer, never()).destroy(); assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); } @@ -164,18 +165,15 @@ public class LwM2mBootstrapCertificateReloadTest { } @Test - public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndRestorationAttempted() { + public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() { // GIVEN ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); - LeshanBootstrapServer mockRestoredServer = mock(LeshanBootstrapServer.class); - LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); - // First call returns the failing server, second call returns the restoration server - doReturn(mockNewServer).doReturn(mockRestoredServer).when(spyService).getLhBootstrapServer(); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); spyService.afterSingletonsInstantiated(); @@ -187,13 +185,14 @@ public class LwM2mBootstrapCertificateReloadTest { reloadCallback.run(); // THEN - // Old server is destroyed to release ports - verify(mockBootstrapServer).destroy(); - // New server fails to start and is destroyed + // Old server is stopped (not destroyed) to release ports + verify(mockBootstrapServer).stop(); + verify(mockBootstrapServer, never()).destroy(); + // The new server fails to start and is destroyed verify(mockNewServer).destroy(); - // Restoration server is started and becomes the active server - verify(mockRestoredServer).start(); - assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockRestoredServer); + // Old server is restarted (not rebuilt from potentially stale credentials) + verify(mockBootstrapServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); } } diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java index c8b7f0d060..93b74447fd 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -139,9 +139,10 @@ public class LwM2mServerCertificateReloadTest { // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) when(mockConfig.getHost()).thenReturn(null); - // With create-then-swap, the old server should NOT be destroyed if the new one fails. + // With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build. reloadCallback.run(); + verify(mockLeshanServer, never()).stop(); verify(mockLeshanServer, never()).destroy(); // Old server should still be the active one assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 0dd1fae724..f243c64c50 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -48,7 +48,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis private static final int MAX_CONSECUTIVE_FAILURES = 10; - @Value("${transport.ssl.certificate.reload.enabled:true}") + @Value("${transport.ssl.certificate.reload.enabled:false}") private boolean reloadEnabled; @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") @@ -103,7 +103,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis List filePaths = credentials.getCertificateFilePaths(); if (filePaths == null || filePaths.isEmpty()) { - log.debug("No certificate files to watch for: {} ({})", config.getName(), beanName); + log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); continue; } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 3c1ef94f09..f9827d488f 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -176,7 +176,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 1b221d1fd9..7efa428a90 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -207,7 +207,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 6140122062..543b11859b 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -307,7 +307,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index ac02fa396b..60d3da8f8e 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -240,7 +240,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" From 635920534d3efe5d25816eb987d604417b5b6d13 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:37:40 +0200 Subject: [PATCH 13/59] Improve SSL reload clarity: document trade-offs, use Path API, deduplicate PEM path logic --- .../coapserver/DefaultCoapServerService.java | 5 +++- .../config/ssl/KeystoreSslCredentials.java | 8 +++---- .../config/ssl/PemSslCredentials.java | 24 ++++++++----------- .../service/CertificateReloadManager.java | 2 ++ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 087f8137c7..03de8b7b91 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -154,6 +154,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial return networkConfig; } + // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig. private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); @@ -195,7 +196,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); - // Stop the old endpoint first to release the port before starting the new one + // Californium binds the DTLS port at connector construction time, so we must stop the old + // endpoint first to release the port. This creates a brief window where the port is unbound; + // if the new endpoint fails to start, we attempt to restore the old one (see rollback below). if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint to release the port..."); server.getEndpoints().remove(oldDtlsEndpoint); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 7a2fb1a545..7cbc4403b7 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -20,9 +20,9 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; -import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -62,9 +62,9 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { @Override public List getCertificateFilePaths() { if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File storeFileObj = new File(storeFile); - if (storeFileObj.exists()) { - return Collections.singletonList(storeFileObj.toPath().toAbsolutePath()); + Path resolved = Path.of(storeFile).toAbsolutePath(); + if (Files.exists(resolved)) { + return Collections.singletonList(resolved); } } return Collections.emptyList(); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index 72ad7af9c5..c6eb75698e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -30,10 +30,10 @@ import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -145,22 +145,18 @@ public class PemSslCredentials extends AbstractSslCredentials { @Override public List getCertificateFilePaths() { List paths = new ArrayList<>(); + addIfFileSystemPath(paths, certFile); + addIfFileSystemPath(paths, keyFile); + return paths; + } - if (!StringUtils.isEmpty(certFile) && !certFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File certFileObj = new File(certFile); - if (certFileObj.exists()) { - paths.add(certFileObj.toPath().toAbsolutePath()); - } - } - - if (!StringUtils.isEmpty(keyFile) && !keyFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File keyFileObj = new File(keyFile); - if (keyFileObj.exists()) { - paths.add(keyFileObj.toPath().toAbsolutePath()); + private static void addIfFileSystemPath(List paths, String filePath) { + if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + Path resolved = Path.of(filePath).toAbsolutePath(); + if (Files.exists(resolved)) { + paths.add(resolved); } } - - return paths; } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index f243c64c50..eec84a22f0 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -247,6 +247,8 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } catch (Exception e) { consecutiveFailures++; failedCombinedChecksum = combinedChecksum; + // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries + // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). log.error("Failed to reload certificate for {} (attempt {}/{}): {}", name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); } From 1317a446390f5b31e570a1383622709a07f0f677 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 26 Mar 2026 10:18:11 +0200 Subject: [PATCH 14/59] Add reload integration tests --- ...pDtlsCertificateReloadIntegrationTest.java | 349 ++++++++++++++++++ ...ttSslCertificateReloadIntegrationTest.java | 276 ++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..11e278b4f5 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java @@ -0,0 +1,349 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.config.CoapConfig; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.elements.util.SslContextUtil; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.californium.scandium.dtls.CertificateType; +import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; +import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; + +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; + +public class CoapDtlsCertificateReloadIntegrationTest { + + private static final String TEST_RESOURCE_PATH = "test"; + private static final String TEST_PAYLOAD = "hello-dtls"; + + @TempDir + Path tempDir; + + private CoapServer coapServer; + + @AfterEach + public void teardown() { + if (coapServer != null) { + coapServer.destroy(); + } + } + + @Test + public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); + } + + @Test + public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); + assertThat(failedResponse).isNull(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + @Test + public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint1); + coapServer.start(); + + CoapResponse response1 = doDtlsRequest(dtlsPort, cert); + assertThat(response1).isNotNull(); + assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpoint1); + endpoint1.stop(); + CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint2); + endpoint2.start(); + endpoint1.destroy(); + + CoapResponse response2 = doDtlsRequest(dtlsPort, cert); + assertThat(response2).isNotNull(); + assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); + config.setEnabled(true); + config.setType(SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new KeystoreSslCredentials()); + config.init(); + return config; + } + + private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { + DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); + dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); + dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); + dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); + dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, + org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); + + SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( + credentials.getPrivateKey(), null, credentials.getCertificateChain()); + + dtlsBuilder.setCertificateIdentityProvider( + new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), + Collections.singletonList(CertificateType.X_509))); + + dtlsBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); + + DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); + + CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); + endpointBuilder.setConfiguration(config); + endpointBuilder.setConnector(connector); + return endpointBuilder.build(); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private Configuration createServerConfig() { + Configuration config = new Configuration(); + config.set(CoapConfig.MAX_RETRANSMIT, 2); + config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + return config; + } + + private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { + try { + Configuration clientConfig = new Configuration(); + clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); + clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); + clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); + clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); + clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); + + DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); + clientDtls.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustedCert) + .build()); + + DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); + CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() + .setConfiguration(clientConfig) + .setConnector(clientConnector) + .build(); + + CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); + client.setEndpoint(clientEndpoint); + client.setTimeout((long) 5000); + + try { + clientEndpoint.start(); + return client.get(); + } finally { + client.shutdown(); + clientEndpoint.destroy(); + } + } catch (Exception e) { + return null; + } + } + + private int findAvailablePort() throws Exception { + try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { + return socket.getLocalPort(); + } + } + + private static class TestResource extends CoapResource { + TestResource() { + super(TEST_RESOURCE_PATH); + } + + @Override + public void handleGET(CoapExchange exchange) { + exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); + } + + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..84ef4f2979 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java @@ -0,0 +1,276 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class MqttSslCertificateReloadIntegrationTest { + + @TempDir + Path tempDir; + + @Mock + private TransportService transportService; + + @Test + public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctxA = getProviderSslContext(provider); + X509Certificate servedA; + try (SSLServerSocket ss = createServerSocket(ctxA)) { + servedA = doHandshakeAndGetServerCert(ss); + } + assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctxB = getProviderSslContext(provider); + assertThat(ctxB).isNotSameAs(ctxA); + X509Certificate servedB; + try (SSLServerSocket ss = createServerSocket(ctxB)) { + servedB = doHandshakeAndGetServerCert(ss); + } + assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); + assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + assertThat(ctx1).isNotNull(); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctx2 = getProviderSslContext(provider); + assertThat(ctx2).isNotSameAs(ctx1); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx2)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + KeyPair keyPairC = generateKeyPair(); + X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx2 = getProviderSslContext(provider); + + writeCertPem(certFile, certC); + writeKeyPem(keyFile, keyPairC); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx3 = getProviderSslContext(provider); + + assertThat(ctx1).isNotSameAs(ctx2); + assertThat(ctx2).isNotSameAs(ctx3); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx3)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); + config.setEnabled(true); + config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); + config.init(); + return config; + } + + private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { + MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); + ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(provider, "transportService", transportService); + provider.afterSingletonsInstantiated(); + return provider; + } + + /** + * Triggers SSLContext creation through the provider's getSslHandler() path, + * then extracts the cached SSLContext for direct server socket use. + */ + private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { + provider.getSslHandler(); + return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { + return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); + } + + private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { + Thread acceptor = new Thread(() -> { + try (var conn = serverSocket.accept()) { + conn.getInputStream().read(); + } catch (Exception ignored) {} + }); + acceptor.setDaemon(true); + acceptor.start(); + + SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); + clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); + + try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() + .createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { + client.setSoTimeout(5000); + client.startHandshake(); + + Certificate[] peerCerts = client.getSession().getPeerCertificates(); + assertThat(peerCerts).isNotEmpty(); + return (X509Certificate) peerCerts[0]; + } finally { + acceptor.join(5000); + } + } + + private static class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + } + +} From cfc3935860b2eed06d9ccb0444b41ecc56c87ac5 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 26 Mar 2026 12:07:46 +0200 Subject: [PATCH 15/59] Set TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED default to 'true' --- application/src/main/resources/thingsboard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index e4bd13cdb3..f297fadde0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1400,8 +1400,8 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" - # Check interval in seconds for certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check an interval in seconds for certificate reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters From ee878ed6d0ae95e9aff45f403e07583eb46ac260 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 27 Mar 2026 12:32:58 +0200 Subject: [PATCH 16/59] Improve CertificateReloadManagerTest to use Awaitility --- .../service/CertificateReloadManagerTest.java | 143 +++++++----------- 1 file changed, 53 insertions(+), 90 deletions(-) diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index ba3aa66c70..3156897210 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -28,7 +28,10 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; public class CertificateReloadManagerTest { @@ -53,24 +56,28 @@ public class CertificateReloadManagerTest { } } + private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + Files.writeString(path, content); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + } + + private long mtime(Path path) throws IOException { + return Files.getLastModifiedTime(path).toMillis(); + } + @Test public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { - CountDownLatch reloadLatch = new CountDownLatch(1); AtomicInteger reloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, () -> { - reloadCount.incrementAndGet(); - reloadLatch.countDown(); - }); + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(reloadTriggered).isTrue(); assertThat(reloadCount.get()).isEqualTo(1); } @@ -80,9 +87,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -94,8 +99,6 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -107,15 +110,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.delete(certFile); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - TimeUnit.MILLISECONDS.sleep(100); - // File deletion changes checksum from real hash to "", so reload is triggered assertThat(reloadCount.get()).isEqualTo(1); } @@ -133,22 +131,19 @@ public class CertificateReloadManagerTest { Path keyFile = tempDir.resolve("test-key.pem"); Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); - CountDownLatch certReloadLatch = new CountDownLatch(1); - CountDownLatch keyReloadLatch = new CountDownLatch(1); + AtomicInteger certReloadCount = new AtomicInteger(0); + AtomicInteger keyReloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); - certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(keyFile); + writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(keyReloaded).isTrue(); - assertThat(certReloadLatch.getCount()).isEqualTo(1); + assertThat(keyReloadCount.get()).isEqualTo(1); + assertThat(certReloadCount.get()).isEqualTo(0); } @Test @@ -162,14 +157,13 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); - Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reload1Count.get()).isEqualTo(1); assertThat(reload2Count.get()).isEqualTo(1); } @@ -186,14 +180,13 @@ public class CertificateReloadManagerTest { }); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); - Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reload2Count.get()).isEqualTo(1); } @@ -203,44 +196,31 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.delete(certFile); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(1); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - - Thread.sleep(200); assertThat(reloadCount.get()).isEqualTo(2); } @Test public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { - CountDownLatch reloadLatch = new CountDownLatch(1); AtomicInteger reloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, () -> { - reloadCount.incrementAndGet(); - reloadLatch.countDown(); - }); - - TimeUnit.MILLISECONDS.sleep(100); + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + long baseline = mtime(certFile); for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - TimeUnit.MILLISECONDS.sleep(100); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> mtime(certFile) != baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(reloadTriggered).isTrue(); assertThat(reloadCount.get()).isEqualTo(1); } @@ -252,9 +232,8 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -273,7 +252,6 @@ public class CertificateReloadManagerTest { boolean completed = doneLatch.await(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); - // With atomic checkAndReload, exactly one reload should happen assertThat(reloadCount.get()).isEqualTo(1); } @@ -284,14 +262,11 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - - Files.writeString(certFile, originalContent); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reloadCount.get()).isEqualTo(0); } @@ -304,11 +279,9 @@ public class CertificateReloadManagerTest { throw new RuntimeException("Persistent failure"); }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); } @@ -328,21 +301,16 @@ public class CertificateReloadManagerTest { } }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // First attempt fails ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(1); - // Fix the callback and change the file to new content shouldFail.set(0); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); - // Should reset failure counter and succeed because file content changed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(2); } @@ -359,23 +327,18 @@ public class CertificateReloadManagerTest { } }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // Exhaust all retries for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); } assertThat(reloadAttempts.get()).isEqualTo(10); - // Fix callback and change file to new content shouldFail.set(0); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); - // Should detect new content, reset counter, and succeed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(11); } From ef4bbc0b49fd785c8acbe1c428700f1eb656d563 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Apr 2026 12:38:00 +0300 Subject: [PATCH 17/59] system alarm comment access control fix --- .../controller/AlarmCommentController.java | 5 +++ .../AlarmCommentControllerTest.java | 32 +++++++++++++++++++ .../dao/alarm/BaseAlarmCommentService.java | 13 ++++---- .../validator/AlarmCommentDataValidator.java | 18 +++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index ae8305c070..1874170139 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -30,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -77,6 +79,9 @@ public class AlarmCommentController extends BaseController { AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); alarmComment.setAlarmId(alarmId); + if (alarmComment.getType() == AlarmCommentType.SYSTEM) { + throw new ThingsboardException("You can`t create or update SYSTEM comments", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index b87473f6e3..231b72a7fb 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.LinkedList; import java.util.List; +import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -356,6 +358,36 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { Assert.assertTrue("Created alarm doesn't match the found one!", equals); } + @Test + public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception { + loginTenantAdmin(); + + AlarmComment alarmComment = AlarmComment.builder() + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin"))) + .build(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment).andExpect(status().isBadRequest()); + + // acknowledge alarm to create system comment + doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk()); + + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=1", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment"))); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("You can`t create or update SYSTEM comments"))); + + // type of comment could not be changed + systemComment.setType(AlarmCommentType.OTHER); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("System alarm comment type can't be updated!"))); + } + private AlarmComment createAlarmComment(AlarmId alarmId, String text) { AlarmComment alarmComment = AlarmComment.builder() .comment(JacksonUtil.newObjectNode().set("text", new TextNode(text))) diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java index 505a319baa..1dec2bbe04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java @@ -48,13 +48,13 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al @Override public AlarmComment createOrUpdateAlarmComment(TenantId tenantId, AlarmComment alarmComment) { - alarmCommentDataValidator.validate(alarmComment, c -> tenantId); + AlarmComment oldAlarmComment = alarmCommentDataValidator.validate(alarmComment, c -> tenantId); boolean isCreated = alarmComment.getId() == null; AlarmComment result; if (isCreated) { result = createAlarmComment(tenantId, alarmComment); } else { - result = updateAlarmComment(tenantId, alarmComment); + result = updateAlarmComment(tenantId, alarmComment, oldAlarmComment); } if (result != null) { eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(result) @@ -101,18 +101,17 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al return alarmCommentDao.save(tenantId, alarmComment); } - private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment) { + private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment, AlarmComment oldAlarmComment) { log.debug("Update Alarm comment : {}", newAlarmComment); - AlarmComment existing = alarmCommentDao.findAlarmCommentById(tenantId, newAlarmComment.getId().getId()); - if (existing != null) { + if (oldAlarmComment != null) { if (newAlarmComment.getComment() != null) { JsonNode comment = newAlarmComment.getComment(); ((ObjectNode) comment).put("edited", "true"); ((ObjectNode) comment).put("editedOn", System.currentTimeMillis()); - existing.setComment(comment); + oldAlarmComment.setComment(comment); } - return alarmCommentDao.save(tenantId, existing); + return alarmCommentDao.save(tenantId, oldAlarmComment); } return null; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java index 006661536c..4951eed8a5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java @@ -16,9 +16,12 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.alarm.AlarmComment; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; @@ -26,6 +29,9 @@ import org.thingsboard.server.dao.service.DataValidator; @AllArgsConstructor public class AlarmCommentDataValidator extends DataValidator { + @Autowired + AlarmCommentService alarmCommentService; + @Override protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) { if (alarmComment.getComment() == null) { @@ -35,4 +41,16 @@ public class AlarmCommentDataValidator extends DataValidator { throw new DataValidationException("Alarm id should be specified!"); } } + + @Override + protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) { + AlarmComment oldAlarmComment = null; + if (alarmComment.getId() != null) { + oldAlarmComment = alarmCommentService.findAlarmCommentById(tenantId, alarmComment.getId()); + if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM && alarmComment.getType() != AlarmCommentType.SYSTEM) { + throw new DataValidationException("System alarm comment type can't be updated!"); + } + } + return oldAlarmComment; + } } From 8594ea3e6337c63486e2726343d8471252f0b11c Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Apr 2026 14:25:30 +0300 Subject: [PATCH 18/59] refactoring --- .../controller/AlarmCommentController.java | 4 +--- .../controller/AlarmCommentControllerTest.java | 17 ++++++++++------- .../validator/AlarmCommentDataValidator.java | 14 ++++++++------ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 1874170139..937eded7ca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -79,9 +79,7 @@ public class AlarmCommentController extends BaseController { AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); alarmComment.setAlarmId(alarmId); - if (alarmComment.getType() == AlarmCommentType.SYSTEM) { - throw new ThingsboardException("You can`t create or update SYSTEM comments", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - } + alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 231b72a7fb..5ef04764d0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -366,26 +366,29 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { .type(AlarmCommentType.SYSTEM) .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin"))) .build(); - doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment).andExpect(status().isBadRequest()); + AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class); + assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER); // acknowledge alarm to create system comment doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk()); Optional systemCommentOpt = doGetTyped( - "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=1", new TypeReference>() { + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { } ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); assertThat(systemCommentOpt).isPresent(); AlarmCommentInfo systemComment = systemCommentOpt.get(); - systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment"))); + // system comment can't be updated with other type + systemComment.setType(AlarmCommentType.OTHER); doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("You can`t create or update SYSTEM comments"))); + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); - // type of comment could not be changed - systemComment.setType(AlarmCommentType.OTHER); + // system comment can't be updated with other text + systemComment.setType(AlarmCommentType.SYSTEM); + systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment"))); doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("System alarm comment type can't be updated!"))); + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); } private AlarmComment createAlarmComment(AlarmId alarmId, String text) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java index 4951eed8a5..0360386559 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java @@ -21,7 +21,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.alarm.AlarmCommentService; +import org.thingsboard.server.dao.alarm.AlarmCommentDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; @@ -29,8 +29,7 @@ import org.thingsboard.server.dao.service.DataValidator; @AllArgsConstructor public class AlarmCommentDataValidator extends DataValidator { - @Autowired - AlarmCommentService alarmCommentService; + private final AlarmCommentDao alarmCommentDao; @Override protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) { @@ -46,9 +45,12 @@ public class AlarmCommentDataValidator extends DataValidator { protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) { AlarmComment oldAlarmComment = null; if (alarmComment.getId() != null) { - oldAlarmComment = alarmCommentService.findAlarmCommentById(tenantId, alarmComment.getId()); - if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM && alarmComment.getType() != AlarmCommentType.SYSTEM) { - throw new DataValidationException("System alarm comment type can't be updated!"); + oldAlarmComment = alarmCommentDao.findAlarmCommentById(tenantId, alarmComment.getId().getId()); + if (oldAlarmComment == null) { + throw new DataValidationException("Can't update non existing alarm comment!"); + } + if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM) { + throw new DataValidationException("System alarm comment can't be updated!"); } } return oldAlarmComment; From 64e7886624db67712461b177a8fe4b59f8987fda Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Apr 2026 14:29:23 +0300 Subject: [PATCH 19/59] import cleanup --- .../server/dao/service/validator/AlarmCommentDataValidator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java index 0360386559..86a6e5224b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentType; From fca4f837a26a2cc6963020cc5c5ce29bf8242585 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Apr 2026 14:57:09 +0300 Subject: [PATCH 20/59] enhanced test --- .../controller/AlarmCommentControllerTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 5ef04764d0..ba86d60852 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -225,6 +225,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) .andExpect(status().isOk()); + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); + assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + TENANT_ADMIN_EMAIL)); + AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) From 9ccf94f93b9cdc408f85aa60ee28abd0ec687415 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 9 Apr 2026 12:19:06 +0300 Subject: [PATCH 21/59] Debounce LwM2M server reload to avoid double restart when both credentials change --- .../coapserver/DefaultCoapServerService.java | 4 +- .../config/LwM2MTransportServerConfig.java | 42 ++++++- ...wM2MTransportServerConfigDebounceTest.java | 106 ++++++++++++++++++ .../src/main/resources/tb-coap-transport.yml | 2 +- .../src/main/resources/tb-http-transport.yml | 2 +- .../src/main/resources/tb-lwm2m-transport.yml | 2 +- .../src/main/resources/tb-mqtt-transport.yml | 2 +- 7 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 03de8b7b91..3b7248ee72 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -196,8 +196,8 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); - // Californium binds the DTLS port at connector construction time, so we must stop the old - // endpoint first to release the port. This creates a brief window where the port is unbound; + // We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port. + // This creates a brief window where the port is unbound; // if the new endpoint fails to start, we attempt to restore the old one (see rollback below). if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint to release the port..."); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index 6034219c4d..c092dcea77 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.config; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -27,12 +28,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -40,6 +46,13 @@ import java.util.concurrent.CopyOnWriteArrayList; @ConfigurationProperties(prefix = "transport.lwm2m") public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { + private static final long RELOAD_DEBOUNCE_SECONDS = 2; + + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + private final ScheduledExecutorService reloadDebouncer = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("lwm2m-reload-debouncer")); + + private volatile ScheduledFuture pendingReload; + @Getter @Value("${transport.lwm2m.dtls.retransmission_timeout:9000}") private int dtlsRetransmissionTimeout; @@ -136,25 +149,42 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mTrustCredentials") private SslCredentialsConfig trustCredentialsConfig; - private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); - @PostConstruct public void init() { credentialsConfig.registerReloadCallback(() -> { - log.info("LwM2M Server DTLS certificates reloaded. Triggering server reload..."); - notifyServerReload(); + log.info("LwM2M Server DTLS certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); }); trustCredentialsConfig.registerReloadCallback(() -> { - log.info("LwM2M Trust certificates reloaded. Triggering server reload..."); - notifyServerReload(); + log.info("LwM2M Trust certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); }); } + @PreDestroy + public void destroy() { + reloadDebouncer.shutdownNow(); + } + public void registerServerReloadCallback(Runnable callback) { serverReloadCallbacks.add(callback); } + /** + * Debounces server reload so that if both server and trust credentials change in the same + * poll cycle, only the 'single server recreation' is triggered after both are reloaded. + */ + private synchronized void scheduleServerReload() { + if (pendingReload != null) { + pendingReload.cancel(false); + } + pendingReload = reloadDebouncer.schedule(() -> { + log.info("Debounce window elapsed. Triggering LwM2M server reload..."); + notifyServerReload(); + }, RELOAD_DEBOUNCE_SECONDS, TimeUnit.SECONDS); + } + private void notifyServerReload() { for (Runnable callback : serverReloadCallbacks) { try { diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java new file mode 100644 index 0000000000..1226d0266e --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ExtendWith(MockitoExtension.class) +public class LwM2MTransportServerConfigDebounceTest { + + private static final long DEBOUNCE_SECONDS = 2; // matches LwM2MTransportServerConfig.RELOAD_DEBOUNCE_SECONDS + + @Mock + private SslCredentialsConfig credentialsConfig; + + @Mock + private SslCredentialsConfig trustCredentialsConfig; + + private LwM2MTransportServerConfig config; + + @BeforeEach + public void setup() { + config = new LwM2MTransportServerConfig(); + ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig); + } + + @AfterEach + public void teardown() { + config.destroy(); + } + + @Test + public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + // Wait extra to confirm no second invocation + await().during(DEBOUNCE_SECONDS + 1, SECONDS) + .atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2)); + } + + private void invokeScheduleServerReload() { + ReflectionTestUtils.invokeMethod(config, "scheduleServerReload"); + } + +} diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index f9827d488f..3c1ef94f09 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -176,7 +176,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 7efa428a90..1b221d1fd9 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -207,7 +207,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 543b11859b..6140122062 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -307,7 +307,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 60d3da8f8e..ac02fa396b 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -240,7 +240,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" From 63654ccb3bf03a9298a797ef29db18d01ea3ed12 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 9 Apr 2026 12:39:24 +0300 Subject: [PATCH 22/59] Fix typo --- application/src/main/resources/thingsboard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index f297fadde0..3bdfa31508 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1401,7 +1401,7 @@ transport: reload: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" - # Check an interval in seconds for certificate reload + # Interval in seconds for certificate reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters From f624c0fcb1b30fa02167239f24ab9e4b0dff625f Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 9 Apr 2026 12:43:20 +0300 Subject: [PATCH 23/59] deleted redundant import --- .../thingsboard/server/controller/AlarmCommentController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 937eded7ca..8a113fb424 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -31,7 +31,6 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentType; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; From 5931921c04b790554776ce20aeb5c19da2633169 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 10 Apr 2026 15:18:05 +0200 Subject: [PATCH 24/59] Fix REST API Call node blocking actor thread and semaphore permit leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blocking semaphore guard with a non-blocking bounded FIFO queue + semaphore pattern: - No semaphore/queue when maxParallelRequestsCount=0 (default): direct doHttpCall, identical to the old behavior. - When a concurrency limit is set, incoming messages are enqueued via non-blocking offer(); a full queue triggers onFailure immediately. - tryProcess() acquires one semaphore slot and dispatches the next valid queued task. Stale tasks (batch deadline expired) are dropped and the slot reused in the same pass. - doFinally hook releases the semaphore and calls tryProcess() exactly once after any terminal signal (success, error, cancel), preventing double-release and permit leaks. - publishOn(externalCallExecutor) moves callbacks off reactor-netty I/O threads. System-level safety caps are wired through thingsboard.yml → ActorSystemContext → TbContext → TbHttpClient, scoped to rule-engine services only via @TbRuleEngineComponent: actors.rule.external.http_client.max_parallel_requests (ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS) actors.rule.external.http_client.max_pending_requests (ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS) actors.rule.external.http_client.pool_max_connections (ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS) Backward compat: TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS still honored via yaml fallback. Observability: five AtomicLong counters (dispatched, success, failure, droppedQueueFull, droppedStale) with periodic WARN anomaly logging including semaphorePermits for leak detection. No configuration changes or upgrade scripts required — docker image update is sufficient. Rename RestApiCallNodeSettings to TbHttpClientSettings The settings are about HTTP client transport concerns (connection pool, concurrency, queue depth), not REST API Call node business logic. The new name matches the consumer (TbHttpClient) and the YAML path (actors.rule.external.http_client.*). --- .../server/actors/ActorSystemContext.java | 5 + .../actors/ruleChain/DefaultTbContext.java | 6 + .../config/TbHttpClientSettingsComponent.java | 51 +++++ .../src/main/resources/thingsboard.yml | 12 ++ .../rule/engine/api/TbContext.java | 6 + .../rule/engine/api/TbHttpClientSettings.java | 49 +++++ .../rule/engine/rest/TbHttpClient.java | 200 +++++++++++++++--- .../rule/engine/rest/TbRestApiCallNode.java | 7 +- .../rule/engine/rest/TbHttpClientTest.java | 2 + .../engine/rest/TbRestApiCallNodeTest.java | 151 ++++++++++++- 10 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index a9692f4b9d..925b60de76 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -35,6 +35,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; @@ -684,6 +685,10 @@ public class ActorSystemContext { @Getter private MqttClientSettings mqttClientSettings; + @Autowired(required = false) + @Getter + private TbHttpClientSettings tbHttpClientSettings; + @Getter @Setter private TbActorSystem actorSystem; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 66a15a651e..3a572503d6 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; @@ -1062,6 +1063,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getMqttClientSettings(); } + @Override + public TbHttpClientSettings getTbHttpClientSettings() { + return mainCtx.getTbHttpClientSettings(); + } + private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("ruleNodeId", ruleNodeId.toString()); diff --git a/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java new file mode 100644 index 0000000000..a5ad4e8742 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; + +@TbRuleEngineComponent +@Component +public class TbHttpClientSettingsComponent implements TbHttpClientSettings { + + @Value("${actors.rule.external.http_client.max_parallel_requests:0}") + private int maxParallelRequests; + + @Value("${actors.rule.external.http_client.max_pending_requests:0}") + private int maxPendingRequests; + + @Value("${actors.rule.external.http_client.pool_max_connections:0}") + private int poolMaxConnections; + + @Override + public int getMaxParallelRequests() { + return maxParallelRequests; + } + + @Override + public int getMaxPendingRequests() { + return maxPendingRequests; + } + + @Override + public int getPoolMaxConnections() { + return poolMaxConnections; + } + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 81b56aff28..bac005d836 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -567,6 +567,18 @@ actors: # Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24). # Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp" ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}" + http_client: + # Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins). + max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}" + # Server-level ceiling for the pending-request queue depth per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction. + max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}" + # Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance. + # Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2 + # (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections. + # 0 = use reactor-netty default. + pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}" rpc: # Maximum number of persistent RPC call retries in case of failed request delivery. max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index dc27f79ee2..464c2df532 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -438,4 +438,10 @@ public interface TbContext { MqttClientSettings getMqttClientSettings(); + // Server-level safety caps for the HTTP client used by the REST API Call rule node (read from thingsboard.yml) + + default TbHttpClientSettings getTbHttpClientSettings() { + return TbHttpClientSettings.DEFAULT; + } + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java new file mode 100644 index 0000000000..658f82c0d7 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java @@ -0,0 +1,49 @@ +/** + * 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.rule.engine.api; + +/** + * Server-level safety caps for the HTTP client used by the REST API Call rule node. + * Values are read from {@code thingsboard.yml} (or the corresponding environment variables) + * and applied as hard ceilings on top of the per-node tenant configuration. + * A value of {@code 0} means no system-level restriction. + */ +public interface TbHttpClientSettings { + + /** System ceiling for {@code maxParallelRequestsCount}. 0 = no system limit. */ + int getMaxParallelRequests(); + + /** System ceiling for the pending-request queue depth. 0 = no system limit. */ + int getMaxPendingRequests(); + + /** + * Maximum number of TCP connections in the reactor-netty pool per node instance. + * 0 = use reactor-netty's default: {@code max(availableProcessors, 8) * 2}. + */ + int getPoolMaxConnections(); + + TbHttpClientSettings DEFAULT = new TbHttpClientSettings() { + @Override + public int getMaxParallelRequests() { return 0; } + + @Override + public int getMaxPendingRequests() { return 0; } + + @Override + public int getPoolMaxConnections() { return 0; } + }; + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index df9ce0194b..de1e73cb66 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -33,6 +33,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; @@ -42,6 +43,7 @@ import org.thingsboard.rule.engine.credentials.CredentialsType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import reactor.core.scheduler.Schedulers; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.ConnectionProvider; import reactor.netty.transport.ProxyProvider; @@ -53,8 +55,11 @@ import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -84,17 +89,48 @@ public class TbHttpClient { public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb"; + private static final long ANOMALY_REPORT_INTERVAL_MS = 60_000; + private final TbRestApiCallNodeConfiguration config; + private final String tenantId; + private final String nodeId; + private final TbHttpClientSettings settings; private EventLoopGroup eventLoopGroup; private WebClient webClient; private Semaphore semaphore; + private BlockingQueue pendingQueue; + + private final AtomicLong dispatchedCount = new AtomicLong(); + private final AtomicLong successCount = new AtomicLong(); + private final AtomicLong failureCount = new AtomicLong(); + private final AtomicLong droppedFullCount = new AtomicLong(); + private final AtomicLong droppedStaleCount = new AtomicLong(); + private volatile long lastAnomalyReportAt = 0; + + private record PendingTask( + TbContext ctx, + TbMsg msg, + Consumer onSuccess, + BiConsumer onFailure, + long enqueuedNanos) {} TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared) throws TbNodeException { + this(config, eventLoopGroupShared, "n/a", "n/a", TbHttpClientSettings.DEFAULT); + } + + TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared, + String tenantId, String nodeId, TbHttpClientSettings settings) throws TbNodeException { try { this.config = config; - if (config.getMaxParallelRequestsCount() > 0) { - semaphore = new Semaphore(config.getMaxParallelRequestsCount()); + this.tenantId = tenantId; + this.nodeId = nodeId; + this.settings = settings; + int effectiveParallel = effectiveMax(config.getMaxParallelRequestsCount(), settings.getMaxParallelRequests()); + if (effectiveParallel > 0) { + semaphore = new Semaphore(effectiveParallel); + int effectivePending = effectiveMax(0, settings.getMaxPendingRequests()); + pendingQueue = effectivePending > 0 ? new LinkedBlockingQueue<>(effectivePending) : new LinkedBlockingQueue<>(); } ConnectionProvider connectionProvider = ConnectionProvider @@ -155,16 +191,20 @@ public class TbHttpClient { } } - private int getPoolMaxConnections() { - String poolMaxConnectionsEnv = System.getenv("TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS"); + /** + * Returns the effective limit: {@code min(userMax, systemMax)} when both are positive, + * {@code systemMax} when only the system ceiling is set, or {@code userMax} otherwise. + * A value of {@code 0} means unlimited. + */ + private static int effectiveMax(int userMax, int systemMax) { + if (systemMax <= 0) return userMax; + if (userMax <= 0) return systemMax; + return Math.min(userMax, systemMax); + } - int poolMaxConnections; - if (poolMaxConnectionsEnv != null) { - poolMaxConnections = Integer.parseInt(poolMaxConnectionsEnv); - } else { - poolMaxConnections = ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; - } - return poolMaxConnections; + private int getPoolMaxConnections() { + int configured = settings.getPoolMaxConnections(); + return configured > 0 ? configured : ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; } private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException { @@ -207,54 +247,150 @@ public class TbHttpClient { if (this.eventLoopGroup != null) { this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); } + long full = droppedFullCount.get(); + long stale = droppedStaleCount.get(); + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + if (full > 0 || stale > 0) { + log.warn("[{}][{}] REST API call node destroyed with anomalies: " + + "droppedQueueFull={}, droppedStale={}, dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, full, stale, + dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } else { + log.debug("[{}][{}] REST API call node destroyed. dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } } public void processMessage(TbContext ctx, TbMsg msg, Consumer onSuccess, BiConsumer onFailure) { - try { - if (semaphore != null && !semaphore.tryAcquire(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS)) { - onFailure.accept(msg, new RuntimeException("Timeout during waiting for reply!")); - return; + if (semaphore == null) { + doHttpCall(new PendingTask(ctx, msg, onSuccess, onFailure, 0L)); + return; + } + if (!pendingQueue.offer(new PendingTask(ctx, msg, onSuccess, onFailure, System.nanoTime()))) { + droppedFullCount.incrementAndGet(); + log.debug("[{}][{}] REST API call queue full, dropping msg {}.", tenantId, nodeId, msg.getId()); + maybeReportAnomalies(); + onFailure.accept(msg, new RuntimeException("Max pending requests limit exceeded!")); + return; + } + tryProcess(); + } + + /** + * Tries to acquire one concurrency slot and fire the next queued task. + * Stale messages (whose message pack has expired) are silently dropped. + * Safe to call from any thread under high concurrency. + */ + private void tryProcess() { + while (true) { + if (!semaphore.tryAcquire()) { + return; // all slots are in use; a callback will call tryProcess() when one frees up + } + PendingTask next = pendingQueue.poll(); + if (next == null) { + semaphore.release(); + return; // queue is empty; slot released } + if (!next.msg().isValid()) { + semaphore.release(); + droppedStaleCount.incrementAndGet(); + log.debug("[{}][{}] Dropping stale msg {} from REST API call queue (queueDepth={}).", + tenantId, nodeId, next.msg().getId(), pendingQueue.size()); + next.onFailure().accept(next.msg(), new RuntimeException("Message is no longer valid. Dropped from queue.")); + maybeReportAnomalies(); + continue; // slot released — loop to check if there's a valid next item + } + dispatchedCount.incrementAndGet(); + if (doHttpCall(next)) { + return; // async HTTP call started — its callback will call tryProcess() + } + // synchronous failure — semaphore already released in doHttpCall, loop to try next task + } + } - String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); + private void maybeReportAnomalies() { + long now = System.currentTimeMillis(); + if (now - lastAnomalyReportAt < ANOMALY_REPORT_INTERVAL_MS) { + return; + } + lastAnomalyReportAt = now; + int queueSize = pendingQueue != null ? pendingQueue.size() : 0; + int queueRemaining = pendingQueue != null ? pendingQueue.remainingCapacity() : Integer.MAX_VALUE; + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + log.warn("[{}][{}] REST API call node anomalies: droppedQueueFull={}, droppedStale={} " + + "(dispatched={}, success={}, failure={}, queueDepth={}, queueRemaining={}, semaphorePermits={}).", + tenantId, nodeId, + droppedFullCount.get(), droppedStaleCount.get(), + dispatchedCount.get(), successCount.get(), failureCount.get(), + queueSize, queueRemaining, availablePermits); + } + + /** + * Initiates an async HTTP call for the given task. + * + * @return {@code true} if the async subscription was started and the semaphore slot is now + * owned by the callback (which will release it and call {@link #tryProcess()}). + * {@code false} if a synchronous exception occurred before the subscription was + * registered; the semaphore slot has already been released and the caller should + * loop rather than recurse to avoid stack overflow when many queued tasks fail + * synchronously (e.g. misconfigured URL pattern). + */ + private boolean doHttpCall(PendingTask task) { + boolean asyncStarted = false; + try { + String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), task.msg()); HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); URI uri = buildEncodedUri(endpointUrl); - RequestBodySpec request = webClient + RequestBodySpec req = webClient .method(method) .uri(uri) - .headers(headers -> prepareHeaders(headers, msg)); + .headers(headers -> prepareHeaders(headers, task.msg())); if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && !config.isIgnoreRequestBody()) { - request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText()))); + req.body(BodyInserters.fromValue(getData(task.msg(), config.isParseToPlainText()))); } - request - .retrieve() + req.retrieve() .toEntity(String.class) - .subscribe(responseEntity -> { + .publishOn(Schedulers.fromExecutor(task.ctx().getExternalCallExecutor())) + .doFinally(signalType -> { + // Runs exactly once after onComplete, onError, or cancel — the only + // place that releases the permit for the async path. if (semaphore != null) { semaphore.release(); + tryProcess(); } - + }) + .subscribe(responseEntity -> { if (responseEntity.getStatusCode().is2xxSuccessful()) { - onSuccess.accept(processResponse(ctx, msg, responseEntity)); + successCount.incrementAndGet(); + task.onSuccess().accept(processResponse(task.ctx(), task.msg(), responseEntity)); } else { - onFailure.accept(processFailureResponse(msg, responseEntity), null); + failureCount.incrementAndGet(); + task.onFailure().accept(processFailureResponse(task.msg(), responseEntity), null); } }, throwable -> { - if (semaphore != null) { - semaphore.release(); - } - - onFailure.accept(processException(msg, throwable), processThrowable(throwable)); + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), throwable), processThrowable(throwable)); }); - } catch (InterruptedException e) { - log.warn("Timeout during waiting for reply!", e); + asyncStarted = true; + return true; + } catch (Exception e) { + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), e), processThrowable(e)); + return false; + } finally { + // Synchronous permit release: only when the async pipeline was never registered + // (asyncStarted=false). If it was, doFinally owns the release. + // tryProcess() is intentionally not called here — the caller loops iteratively. + if (!asyncStarted && semaphore != null) { + semaphore.release(); + } } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 0217edf5e0..02ed33138d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.rest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -58,7 +59,11 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); - httpClient = new TbHttpClient(config, ctx.getSharedEventLoop()); + TbHttpClientSettings httpClientSettings = ctx.getTbHttpClientSettings(); + httpClient = new TbHttpClient(config, ctx.getSharedEventLoop(), + ctx.getTenantId() != null ? ctx.getTenantId().getId().toString() : "n/a", + ctx.getSelfId() != null ? ctx.getSelfId().getId().toString() : "n/a", + httpClientSettings != null ? httpClientSettings : TbHttpClientSettings.DEFAULT); } @Override diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java index a33d25b038..8b31c5a5ac 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java @@ -27,6 +27,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.mockserver.integration.ClientAndServer; import org.springframework.util.LinkedMultiValueMap; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -156,6 +157,7 @@ public class TbHttpClientTest { .build(); var ctx = mock(TbContext.class); + when(ctx.getExternalCallExecutor()).thenReturn(DirectListeningExecutor.INSTANCE); when(ctx.transformMsg( eq(msg), eq(msg.getMetaData()), diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index a93417275f..493c5abf8c 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -27,13 +27,17 @@ import org.apache.http.protocol.HttpRequestHandler; import org.junit.jupiter.api.AfterEach; 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.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -43,6 +47,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -53,15 +58,22 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { + static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); + @Spy private TbRestApiCallNode restNode; @@ -125,6 +137,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -148,7 +162,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); @@ -183,6 +197,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -206,12 +222,143 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); } + @Test + public void givenForceAckTrue_whenOnMsgAndServerReturns200_thenAckedImmediatelyAndEnqueuedForTellNext() throws IOException { + final String path = "/path/to/get"; + setupServer("*", new HttpRequestHandler() { + @Override + public void handle(HttpRequest request, HttpResponse response, HttpContext context) + throws HttpException, IOException { + response.setStatusCode(200); + } + }); + + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + + given(ctx.isExternalNodeForceAck()).willReturn(true); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + verify(ctx).ack(msg); + verify(ctx, timeout(TIMEOUT)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + verify(ctx, never()).tellSuccess(any()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void givenMaxParallelRequestsCountAndBadUrl_whenOnMsg_thenSemaphoreIsReleasedAndFailureReported(boolean forceAck) throws IOException { + given(ctx.isExternalNodeForceAck()).willReturn(forceAck); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRestEndpointUrlPattern(""); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertThat(restNode.httpClient.getSemaphore().availablePermits()).isEqualTo(1); + if (forceAck) { + verify(ctx).enqueueForTellFailure(any(), any(Throwable.class)); + } else { + verify(ctx).tellFailure(any(), any()); + } + } + + @Test + public void givenMaxPendingRequestsExceeded_whenOnMsg_thenFailsImmediatelyAndQueuedRequestFiresAfterSlotOpens() throws IOException, InterruptedException { + CountDownLatch releaseResponse = new CountDownLatch(1); + setupServer("*", (request, response, context) -> { + try { + releaseResponse.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + response.setStatusCode(200); + }); + + given(ctx.isExternalNodeForceAck()).willReturn(false); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + // Simulate server-level cap: maxPendingRequests=1 via TbHttpClientSettings + given(ctx.getTbHttpClientSettings()).willReturn(new TbHttpClientSettings() { + @Override public int getMaxParallelRequests() { return 0; } + @Override public int getMaxPendingRequests() { return 1; } + @Override public int getPoolMaxConnections() { return 0; } + }); + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d/path", server.getLocalPort())); + initWithConfig(config); + + TbMsg msg1 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg2 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg3 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + + restNode.onMsg(ctx, msg1); // fires immediately (semaphore acquired) + restNode.onMsg(ctx, msg2); // queues (semaphore exhausted, queue has room) + restNode.onMsg(ctx, msg3); // fails immediately (queue full — server-level maxPendingRequests=1) + + verify(ctx, timeout(TIMEOUT)).tellFailure(any(), any()); + + releaseResponse.countDown(); + verify(ctx, timeout(TIMEOUT).times(2)).tellSuccess(any()); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( // config for version 2 with upgrade from version 0 From f3ff0e18c19bdbb75b3deda955a6bdcdd9570670 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 14 Apr 2026 09:30:25 +0200 Subject: [PATCH 25/59] Fixed CVE-2026-34487, CVE-2026-34486, CVE-2026-34483 --- pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pom.xml b/pom.xml index 2d9a57843b..9a81a3d80d 100755 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ 4.2.25 5.0.4 33.1.0-jre + 10.1.54 3.18.0 2.16.1 1.3.1 @@ -1225,6 +1226,23 @@ jaxb-api ${javax.xml.bind-api.version} + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-el + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + + org.springframework.boot spring-boot-starter-test From 1289b4205424830f384ff66325b3ac03a818086a Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 14 Apr 2026 16:33:41 +0200 Subject: [PATCH 26/59] Fixed Alarm Assignee icon placement --- .../modules/home/components/alarm/alarm-assignee.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss index c8de20ad36..925c6defc9 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss @@ -40,7 +40,7 @@ margin-left: 12px; margin-right: 20px; &.inline-icon { - margin-left: 0; + margin-left: 8px; margin-right: 8px; } } From 2d43d6895f8adad2c21823c09bee07725c075d66 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 10 Apr 2026 13:17:01 +0200 Subject: [PATCH 27/59] A text truncation feature has been added to improve the display of long texts --- .../home/components/alarm/alarm-assignee-panel.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index c0f983e6d0..fbbce2a469 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -42,7 +42,7 @@
- +
From 907253a53bf2cbe2a16a0a1d0cb90373446fcb62 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 15 Apr 2026 09:30:38 +0200 Subject: [PATCH 28/59] fix(pom): move tomcat override before spring-boot BOM import Dependencies managed by spring-boot-dependencies must be declared before the BOM import to take precedence over BOM-managed versions. --- pom.xml | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/pom.xml b/pom.xml index 9a81a3d80d..409e16cdc9 100755 --- a/pom.xml +++ b/pom.xml @@ -992,6 +992,25 @@ + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-el + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + + org.springframework.boot spring-boot-dependencies @@ -1226,23 +1245,6 @@ jaxb-api ${javax.xml.bind-api.version} - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - org.springframework.boot spring-boot-starter-test From 3fe1110123b53929a279e03b92fd910846439220 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 15 Apr 2026 13:08:09 +0300 Subject: [PATCH 29/59] fix: use presencePenalty instead of frequencyPenalty for Vertex AI GenerationConfig Copy-paste bug caused frequencyPenalty to be set as both frequency and presence penalty when building the GenerationConfig for Vertex AI Gemini. --- .../Langchain4jChatModelConfigurerImpl.java | 4 +- ...angchain4jChatModelConfigurerImplTest.java | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 28d696468c..103617c890 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -177,8 +177,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (chatModelConfig.frequencyPenalty() != null) { generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); + if (chatModelConfig.presencePenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); } if (chatModelConfig.maxOutputTokens() != null) { generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java new file mode 100644 index 0000000000..014e3f1b64 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.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.ai; + +import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.chat.ChatModel; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +class Langchain4jChatModelConfigurerImplTest { + + private static final String TEST_SERVICE_ACCOUNT_KEY = """ + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + """; + + private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); + + @Test + void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { + // GIVEN + var providerConfig = new GoogleVertexAiGeminiProviderConfig( + "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY + ); + var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(providerConfig) + .modelId("gemini-2.0-flash") + .frequencyPenalty(0.3) + .presencePenalty(0.7) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(chatModelConfig); + + // THEN + var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); + assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); + assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); + } + +} From 48fe4074fdfa029ab33f77b49dab610d28880f4d Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 12:22:18 +0200 Subject: [PATCH 30/59] Fix mat icon styles --- .../home/components/alarm/alarm-assignee.component.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss index 925c6defc9..1aeded1767 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss @@ -42,5 +42,9 @@ &.inline-icon { margin-left: 8px; margin-right: 8px; + padding: 6px 0 !important; + } + &.mat-icon { + font-size: 28px !important; } } From 7c869239d3263794f4128ae38596e9e7a739167a Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 12:24:08 +0200 Subject: [PATCH 31/59] Adjusted size of entity type select to fit error message --- .../shared/components/entity/entity-list-select.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index e6a5067783..595b33ec1d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -19,7 +19,7 @@ Date: Wed, 15 Apr 2026 15:28:55 +0300 Subject: [PATCH 32/59] updated gateway version --- application/src/main/resources/thingsboard.yml | 2 +- .../server/controller/DeviceConnectivityControllerTest.java | 2 +- .../server/dao/device/DeviceConnectivityServiceImpl.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 81b56aff28..d0e2a110c9 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1530,7 +1530,7 @@ device: pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" gateway: # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch - image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}" + image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" # Edges parameters edges: diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index 7d78877b9e..3060bfd8bf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { private DeviceProfileId mqttDeviceProfileId; private DeviceProfileId coapDeviceProfileId; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Before diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 89cb538c28..70f5f48272 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -85,7 +85,7 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService private String mqttsPemCertFile; @Value("${device.connectivity.coaps.pem_cert_file:}") private String coapsPemCertFile; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Override From 542ae6fa973a41298005214c23394eef4ac22bb7 Mon Sep 17 00:00:00 2001 From: imbeacon Date: Tue, 5 Aug 2025 15:03:53 +0300 Subject: [PATCH 33/59] Updated environmental variable names in docker compose file for gateway launch command --- .../DeviceConnectivityControllerTest.java | 7 ++++--- .../server/dao/util/DeviceConnectivityUtil.java | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index 3060bfd8bf..9a80ad2e25 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { "\n" + " # Environment variables\n" + " environment:\n" + - " - host=host.docker.internal\n" + - " - port=1883\n" + - " - accessToken=" + credentials.getCredentialsId() + "\n" + + " - TB_GW_HOST=host.docker.internal\n" + + " - TB_GW_PORT=1883\n" + + " - TB_GW_SECURITY_TYPE=accessToken\n" + + " - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" + "\n" + " # Volumes bind\n" + " volumes:\n" + diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index 5b50d062a2..d1d31c4745 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -117,24 +117,26 @@ public class DeviceConnectivityUtil { dockerComposeBuilder.append("\n"); dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" environment:\n"); - dockerComposeBuilder.append(" - host=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); - dockerComposeBuilder.append(" - port=1883\n"); + dockerComposeBuilder.append(" - TB_GW_HOST=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PORT=1883\n"); switch (deviceCredentials.getCredentialsType()) { case ACCESS_TOKEN: - dockerComposeBuilder.append(" - accessToken=").append(deviceCredentials.getCredentialsId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n"); + dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n"); break; case MQTT_BASIC: + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n"); BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); if (credentials != null) { if (StringUtils.isNotEmpty(credentials.getClientId())) { - dockerComposeBuilder.append(" - clientId=").append(credentials.getClientId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getUserName())) { - dockerComposeBuilder.append(" - username=").append(credentials.getUserName()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getPassword())) { - dockerComposeBuilder.append(" - password=").append(credentials.getPassword()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n"); } } break; From d42b0707ac028ed83839f1d93e6349939a868cbf Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 15 Apr 2026 16:13:33 +0300 Subject: [PATCH 34/59] feat: update AI model autocomplete options Add latest models for OpenAI (gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, gpt-5.2), Google Gemini (gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-3.1-flash-lite-preview), Anthropic (claude-opus-4-6, claude-sonnet-4-6), and update Mistral models list. Remove deprecated model entries. --- .../src/app/shared/models/ai-model.models.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index c861b33225..504e4d2659 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -110,6 +110,10 @@ export const AiModelMap = new Map Date: Wed, 15 Apr 2026 15:35:00 +0200 Subject: [PATCH 35/59] Propagated size of page to child nodes in Entities hierarchy widget --- .../widget/lib/entity/entities-hierarchy-widget.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts index 12cd55f5b1..1aee063a96 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts @@ -471,7 +471,8 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O childrenNodesLoadCb(this.prepareNodes(childNodes)); } } - } + }, + pageSize: this.ctx.widgetConfig.pageSize }; this.ctx.subscriptionApi.createSubscription(subscriptionOptions, true); } From a46592b6a75dad45aba63489021c19d5e1918959 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 16:55:52 +0200 Subject: [PATCH 36/59] Minor rework --- .../widget/lib/entity/entities-hierarchy-widget.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts index 1aee063a96..ea8f02756b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts @@ -448,6 +448,7 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O const subscriptionOptions: WidgetSubscriptionOptions = { type: widgetType.latest, datasources: [childrenDatasource], + pageSize: this.ctx.widgetConfig.pageSize, callbacks: { onSubscriptionMessage: (subscription, message) => { this.ctx.showToast(message.severity, message.message, undefined, @@ -471,8 +472,7 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O childrenNodesLoadCb(this.prepareNodes(childNodes)); } } - }, - pageSize: this.ctx.widgetConfig.pageSize + } }; this.ctx.subscriptionApi.createSubscription(subscriptionOptions, true); } From c8c1d49eaf88baa28be749b3bb9865132b30e9c8 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 15:01:57 +0200 Subject: [PATCH 37/59] Added update run after custom header actions available --- .../src/app/modules/home/components/widget/widget.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index c42a4c6b48..cb96a493a1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -332,6 +332,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, if (customHeaderActions$.length) { forkJoin(customHeaderActions$).subscribe((customHeaderActions) => { this.widgetContext.customHeaderActions.push(...customHeaderActions); + this.dashboardWidget.updateParamsFromData(true); }); } From f3a1c5faab2b6b445cdfe14e7a8409acb5bb7f12 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 17:08:05 +0200 Subject: [PATCH 38/59] Fixed not proper set of timewindow for child nodes in Entities hierarchy widget --- .../widget/lib/entity/entities-hierarchy-widget.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts index 12cd55f5b1..e1e140e326 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts @@ -448,6 +448,9 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O const subscriptionOptions: WidgetSubscriptionOptions = { type: widgetType.latest, datasources: [childrenDatasource], + useDashboardTimewindow: this.ctx.widgetConfig.useDashboardTimewindow, + dashboardTimewindow: this.ctx.dashboardTimewindow, + timeWindowConfig: this.ctx.widgetConfig.useDashboardTimewindow ? this.ctx.dashboardTimewindow : this.ctx.widgetConfig.timewindow, callbacks: { onSubscriptionMessage: (subscription, message) => { this.ctx.showToast(message.severity, message.message, undefined, From a0fd36bc1e66eecb7102c9fd46a03141e70b76f8 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 17:20:19 +0200 Subject: [PATCH 39/59] Approach change --- .../components/entity/entity-list-select.component.html | 3 ++- .../components/entity/entity-type-select.component.html | 2 +- .../shared/components/entity/entity-type-select.component.ts | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index 595b33ec1d..ea9248f17a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -19,10 +19,11 @@ {{ label }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index 4e6d6d4347..ea24c373d6 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -21,7 +21,7 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@app/shared import { EntityService } from '@core/http/entity.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-type-select', @@ -69,6 +69,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + @Input() @coerceBoolean() inlineField: boolean; From e2260ff5577cf653a9729ae68f192ccca3f03651 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 15 Apr 2026 17:27:04 +0200 Subject: [PATCH 40/59] Add hint to reserve space --- .../shared/components/entity/entity-type-select.component.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index 65e3615887..b3f4fa5300 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -24,6 +24,9 @@ {{ displayEntityTypeFn(type) }} + @if (!inlineField && subscriptSizing === 'dynamic') { + + } {{ 'entity.type-required' | translate }} From e782f4c84672190a97b80500d36bc942be688b9c Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 16 Apr 2026 11:41:42 +0300 Subject: [PATCH 41/59] Enhance SystemPatchApplier for LTS version support Relax version check to allow maintenance digit increases within the same LTS family (e.g. 4.3.0 -> 4.3.1), not just patch digit increases. Add LTS SQL schema patch execution from upgrade/lts/schema_update.sql, running before views and widget updates so schema changes are in place for dependent objects. --- .../main/data/upgrade/lts/schema_update.sql | 19 ++ .../service/system/SystemPatchApplier.java | 35 ++- .../server/system/SystemPatchApplierTest.java | 245 ++++++++++++++++++ 3 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 application/src/main/data/upgrade/lts/schema_update.sql diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql new file mode 100644 index 0000000000..3140b4f253 --- /dev/null +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -0,0 +1,19 @@ +-- +-- 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. +-- + +-- LTS cumulative schema update file. +-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.). +-- This file is executed by SystemPatchApplier on every version increase within the LTS family. diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index d80961923d..256b230b5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -40,6 +40,7 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -48,7 +49,7 @@ import java.util.stream.Stream; /** * Runs at application startup and applies no-downtime data updates - * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). + * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1). */ @Slf4j @Component @@ -91,6 +92,8 @@ public class SystemPatchApplier { } try { + updateLtsSqlSchema(); + updateSqlViews(); log.info("Updated sql database views"); @@ -119,17 +122,37 @@ public class SystemPatchApplier { return false; } - if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) { return false; } - log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); return true; } - private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { - return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor - && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) { + if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) { + return false; + } + if (packageVersion.maintenance != dbVersion.maintenance) { + return packageVersion.maintenance > dbVersion.maintenance; + } + return packageVersion.patch > dbVersion.patch; + } + + private void updateLtsSqlSchema() { + Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql"); + if (!Files.exists(sqlFile)) { + log.trace("LTS schema update file does not exist: {}", sqlFile); + return; + } + try { + String sql = Files.readString(sqlFile); + jdbcTemplate.execute(sql); + log.info("Applied LTS SQL schema update from {}", sqlFile); + } catch (IOException e) { + throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e); + } } private void updateSqlViews() { diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 67e5e946c8..f3ea4e838c 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.system.SystemPatchApplier; @@ -71,6 +72,9 @@ public class SystemPatchApplierTest { @Mock private InstallScripts installScripts; + @Mock + private DatabaseSchemaSettingsService schemaSettingsService; + @Mock private WidgetTypeService widgetTypeService; @@ -373,6 +377,247 @@ public class SystemPatchApplierTest { verify(widgetTypeService, times(1)).saveWidgetType(any()); } + // --- isVersionIncreased tests --- + + @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}") + @MethodSource("provideVersionComparisonTestCases") + void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion, + SystemPatchApplier.VersionInfo dbVersion, boolean expected) { + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion); + assertEquals(expected, result, testName); + } + + private static Stream provideVersionComparisonTestCases() { + return Stream.of( + // Maintenance digit increases within same LTS family + Arguments.of("maintenance increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("maintenance increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 3, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Patch digit increases within same maintenance + Arguments.of("patch increased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("patch increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 0, 5), + new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true), + + // Both maintenance and patch increased + Arguments.of("maintenance and patch both increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Maintenance increased, patch value is lower (irrelevant — maintenance wins) + Arguments.of("maintenance increased, patch is lower", + new SystemPatchApplier.VersionInfo(4, 3, 2, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true), + + // Same version — no increase + Arguments.of("same version", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("same version with non-zero parts", + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false), + + // Decreased versions — no increase + Arguments.of("maintenance decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false), + Arguments.of("patch decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false), + + // Different major — different family, skip + Arguments.of("different major", + new SystemPatchApplier.VersionInfo(5, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("major decreased", + new SystemPatchApplier.VersionInfo(3, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + + // Different minor — different LTS family, skip + Arguments.of("minor increased (different LTS family)", + new SystemPatchApplier.VersionInfo(4, 4, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("minor decreased", + new SystemPatchApplier.VersionInfo(4, 2, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false) + ); + } + + // --- isVersionChanged tests --- + + @Test + void whenVersionIncreased_thenVersionChangedReturnsTrue() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertTrue(result); + } + + @Test + void whenVersionNotIncreased_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + // --- updateLtsSqlSchema tests --- + + @Test + void whenLtsSqlFileExists_thenExecutesSql() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + } + + @Test + void whenLtsSqlFileDoesNotExist_thenSkips() { + Path dataDir = tempDir.resolve("data"); + // Don't create the file + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + String sql = "DO $$ BEGIN\n" + + " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" + + " CREATE TYPE test_type AS ENUM ('A', 'B');\n" + + " END IF;\n" + + "END $$;\n" + + "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"; + Files.writeString(ltsDir.resolve("schema_update.sql"), sql); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute(sql); + } + + // --- applyPatchIfNeeded flow tests --- + + @Test + void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // LTS SQL was executed + verify(jdbcTemplate).execute("SELECT 1;"); + // Schema version was updated + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenSkipsEverything() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // No lock acquired + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + // No schema update + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + + @Test + void whenLockNotAcquired_thenSkipsPatchApplication() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService, never()).updateSchemaVersion(); + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenDifferentLtsFamily_thenSkipsPatch() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + private static Stream provideDescriptorComparisonTestCases() { return Stream.of( Arguments.of("Both null", null, null, true), From bf33b97a713f8288976660c565f60f86b41871d4 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 17 Apr 2026 16:56:40 +0300 Subject: [PATCH 42/59] Ignore unknown properties when deserializing Redis cache entries Prevents UnrecognizedPropertyException during rolling upgrades when a newer node writes a cached entity with an added field and an older node reads it back. The Redis-backed TbJsonRedisSerializer now uses JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER instead of the strict OBJECT_MAPPER used by JacksonUtil.fromBytes. --- .../server/cache/TbJsonRedisSerializer.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java index bf5fb7e448..3e8aef83a1 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java @@ -18,6 +18,8 @@ package org.thingsboard.server.cache; import org.springframework.data.redis.serializer.SerializationException; import org.thingsboard.common.util.JacksonUtil; +import java.io.IOException; + public class TbJsonRedisSerializer implements TbRedisSerializer { private final Class clazz; @@ -33,6 +35,13 @@ public class TbJsonRedisSerializer implements TbRedisSerializer { @Override public V deserialize(K key, byte[] bytes) throws SerializationException { - return JacksonUtil.fromBytes(bytes, clazz); + if (bytes == null) { + return null; + } + try { + return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz); + } catch (IOException e) { + throw new SerializationException("Failed to deserialize cached value", e); + } } } From 08e94a7f2a2e9faa586ae79e10ea18be968d58b6 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 17 Apr 2026 16:36:23 +0200 Subject: [PATCH 43/59] Fix flaky TbRestApiCallNodeTest via SsrfProtectionValidator ResourceLock TbRestApiCallNodeTest ran concurrently with SsrfSafeAddressResolverGroupTest, which toggles the static SsrfProtectionValidator.enabled flag in its setUp/tearDown. When the flag leaked into the REST test's async HTTP calls, 'localhost' was rejected by SSRF and extra tellFailure invocations broke the Mockito verify count. TbHttpClientTest and SsrfSafeAddressResolverGroupTest already declare @ResourceLock("SsrfProtectionValidator"); apply the same lock to TbRestApiCallNodeTest so all three SSRF-sensitive tests serialize. Fixes #15453 --- .../org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 493c5abf8c..cc2daa2421 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -27,6 +27,7 @@ import org.apache.http.protocol.HttpRequestHandler; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.ValueSource; @@ -70,6 +71,7 @@ import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); From c66da7e18d5fdb2710d93fa15ec148e8de88c73a Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 20 Apr 2026 10:24:43 +0300 Subject: [PATCH 44/59] edqs: fixed entity filtering by string data points --- .../server/edqs/data/dp/StringDataPoint.java | 5 ++ .../edqs/repo/DeviceTypeFilterTest.java | 57 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java index 8ae3499d63..2d9e77633d 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java @@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint { this.value = deduplicate ? TbStringPool.intern(value) : value; } + @Override + public boolean getBool() { + return Boolean.parseBoolean(value); + } + @Override public double getDouble() { return Double.parseDouble(value); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java index 5d4905f0ef..11423d7484 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java @@ -19,15 +19,20 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -39,8 +44,10 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.UUID; public class DeviceTypeFilterTest extends AbstractEDQTest { @@ -119,7 +126,50 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); } + @Test + public void testFindDeviceByBooleanAttributeWithMixedTypes() { + DeviceId device1Id = createLoraDevice("LoRa-1"); + DeviceId device2Id = createLoraDevice("LoRa-2"); + DeviceId device3Id = createLoraDevice("LoRa-3"); + + long ts = System.currentTimeMillis(); + addOrUpdate(new AttributeKv(device1Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", true), ts), 1L)); + addOrUpdate(new AttributeKv(device2Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", false), ts), 1L)); + addOrUpdate(new AttributeKv(device3Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new StringDataEntry("active", "true"), ts), 1L)); + + KeyFilter activeFilter = new KeyFilter(); + activeFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "active")); + activeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate predicate = new BooleanFilterPredicate(); + predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + predicate.setValue(FilterPredicateValue.fromBoolean(true)); + activeFilter.setPredicate(predicate); + + var result = repository.countEntitiesByQuery(tenantId, null, + getDeviceTypeQuery("LoRa", List.of(activeFilter)), false); + Assert.assertEquals(2, result); + } + + private DeviceId createLoraDevice(String name) { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(loraProfileId); + device.setName(name); + device.setCreatedTime(42L); + addOrUpdate(EntityType.DEVICE, device); + return deviceId; + } + private static EntityDataQuery getDeviceTypeQuery(String deviceType) { + return getDeviceTypeQuery(deviceType, null); + } + + private static EntityDataQuery getDeviceTypeQuery(String deviceType, List extraFilters) { DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(Collections.singletonList(deviceType)); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); @@ -135,7 +185,12 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { nameFilter.setPredicate(predicate); nameFilter.setValueType(EntityKeyValueType.STRING); - return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + List keyFilters = new ArrayList<>(); + keyFilters.add(nameFilter); + if (extraFilters != null) { + keyFilters.addAll(extraFilters); + } + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); } } From 1259fdbced1f002669b2394430667a1aa1d261bb Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 20 Apr 2026 16:12:12 +0300 Subject: [PATCH 45/59] Fixes after review --- .../server/DefaultLwM2mTransportService.java | 40 ++++++++++++++----- ...wM2MTransportServerConfigDebounceTest.java | 2 +- .../mqtt/MqttSslHandlerProvider.java | 12 ++++-- .../mqtt/MqttSslHandlerProviderTest.java | 33 +++++++-------- .../config/ssl/KeystoreSslCredentials.java | 8 ++-- .../config/ssl/PemSslCredentials.java | 8 ++-- .../config/ssl/SslCredentialsConfig.java | 23 ++++++----- .../service/CertificateReloadManager.java | 37 ++++++++++------- .../config/ssl/SslCredentialsConfigTest.java | 7 +++- 9 files changed, 101 insertions(+), 69 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 5815a47555..b72cf62d99 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -32,6 +32,7 @@ import org.eclipse.leshan.server.californium.LwM2mPskStore; import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpointsProvider; import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider; import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider; +import org.eclipse.leshan.server.endpoint.LwM2mServerEndpointsProvider; import org.eclipse.leshan.server.registration.RegistrationStore; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.DependsOn; @@ -236,36 +237,41 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - // Stop (not destroy) old server to release ports but keep it restartable for rollback + // Only cycle the endpoint providers (CoAP/DTLS). The RegistrationStore and SecurityStore are + // Spring singletons shared with newServer — calling oldServer.stop()/destroy() would propagate + // to them (LeshanServer.stop/destroy propagate to Stoppable/Destroyable stores), which would + // shut down the shared schedulers (TbInMemoryRegistrationStore.destroy calls schedExecutor.shutdownNow), + // killing newServer's cleaner tasks. Leaving the stores running preserves existing device + // registrations across the swap — clients only need to re-establish DTLS on next uplink. if (oldServer != null) { - log.info("Stopping old LwM2M server to release ports..."); + log.info("Stopping old LwM2M endpoints to release ports..."); if (oldListener != null) { oldServer.getRegistrationService().removeListener(oldListener.registrationListener); oldServer.getPresenceService().removeListener(oldListener.presenceListener); oldServer.getObservationService().removeListener(oldListener.observationListener); oldServer.getSendService().removeListener(oldListener.sendListener); } - oldServer.stop(); + stopEndpoints(oldServer); } try { newServer.start(); } catch (Exception e) { log.error("Failed to start new LwM2M server", e); - newServer.destroy(); - // Attempt to restart the old server (only stopped, not destroyed) + destroyEndpoints(newServer); + // Attempt to restart the old endpoints (shared stores are still running). if (oldServer != null) { try { - oldServer.start(); + startEndpoints(oldServer); if (oldListener != null) { oldServer.getRegistrationService().addListener(oldListener.registrationListener); oldServer.getPresenceService().addListener(oldListener.presenceListener); oldServer.getObservationService().addListener(oldListener.observationListener); oldServer.getSendService().addListener(oldListener.sendListener); } - log.info("Restored old LwM2M server successfully."); + log.info("Restored old LwM2M endpoints successfully."); } catch (Exception restoreEx) { - log.error("Failed to restore old LwM2M server", restoreEx); + log.error("Failed to restore old LwM2M endpoints", restoreEx); } } throw e; @@ -280,14 +286,26 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar this.server = newServer; this.context.setServer(newServer); this.serverListener = newListener; - log.info("New LwM2M server started successfully."); + log.info("New LwM2M server started with refreshed certificates. Existing device registrations preserved; clients will re-establish DTLS on next uplink."); - // Destroy old server only after successful swap + // Destroy old endpoints only — leave the shared stores alone. if (oldServer != null) { - oldServer.destroy(); + destroyEndpoints(oldServer); } } + private void stopEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::stop); + } + + private void startEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::start); + } + + private void destroyEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::destroy); + } + @Override public String getName() { return DataConstants.LWM2M_TRANSPORT_NAME; diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java index 1226d0266e..93f305a190 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java @@ -33,7 +33,7 @@ import static org.awaitility.Awaitility.await; @ExtendWith(MockitoExtension.class) public class LwM2MTransportServerConfigDebounceTest { - private static final long DEBOUNCE_SECONDS = 2; // matches LwM2MTransportServerConfig.RELOAD_DEBOUNCE_SECONDS + private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS"); @Mock private SslCredentialsConfig credentialsConfig; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index fcb58bc20c..a954d7ae7f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -71,15 +71,21 @@ public class MqttSslHandlerProvider implements SmartInitializingSingleton { @Override public void afterSingletonsInstantiated() { + // Eagerly build the initial context so the handshake path is a lock-free volatile read. + this.sslContext = createSslContext(); mqttSslCredentialsConfig.registerReloadCallback(() -> { - log.info("MQTT SSL certificates reloaded. Invalidating SSL context..."); - sslContext = null; - log.info("MQTT SSL context invalidated. Will be recreated on next connection."); + log.info("MQTT SSL certificates reloaded. Rebuilding SSL context..."); + // Build the new context first; if it fails, the old one stays in place, and + // the exception propagates to CertificateReloadManager's retry/backoff logic. + this.sslContext = createSslContext(); + log.info("MQTT SSL context rebuilt. New connections will use the new certificate."); }); } public SslHandler getSslHandler() { SSLContext ctx = sslContext; + // Defensive lazy init in case afterSingletonsInstantiated hasn't run yet (e.g., test wiring). + // In normal operation ctx is non-null here, so the handshake path is lock-free. if (ctx == null) { synchronized (this) { ctx = sslContext; diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java index 96183c2934..8c4f3c7a29 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -89,38 +89,33 @@ public class MqttSslHandlerProviderTest { } @Test - public void givenCertificatesReloaded_whenGetSslHandler_thenShouldRecreateSSLContext() { + public void givenCertificatesReloaded_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() { sslHandlerProvider.afterSingletonsInstantiated(); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); Runnable reloadCallback = callbackCaptor.getValue(); - SslHandler handler1 = sslHandlerProvider.getSslHandler(); SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); assertThat(initialContext).isNotNull(); reloadCallback.run(); - assertThat(handler1).isNotNull(); + // After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free. SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); - assertThat(contextAfterReload).isNull(); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); - SslHandler handler2 = sslHandlerProvider.getSslHandler(); - SSLContext newContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); - - assertThat(handler2).isNotNull(); - assertThat(newContext).isNotNull(); - assertThat(newContext).isNotSameAs(initialContext); + SslHandler handler = sslHandlerProvider.getSslHandler(); + assertThat(handler).isNotNull(); } @Test - public void givenConcurrentGetSslHandlerCalls_whenSSLContextNull_thenShouldCreateOnlyOnce() throws Exception { + public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception { sslHandlerProvider.afterSingletonsInstantiated(); - ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); - verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); - callbackCaptor.getValue().run(); + SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextBefore).isNotNull(); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(5); @@ -143,12 +138,13 @@ public class MqttSslHandlerProviderTest { boolean completed = doneLatch.await(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); - SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); - assertThat(context).isNotNull(); + // Concurrent handshakes read the same pre-built context without the old sync bottleneck. + SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfter).isSameAs(contextBefore); } @Test - public void givenReloadCallback_whenInvoked_thenShouldInvalidateSSLContext() { + public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() { sslHandlerProvider.afterSingletonsInstantiated(); sslHandlerProvider.getSslHandler(); @@ -161,7 +157,8 @@ public class MqttSslHandlerProviderTest { callbackCaptor.getValue().run(); SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); - assertThat(contextAfterReload).isNull(); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); } @Test diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 7cbc4403b7..3986704a33 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -22,7 +22,6 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -62,10 +61,9 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { @Override public List getCertificateFilePaths() { if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - Path resolved = Path.of(storeFile).toAbsolutePath(); - if (Files.exists(resolved)) { - return Collections.singletonList(resolved); - } + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g., mounted after boot) will be detected and trigger a reload. + return Collections.singletonList(Path.of(storeFile).toAbsolutePath()); } return Collections.emptyList(); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index c6eb75698e..fb2e5a4a0d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -33,7 +33,6 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -152,10 +151,9 @@ public class PemSslCredentials extends AbstractSslCredentials { private static void addIfFileSystemPath(List paths, String filePath) { if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - Path resolved = Path.of(filePath).toAbsolutePath(); - if (Files.exists(resolved)) { - paths.add(resolved); - } + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g. mounted after boot) will be detected and trigger a reload. + paths.add(Path.of(filePath).toAbsolutePath()); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index 3646c4f37d..e747de4931 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -68,20 +68,23 @@ public class SslCredentialsConfig { } public void onCertificateFileChanged() { + log.info("{}: Certificate file changed. Reloading SSL credentials...", name); try { - log.info("{}: Certificate file changed. Reloading SSL credentials...", name); this.credentials.reload(this.trustsOnly); - log.info("{}: SSL credentials reloaded successfully.", name); - - for (Runnable callback : reloadCallbacks) { - try { - callback.run(); - } catch (Exception e) { - log.error("{}: Error executing reload callback", name, e); - } - } } catch (Exception e) { log.error("{}: Failed to reload SSL credentials", name, e); + // Rethrow, so CertificateReloadManager's watcher counts this as a failure + // and applies MAX_CONSECUTIVE_FAILURES backoff instead of treating it as a successful reload. + throw new RuntimeException(name + ": Failed to reload SSL credentials", e); + } + log.info("{}: SSL credentials reloaded successfully.", name); + + for (Runnable callback : reloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("{}: Error executing reload callback", name, e); + } } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index eec84a22f0..63f2247aba 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -32,6 +32,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -48,7 +49,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis private static final int MAX_CONSECUTIVE_FAILURES = 10; - @Value("${transport.ssl.certificate.reload.enabled:false}") + @Value("${transport.ssl.certificate.reload.enabled:true}") private boolean reloadEnabled; @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") @@ -107,19 +108,24 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis continue; } - List existingPaths = filePaths.stream() - .filter(p -> p != null && Files.exists(p)) - .toList(); - + // Register all configured paths, including those that don't exist yet — the watcher uses + // mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are + // picked up and trigger a reload on the next poll. + List pathsToWatch = new ArrayList<>(filePaths.size()); for (Path filePath : filePaths) { - if (filePath == null || !Files.exists(filePath)) { - log.warn("Certificate file does not exist: {} (from {})", filePath, config.getName()); + if (filePath == null) { + continue; + } + pathsToWatch.add(filePath); + if (!Files.exists(filePath)) { + log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears", + filePath, config.getName()); } } - if (!existingPaths.isEmpty()) { - registerWatcher(config.getName(), existingPaths, config::onCertificateFileChanged); - log.info("Registered certificate watcher: {} -> {}", config.getName(), existingPaths); + if (!pathsToWatch.isEmpty()) { + registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged); + log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch); } } catch (Exception e) { @@ -190,10 +196,13 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis return; } - // Compute combined checksum of all files + // Capture mtimes and checksums together before the callback runs. + // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll. + Map currentModifiedTimes = new HashMap<>(); Map currentChecksums = new HashMap<>(); StringBuilder combined = new StringBuilder(); for (Path path : paths) { + currentModifiedTimes.put(path, getLastModifiedTime(path)); String checksum = calculateChecksum(path); currentChecksums.put(path, checksum); if (!combined.isEmpty()) { @@ -216,7 +225,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis if (combinedChecksum.equals(oldCombinedChecksum)) { // Content unchanged, just update modification times for (Path path : paths) { - lastModifiedMap.put(path, getLastModifiedTime(path)); + lastModifiedMap.put(path, currentModifiedTimes.get(path)); } return; } @@ -230,7 +239,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle for (Path path : paths) { - lastModifiedMap.put(path, getLastModifiedTime(path)); + lastModifiedMap.put(path, currentModifiedTimes.get(path)); } return; } @@ -239,7 +248,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis log.info("Certificate change detected for: {}. Triggering reload...", name); reloadCallback.run(); for (Path path : paths) { - lastModifiedMap.put(path, getLastModifiedTime(path)); + lastModifiedMap.put(path, currentModifiedTimes.get(path)); lastChecksumMap.put(path, currentChecksums.get(path)); } consecutiveFailures = 0; diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java index 6e16b2dc83..ec6d2a0117 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java @@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; @@ -114,7 +115,7 @@ public class SslCredentialsConfigTest { } @Test - public void givenCredentialsReloadFails_whenCertificateChanged_thenCallbacksShouldNotBeCalled() throws Exception { + public void givenCredentialsReloadFails_whenCertificateChanged_thenShouldRethrowAndNotCallCallbacks() throws Exception { AtomicInteger callbackCount = new AtomicInteger(0); config.registerReloadCallback(callbackCount::incrementAndGet); @@ -122,7 +123,9 @@ public class SslCredentialsConfigTest { doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); - config.onCertificateFileChanged(); + assertThatThrownBy(() -> config.onCertificateFileChanged()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to reload SSL credentials"); assertThat(callbackCount.get()).isEqualTo(0); } From c63665ab81c88176bb0908cf8d9b1b1b4e2aa643 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 21 Apr 2026 10:56:37 +0300 Subject: [PATCH 46/59] Fixed CVE-2026-39364, CVE-2026-39363 --- ui-ngx/package.json | 32 +- ....22.patch => @angular+build+20.3.24.patch} | 0 ...3.18.patch => @angular+core+20.3.19.patch} | 2 +- ui-ngx/yarn.lock | 526 +++++++++--------- 4 files changed, 280 insertions(+), 280 deletions(-) rename ui-ngx/patches/{@angular+build+20.3.22.patch => @angular+build+20.3.24.patch} (100%) rename ui-ngx/patches/{@angular+core+20.3.18.patch => @angular+core+20.3.19.patch} (97%) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index b88a0583e2..4f34216e6c 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -13,16 +13,16 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.18", + "@angular/animations": "20.3.19", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.18", - "@angular/compiler": "20.3.18", - "@angular/core": "20.3.18", - "@angular/forms": "20.3.18", + "@angular/common": "20.3.19", + "@angular/compiler": "20.3.19", + "@angular/core": "20.3.19", + "@angular/forms": "20.3.19", "@angular/material": "20.2.14", - "@angular/platform-browser": "20.3.18", - "@angular/platform-browser-dynamic": "20.3.18", - "@angular/router": "20.3.18", + "@angular/platform-browser": "20.3.19", + "@angular/platform-browser-dynamic": "20.3.19", + "@angular/router": "20.3.19", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", @@ -94,13 +94,13 @@ }, "devDependencies": { "@angular-builders/custom-esbuild": "20.0.0", - "@angular-devkit/build-angular": "20.3.22", - "@angular-devkit/core": "20.3.22", - "@angular-devkit/schematics": "20.3.22", - "@angular/build": "20.3.22", - "@angular/cli": "20.3.22", - "@angular/compiler-cli": "20.3.18", - "@angular/language-service": "20.3.18", + "@angular-devkit/build-angular": "20.3.24", + "@angular-devkit/core": "20.3.24", + "@angular-devkit/schematics": "20.3.24", + "@angular/build": "20.3.24", + "@angular/cli": "20.3.24", + "@angular/compiler-cli": "20.3.19", + "@angular/language-service": "20.3.19", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", @@ -139,7 +139,7 @@ "ace-builds": "1.43.6", "tinymce": "6.8.6", "@babel/core": "7.28.3", - "esbuild": "0.25.9", + "esbuild": "0.28.0", "rollup": "4.59.0", "jquery.terminal/**/form-data": ">=4.0.4", "js-beautify/**/minimatch": "^9.0.7" diff --git a/ui-ngx/patches/@angular+build+20.3.22.patch b/ui-ngx/patches/@angular+build+20.3.24.patch similarity index 100% rename from ui-ngx/patches/@angular+build+20.3.22.patch rename to ui-ngx/patches/@angular+build+20.3.24.patch diff --git a/ui-ngx/patches/@angular+core+20.3.18.patch b/ui-ngx/patches/@angular+core+20.3.19.patch similarity index 97% rename from ui-ngx/patches/@angular+core+20.3.18.patch rename to ui-ngx/patches/@angular+core+20.3.19.patch index 12ceb3739d..5295946f7b 100644 --- a/ui-ngx/patches/@angular+core+20.3.18.patch +++ b/ui-ngx/patches/@angular+core+20.3.19.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs -index 35c61af..d89462b 100755 +index 4f7d936..4a98b2c 100755 --- a/node_modules/@angular/core/fesm2022/debug_node.mjs +++ b/node_modules/@angular/core/fesm2022/debug_node.mjs @@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 7a77f42959..ec9183bcc7 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -160,24 +160,24 @@ "@angular-devkit/core" "^20.0.0" "@angular/build" "^20.0.0" -"@angular-devkit/architect@0.2003.22", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": - version "0.2003.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.22.tgz#cb660579890be1d0622339bc74a85b6f1697ae5f" - integrity sha512-gxVOslVweD+Co6gpRVlByHus/3HVAnsl99MobS9PBh8vh2g6bJ011PBgl0TKsP/pqBGawZOkJXYrRPeMKnobYA== +"@angular-devkit/architect@0.2003.24", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.24.tgz#1c59822ffeada4248f669424c7c0534f39fe8bc5" + integrity sha512-E7mCdkL6SWnW60G1nGLuugmsopza/eVIrDWB1y0vLkWN8gepOvnHz2Uf637kdzed/F1WoqR+dhv1SfsaJapzKA== dependencies: - "@angular-devkit/core" "20.3.22" + "@angular-devkit/core" "20.3.24" rxjs "7.8.2" -"@angular-devkit/build-angular@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.22.tgz#a96ba0d41a708ed3402f2568db343b1e85fe0e4e" - integrity sha512-PnKIRue/j30sWLWC9q2T3WwE5+GJekDX/zCXTYgjW6u0y/kt+7G/sR0uxXGkX2NwSVGnIz0ucVOBsbSl9PKd6A== +"@angular-devkit/build-angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.24.tgz#871b7c134d9c9f77b16cd68bca340801638e56a7" + integrity sha512-zBjETGqKtiojF8VHvjELW+UAMBRJ7ziqNc1xwUtqRuWZQVz8HH6x8m8Ncw4mSq9ecCFC4VB7OWTJ1VkAdYSBnQ== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.22" - "@angular-devkit/build-webpack" "0.2003.22" - "@angular-devkit/core" "20.3.22" - "@angular/build" "20.3.22" + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/build-webpack" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular/build" "20.3.24" "@babel/core" "7.28.3" "@babel/generator" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" @@ -188,14 +188,14 @@ "@babel/preset-env" "7.28.3" "@babel/runtime" "7.28.3" "@discoveryjs/json-ext" "0.6.3" - "@ngtools/webpack" "20.3.22" + "@ngtools/webpack" "20.3.24" ansi-colors "4.1.3" autoprefixer "10.4.21" babel-loader "10.0.0" browserslist "^4.21.5" copy-webpack-plugin "14.0.0" css-loader "7.1.2" - esbuild-wasm "0.25.9" + esbuild-wasm "0.28.0" fast-glob "3.3.3" http-proxy-middleware "3.0.5" istanbul-lib-instrument "6.0.3" @@ -228,20 +228,20 @@ webpack-merge "6.0.1" webpack-subresource-integrity "5.1.0" optionalDependencies: - esbuild "0.25.9" + esbuild "0.28.0" -"@angular-devkit/build-webpack@0.2003.22": - version "0.2003.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.22.tgz#9769c91cfd0203b1136cdd6b6ed4d205e17da70b" - integrity sha512-ad4iW5CDGDYFXR9ZpdY8O2n4mx+tJtjx4mfC9Z+0ikwA4ir2IW2BOUhWvJ6IDq3f7kulFxP8S4zWN9r7jsZw3A== +"@angular-devkit/build-webpack@0.2003.24": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.24.tgz#9d96b1d2c05faf3bcb696059c736d9498230ba36" + integrity sha512-gfadsLK3SxbGpZQMSJGr8RK65R2mZi/VmHXdztXQHATvMpy+00CX1Nu3n+lPTxOVbC73aIczUkUbIuRC1HBCqg== dependencies: - "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/architect" "0.2003.24" rxjs "7.8.2" -"@angular-devkit/core@20.3.22", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.22.tgz#6040c5673c4f4bb2eaaaac0bb9801ea6fa0886ea" - integrity sha512-1vZnZTAjGcCM+86v2al+2eiROiSw0uAWeVllfHSQe0KsKOP1FE8UUUiWChhxVn7vIxypphlfGunkeeIn1C/ZFw== +"@angular-devkit/core@20.3.24", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.24.tgz#e4108ad599d507d934b9ca990d98a4bb23bd0471" + integrity sha512-kmOjXJcbFxUI91nds9n6XZ6Y/DyQ7/TqRXbHHqvkz9RtlIpdbgWHlIZIq6mgsPOgPBzkxFjtncVARYZUI3yxaw== dependencies: ajv "8.18.0" ajv-formats "3.0.1" @@ -250,12 +250,12 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.22", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.22.tgz#e1aab6eaa431f323db2425af05276dbe45c63fc2" - integrity sha512-gN2XSXRn3eErGEJlH0iSfQZZ7NdxVZNdjSxuVEGBEFhe3cVeC21LzM3GTWW6xwtBb4pxHglFyc7BUFiYtZiYtg== +"@angular-devkit/schematics@20.3.24", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.24.tgz#4631ec0755363173db0e89eadc0d756985e0bae1" + integrity sha512-B5fBPi0xnEDI0wLLkCjsrYjazRPyf+rnHLAHi34thMdeY9dqljJGWYdNuyUUBak6HNPBLdEo1EUSNcOF9OWt4A== dependencies: - "@angular-devkit/core" "20.3.22" + "@angular-devkit/core" "20.3.24" jsonc-parser "3.3.1" magic-string "0.30.17" ora "8.2.0" @@ -321,20 +321,20 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.18.tgz#e675e045839d559b4917053eb0f74313bf3235ef" - integrity sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA== +"@angular/animations@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.19.tgz#636cf19528c548427f3f2ce6c4dd3b863b1475c4" + integrity sha512-/FjU9i7J58/yBURhgVSIiLDcuyOfJxAa0b7ZrOsx6P+FES+M2T2BKZl5V2NuiP2fDFtjsV7U+M/Z9UNUmeHCEw== dependencies: tslib "^2.3.0" -"@angular/build@20.3.22", "@angular/build@^20.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.22.tgz#c29b7980a96f6353b2d1c5cc2d7bcaf82c46d181" - integrity sha512-sxjVZU6AZHXyKRHJUMawXOj4qMf3vm8XK6wUejr01UKj6BqW2YWaQO26RpRJssXD2ITTqn6+UBwL7pEwe2a4Jg== +"@angular/build@20.3.24", "@angular/build@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.24.tgz#79e423a077a4177074b52e3a20c76f082fc08824" + integrity sha512-AMGXOr268y+kVutl4LpOXY2xv9P+RXLCyXUkzYwi8XwGyxAJZfyu/L5qtcO2llExp5CuvP0OxkWxk4JOGRi9TA== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/architect" "0.2003.24" "@babel/core" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" @@ -342,7 +342,7 @@ "@vitejs/plugin-basic-ssl" "2.1.0" beasties "0.3.5" browserslist "^4.23.0" - esbuild "0.25.9" + esbuild "0.28.0" https-proxy-agent "7.0.6" istanbul-lib-instrument "6.0.3" jsonc-parser "3.3.1" @@ -357,7 +357,7 @@ semver "7.7.2" source-map-support "0.5.21" tinyglobby "0.2.14" - vite "7.1.11" + vite "7.3.2" watchpack "2.4.4" optionalDependencies: lmdb "3.4.2" @@ -370,18 +370,18 @@ parse5 "^8.0.0" tslib "^2.3.0" -"@angular/cli@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.22.tgz#ce00d20d55f458de39c80b080a749d4d03e4f9b6" - integrity sha512-0uyQPF0gGuzioWJKNyOzWSQrrC5GiidR+8gz1lODoJTnJZZdsP5n3nvccbcRmhy55B1WByHvQBE+6eDBbh06/g== +"@angular/cli@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.24.tgz#e6ab456b83eede47ccc3503dd6068bdf04be4e05" + integrity sha512-TT7LldRJPCi1VGBJMzcrYU+P3w2G6zgubwhFUdJthUiS77A+At6WJqaRY2BdIS3l7HZOXTEaU2Vj2Gkf2ol2Yw== dependencies: - "@angular-devkit/architect" "0.2003.22" - "@angular-devkit/core" "20.3.22" - "@angular-devkit/schematics" "20.3.22" + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" "@inquirer/prompts" "7.8.2" "@listr2/prompt-adapter-inquirer" "3.0.1" "@modelcontextprotocol/sdk" "1.26.0" - "@schematics/angular" "20.3.22" + "@schematics/angular" "20.3.24" "@yarnpkg/lockfile" "1.1.0" algoliasearch "5.35.0" ini "5.0.0" @@ -394,17 +394,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.18.tgz#311dc0658be69f1368db2eeea92ea1b940329975" - integrity sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw== +"@angular/common@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.19.tgz#fb19c335a5a5ea84cd6c90a4abda99c2de5c8b17" + integrity sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.18.tgz#0f4726d1624c9def0f60c65f60e61a226676459c" - integrity sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g== +"@angular/compiler-cli@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.19.tgz#baeb020e693a759b0b8190931611309678b1e011" + integrity sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -415,31 +415,31 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.18.tgz#5370dc1a24d55623828a2b0875c776c8da13fdc6" - integrity sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA== +"@angular/compiler@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.19.tgz#cce87072c55c1bab3fda92d83048d0136cea83ad" + integrity sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA== dependencies: tslib "^2.3.0" -"@angular/core@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.18.tgz#af91805841184cd87e862134a806bba019d170ea" - integrity sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw== +"@angular/core@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.19.tgz#ce8b2a0df48612b3d9bfefe8098a1018a3186406" + integrity sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.18.tgz#ebd249a381dd3f6e5af3f760d7c3a8a6a76531ac" - integrity sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg== +"@angular/forms@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.19.tgz#179fa22f6e2daf3fe58c400d3e3a7c10647f0ebc" + integrity sha512-WJotd+Lhl4FG2b0K+aQNyQDHhR515zKCuphjiUqEW7sifWrOQxANLKzPBngGrH75ayANFgPaDf7U3ZRIoblcQA== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.18.tgz#b0c8c9bcf085907765e3cb7fbf0e14e79259e3c0" - integrity sha512-V1ZBqeTtZYH9H8/G1qCw6gafsJmhMIMFjLX0Hv2KpTpmfK9nxIHPEVnshr3xT+qKYJIrMV/cU5YOzInEapLpuQ== +"@angular/language-service@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.19.tgz#ba9e04a35717d948ff972476486eae2ea25a109d" + integrity sha512-9J0XrAKXInz11KKyNMrMZmn2NSjVbxzt/DsAumbrzzixeZwiY7vDy2Kqw/LLFLi7IlfMQ/gznz/mCVVgUWI5Gg== "@angular/material@20.2.14": version "20.2.14" @@ -448,24 +448,24 @@ dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.18.tgz#c4c633fcc4782e1a361a0904d2bc3f180bbf0f81" - integrity sha512-NyTobOGYVzGmPmtI+3lxMzxi0TbLq4SRNQ2ENEJAt6k2JnMmHBm483ppLRAM47nGlDdiraW0IX93EtYYNkiK3g== +"@angular/platform-browser-dynamic@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.19.tgz#b13cb94dc39be24e22d70712605ca879aa5e83b2" + integrity sha512-OgErw7wjcC+8yKF5h99hJq8x+tvc091wThfmdL5YC+U3HgRmUaNZFgB/jR7cb/NeeeC42QW5Vc0qoUTC9rMnLQ== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.18.tgz#33b5d5cdddc4a0f60a9ddf886b941587a348d304" - integrity sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ== +"@angular/platform-browser@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.19.tgz#4bffa36591d9ba5adc89a1b48952fdfd982d2f30" + integrity sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w== dependencies: tslib "^2.3.0" -"@angular/router@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.18.tgz#276fe53975ceb858f4e76405f8219fbe5cb20204" - integrity sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng== +"@angular/router@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.19.tgz#2346ff945a8039194ba921a473a8f4565a92b949" + integrity sha512-qHrMniHOsCJ4neZmcQVodjutJilyXAXk7EhLa931QyL0qyVKVomv6E0I3UFzRaC3ZeHc+hzBdU6C6bvMFKTl1g== dependencies: tslib "^2.3.0" @@ -1389,135 +1389,135 @@ resolved "https://registry.yarnpkg.com/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz#fe541a68aa080255f798c8561714ac8fad72cdd5" integrity sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g== -"@esbuild/aix-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" - integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== - -"@esbuild/android-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" - integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== - -"@esbuild/android-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" - integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== - -"@esbuild/android-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" - integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== - -"@esbuild/darwin-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae" - integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== - -"@esbuild/darwin-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" - integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== - -"@esbuild/freebsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" - integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== - -"@esbuild/freebsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" - integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== - -"@esbuild/linux-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" - integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== - -"@esbuild/linux-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" - integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== - -"@esbuild/linux-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" - integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== - -"@esbuild/linux-loong64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" - integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== - -"@esbuild/linux-mips64el@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" - integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== - -"@esbuild/linux-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" - integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== - -"@esbuild/linux-riscv64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" - integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== - -"@esbuild/linux-s390x@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" - integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== - -"@esbuild/linux-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" - integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== - -"@esbuild/netbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" - integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== - -"@esbuild/netbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" - integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== - -"@esbuild/openbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" - integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== - -"@esbuild/openbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" - integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== - -"@esbuild/openharmony-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" - integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== - -"@esbuild/sunos-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" - integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== - -"@esbuild/win32-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" - integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== - -"@esbuild/win32-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" - integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== - -"@esbuild/win32-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" - integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -2397,10 +2397,10 @@ dependencies: tslib "^2.0.0" -"@ngtools/webpack@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.22.tgz#65c7eefb187516f00d2268c60613294cf8d55028" - integrity sha512-EgOiRjYpNG5Mu/WAhMwQrAB1BBwWrApbYm2hTU9KjaxOZvtWHjeFfsiULgd1T76GpiF4t3Iw1GtNljzgD6fT/A== +"@ngtools/webpack@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.24.tgz#8e344ebaea46d7e669220f02a33bb61826bb35f6" + integrity sha512-PIP9hFVF6OOmDxG0s6vX7cHm/6wwWK8jXd6e1I/CewR0zpVPtR1vxhhw9CrY4VEUCFSL6x2NuW/U3cI7LU+Z1Q== "@ngx-translate/core@^17.0.0": version "17.0.0" @@ -2751,13 +2751,13 @@ resolved "https://registry.yarnpkg.com/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz#7ce587273f7372a146308cb1075ba26177d42cdb" integrity sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww== -"@schematics/angular@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.22.tgz#acd7a990148a99f04793ee2e92a56694667790a4" - integrity sha512-wXTdFaPIBnSSNj/m0kclvPCYQOc2EGTQN1+Q3j9RIghS9gKgPxI1unSfgieJldZWKzcl8+WdB2zUuDzE7tEshQ== +"@schematics/angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.24.tgz#1cef967a08a55336b296dfd0e2c5207dc1c43226" + integrity sha512-GNB8zI8Lz0rJl4Q7FH4Y8ZmRpODkNDKGxWObfZ39POgiyr3CtT5sMRTQq1lWRWTlZeV8uD51DvW/EsAsbaS4HA== dependencies: - "@angular-devkit/core" "20.3.22" - "@angular-devkit/schematics" "20.3.22" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" jsonc-parser "3.3.1" "@sigstore/bundle@^4.0.0": @@ -5513,42 +5513,42 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild-wasm@0.25.9: - version "0.25.9" - resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.25.9.tgz#70e15ff86d6d3e55b0e10817c826783f7ff6612a" - integrity sha512-Jpv5tCSwQg18aCqCRD3oHIX/prBhXMDapIoG//A+6+dV0e7KQMGFg85ihJ5T1EeMjbZjON3TqFy0VrGAnIHLDA== +esbuild-wasm@0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz#e2e60b86ed19e0f6f5a1e6c1c722f5a811d7d5f7" + integrity sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg== -esbuild@0.25.9, esbuild@^0.25.0: - version "0.25.9" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.9.tgz#15ab8e39ae6cdc64c24ff8a2c0aef5b3fd9fa976" - integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g== +esbuild@0.28.0, esbuild@^0.27.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.9" - "@esbuild/android-arm" "0.25.9" - "@esbuild/android-arm64" "0.25.9" - "@esbuild/android-x64" "0.25.9" - "@esbuild/darwin-arm64" "0.25.9" - "@esbuild/darwin-x64" "0.25.9" - "@esbuild/freebsd-arm64" "0.25.9" - "@esbuild/freebsd-x64" "0.25.9" - "@esbuild/linux-arm" "0.25.9" - "@esbuild/linux-arm64" "0.25.9" - "@esbuild/linux-ia32" "0.25.9" - "@esbuild/linux-loong64" "0.25.9" - "@esbuild/linux-mips64el" "0.25.9" - "@esbuild/linux-ppc64" "0.25.9" - "@esbuild/linux-riscv64" "0.25.9" - "@esbuild/linux-s390x" "0.25.9" - "@esbuild/linux-x64" "0.25.9" - "@esbuild/netbsd-arm64" "0.25.9" - "@esbuild/netbsd-x64" "0.25.9" - "@esbuild/openbsd-arm64" "0.25.9" - "@esbuild/openbsd-x64" "0.25.9" - "@esbuild/openharmony-arm64" "0.25.9" - "@esbuild/sunos-x64" "0.25.9" - "@esbuild/win32-arm64" "0.25.9" - "@esbuild/win32-ia32" "0.25.9" - "@esbuild/win32-x64" "0.25.9" + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -10397,12 +10397,12 @@ vary@^1, vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -vite@7.1.11: - version "7.1.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.11.tgz#4d006746112fee056df64985191e846ebfb6007e" - integrity sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg== +vite@7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" + integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== dependencies: - esbuild "^0.25.0" + esbuild "^0.27.0" fdir "^6.5.0" picomatch "^4.0.3" postcss "^8.5.6" From 0765d1f66231341bb39cb422a7850c3ec6264b0a Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 21 Apr 2026 11:07:44 +0300 Subject: [PATCH 47/59] Fixed CVE-2026-4800 --- ui-ngx/yarn.lock | 579 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 391 insertions(+), 188 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index ec9183bcc7..454822d186 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -469,18 +469,13 @@ dependencies: tslib "^2.3.0" -"@antfu/install-pkg@^0.4.0": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-0.4.1.tgz#d1d7f3be96ecdb41581629cafe8626d1748c0cf1" - integrity sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw== +"@antfu/install-pkg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz#78fa036be1a6081b5a77a5cf59f50c7752b6ba26" + integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ== dependencies: - package-manager-detector "^0.2.0" - tinyexec "^0.3.0" - -"@antfu/utils@^0.7.10": - version "0.7.10" - resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" - integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== + package-manager-detector "^1.3.0" + tinyexec "^1.0.1" "@auth0/angular-jwt@^5.2.0": version "5.2.0" @@ -1324,42 +1319,40 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@braintree/sanitize-url@^7.0.1": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.0.tgz#048e48aab4f1460e3121e22aa62459d16653dc85" - integrity sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg== +"@braintree/sanitize-url@^7.1.1": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f" + integrity sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA== -"@chevrotain/cst-dts-gen@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" - integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== +"@chevrotain/cst-dts-gen@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz#ec068e1e83c5fdad69d81773556cae97f0b5dcdb" + integrity sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg== dependencies: - "@chevrotain/gast" "11.0.3" - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/gast" "12.0.0" + "@chevrotain/types" "12.0.0" -"@chevrotain/gast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" - integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== +"@chevrotain/gast@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-12.0.0.tgz#0e0cbf8eee01c7a4449b9caf19e5f3834dba2c35" + integrity sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ== dependencies: - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/types" "12.0.0" -"@chevrotain/regexp-to-ast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" - integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== +"@chevrotain/regexp-to-ast@12.0.0", "@chevrotain/regexp-to-ast@~12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz#a90bc4b4f5337a883a88dddd0cca7c38cfe66a7a" + integrity sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA== -"@chevrotain/types@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" - integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== +"@chevrotain/types@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-12.0.0.tgz#a762b5c2b4f07496b56c93c30ce224b3637cc2c8" + integrity sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA== -"@chevrotain/utils@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" - integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== +"@chevrotain/utils@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-12.0.0.tgz#9aab2055df43d0bb55919eaca76a9cda45e52b89" + integrity sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA== "@cspotcode/source-map-support@^0.8.0": version "0.8.1" @@ -1649,18 +1642,14 @@ resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== -"@iconify/utils@^2.1.32": - version "2.1.33" - resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-2.1.33.tgz#cbf7242a52fd0ec58c42d37d28e4406b5327e8c0" - integrity sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw== +"@iconify/utils@^3.0.2": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-3.1.0.tgz#fb41882915f97fee6f91a2fbb8263e8772ca0438" + integrity sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw== dependencies: - "@antfu/install-pkg" "^0.4.0" - "@antfu/utils" "^0.7.10" + "@antfu/install-pkg" "^1.1.0" "@iconify/types" "^2.0.0" - debug "^4.3.6" - kolorist "^1.8.0" - local-pkg "^0.5.0" - mlly "^1.7.1" + mlly "^1.8.0" "@inquirer/ansi@^1.0.2": version "1.0.2" @@ -2165,12 +2154,12 @@ resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-7.4.47.tgz#f8e5516aae129764a76d1bb2f27e55bee03e6e90" integrity sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw== -"@mermaid-js/parser@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.3.0.tgz#7a28714599f692f93df130b299fa1aadc9f9c8ab" - integrity sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA== +"@mermaid-js/parser@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-1.1.0.tgz#8f96c35ddab34a1b12af58f2c59f5abb7d4743fc" + integrity sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw== dependencies: - langium "3.0.0" + langium "^4.0.0" "@messageformat/core@^3.4.0": version "3.4.0" @@ -3087,6 +3076,216 @@ dependencies: "@types/node" "*" +"@types/d3-array@*": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-axis@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.6.tgz#e760e5765b8188b1defa32bc8bb6062f81e4c795" + integrity sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.6.tgz#c2f4362b045d472e1b186cdbec329ba52bdaee6c" + integrity sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.6.tgz#1706ca40cf7ea59a0add8f4456efff8f8775793d" + integrity sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-contour@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.6.tgz#9ada3fa9c4d00e3a5093fed0356c7ab929604231" + integrity sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz#185c1a80cc807fdda2a3fe960f7c11c4a27952e1" + integrity sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw== + +"@types/d3-dispatch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz#ef004d8a128046cfce434d17182f834e44ef95b2" + integrity sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA== + +"@types/d3-drag@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + +"@types/d3-ease@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-fetch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + +"@types/d3-geo@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-polygon@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz#dfae54a6d35d19e76ac9565bcb32a8e54693189c" + integrity sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA== + +"@types/d3-quadtree@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + +"@types/d3-scale@*": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-shape@*": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + +"@types/d3-time@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + +"@types/d3-transition@*": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" + integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/eslint-scope@^3.7.7": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -3384,6 +3583,11 @@ dependencies: "@types/jquery" "*" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/ws@^8.5.10": version "8.5.12" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" @@ -3487,6 +3691,14 @@ "@typescript-eslint/types" "8.54.0" eslint-visitor-keys "^4.2.1" +"@upsetjs/venn.js@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz#3be192038cdda927aa4f8b22ab51af82abf47f34" + integrity sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw== + optionalDependencies: + d3-selection "^3.0.0" + d3-transition "^3.0.1" + "@vitejs/plugin-basic-ssl@2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz#c70d2a922bc437f154089d7ef0505db4b383eb7b" @@ -3683,10 +3895,10 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^8.11.0, acorn@^8.11.3, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.4.1: - version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -4305,24 +4517,23 @@ chardet@^2.1.1: resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.1.1.tgz#5c75593704a642f71ee53717df234031e65373c8" integrity sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ== -chevrotain-allstar@~0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" - integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== +chevrotain-allstar@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz#04e1429faca94a14d4572e0107c4865beac36298" + integrity sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA== dependencies: lodash-es "^4.17.21" -chevrotain@~11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" - integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== +chevrotain@~12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-12.0.0.tgz#8ebefe0a0516b1b314a8d9c7f4e948a509098d1c" + integrity sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ== dependencies: - "@chevrotain/cst-dts-gen" "11.0.3" - "@chevrotain/gast" "11.0.3" - "@chevrotain/regexp-to-ast" "11.0.3" - "@chevrotain/types" "11.0.3" - "@chevrotain/utils" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/cst-dts-gen" "12.0.0" + "@chevrotain/gast" "12.0.0" + "@chevrotain/regexp-to-ast" "12.0.0" + "@chevrotain/types" "12.0.0" + "@chevrotain/utils" "12.0.0" chokidar@^3.6.0: version "3.6.0" @@ -4538,10 +4749,10 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -confbox@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.7.tgz#ccfc0a2bcae36a84838e83a3b7f770fb17d6c579" - integrity sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA== +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== config-chain@^1.1.13: version "1.1.13" @@ -4746,10 +4957,10 @@ cytoscape-fcose@^2.2.0: dependencies: cose-base "^2.2.0" -cytoscape@^3.29.2: - version "3.30.2" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.30.2.tgz#94149707fb6547a55e3b44f03ffe232706212161" - integrity sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw== +cytoscape@^3.33.1: + version "3.33.2" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.2.tgz#3a58906b4002b7c237f54dfc9b971983757da791" + integrity sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw== "d3-array@1 - 2": version "2.12.1" @@ -4926,7 +5137,7 @@ d3-scale@4: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -"d3-selection@2 - 3", d3-selection@3: +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== @@ -4964,7 +5175,7 @@ d3-shape@^1.2.0: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== -"d3-transition@2 - 3", d3-transition@3: +"d3-transition@2 - 3", d3-transition@3, d3-transition@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== @@ -4986,7 +5197,7 @@ d3-zoom@3: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.8.2, d3@^7.9.0: +d3@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -5022,12 +5233,12 @@ d3@^7.8.2, d3@^7.9.0: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.10: - version "7.0.10" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" - integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== +dagre-d3-es@7.0.14: + version "7.0.14" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz#1272276e26457cf3b97dac569f8f0531ec33c377" + integrity sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg== dependencies: - d3 "^7.8.2" + d3 "^7.9.0" lodash-es "^4.17.21" data-uri-to-buffer@^4.0.0: @@ -5062,11 +5273,16 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@1.11.19, dayjs@^1.11.10, dayjs@^1.11.5: +dayjs@1.11.19: version "1.11.19" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== +dayjs@^1.11.19, dayjs@^1.11.5: + version "1.11.20" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938" + integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -5256,10 +5472,12 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.0.11: - version "3.1.7" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.7.tgz#711a8c96479fb6ced93453732c160c3c72418a6a" - integrity sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ== +dompurify@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" + integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" domutils@^3.2.2: version "3.2.2" @@ -7206,10 +7424,10 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -katex@^0.16.0, katex@^0.16.9: - version "0.16.11" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" - integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== +katex@^0.16.0, katex@^0.16.25: + version "0.16.45" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c" + integrity sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA== dependencies: commander "^8.3.0" @@ -7242,21 +7460,17 @@ klaw-sync@^6.0.0: dependencies: graceful-fs "^4.1.11" -kolorist@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" - integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== - -langium@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/langium/-/langium-3.0.0.tgz#4938294eb57c59066ef955070ac4d0c917b26026" - integrity sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg== +langium@^4.0.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/langium/-/langium-4.2.2.tgz#d7409c23475d591ed6fc7d123c396e4fa4134e60" + integrity sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ== dependencies: - chevrotain "~11.0.3" - chevrotain-allstar "~0.3.0" + "@chevrotain/regexp-to-ast" "~12.0.0" + chevrotain "~12.0.0" + chevrotain-allstar "~0.4.1" vscode-languageserver "~9.0.1" vscode-languageserver-textdocument "~1.0.11" - vscode-uri "~3.0.8" + vscode-uri "~3.1.0" launch-editor@^2.6.1: version "2.9.1" @@ -7422,14 +7636,6 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -local-pkg@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" - integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== - dependencies: - mlly "^1.4.2" - pkg-types "^1.0.3" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -7444,10 +7650,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21, lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.17.21, lodash-es@^4.17.23: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.camelcase@^4.3.0: version "4.3.0" @@ -7584,12 +7790,7 @@ maplibre-gl@5.2.0: tinyqueue "^3.0.0" vt-pbf "^3.1.3" -marked@^13.0.2: - version "13.0.3" - resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" - integrity sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA== - -marked@~16.4.2: +marked@^16.3.0, marked@~16.4.2: version "16.4.2" resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3" integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== @@ -7650,29 +7851,31 @@ merge2@^1.3.0: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== "mermaid@>= 10.6.0 < 12.0.0": - version "11.2.1" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.2.1.tgz#b168c6f862268f77a0d3559926b193926ddc60bc" - integrity sha512-F8TEaLVVyxTUmvKswVFyOkjPrlJA5h5vNR1f7ZnSWSpqxgEZG1hggtn/QCa7znC28bhlcrNh10qYaIiill7q4A== - dependencies: - "@braintree/sanitize-url" "^7.0.1" - "@iconify/utils" "^2.1.32" - "@mermaid-js/parser" "^0.3.0" - cytoscape "^3.29.2" + version "11.14.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.14.0.tgz#ce81b22bc10f3117ef7737406ef2d10ee1741769" + integrity sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g== + dependencies: + "@braintree/sanitize-url" "^7.1.1" + "@iconify/utils" "^3.0.2" + "@mermaid-js/parser" "^1.1.0" + "@types/d3" "^7.4.3" + "@upsetjs/venn.js" "^2.0.0" + cytoscape "^3.33.1" cytoscape-cose-bilkent "^4.1.0" cytoscape-fcose "^2.2.0" d3 "^7.9.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.10" - dayjs "^1.11.10" - dompurify "^3.0.11" - katex "^0.16.9" + dagre-d3-es "7.0.14" + dayjs "^1.11.19" + dompurify "^3.3.1" + katex "^0.16.25" khroma "^2.1.0" - lodash-es "^4.17.21" - marked "^13.0.2" + lodash-es "^4.17.23" + marked "^16.3.0" roughjs "^4.6.6" - stylis "^4.3.1" + stylis "^4.3.6" ts-dedent "^2.2.0" - uuid "^9.0.1" + uuid "^11.1.0" methods@~1.1.2: version "1.1.2" @@ -7818,15 +8021,15 @@ minizlib@^3.0.1, minizlib@^3.1.0: dependencies: minipass "^7.1.2" -mlly@^1.4.2, mlly@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f" - integrity sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA== +mlly@^1.7.4, mlly@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb" + integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA== dependencies: - acorn "^8.11.3" - pathe "^1.1.2" - pkg-types "^1.1.1" - ufo "^1.5.3" + acorn "^8.16.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.3" moment-timezone@^0.6.0: version "0.6.0" @@ -8400,10 +8603,10 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -package-manager-detector@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" - integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog== +package-manager-detector@^1.3.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734" + integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA== pacote@21.0.4: version "21.0.4" @@ -8561,10 +8764,10 @@ path-to-regexp@~0.1.12: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== -pathe@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" - integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== pbf@^3.2.1, pbf@^3.3.0: version "3.3.0" @@ -8616,14 +8819,14 @@ pkce-challenge@^5.0.0: resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== -pkg-types@^1.0.3, pkg-types@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.2.0.tgz#d0268e894e93acff11a6279de147e83354ebd42d" - integrity sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA== +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== dependencies: - confbox "^0.1.7" - mlly "^1.7.1" - pathe "^1.1.2" + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" pngjs@^5.0.0: version "5.0.0" @@ -9832,10 +10035,10 @@ strip-json-comments@3.1.1, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -stylis@^4.3.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.4.tgz#ca5c6c4a35c4784e4e93a2a24dc4e9fa075250a4" - integrity sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now== +stylis@^4.3.6: + version "4.4.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.4.0.tgz#c5846c9345f4bfc51bd0cbd7ca35a0744f485a5d" + integrity sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA== sucrase@^3.35.0: version "3.35.1" @@ -10013,10 +10216,10 @@ tinycolor2@^1.6.0: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinyexec@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.0.tgz#ed60cfce19c17799d4a241e06b31b0ec2bee69e6" - integrity sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg== +tinyexec@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b" + integrity sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg== tinyglobby@0.2.14: version "0.2.14" @@ -10260,10 +10463,10 @@ typical@^5.2.0: resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== -ufo@^1.5.3: - version "1.5.4" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" - integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== +ufo@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f" + integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q== unbox-primitive@^1.1.0: version "1.1.0" @@ -10359,16 +10562,16 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -10441,10 +10644,10 @@ vscode-languageserver@~9.0.1: dependencies: vscode-languageserver-protocol "3.17.5" -vscode-uri@~3.0.8: - version "3.0.8" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" - integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== +vscode-uri@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== vt-pbf@^3.1.3: version "3.1.3" From 1549dc9cc16b3cdd34bf1be2535c8516e191ce35 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 21 Apr 2026 11:28:45 +0300 Subject: [PATCH 48/59] Fixed CVE-2026-4800 --- ui-ngx/package.json | 2 +- .../widget/lib/maps-legacy/leaflet-map.ts | 4 +- ui-ngx/yarn.lock | 365 +++++++++--------- 3 files changed, 180 insertions(+), 191 deletions(-) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 4f34216e6c..7952d34db8 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -26,7 +26,7 @@ "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", - "@geoman-io/leaflet-geoman-free": "2.18.3", + "@geoman-io/leaflet-geoman-free": "2.19.3", "@iplab/ngx-color-picker": "^20.0.0", "@mat-datetimepicker/core": "~16.0.1", "@mdi/svg": "^7.4.47", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index 0b00c5970e..ba40af770c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -289,7 +289,7 @@ export default abstract class LeafletMap { } private toggleDrawMode(type: string) { - this.map.pm.Draw[type].toggle(); + (this.map.pm.Draw[type] as any).toggle(); } addEditControl() { @@ -373,7 +373,7 @@ export default abstract class LeafletMap { }, // @ts-ignore afterClick: (e, ctx) => { - this.map.pm.Draw[ctx.button._button.jsClass].toggle({ + (this.map.pm.Draw[ctx.button._button.jsClass] as any).toggle({ snappable: this.options.snappable, cursorMarker: true, allowSelfIntersection: false, diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 454822d186..319cf7eaf1 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -1592,17 +1592,17 @@ dependencies: tslib "^2.3.0" -"@geoman-io/leaflet-geoman-free@2.18.3": - version "2.18.3" - resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.18.3.tgz#a41489920b175931fba2a1e8e81347f9e3be5481" - integrity sha512-XzxSKRk2UJUVeGiOt1jU2hyo412Qee1Q0Xsfw4A2r8EoUIo48XKSWfusYe7E53fSPr0aYgZxPevnFdcUXimpdA== - dependencies: - "@turf/boolean-contains" "^6.5.0" - "@turf/kinks" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-split" "^6.5.0" - lodash "4.17.21" - polyclip-ts "^0.16.5" +"@geoman-io/leaflet-geoman-free@2.19.3": + version "2.19.3" + resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.19.3.tgz#42ea4d10be3a76c64376cdd8736b191dd0c5ae2d" + integrity sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ== + dependencies: + "@turf/boolean-contains" "^7.3.3" + "@turf/kinks" "^7.3.3" + "@turf/line-intersect" "^7.3.3" + "@turf/line-split" "^7.3.3" + lodash "4.18.1" + polyclip-ts "^0.16.8" "@hono/node-server@^1.19.9": version "1.19.11" @@ -2860,181 +2860,167 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^10.1.1" -"@turf/bbox@*": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.1.0.tgz#45a9287c084f7b79577ee88b7b539d83562b923b" - integrity sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA== +"@turf/bbox@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.3.5.tgz#bf93e0a81aa98ca8809bea1bfa6cdfd03ce2470a" + integrity sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw== dependencies: - "@turf/helpers" "^7.1.0" - "@turf/meta" "^7.1.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" "@types/geojson" "^7946.0.10" - tslib "^2.6.2" - -"@turf/bbox@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" - integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/bearing@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.5.0.tgz#462a053c6c644434bdb636b39f8f43fb0cd857b0" - integrity sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/boolean-contains@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz#f802e7432fb53109242d5bf57393ef2f53849bbf" - integrity sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/boolean-point-in-polygon" "^6.5.0" - "@turf/boolean-point-on-line" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/boolean-point-in-polygon@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" - integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + tslib "^2.8.1" + +"@turf/boolean-contains@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-7.3.5.tgz#8f3d3bd996b98a23953c42a5db932f17bcb98b68" + integrity sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/boolean-point-in-polygon" "7.3.5" + "@turf/boolean-point-on-line" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-split" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/boolean-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz#a8efa7bad88760676f395afb9980746bc5b376e9" - integrity sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ== +"@turf/boolean-point-in-polygon@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz#4416dbde721225153590cc2204f614e44f388b55" + integrity sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + point-in-polygon-hao "^1.1.0" + tslib "^2.8.1" -"@turf/destination@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-6.5.0.tgz#30a84702f9677d076130e0440d3223ae503fdae1" - integrity sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ== +"@turf/boolean-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-7.3.5.tgz#9ae059b836240c5e4619c5632682db8acbe511b6" + integrity sha512-TuWfrAT63W43BDzgYc94UzQ5/PjF1aTnh4AIzmQwez1YnimShYcOTwo8OIHzDaB6gbbvFsfxYMuOA5JOp942Kg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/distance@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.5.0.tgz#21f04d5f86e864d54e2abde16f35c15b4f36149a" - integrity sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg== +"@turf/distance@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.3.5.tgz#3aa16a1fde30e5cf4cf40b7e497be8cc5d6bbaaf" + integrity sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/helpers@6.x", "@turf/helpers@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" - integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/helpers@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.1.0.tgz#eb734e291c9c205822acdd289fe20e91c3cb1641" - integrity sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ== +"@turf/geojson-rbush@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/geojson-rbush/-/geojson-rbush-7.3.5.tgz#32e38b0b85b4dcf9af5aa433ddbb2e2702048e0a" + integrity sha512-30/hQqc+ErnlcavvDdxGfgm8VtsJDEzSYpf3mPqYxOyI976l49T6+1jCQD5xKswml6o8zZAaTSe6ZcSKF+SCNw== dependencies: + "@turf/bbox" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" "@types/geojson" "^7946.0.10" - tslib "^2.6.2" + rbush "^3.0.1" + tslib "^2.8.1" -"@turf/invariant@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" - integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== +"@turf/helpers@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.5.tgz#051928c03cdf9ffcc7ae36581c317afc49bfd999" + integrity sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg== dependencies: - "@turf/helpers" "^6.5.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/kinks@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-6.5.0.tgz#80e7456367535365012f658cf1a988b39a2c920b" - integrity sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ== +"@turf/invariant@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.5.tgz#5619d0e0ef3755e2be69855bad47e10158a1820b" + integrity sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/line-intersect@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.5.0.tgz#dea48348b30c093715d2195d2dd7524aee4cf020" - integrity sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA== +"@turf/kinks@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-7.3.5.tgz#746c2295dd8e7ddc8822d13fc6b9ae8d4148babc" + integrity sha512-dPW8d4vs1v8WMobjyq/TVqajjPwkMsl94IF58yp1UYlmJDQrW4iNRUmI9fFzww+fl7epCKNwY+jZhXf1DRi93w== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - geojson-rbush "3.x" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/line-segment@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-6.5.0.tgz#ee73f3ffcb7c956203b64ed966d96af380a4dd65" - integrity sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw== +"@turf/line-intersect@7.3.5", "@turf/line-intersect@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-7.3.5.tgz#841f8f713218cbd4532269754e3e1f6a15d4e374" + integrity sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/meta" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + sweepline-intersections "^1.5.0" + tslib "^2.8.1" -"@turf/line-split@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-6.5.0.tgz#116d7fbf714457878225187f5820ef98db7b02c2" - integrity sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - "@turf/nearest-point-on-line" "^6.5.0" - "@turf/square" "^6.5.0" - "@turf/truncate" "^6.5.0" - geojson-rbush "3.x" - -"@turf/meta@6.x", "@turf/meta@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" - integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== +"@turf/line-segment@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-7.3.5.tgz#275f03fd86640fce44b40d6806b95dc842801156" + integrity sha512-TM1aCu7utM6fllAEHO8PNqBJZ/uoFJVNp2A0YI7FyWN928hPbacsvNtLeVz/Kq1ZbeqQ1ZIKRxo9FdVjaj8hGg== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-split@7.3.5", "@turf/line-split@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-7.3.5.tgz#63aa7c5d3fc606db7c4beff33dccc8786fc9c07b" + integrity sha512-GEuy+LdbbaqtYjHk/i1G8sK51wfCdPqTO8uH0dJZ6WlcIcZQfRcKKI4ksFm7NkVyfmw8gXWbpMJD8lO380GFBQ== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/geojson-rbush" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-intersect" "7.3.5" + "@turf/line-segment" "7.3.5" + "@turf/meta" "7.3.5" + "@turf/nearest-point-on-line" "7.3.5" + "@turf/truncate" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/meta@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.1.0.tgz#b2af85afddd0ef08aeae8694a12370a4f06b6d13" - integrity sha512-ZgGpWWiKz797Fe8lfRj7HKCkGR+nSJ/5aKXMyofCvLSc2PuYJs/qyyifDPWjASQQCzseJ7AlF2Pc/XQ/3XkkuA== +"@turf/meta@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.3.5.tgz#c498a6f1e8603e014e8bfbc9a4b2760b6219edb9" + integrity sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg== dependencies: - "@turf/helpers" "^7.1.0" + "@turf/helpers" "7.3.5" "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/nearest-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz#8e1cd2cdc0b5acaf4c8d8b3b33bb008d3cb99e7b" - integrity sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg== - dependencies: - "@turf/bearing" "^6.5.0" - "@turf/destination" "^6.5.0" - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/square@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" - integrity sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ== +"@turf/nearest-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.5.tgz#e74515e017feff2c8d7c208b634a5cbfdbebe998" + integrity sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg== dependencies: - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" + "@turf/distance" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/truncate@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-6.5.0.tgz#c3a16cad959f1be1c5156157d5555c64b19185d8" - integrity sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg== +"@turf/truncate@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-7.3.5.tgz#d57a856994e2928e798fc7929e42cc2890d1b201" + integrity sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" "@types/ace-diff@^2.1.4": version "2.1.4" @@ -3376,11 +3362,6 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== -"@types/geojson@7946.0.8": - version "7946.0.8" - resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" - integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== - "@types/hammerjs@^2.0.45": version "2.0.45" resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.45.tgz#ffa764bb68a66c08db6efb9c816eb7be850577b1" @@ -6384,17 +6365,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -geojson-rbush@3.x: - version "3.2.0" - resolved "https://registry.yarnpkg.com/geojson-rbush/-/geojson-rbush-3.2.0.tgz#8b543cf0d56f99b78faf1da52bb66acad6dfc290" - integrity sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w== - dependencies: - "@turf/bbox" "*" - "@turf/helpers" "6.x" - "@turf/meta" "6.x" - "@types/geojson" "7946.0.8" - rbush "^3.0.1" - geojson-vt@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-4.0.2.tgz#1162f6c7d61a0ba305b1030621e6e111f847828a" @@ -7670,10 +7640,10 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.14: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@4.18.1, lodash@^4.17.14: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log-driver@1.2.7: version "1.2.7" @@ -8833,6 +8803,13 @@ pngjs@^5.0.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +point-in-polygon-hao@^1.1.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz#8662abdcc84bcca230cc3ecbb0b0ab1a306f1bd6" + integrity sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ== + dependencies: + robust-predicates "^3.0.2" + points-on-curve@0.2.0, points-on-curve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1" @@ -8846,13 +8823,13 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" -polyclip-ts@^0.16.5: - version "0.16.5" - resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.5.tgz#053e073e640449f1b1a1d88471f8758779d0b030" - integrity sha512-ZchnG0zGZReHgEo3EYzEUi6UmfQFFzNnj6AFU+gBm+IJJ4qG9gL4CwjtCV6oi/PittUPpJLiLJxcn/AgrCBO+g== +polyclip-ts@^0.16.8: + version "0.16.8" + resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.8.tgz#503160d05e9d56380533aab0bc2dae835d6da5f9" + integrity sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ== dependencies: bignumber.js "^9.1.0" - splaytree-ts "^1.0.1" + splaytree-ts "^1.0.2" possible-typed-array-names@^1.0.0: version "1.0.0" @@ -9877,10 +9854,10 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" -splaytree-ts@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.1.tgz#4ddcfe2684da017d02b599d53d67f6d07a90745b" - integrity sha512-B+VzCm33/KEchi/fzT6/3NRHm8k5+Kf37SBQO3meHHS/tK2xBnIm4ZvusQ1wUpHgKMCCqEWgXnwFXAa1nD289g== +splaytree-ts@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.2.tgz#34963704587aff45eaa09c24713f552bbf56e8f0" + integrity sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA== split.js@^1.6.5: version "1.6.5" @@ -10086,6 +10063,13 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +sweepline-intersections@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz#85ab3629a291875926fae0acd508496430d8a647" + integrity sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ== + dependencies: + tinyqueue "^2.0.0" + systemjs@6.15.1: version "6.15.1" resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.15.1.tgz#74175b6810e27a79e1177d21db5f0e3057118cea" @@ -10242,6 +10226,11 @@ tinymce@6.8.6, "tinymce@^7.0.0 || ^6.0.0 || ^5.5.0", tinymce@~6.8.6: resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.6.tgz#799e4f03eeb4399399dfdeb12ba17b3b91887adf" integrity sha512-++XYEs8lKWvZxDCjrr8Baiw7KiikraZ5JkLMg6EdnUVNKJui0IsrAADj5MsyUeFkcEryfn2jd3p09H7REvewyg== +tinyqueue@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== + tinyqueue@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" @@ -10345,7 +10334,7 @@ tslib@2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.1, tslib@~2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.8.1, tslib@~2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== From fe7d860c21393cf132b05f3452e4b8ab68eb6ad4 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 21 Apr 2026 11:43:40 +0300 Subject: [PATCH 49/59] Fixed dependencies --- ui-ngx/package.json | 2 +- ui-ngx/yarn.lock | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 7952d34db8..076e2d316a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -45,7 +45,7 @@ "angular2-hotkeys": "^16.0.1", "canvas-gauges": "^2.1.7", "core-js": "^3.48.0", - "dayjs": "1.11.19", + "dayjs": "1.11.20", "echarts": "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz", "flot": "https://github.com/thingsboard/flot.git#0.9-work", "flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master", diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 319cf7eaf1..4da832c885 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -5254,12 +5254,7 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@1.11.19: - version "1.11.19" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" - integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== - -dayjs@^1.11.19, dayjs@^1.11.5: +dayjs@1.11.20, dayjs@^1.11.19, dayjs@^1.11.5: version "1.11.20" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938" integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== From 75d2613a73eac81ee7e98fe86df87bc9af2069b3 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 21 Apr 2026 12:23:32 +0300 Subject: [PATCH 50/59] Apply LTS schema update during full-version upgrade --- .../server/service/install/SqlDatabaseUpgradeService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 2babc3feeb..3932aa9dcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService public void upgradeDatabase() { log.info("Updating schema..."); loadSql(getSchemaUpdateFile("basic")); + loadSql(getSchemaUpdateFile("lts")); log.info("Schema updated."); } From b0030087e3f0eaaaa7f6eee5d91fb83f3ed13f2c Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 21 Apr 2026 08:39:51 +0200 Subject: [PATCH 51/59] Bump PostgreSQL test images to 18 - postgres: 16.6 -> 18 (dao sql-test.properties / nosql-test.properties) - timescaledb: latest-pg12 -> latest-pg18 (dao timescale-test.properties) TimescaleDB pg15+ images crash on cgroup v2 CI hosts because /docker-entrypoint-initdb.d/001_timescaledb_tune.sh evaluates [ ${TS_TUNE_MEMORY} -gt ${FREE_BYTES} ] with an empty left operand after the kernel reports the 64-bit max for /sys/fs/cgroup/memory.max. Work around the upstream bug by setting NO_TS_TUNE=true. The Testcontainers JDBC URL (jdbc:tc:timescaledb:...) does not support docker env vars, so register a custom JdbcDatabaseContainerProvider (TbTimescaleDBContainerProvider, activated via jdbc:tc:tbtimescaledb:...) that starts a PostgreSQLContainer backed by timescale/timescaledb with NO_TS_TUNE=true. Production docker-compose files and tb-postgres image are untouched. --- .../dao/TbTimescaleDBContainerProvider.java | 47 +++++++++++++++++++ ...s.containers.JdbcDatabaseContainerProvider | 1 + dao/src/test/resources/nosql-test.properties | 2 +- dao/src/test/resources/sql-test.properties | 2 +- .../test/resources/timescale-test.properties | 2 +- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java create mode 100644 dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider diff --git a/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java new file mode 100644 index 0000000000..8afcaa1c22 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.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.dao; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.TimescaleDBContainerProvider; + +/** + * Extends the upstream {@link TimescaleDBContainerProvider} to disable the + * timescaledb-tune entrypoint script via NO_TS_TUNE=true. + * + * Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh + * that crashes the container entrypoint on cgroup v2 hosts (including CI agents) + * when the kernel reports the 64-bit max for memory.max. + * + * Activated by the jdbc:tc:tbtimescaledb:<tag>:///... URL prefix + * registered via META-INF/services. + */ +public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider { + + private static final String NAME = "tbtimescaledb"; + + @Override + public boolean supports(String databaseType) { + return NAME.equals(databaseType); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + JdbcDatabaseContainer container = super.newInstance(tag); + container.withEnv("NO_TS_TUNE", "true"); + return container; + } +} diff --git a/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 0000000000..ab36744aa9 --- /dev/null +++ b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.thingsboard.server.dao.TbTimescaleDBContainerProvider diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index b688c3c40f..921aebd5fe 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index e3f4861aa9..0639c461a3 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/timescale-test.properties b/dao/src/test/resources/timescale-test.properties index 2c5552cb75..e0c0bef25e 100644 --- a/dao/src/test/resources/timescale-test.properties +++ b/dao/src/test/resources/timescale-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb +spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize = 50 From 678312830cbff3df7a12cc0ae9a86224602a7ace Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 21 Apr 2026 17:06:06 +0200 Subject: [PATCH 52/59] chore(build): silence ~200 lines of harmless warnings on lts-4.2 Build hygiene for developers who rebuild lts-4.2 frequently: cleans the pom.xml sources that generate noise without any code change. - Pin maven-clean-plugin to 3.5.0 (latest stable) via a property, matching the convention already used for surefire/install/deploy/jar plugins. Removes 55 "version is missing" warnings plus the cascading "Some problems were encountered while building the effective model" messages for every child module. - Extend license-maven-plugin excludes for files that never carry a license header: **/lombok.config, **/eslint.config.mjs, **/config.monitoring, **/valkey-certs/**, **/data/certs/**, **/*.otf. Directory-scoped patterns are used instead of broad extension globs (**/*.crt, **/*.key, **/*.pem) so a stray cert dropped outside these directories still raises a warning. - Exclude sjk-jfr5 / sjk-jfr6 / sjk-nps transitive deps from cassandra-all in tools/pom.xml. Their published POMs declare system-scope deps against unresolved ${jmc5.path}, ${jmc6.path}, ${visualvm.path} properties, producing 7 ERROR-level lines on every build. No ThingsBoard code imports sjk, jmc, or netbeans profiler classes. Net impact: 1040 -> 843 WARNING lines, 7 -> 0 ERROR lines. Build still green. Full categorization of remaining warnings and Tier 2/3 migration plan is tracked in issue #15481. --- pom.xml | 8 ++++++++ tools/pom.xml | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/pom.xml b/pom.xml index 409e16cdc9..5604ca39d7 100755 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,7 @@ 3.5.4 3.1.4 3.1.4 + 3.5.0 2.8.8TB 2.2.30 0.8 @@ -915,6 +916,12 @@ **/resources/lwm2m/models/** src/main/data/resources/** .claude/** + **/lombok.config + **/eslint.config.mjs + **/config.monitoring + **/valkey-certs/** + **/data/certs/** + **/*.otf JAVADOC_STYLE @@ -967,6 +974,7 @@ org.apache.maven.plugins maven-clean-plugin + ${maven-clean-plugin.version} false diff --git a/tools/pom.xml b/tools/pom.xml index a2820d48f6..46c56c634b 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -54,6 +54,24 @@ org.apache.cassandra cassandra-all + + + + org.perfkit.sjk.parsers + sjk-jfr5 + + + org.perfkit.sjk.parsers + sjk-jfr6 + + + org.perfkit.sjk.parsers + sjk-nps + + at.yawk.lz4 From 600c8bf280b32f1a4bbafd0bfb537a2c601cad50 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 20 Feb 2026 16:27:19 +0200 Subject: [PATCH 53/59] Fix flaky DefaultResourceDataCacheTest.testGetCachedResourceData Await cached resource data to become available after save eviction before asserting, and await null after deletion. Prevents Mockito verifyNoMoreInteractions(resourceService) failure caused by racing background cache-load invocations. Backport of 99334ba7fe0 from master. --- .../service/resource/DefaultResourceDataCacheTest.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java index c227ec76aa..d33f1755d5 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.resource; +import org.awaitility.Awaitility; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @@ -29,6 +30,8 @@ import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.timeout; @@ -61,6 +64,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResourceInfo savedResource = tbResourceService.save(resource); verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNotNull()); TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); assertThat(cachedData.getData()).isEqualTo(data); assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); @@ -76,8 +81,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); tbResourceService.delete(resourceById, true, null); verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); - TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); - assertThat(cachedDataAfterDeletion).isEqualTo(null); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNull()); } } From 1b749ebadfe51de8837fbcaab3cc2d21b7a4e6e3 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 21 Apr 2026 20:01:31 +0200 Subject: [PATCH 54/59] Fix flaky UserEdgeTest.testCreateUpdateDeleteTenantUser The test asserts exactly 2 UserCredentialsUpdateMsg after creating a new tenant-admin user, but the user activation flow can emit either 2 or 3 depending on timing: - activateUserCredentials publishes CREDENTIALS_UPDATED (msg #1) - setUserCredentialsEnabled publishes CREDENTIALS_UPDATED (msg #2) - the initial USER ADDED edge event is processed asynchronously in UserEdgeProcessor and bundles an extra UserCredentialsUpdateMsg when it finds userCredentials.isEnabled() == true (i.e. activation already raced past the ADDED event) When the race goes the second way we end up with 1 UserUpdateMsg plus 3 UserCredentialsUpdateMsg, which currently fails the hard-coded assertEquals(2, ...) assertion. Accept both 2 and 3 UserCredentialsUpdateMsg instead of asserting an exact count, matching the reality of the asynchronous edge event pipeline. --- .../java/org/thingsboard/server/edge/UserEdgeTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 6b3bffc883..2ec67a7886 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -57,7 +57,12 @@ public class UserEdgeTest extends AbstractEdgeTest { User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when + // user activation completes before the event is processed, in addition to the 2 + // messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3. + int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size(); + Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount, + credMsgCount == 2 || credMsgCount == 3); Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); Assert.assertTrue(userUpdateMsgOpt.isPresent()); UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); From 310e53fb23ab539d8c244149629e5ab4ed621a33 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 21 Apr 2026 20:13:49 +0200 Subject: [PATCH 55/59] Fix flaky DeviceProfileControllerTest invalid-RPC-schema tests Several testSaveProtoDeviceProfileWithInvalidRpcRequestSchema* tests intermittently fail with: org.thingsboard.server.dao.exception.TenantNotFoundException: Tenant with id not found when the tenant created in @Before has not yet been populated in the tenant profile cache by the time the request hits the partition-lookup path (DefaultTenantRoutingInfoService -> TbTenantProfileCache -> TenantService#findTenantById). The underlying request is idempotent (the schema is invalid so it is rejected with 400 regardless of retries), so wrap the doPost + status assertion in Awaitility with Mockito.reset inside the retry block: only the last attempt's invocations are visible to the subsequent verify* assertions. Applies to all testSaveDeviceProfileWithInvalidRpcRequestProtoSchema callers, including the currently-muted testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType. --- .../DeviceProfileControllerTest.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java index 225757e451..7d88edd73f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java @@ -54,11 +54,13 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.DeviceProfileDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.awaitility.Awaitility; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -1028,11 +1030,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); - Mockito.reset(tbClusterService, auditLogService); - - doPost("/api/deviceProfile", deviceProfile) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString(errorMsg))); + // The request may hit a transient TenantNotFoundException right after the @Before tenant creation + // if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the + // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the + // retry loop so the subsequent verify* assertions see only the invocations from the last attempt. + Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS) + .ignoreExceptions().untilAsserted(() -> { + Mockito.reset(tbClusterService, auditLogService); + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(errorMsg))); + }); testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); From 83c4a9325545b800a69f343f5db490596bf0d57b Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 22 Apr 2026 00:11:47 +0200 Subject: [PATCH 56/59] Fix maven-assembly-plugin skipAssembly leaking into every execution Root pom.xml wired ${pkg.skip.zip} at the plugin-level of maven-assembly-plugin inside the always-active `packaging` profile's . Maven merges plugin-level into every execution of that plugin, so -Dpkg.skip.zip=true (and the -Dpkg.skip=true alias that activates it) suppressed any maven-assembly-plugin execution across the reactor - not only the intended Windows ZIP execution. In CE lts-4.2/4.3 this is latent (no CE module declares a non-ZIP assembly execution), but it breaks downstream forks that do. PE's rule-node-twilio-sms, for instance, declares a custom make-assembly execution producing the classified -rule-node.jar consumed by application's copy-pe-rule-nodes step; under -Dpkg.skip.zip=true that assembly silently became a no-op and the downstream build failed to resolve the classified artifact. tools/pom.xml already sidesteps this via `combine.self="override"` on its own - earlier evidence that the placement was fragile. Move into the `assembly` execution's own so it scopes only to the Windows ZIP execution. Verified via mvn help:effective-pom on application/: with the fix, true no longer appears at plugin-level , only inside the `assembly` . --- pom.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 409e16cdc9..d4f5d0f9df 100755 --- a/pom.xml +++ b/pom.xml @@ -618,7 +618,6 @@ org.apache.maven.plugins maven-assembly-plugin - ${pkg.skip.zip} ${pkg.name} ${main.dir}/packaging/${pkg.type}/assembly/windows.xml @@ -631,6 +630,9 @@ single + + ${pkg.skip.zip} + From 744773b22851ee13f7ba1432404050d8474f3e51 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 23 Apr 2026 11:36:04 +0300 Subject: [PATCH 57/59] Add system images to LTS patch applier Creates missing system images from application/src/main/data/resources/images during LTS patch startup, mirroring the upgrade-path loadSystemResources logic. Existing system images in the DB are left untouched. --- .../service/system/SystemPatchApplier.java | 39 ++++ .../server/system/SystemPatchApplierTest.java | 202 ++++++++++++++++++ .../server/dao/resource/ImageService.java | 3 + .../server/dao/resource/BaseImageService.java | 6 + 4 files changed, 250 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index 256b230b5a..bb5052fd76 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -28,6 +28,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; @@ -42,6 +43,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -65,6 +67,7 @@ public class SystemPatchApplier { private final InstallScripts installScripts; private final DatabaseSchemaSettingsService schemaSettingsService; private final WidgetTypeService widgetTypeService; + private final ImageService imageService; @PostConstruct private void init() { @@ -100,6 +103,9 @@ public class SystemPatchApplier { int updated = updateWidgetTypes(); log.info("Updated {} widget types", updated); + int createdImages = createMissingSystemImages(); + log.info("Created {} new system images", createdImages); + schemaSettingsService.updateSchemaVersion(); log.info("System data patch update completed successfully"); @@ -215,6 +221,39 @@ public class SystemPatchApplier { return false; } + private int createMissingSystemImages() { + AtomicInteger created = new AtomicInteger(); + Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images"); + + if (!Files.exists(imagesDir)) { + log.warn("System images directory does not exist: {}", imagesDir); + return 0; + } + + Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + + try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) { + dirStream.forEach(path -> { + String resourceKey = path.getFileName().toString(); + if (existingKeys.contains(resourceKey)) { + log.trace("System image already exists, skipping: {}", resourceKey); + return; + } + try { + byte[] data = Files.readAllBytes(path); + imageService.createOrUpdateSystemImage(resourceKey, data); + created.incrementAndGet(); + log.trace("Created system image: {}", resourceKey); + } catch (Exception e) { + log.error("Unable to create system image from file: [{}]", path); + throw new RuntimeException("Unable to create system image " + resourceKey, e); + } + }); + } + + return created.get(); + } + private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) { return true; diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index f3ea4e838c..51e35bd999 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -32,6 +32,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; @@ -39,6 +40,8 @@ import org.thingsboard.server.service.system.SystemPatchApplier; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -78,6 +81,9 @@ public class SystemPatchApplierTest { @Mock private WidgetTypeService widgetTypeService; + @Mock + private ImageService imageService; + @InjectMocks private SystemPatchApplier reconciler; @@ -652,4 +658,200 @@ public class SystemPatchApplierTest { return widget; } + // --- createMissingSystemImages tests --- + + @Test + void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() { + Path dataDir = tempDir.resolve("data"); + // Intentionally do not create resources/images dir + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{1, 2, 3, 4, 5}; + Files.write(imagesDir.resolve("gateway.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes)); + } + + @Test + void whenSystemImageExistsInDb_thenSkipIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] newImageBytes = new byte[]{9, 9, 9}; + byte[] existingImageBytes = new byte[]{1, 1, 1}; + Files.write(imagesDir.resolve("new.png"), newImageBytes); + Files.write(imagesDir.resolve("existing.svg"), existingImageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class)); + } + + @Test + void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + Files.createDirectories(imagesDir.resolve("nested")); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{5, 6, 7}; + Files.write(imagesDir.resolve("logo.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class)); + } + + @Test + void whenMultipleNewImages_thenCreatesAll() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("a.png"), new byte[]{1}); + Files.write(imagesDir.resolve("b.svg"), new byte[]{2}); + Files.write(imagesDir.resolve("c.jpg"), new byte[]{3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(3, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class)); + } + + @Test + void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class))) + .thenThrow(new RuntimeException("DB error")); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + assertTrue(thrown.getMessage().contains("broken.png")); + } + + @Test + void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("img.png"), new byte[]{1}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)) + .thenThrow(new RuntimeException("lookup failed")); + + assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + // --- applyPatchIfNeeded integration with createMissingSystemImages --- + + @Test + void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + Path imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + byte[] imgBytes = new byte[]{7, 7, 7}; + Files.write(imagesDir.resolve("new_icon.svg"), imgBytes); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes)); + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenImagesAreNotTouched() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index f412c5488a..406263aaed 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import java.util.Collection; +import java.util.Set; public interface ImageService { @@ -38,6 +39,8 @@ public interface ImageService { TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); + Set getAllImageKeysByTenantId(TenantId tenantId); + TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 639c60e1d6..11bbe797cf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -188,6 +188,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key); } + @Override + public Set getAllImageKeysByTenantId(TenantId tenantId) { + log.trace("Executing getAllImageKeysByTenantId [{}]", tenantId); + return resourceInfoDao.findKeysByTenantIdAndResourceTypeAndResourceKeyPrefix(tenantId, ResourceType.IMAGE, ""); + } + @Override public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) { return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey); From ba8d9af3629c8c937c051081773f78a9bac39a52 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 24 Apr 2026 16:06:23 +0300 Subject: [PATCH 58/59] Improve SSL reload test assertions --- .../transport/mqtt/MqttSslHandlerProviderTest.java | 7 +++++-- .../service/CertificateReloadManagerTest.java | 11 ++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java index 8c4f3c7a29..9b4ce007f8 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -33,6 +33,8 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -119,13 +121,13 @@ public class MqttSslHandlerProviderTest { CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(5); + List handlers = new CopyOnWriteArrayList<>(); for (int i = 0; i < 5; i++) { new Thread(() -> { try { startLatch.await(); - SslHandler handler = sslHandlerProvider.getSslHandler(); - assertThat(handler).isNotNull(); + handlers.add(sslHandlerProvider.getSslHandler()); } catch (Exception e) { throw new RuntimeException(e); } finally { @@ -138,6 +140,7 @@ public class MqttSslHandlerProviderTest { boolean completed = doneLatch.await(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); + assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull()); // Concurrent handshakes read the same pre-built context without the old sync bottleneck. SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); assertThat(contextAfter).isSameAs(contextBefore); diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index 3156897210..6f78eaefae 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -24,7 +24,10 @@ import org.springframework.test.util.ReflectionTestUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -99,6 +102,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L; + Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime)); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -120,10 +126,13 @@ public class CertificateReloadManagerTest { @Test public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler); certificateReloadManager.destroy(); - assertThat(certificateReloadManager).isNotNull(); + assertThat(scheduler.isShutdown()).isTrue(); + assertThat(scheduler.isTerminated()).isTrue(); } @Test From 5608fd2284c752ab5590b4c80c72bcc200441a28 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 24 Apr 2026 16:34:20 +0200 Subject: [PATCH 59/59] Fix map shape labels drifting from center after viewport resize --- .../widget/lib/maps/data-layer/circles-data-layer.ts | 3 ++- .../widget/lib/maps/data-layer/polygons-data-layer.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index bec45b32e0..dcaf63c2ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -100,8 +100,9 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updateCircleShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index df642a97cf..5c500335f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -105,8 +105,9 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updatePolygonShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void {