From 30b046fa30ce6d6c3b490733a78d3bb6f769d779 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 5 Mar 2026 17:53:27 +0200 Subject: [PATCH 01/18] sending ws error when the telemetry queries exceed limit --- ...efaultTbEntityDataSubscriptionService.java | 19 +++++++- .../server/controller/WebsocketApiTest.java | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index f0f2e1eaa7..71df830f8d 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -34,6 +34,7 @@ import org.springframework.web.socket.CloseStatus; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; @@ -242,7 +243,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc @Override public void onFailure(Throwable t) { - log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId()); + log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId(), t); + if (t instanceof ResultSetSizeLimitExceededException) { + finalCtx.sendWsMsg(new EntityDataUpdate(finalCtx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + } } }, wsCallBackExecutor); } @@ -258,7 +262,18 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc handleLatestCmd(ctx, cmd.getLatestCmd()); } if (cmd.getTsCmd() != null) { - handleTimeSeriesCmd(ctx, cmd.getTsCmd()); + Futures.addCallback(handleTimeSeriesCmd(ctx, cmd.getTsCmd()), new FutureCallback<>() { + @Override + public void onSuccess(TbEntityDataSubCtx result) {} + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to process timeseries command", ctx.getSessionId(), ctx.getCmdId(), t); + if (t instanceof ResultSetSizeLimitExceededException) { + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + } + } + }, wsCallBackExecutor); } } else { checkAndSendInitialData(ctx); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index 87ba0ec3e8..aa7ffaeaf5 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -19,13 +19,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; @@ -60,7 +63,9 @@ import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.subscription.SubscriptionErrorCode; import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; @@ -95,6 +100,9 @@ public class WebsocketApiTest extends AbstractControllerTest { @Autowired private TelemetrySubscriptionService tsService; + @SpyBean + private TimeseriesService timeseriesService; + Device device; DeviceTypeFilter dtf; @@ -965,6 +973,44 @@ public class WebsocketApiTest extends AbstractControllerTest { } + @Test + public void testHistoryCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception { + ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L); + Mockito.doReturn(Futures.immediateFailedFuture(exception)) + .when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any()); + + List keys = List.of("temperature"); + long now = System.currentTimeMillis(); + + // Register for 2 messages: initial entity page data + error + getWsClient().registerWaitForUpdate(2); + getWsClient().sendHistoryCmd(keys, now, TimeUnit.HOURS.toMillis(1), dtf); + getWsClient().waitForUpdate(); + + EntityDataUpdate errorUpdate = JacksonUtil.fromString(getWsClient().getLastMsg(), EntityDataUpdate.class); + assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); + assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); + } + + @Test + public void testTimeSeriesCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception { + ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L); + Mockito.doReturn(Futures.immediateFailedFuture(exception)) + .when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any()); + + List keys = List.of("temperature"); + long now = System.currentTimeMillis(); + + // Register for 2 messages: initial entity page data + error + getWsClient().registerWaitForUpdate(2); + getWsClient().subscribeTsUpdate(keys, now, TimeUnit.HOURS.toMillis(1), dtf); + getWsClient().waitForUpdate(); + + EntityDataUpdate errorUpdate = JacksonUtil.fromString(getWsClient().getLastMsg(), EntityDataUpdate.class); + assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); + assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); + } + private void sendTelemetry(Device device, List tsData) throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); tsService.saveTimeseries(TimeseriesSaveRequest.builder() From bc39695fabe5441ce1a18861664bcd3d86aead74 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 5 Mar 2026 18:24:39 +0200 Subject: [PATCH 02/18] test fixes --- .../server/controller/WebsocketApiTest.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index aa7ffaeaf5..eab878854d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -982,12 +982,7 @@ public class WebsocketApiTest extends AbstractControllerTest { List keys = List.of("temperature"); long now = System.currentTimeMillis(); - // Register for 2 messages: initial entity page data + error - getWsClient().registerWaitForUpdate(2); - getWsClient().sendHistoryCmd(keys, now, TimeUnit.HOURS.toMillis(1), dtf); - getWsClient().waitForUpdate(); - - EntityDataUpdate errorUpdate = JacksonUtil.fromString(getWsClient().getLastMsg(), EntityDataUpdate.class); + EntityDataUpdate errorUpdate = getWsClient().sendHistoryCmd(keys, now, TimeUnit.HOURS.toMillis(1), dtf); assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); } @@ -1001,12 +996,7 @@ public class WebsocketApiTest extends AbstractControllerTest { List keys = List.of("temperature"); long now = System.currentTimeMillis(); - // Register for 2 messages: initial entity page data + error - getWsClient().registerWaitForUpdate(2); - getWsClient().subscribeTsUpdate(keys, now, TimeUnit.HOURS.toMillis(1), dtf); - getWsClient().waitForUpdate(); - - EntityDataUpdate errorUpdate = JacksonUtil.fromString(getWsClient().getLastMsg(), EntityDataUpdate.class); + EntityDataUpdate errorUpdate = getWsClient().subscribeTsUpdate(keys, now, TimeUnit.HOURS.toMillis(1), dtf); assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); } From dd9fdb4181166084a21faa76f91e9708bb98f308 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 9 Mar 2026 11:07:26 +0200 Subject: [PATCH 03/18] refactoring --- .../DefaultTbEntityDataSubscriptionService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index 71df830f8d..fc8c5c3be2 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -245,7 +245,7 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc public void onFailure(Throwable t) { log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId(), t); if (t instanceof ResultSetSizeLimitExceededException) { - finalCtx.sendWsMsg(new EntityDataUpdate(finalCtx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + sendError(finalCtx, t); } } }, wsCallBackExecutor); @@ -270,7 +270,7 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc public void onFailure(Throwable t) { log.warn("[{}][{}] Failed to process timeseries command", ctx.getSessionId(), ctx.getCmdId(), t); if (t instanceof ResultSetSizeLimitExceededException) { - ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + sendError(ctx, t); } } }, wsCallBackExecutor); @@ -283,6 +283,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc } } + private void sendError(TbEntityDataSubCtx ctx, Throwable t) { + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + } + private void checkAndSendInitialData(@Nullable TbEntityDataSubCtx theCtx) { if (!theCtx.isInitialDataSent()) { EntityDataUpdate update = new EntityDataUpdate(theCtx.getCmdId(), theCtx.getData(), null, theCtx.getMaxEntitiesPerDataSubscription()); From 2e612899e250b5313d0268c7214dab12475a266b Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 10:16:42 +0100 Subject: [PATCH 04/18] Fix NotificationRuleRecipientsConfig serialization with Jackson 2.18.x In Jackson 2.18.x, EXISTING_PROPERTY type info combined with the no-arg @JsonIgnoreProperties causes the triggerType discriminator field to be silently excluded from the serialized JSON. When the server then tries to deserialize the POST body for /api/notification/rule, Jackson cannot find triggerType and throws "missing type id property 'triggerType'", resulting in a 500 for NotificationEdgeTest.testNotificationRule. Fix by: 1. Adding @JsonProperty("triggerType") to force the field into normal bean serialization, overriding any suppression by the type info machinery. 2. Replacing the no-arg @JsonIgnoreProperties with @JsonIgnoreProperties( ignoreUnknown = true) so unknown properties are ignored rather than causing errors (e.g. for forward compatibility). Co-Authored-By: Claude Sonnet 4.6 --- .../notification/rule/NotificationRuleRecipientsConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java index fda73b8059..de4e05a2cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.notification.rule; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -29,7 +30,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; -@JsonIgnoreProperties +@JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) @JsonSubTypes({ @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), @@ -38,6 +39,7 @@ import java.util.UUID; public abstract class NotificationRuleRecipientsConfig implements Serializable { @NotNull + @JsonProperty("triggerType") private NotificationRuleTriggerType triggerType; @JsonIgnore From 95016447ccdaf9b120c0885292ed346748166b56 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 11:10:04 +0100 Subject: [PATCH 05/18] Fix EntityViewControllerTest MQTT port collision with other test contexts EntityViewControllerTest was importing MQTT_PORT from AbstractMqttIntegrationTest, a static final field initialized once per JVM. When running in the same Surefire fork alongside other test classes that also use this constant (e.g. MqttGatewayRateLimitsTest, DeviceEdgeTest), each class gets a different Spring context key but all try to bind MqttTransportService to the same port, causing BindException. Fix: define a private static MQTT_PORT/MQTT_URL directly in EntityViewControllerTest so its Spring context gets its own independently allocated port. Co-Authored-By: Claude Sonnet 4.6 --- .../server/controller/EntityViewControllerTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 421f3785ca..311ef54fa4 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -41,6 +41,7 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; @@ -87,8 +88,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; -import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; -import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_URL; @TestPropertySource(properties = { "transport.mqtt.enabled=true", @@ -98,6 +97,9 @@ import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest. @ContextConfiguration(classes = {EntityViewControllerTest.Config.class}) @DaoSqlTest public class EntityViewControllerTest extends AbstractControllerTest { + static final int MQTT_PORT = TestSocketUtils.findAvailableTcpPort(); + static final String MQTT_URL = "tcp://localhost:" + MQTT_PORT; + @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); From 42179bddf8dadf78a242c8943cc81574f9628245 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 11:24:53 +0100 Subject: [PATCH 06/18] Add comment explaining why EntityViewControllerTest owns its MQTT port Co-Authored-By: Claude Sonnet 4.6 --- .../server/controller/EntityViewControllerTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 311ef54fa4..b65d4178f0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -97,6 +97,12 @@ import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @ContextConfiguration(classes = {EntityViewControllerTest.Config.class}) @DaoSqlTest public class EntityViewControllerTest extends AbstractControllerTest { + // Must NOT be imported from AbstractMqttIntegrationTest. That field is a static final initialized + // once per JVM. Other test classes (e.g. MqttGatewayRateLimitsTest, DeviceEdgeTest) share the same + // constant but produce a different Spring context cache key, so Spring creates a separate + // ApplicationContext for each of them. Every context starts its own MqttTransportService and tries + // to bind the same port, causing BindException when tests run in the same Surefire JVM fork. + // Declaring the port here gives this context its own independently allocated port. static final int MQTT_PORT = TestSocketUtils.findAvailableTcpPort(); static final String MQTT_URL = "tcp://localhost:" + MQTT_PORT; From 9dd1541ff7952b76ed34704c7408cf34e1dc2658 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 18:35:38 +0100 Subject: [PATCH 07/18] Fix flaky tests: await async save futures before assertions - TimeseriesServiceNoSqlTest.shouldSaveEntryOfEachTypeWithTtl: await tsService.save() with bounded .get(MAX_TIMEOUT, TimeUnit.SECONDS) - EntityServiceTest.testFindTenantTelemetry: await timeseriesService.save() and attributesService.save() with .get(TIMEOUT, TimeUnit.SECONDS) to prevent both the race condition and an indefinite hang Co-Authored-By: Claude Sonnet 4.6 --- .../server/service/entitiy/EntityServiceTest.java | 8 +++++--- .../timeseries/nosql/TimeseriesServiceNoSqlTest.java | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index c54ffe4a07..05ab1165f6 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -115,6 +115,8 @@ import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -1749,13 +1751,13 @@ public class EntityServiceTest extends AbstractControllerTest { } @Test - public void testFindTenantTelemetry() { + public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException { // save timeseries by sys admin BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, new DoubleDataEntry("temperature", 45.5)); - timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries); + timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries).get(TIMEOUT, TimeUnit.SECONDS); AttributeKvEntry attr = new BaseAttributeKvEntry(new LongDataEntry("attr", 10L), 42L); - attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)); + attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)).get(TIMEOUT, TimeUnit.SECONDS); SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java index aa43826734..b66cdb4363 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java @@ -65,7 +65,7 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}"))); DeviceId deviceId = new DeviceId(Uuids.timeBased()); - tsService.save(tenantId, deviceId, timeseries, ttlInSec); + tsService.save(tenantId, deviceId, timeseries, ttlInSec).get(MAX_TIMEOUT, TimeUnit.SECONDS); List fullList = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery("test", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); From 9de53e77811d8d5be7b3cdcc28e4c650cb4e08e7 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:12:10 +0100 Subject: [PATCH 08/18] Support combined PEM cert+key for Edge gRPC SSL Reuse PemSslCredentials (already handles combined PEM, separate files, and encrypted keys) instead of duplicating PEM parsing logic. Wire it into gRPC via GrpcSslContexts + KeyManagerFactory. - Make private_key config optional (default empty) for combined PEM - Add key_password config for encrypted private keys Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcService.java | 27 ++++++++++++++----- .../src/main/resources/thingsboard.yml | 9 ++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 03228c52d4..e6c2824da3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import io.grpc.stub.StreamObserver; import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; @@ -37,7 +40,8 @@ import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.ResourceUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.EdgeId; @@ -67,7 +71,6 @@ import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -110,8 +113,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private boolean sslEnabled; @Value("${edges.rpc.ssl.cert}") private String certFileResource; - @Value("${edges.rpc.ssl.private_key}") + @Value("${edges.rpc.ssl.private_key:}") private String privateKeyResource; + @Value("${edges.rpc.ssl.key_password:}") + private String keyPassword; @Value("${edges.state.persistToTelemetry:false}") private boolean persistToTelemetry; @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") @@ -176,9 +181,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i .addService(this); if (sslEnabled) { try { - InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource); - InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource); - builder.useTransportSecurity(certFileIs, privateKeyFileIs); + setupSsl(builder); } catch (Exception e) { log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); throw new RuntimeException("Unable to set up SSL context!", e); @@ -199,6 +202,18 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } + private void setupSsl(NettyServerBuilder builder) throws Exception { + PemSslCredentials credentials = new PemSslCredentials(); + credentials.setCertFile(certFileResource); + credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); + credentials.setKeyPassword(keyPassword); + credentials.init(false); + + SslContext sslContext = GrpcSslContexts.configure( + SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); + builder.sslContext(sslContext); + } + @PreDestroy public void destroy() { if (server != null) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 751d1c0e0b..5c71a834c3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1487,10 +1487,13 @@ edges: ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" - # Cert file to be used during TLS connectivity to the cloud + # Path to the server certificate PEM file. May contain the full chain and optionally the private key (combined PEM). + # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" - # Private key file associated with the Cert certificate. This key is used in the encryption process during a secure connection - private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" + # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). + private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:}" + # Password for encrypted private keys. Optional, only needed for password-protected keys. + key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" # Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation. From 8f99510d07d40850f2e166f19db3713d92e5252f Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:16:06 +0100 Subject: [PATCH 09/18] Restore default private_key value for backward compatibility Keep privateKeyFile.pem as the default so existing users with separate cert/key file configs are not affected. Co-Authored-By: Claude Opus 4.6 --- .../thingsboard/server/service/edge/rpc/EdgeGrpcService.java | 2 +- application/src/main/resources/thingsboard.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index e6c2824da3..1bb61910ec 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -113,7 +113,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private boolean sslEnabled; @Value("${edges.rpc.ssl.cert}") private String certFileResource; - @Value("${edges.rpc.ssl.private_key:}") + @Value("${edges.rpc.ssl.private_key}") private String privateKeyResource; @Value("${edges.rpc.ssl.key_password:}") private String keyPassword; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5c71a834c3..91c475ec4e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1491,7 +1491,7 @@ edges: # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). - private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:}" + private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" # Password for encrypted private keys. Optional, only needed for password-protected keys. key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes From 521efe106797f2f004d01d5217d1d6adbeca9700 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:37:10 +0100 Subject: [PATCH 10/18] Add EdgeGrpcSslTest covering all SSL setup scenarios Tests mirror EdgeGrpcService.setupSsl() using PemSslCredentials: - Separate cert and key files (existing behavior) - Combined PEM file (cert + key) - Encrypted private key with key_password - Error when no private key found Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java new file mode 100644 index 0000000000..9e3aa68b12 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -0,0 +1,241 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.TestSocketUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for Edge gRPC SSL setup using PemSslCredentials. + * Covers all test plan scenarios: + * 1. Separate cert and key files (existing behavior) + * 2. Combined PEM file (cert + key) + * 3. Encrypted private key + key_password + * 4. Error when combined PEM has no private key and private_key is empty + */ +class EdgeGrpcSslTest { + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final List tempFiles = new ArrayList<>(); + private Server server; + private ManagedChannel channel; + + @AfterEach + void cleanup() throws Exception { + if (channel != null) { + channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + for (Path p : tempFiles) { + Files.deleteIfExists(p); + } + } + + @Test + void separateCertAndKeyFiles() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path certFile = writeTempPem("cert", cert); + Path keyFile = writeTempPem("key", kp.getPrivate()); + + server = startServer(certFile.toString(), keyFile.toString(), null); + assertTlsConnectivity(cert); + } + + @Test + void combinedPemFile() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", null); + assertTlsConnectivity(cert); + } + + @Test + void encryptedPrivateKey() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + String password = "test-password"; + + Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", password); + assertTlsConnectivity(cert); + } + + @Test + void combinedPemWithoutKey_throwsException() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path certOnlyFile = writeTempPem("cert-only", cert); + + assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- Helpers that mirror EdgeGrpcService.setupSsl() --- + + private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { + PemSslCredentials credentials = new PemSslCredentials(); + credentials.setCertFile(certFileResource); + credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); + credentials.setKeyPassword(keyPassword); + credentials.init(false); + + SslContext sslContext = GrpcSslContexts.configure( + SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); + + int port = TestSocketUtils.findAvailableTcpPort(); + return NettyServerBuilder.forPort(port) + .sslContext(sslContext) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) + .build() + .start(); + } + + private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { + String certPem = toPem(trustedCert); + SslContext clientSsl = GrpcSslContexts.forClient() + .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) + .build(); + + channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) + .sslContext(clientSsl) + .build(); + + // Trigger connection and wait for READY state + channel.getState(true); + long deadline = System.currentTimeMillis() + 5_000; + while (System.currentTimeMillis() < deadline) { + var state = channel.getState(false); + if (state == io.grpc.ConnectivityState.READY) { + break; + } + if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { + throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); + } + Thread.sleep(50); + } + assertThat(channel.getState(false)) + .as("Client should connect via TLS") + .isEqualTo(io.grpc.ConnectivityState.READY); + } + + // --- Cert/key generation --- + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp) throws Exception { + X500Name subject = new X500Name("CN=localhost"); + Date now = new Date(); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.ONE, now, + new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + // --- PEM file helpers --- + + private String toPem(Object obj) throws Exception { + java.io.StringWriter sw = new java.io.StringWriter(); + try (JcaPEMWriter w = new JcaPEMWriter(sw)) { + w.writeObject(obj); + } + return sw.toString(); + } + + private Path writeTempPem(String prefix, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + w.writeObject(o); + } + } + return p; + } + + private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC") + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(password.toCharArray()); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + if (o instanceof PrivateKey) { + w.writeObject(o, encryptor); + } else { + w.writeObject(o); + } + } + } + return p; + } +} From 9e19d0818e2ee4c8a25e573f2253415fa034052d Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:43:52 +0100 Subject: [PATCH 11/18] Improve EdgeGrpcSslTest: use port 0 and Awaitility - Use forPort(0) instead of TestSocketUtils for random port - Replace manual poll loop with Awaitility Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 9e3aa68b12..b86409ee23 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -31,7 +31,6 @@ import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.springframework.test.util.TestSocketUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; @@ -53,6 +52,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; /** * Tests for Edge gRPC SSL setup using PemSslCredentials. @@ -145,8 +145,7 @@ class EdgeGrpcSslTest { SslContext sslContext = GrpcSslContexts.configure( SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); - int port = TestSocketUtils.findAvailableTcpPort(); - return NettyServerBuilder.forPort(port) + return NettyServerBuilder.forPort(0) .sslContext(sslContext) .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) .build() @@ -163,22 +162,16 @@ class EdgeGrpcSslTest { .sslContext(clientSsl) .build(); - // Trigger connection and wait for READY state - channel.getState(true); - long deadline = System.currentTimeMillis() + 5_000; - while (System.currentTimeMillis() < deadline) { - var state = channel.getState(false); - if (state == io.grpc.ConnectivityState.READY) { - break; - } - if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { - throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); - } - Thread.sleep(50); - } - assertThat(channel.getState(false)) - .as("Client should connect via TLS") - .isEqualTo(io.grpc.ConnectivityState.READY); + channel.getState(true); // trigger connection attempt + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(50, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + var state = channel.getState(false); + if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { + throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); + } + assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY); + }); } // --- Cert/key generation --- From c149927a6eed92615584b2bcee654de8f30f4501 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:53:03 +0100 Subject: [PATCH 12/18] Add ECDSA P-384 test, refactor to parameterized tests - Add EC_P384 key type alongside RSA_2048 - Parameterize separateCertAndKeyFiles and combinedPemFile tests - Write private keys in PKCS#8 format for EC compatibility Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 77 ++++++++++++++----- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index b86409ee23..96a27cdb4b 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -28,9 +28,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; @@ -45,6 +48,7 @@ import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.Security; import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -61,6 +65,7 @@ import static org.awaitility.Awaitility.await; * 2. Combined PEM file (cert + key) * 3. Encrypted private key + key_password * 4. Error when combined PEM has no private key and private_key is empty + * 5. ECDSA P-384 key support */ class EdgeGrpcSslTest { @@ -70,6 +75,33 @@ class EdgeGrpcSslTest { } } + enum KeyType { + RSA_2048("RSA", 2048, null, "SHA256withRSA"), + EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); + + final String algorithm; + final int size; + final String curve; + final String sigAlg; + + KeyType(String algorithm, int size, String curve, String sigAlg) { + this.algorithm = algorithm; + this.size = size; + this.curve = curve; + this.sigAlg = sigAlg; + } + + KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm); + if (curve != null) { + kpg.initialize(new ECGenParameterSpec(curve)); + } else { + kpg.initialize(size); + } + return kpg.generateKeyPair(); + } + } + private final List tempFiles = new ArrayList<>(); private Server server; private ManagedChannel channel; @@ -87,10 +119,11 @@ class EdgeGrpcSslTest { } } - @Test - void separateCertAndKeyFiles() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + @ParameterizedTest(name = "separateCertAndKeyFiles_{0}") + @EnumSource(KeyType.class) + void separateCertAndKeyFiles(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path certFile = writeTempPem("cert", cert); Path keyFile = writeTempPem("key", kp.getPrivate()); @@ -99,10 +132,11 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void combinedPemFile() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + @ParameterizedTest(name = "combinedPemFile_{0}") + @EnumSource(KeyType.class) + void combinedPemFile(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); @@ -112,8 +146,8 @@ class EdgeGrpcSslTest { @Test void encryptedPrivateKey() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + KeyPair kp = KeyType.RSA_2048.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); String password = "test-password"; Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); @@ -124,8 +158,8 @@ class EdgeGrpcSslTest { @Test void combinedPemWithoutKey_throwsException() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + KeyPair kp = KeyType.RSA_2048.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); Path certOnlyFile = writeTempPem("cert-only", cert); @@ -176,13 +210,7 @@ class EdgeGrpcSslTest { // --- Cert/key generation --- - private KeyPair generateKeyPair() throws Exception { - KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); - kpg.initialize(2048); - return kpg.generateKeyPair(); - } - - private X509Certificate generateSelfSignedCert(KeyPair kp) throws Exception { + private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception { X500Name subject = new X500Name("CN=localhost"); Date now = new Date(); return new JcaX509CertificateConverter().getCertificate( @@ -190,7 +218,7 @@ class EdgeGrpcSslTest { subject, BigInteger.ONE, now, new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), subject, kp.getPublic()) - .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + .build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate()))); } // --- PEM file helpers --- @@ -208,7 +236,7 @@ class EdgeGrpcSslTest { tempFiles.add(p); try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { for (Object o : objects) { - w.writeObject(o); + w.writeObject(toPkcs8IfKey(o)); } } return p; @@ -231,4 +259,11 @@ class EdgeGrpcSslTest { } return p; } + + private Object toPkcs8IfKey(Object o) { + if (o instanceof PrivateKey pk) { + return new PemObject("PRIVATE KEY", pk.getEncoded()); + } + return o; + } } From 561a8597aae405d792375b43dcad5661da9bd5c1 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:00:10 +0100 Subject: [PATCH 13/18] Add RSA-4096 and EC-P256 key types, parameterize all tests - Add RSA_4096 and EC_P256 alongside RSA_2048 and EC_P384 - Parameterize encrypted key tests (RSA-only, EC encrypted keys are a pre-existing PemSslCredentials limitation) - 14 test scenarios total Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 96a27cdb4b..0f37f9f199 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -77,6 +77,8 @@ class EdgeGrpcSslTest { enum KeyType { RSA_2048("RSA", 2048, null, "SHA256withRSA"), + RSA_4096("RSA", 4096, null, "SHA256withRSA"), + EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"), EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); final String algorithm; @@ -144,10 +146,14 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void encryptedPrivateKey() throws Exception { - KeyPair kp = KeyType.RSA_2048.generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); + // RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY), + // which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials + // to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null". + @ParameterizedTest(name = "encryptedPrivateKey_{0}") + @EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"}) + void encryptedPrivateKey(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); String password = "test-password"; Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); @@ -156,10 +162,11 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void combinedPemWithoutKey_throwsException() throws Exception { - KeyPair kp = KeyType.RSA_2048.generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); + @ParameterizedTest(name = "combinedPemWithoutKey_{0}") + @EnumSource(KeyType.class) + void combinedPemWithoutKey_throwsException(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path certOnlyFile = writeTempPem("cert-only", cert); From c65b172d7ef757e08be56d7cef6d2695519c006e Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:07:19 +0100 Subject: [PATCH 14/18] Improve test method names for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - separateCertAndKey — separate cert and key PEM inputs - combinedPemWithCertAndKey — combined PEM, both present - combinedPemWithCertOnly — combined PEM, key missing → error - encryptedPrivateKey — encrypted key with password Co-Authored-By: Claude Opus 4.6 --- .../server/service/edge/rpc/EdgeGrpcSslTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 0f37f9f199..4401738f5d 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -121,9 +121,9 @@ class EdgeGrpcSslTest { } } - @ParameterizedTest(name = "separateCertAndKeyFiles_{0}") + @ParameterizedTest(name = "separateCertAndKey_{0}") @EnumSource(KeyType.class) - void separateCertAndKeyFiles(KeyType keyType) throws Exception { + void separateCertAndKey(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); @@ -134,9 +134,9 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @ParameterizedTest(name = "combinedPemFile_{0}") + @ParameterizedTest(name = "combinedPemWithCertAndKey_{0}") @EnumSource(KeyType.class) - void combinedPemFile(KeyType keyType) throws Exception { + void combinedPemWithCertAndKey(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); @@ -162,9 +162,9 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @ParameterizedTest(name = "combinedPemWithoutKey_{0}") + @ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}") @EnumSource(KeyType.class) - void combinedPemWithoutKey_throwsException(KeyType keyType) throws Exception { + void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); From 4d2545052d4c6453ef726eb1df379c7d520fb6b5 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:19:02 +0100 Subject: [PATCH 15/18] Use production EdgeGrpcService.setupSsl() in tests instead of copy-pasted logic Make setupSsl package-private so the test can call it directly, ensuring tests exercise the real production code path. Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcService.java | 2 +- .../service/edge/rpc/EdgeGrpcSslTest.java | 53 +++++++++---------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 1bb61910ec..5a8fabbf3d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -202,7 +202,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } - private void setupSsl(NettyServerBuilder builder) throws Exception { + void setupSsl(NettyServerBuilder builder) throws Exception { PemSslCredentials credentials = new PemSslCredentials(); credentials.setCertFile(certFileResource); credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 4401738f5d..ed736ebdfb 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -20,22 +20,18 @@ import io.grpc.Server; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; -import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import java.io.ByteArrayInputStream; @@ -59,13 +55,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; /** - * Tests for Edge gRPC SSL setup using PemSslCredentials. - * Covers all test plan scenarios: - * 1. Separate cert and key files (existing behavior) - * 2. Combined PEM file (cert + key) - * 3. Encrypted private key + key_password - * 4. Error when combined PEM has no private key and private_key is empty - * 5. ECDSA P-384 key support + * Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. + *

+ * Covers: + * 1. Separate cert and key PEM inputs + * 2. Combined PEM (cert + key in one file) + * 3. Encrypted private key with password + * 4. Missing key in combined PEM → error + *

+ * Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384. */ class EdgeGrpcSslTest { @@ -174,28 +172,25 @@ class EdgeGrpcSslTest { .isInstanceOf(IllegalArgumentException.class); } - // --- Helpers that mirror EdgeGrpcService.setupSsl() --- + // --- Server startup using production EdgeGrpcService.setupSsl() --- private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { - PemSslCredentials credentials = new PemSslCredentials(); - credentials.setCertFile(certFileResource); - credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); - credentials.setKeyPassword(keyPassword); - credentials.init(false); - - SslContext sslContext = GrpcSslContexts.configure( - SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); - - return NettyServerBuilder.forPort(0) - .sslContext(sslContext) - .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) - .build() - .start(); + EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); + ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); + ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); + ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); + + NettyServerBuilder builder = NettyServerBuilder.forPort(0) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); + + edgeGrpcService.setupSsl(builder); + + return builder.build().start(); } private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { String certPem = toPem(trustedCert); - SslContext clientSsl = GrpcSslContexts.forClient() + var clientSsl = GrpcSslContexts.forClient() .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) .build(); From fbf690cd8ff381ea48c09ae964e5b022e8feb041 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:29:49 +0100 Subject: [PATCH 16/18] Reuse AbstractWebTest.TIMEOUT for Awaitility timeout in EdgeGrpcSslTest Co-Authored-By: Claude Opus 4.6 --- .../thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index ed736ebdfb..04a1b87833 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import java.io.ByteArrayInputStream; @@ -199,7 +200,7 @@ class EdgeGrpcSslTest { .build(); channel.getState(true); // trigger connection attempt - await().atMost(5, TimeUnit.SECONDS) + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) .pollInterval(50, TimeUnit.MILLISECONDS) .untilAsserted(() -> { var state = channel.getState(false); From a9287999095a146d0416b580af484559599fe627 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 21:18:19 +0100 Subject: [PATCH 17/18] Enrich SSL configuration documentation with path resolution details Co-Authored-By: Claude Opus 4.6 --- .../server/service/edge/rpc/EdgeGrpcService.java | 15 +++++++++++++++ application/src/main/resources/thingsboard.yml | 12 ++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 5a8fabbf3d..e1211c53a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -202,6 +202,21 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } + /** + * Configures TLS for the Edge gRPC server. + *

+ * Delegates PEM parsing and key management to {@link PemSslCredentials} — the same + * class used by MQTT, CoAP, and LwM2M transports — which supports: + *

    + *
  • Separate certificate and private key files (classic two-file setup)
  • + *
  • Combined PEM: certificate chain + private key in a single {@code cert} file + * ({@code private_key} left empty)
  • + *
  • Encrypted private keys (password supplied via {@code key_password})
  • + *
+ * Path resolution (for both {@code cert} and {@code private_key}) is handled by + * {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}: + * absolute path → relative / working-dir → classpath → {@code classpath:} prefix. + */ void setupSsl(NettyServerBuilder builder) throws Exception { PemSslCredentials credentials = new PemSslCredentials(); credentials.setCertFile(certFileResource); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 91c475ec4e..21688abc50 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1487,12 +1487,16 @@ edges: ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" - # Path to the server certificate PEM file. May contain the full chain and optionally the private key (combined PEM). - # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key). + # Accepts an absolute filesystem path (e.g. /etc/thingsboard/certChainFile.pem), + # a relative path resolved against the working directory first then the classpath, + # or a classpath resource with the explicit "classpath:" prefix (e.g. classpath:conf/certChainFile.pem). cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" - # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). + # Path to the server certificate private key file. Optional if the private key is already present in the cert file above. + # Supports the same path resolution as 'cert': absolute, relative/classpath, or "classpath:" prefix. + # Leave empty when using a combined PEM cert that already contains the private key. private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" - # Password for encrypted private keys. Optional, only needed for password-protected keys. + # Server certificate private key password (optional). Leave empty if the key is not encrypted. key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" From 5d6869b3b4cd36bb00ab3245472b2cd7c74d543b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 11 Mar 2026 14:29:53 +0200 Subject: [PATCH 18/18] Add V_4_2_2_1 to EdgeVersion enum in edge.proto --- common/edge-api/src/main/proto/edge.proto | 1 + 1 file changed, 1 insertion(+) diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 79b69e60c8..e2f565c7a3 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -46,6 +46,7 @@ enum EdgeVersion { V_4_2_0 = 12; V_4_2_1_2 = 14; V_4_2_2 = 4220; + V_4_2_2_1 = 4221; V_LATEST = 99999; }