1175 changed files with 14032 additions and 10795 deletions
@ -0,0 +1,79 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.edge; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.protobuf.AbstractMessage; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.AdminSettings; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.settings.AdminSettingsService; |
|||
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; |
|||
|
|||
@DaoSqlTest |
|||
public class AdminSettingsEdgeTest extends AbstractEdgeTest { |
|||
|
|||
@Autowired |
|||
private AdminSettingsService adminSettingsService; |
|||
|
|||
@Test |
|||
public void testAdminSettings() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
// save
|
|||
AdminSettings adminSettings = new AdminSettings(); |
|||
adminSettings.setKey("edgeTest"); |
|||
ObjectNode jsonValue = JacksonUtil.newObjectNode(); |
|||
jsonValue.put("key1", "value1"); |
|||
adminSettings.setJsonValue(jsonValue); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
AdminSettings savedAdminSettings = doPost("/api/admin/settings", adminSettings, AdminSettings.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
AbstractMessage latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); |
|||
AdminSettingsUpdateMsg adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; |
|||
AdminSettings adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); |
|||
Assert.assertNotNull(adminSettingsMsg); |
|||
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); |
|||
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); |
|||
|
|||
// update
|
|||
ObjectNode updatedJsonValue = (ObjectNode) savedAdminSettings.getJsonValue(); |
|||
updatedJsonValue.put("key2", "value2"); |
|||
savedAdminSettings.setJsonValue(updatedJsonValue); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
doPost("/api/admin/settings", savedAdminSettings, AdminSettings.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); |
|||
adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; |
|||
adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); |
|||
Assert.assertNotNull(adminSettingsMsg); |
|||
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); |
|||
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); |
|||
Assert.assertEquals("value2", adminSettingsMsg.getJsonValue().get("key2").asText()); |
|||
|
|||
adminSettingsService.deleteAdminSettingsByTenantIdAndKey(savedAdminSettings.getTenantId(), "edgeTest"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.edge; |
|||
|
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.thingsboard.edge.rpc.EdgeVersionComparator; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeVersion; |
|||
|
|||
public class EdgeLatestVersionTest { |
|||
|
|||
@Test |
|||
public void edgeLatestVersionIsSynchronizedTest() { |
|||
EdgeVersion currentHighestEdgeVersion = EdgeVersionComparator.getNewestEdgeVersion(); |
|||
|
|||
String projectVersion = EdgeLatestVersionTest.class.getPackage().getImplementationVersion(); |
|||
if (projectVersion == null || projectVersion.isBlank()) { |
|||
projectVersion = System.getProperty("project.version", "UNKNOWN"); |
|||
} |
|||
|
|||
String projectVersionDigits = projectVersion.replaceAll("\\D", ""); |
|||
String currentHighestEdgeVersionDigits = currentHighestEdgeVersion.name().replaceAll("\\D", ""); |
|||
|
|||
String msg = "EdgeVersion enum in edge.proto is out of sync. Please add respective " + projectVersionDigits + " to EdgeVersion"; |
|||
Assert.assertEquals(msg, projectVersionDigits, currentHighestEdgeVersionDigits); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* 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.nosql; |
|||
|
|||
import lombok.Getter; |
|||
|
|||
@Getter |
|||
public class ResultSetSizeLimitExceededException extends IllegalArgumentException { |
|||
|
|||
private final long limitBytes; |
|||
private final long actualBytes; |
|||
|
|||
public ResultSetSizeLimitExceededException(long limitBytes, long actualBytes) { |
|||
super("Result set size exceeds the maximum allowed limit. Please narrow your query"); |
|||
this.limitBytes = limitBytes; |
|||
this.actualBytes = actualBytes; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,140 @@ |
|||
/** |
|||
* 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.nosql; |
|||
|
|||
import com.datastax.oss.driver.api.core.cql.AsyncResultSet; |
|||
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions; |
|||
import com.datastax.oss.driver.api.core.cql.ExecutionInfo; |
|||
import com.datastax.oss.driver.api.core.cql.Row; |
|||
import com.datastax.oss.driver.api.core.cql.Statement; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import com.google.common.util.concurrent.MoreExecutors; |
|||
import com.google.common.util.concurrent.SettableFuture; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.nio.ByteBuffer; |
|||
import java.util.List; |
|||
import java.util.concurrent.ExecutionException; |
|||
import java.util.function.Function; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.Mockito.doReturn; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
class TbResultSetTest { |
|||
|
|||
@Test |
|||
void allRows_withinLimit_returnsAllRows() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 1000); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
assertThat(result.get(0)).isSameAs(row); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_exceedsLimitOnFirstPage_failsWithException() { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 6000); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
assertThatThrownBy(future::get) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_exceedsLimitOnSecondPage_failsAfterSecondPage() { |
|||
Row row1 = mock(Row.class); |
|||
Row row2 = mock(Row.class); |
|||
Statement<?> statement = mock(Statement.class); |
|||
doReturn(statement).when(statement).setPagingState((ByteBuffer) null); |
|||
|
|||
AsyncResultSet page2 = createMockResultSet(List.of(row2), false, 3000); |
|||
TbResultSet tbResultSetPage2 = new TbResultSet(statement, page2, s -> null); |
|||
SettableFuture<TbResultSet> page2Future = SettableFuture.create(); |
|||
page2Future.set(tbResultSetPage2); |
|||
TbResultSetFuture tbPage2Future = new TbResultSetFuture(page2Future); |
|||
|
|||
ExecutionInfo page1ExecInfo = mock(ExecutionInfo.class); |
|||
when(page1ExecInfo.getResponseSizeInBytes()).thenReturn(3000); |
|||
when(page1ExecInfo.getPagingState()).thenReturn(null); |
|||
|
|||
AsyncResultSet page1 = createMockResultSet(List.of(row1), true, 3000); |
|||
when(page1.getExecutionInfo()).thenReturn(page1ExecInfo); |
|||
|
|||
Function<Statement, TbResultSetFuture> executeAsync = s -> tbPage2Future; |
|||
TbResultSet tbResultSet = new TbResultSet(statement, page1, executeAsync); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
assertThatThrownBy(future::get) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_unlimitedWithZero_returnsAllRowsRegardlessOfSize() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 0); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_noLimitOverload_returnsAllRows() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor()); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
} |
|||
|
|||
private AsyncResultSet createMockResultSet(List<Row> rows, boolean hasMorePages, int responseSizeInBytes) { |
|||
AsyncResultSet resultSet = mock(AsyncResultSet.class); |
|||
ExecutionInfo executionInfo = mock(ExecutionInfo.class); |
|||
ColumnDefinitions columnDefs = mock(ColumnDefinitions.class); |
|||
|
|||
when(executionInfo.getResponseSizeInBytes()).thenReturn(responseSizeInBytes); |
|||
when(executionInfo.getPagingState()).thenReturn(null); |
|||
when(resultSet.getExecutionInfo()).thenReturn(executionInfo); |
|||
when(resultSet.getColumnDefinitions()).thenReturn(columnDefs); |
|||
when(resultSet.currentPage()).thenReturn(rows); |
|||
when(resultSet.hasMorePages()).thenReturn(hasMorePages); |
|||
when(resultSet.remaining()).thenReturn(rows.size()); |
|||
|
|||
return resultSet; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.edge.rpc; |
|||
|
|||
import org.thingsboard.server.gen.edge.v1.EdgeVersion; |
|||
|
|||
import java.util.Comparator; |
|||
|
|||
public class EdgeVersionComparator implements Comparator<EdgeVersion> { |
|||
|
|||
public static final EdgeVersionComparator INSTANCE = new EdgeVersionComparator(); |
|||
|
|||
@Override |
|||
public int compare(EdgeVersion v1, EdgeVersion v2) { |
|||
if (v1 == v2) { |
|||
return 0; |
|||
} |
|||
// UNRECOGNIZED is less than any other version
|
|||
if (v1 == EdgeVersion.UNRECOGNIZED) { |
|||
return -1; |
|||
} |
|||
if (v2 == EdgeVersion.UNRECOGNIZED) { |
|||
return 1; |
|||
} |
|||
// V_LATEST is treated as the newest version
|
|||
if (v1 == EdgeVersion.V_LATEST) { |
|||
v1 = getNewestEdgeVersion(); |
|||
} |
|||
if (v2 == EdgeVersion.V_LATEST) { |
|||
v2 = getNewestEdgeVersion(); |
|||
} |
|||
return compareVersionParts(parseVersionParts(v1), parseVersionParts(v2)); |
|||
} |
|||
|
|||
public static EdgeVersion getNewestEdgeVersion() { |
|||
EdgeVersion newest = null; |
|||
for (EdgeVersion v : EdgeVersion.values()) { |
|||
if (v == EdgeVersion.V_LATEST || v == EdgeVersion.UNRECOGNIZED) { |
|||
continue; |
|||
} |
|||
if (newest == null || INSTANCE.compare(v, newest) > 0) { |
|||
newest = v; |
|||
} |
|||
} |
|||
return newest; |
|||
} |
|||
|
|||
private static int[] parseVersionParts(EdgeVersion version) { |
|||
String name = version.name(); |
|||
if (name.startsWith("V_")) { |
|||
name = name.substring(2); |
|||
} |
|||
String[] parts = name.split("_"); |
|||
int[] result = new int[parts.length]; |
|||
for (int i = 0; i < parts.length; i++) { |
|||
result[i] = Integer.parseInt(parts[i]); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
private static int compareVersionParts(int[] a, int[] b) { |
|||
int maxLen = Math.max(a.length, b.length); |
|||
for (int i = 0; i < maxLen; i++) { |
|||
int partA = i < a.length ? a[i] : 0; |
|||
int partB = i < b.length ? b[i] : 0; |
|||
if (partA != partB) { |
|||
return Integer.compare(partA, partB); |
|||
} |
|||
} |
|||
return 0; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.edge.rpc; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeVersion; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class EdgeVersionComparatorTest { |
|||
|
|||
@Test |
|||
void compare_sameVersion_returnsZero() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_3_0)).isEqualTo(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_0_0)).isEqualTo(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_1_2)).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_majorVersionDifference_returnsCorrectOrder() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_4_0_0)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_3_3_0)).isGreaterThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_minorVersionDifference_returnsCorrectOrder() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_6_0)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_3_0)).isGreaterThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_1_0)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_1_0, EdgeVersion.V_4_0_0)).isGreaterThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_patchVersionDifference_returnsCorrectOrder() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_6_1)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_0)).isGreaterThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_2)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_2, EdgeVersion.V_3_6_4)).isLessThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_fourPartVersion_returnsCorrectOrder() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_0)).isGreaterThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_threePartVsFourPart_treatsImplicitZero() { |
|||
// V_4_2_0 should be less than V_4_2_1_2 (4.2.0.0 < 4.2.1.2)
|
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void getNewestEdgeVersion_excludesLatestAndUnrecognized() { |
|||
EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion(); |
|||
assertThat(newest).isNotNull(); |
|||
assertThat(newest).isNotEqualTo(EdgeVersion.V_LATEST); |
|||
assertThat(newest).isNotEqualTo(EdgeVersion.UNRECOGNIZED); |
|||
} |
|||
|
|||
@Test |
|||
void compare_vLatest_treatedAsNewestVersion() { |
|||
EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion(); |
|||
// V_LATEST equals the newest version
|
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, newest)).isEqualTo(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(newest, EdgeVersion.V_LATEST)).isEqualTo(0); |
|||
// V_LATEST is greater than older versions
|
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_3_3_0)).isGreaterThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_LATEST)).isLessThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_vLatest_withItself_returnsZero() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_LATEST)).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_unrecognized_isLessThanAnyVersion() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_3_3_0)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_LATEST)).isLessThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0); |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0); |
|||
} |
|||
|
|||
@Test |
|||
void compare_unrecognized_withItself_returnsZero() { |
|||
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.UNRECOGNIZED)).isEqualTo(0); |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
/** |
|||
* 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.msg; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.id.RuleChainId; |
|||
import org.thingsboard.server.common.data.id.RuleNodeId; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class TbMsgProcessingCtxTest { |
|||
|
|||
private final RuleChainId RULE_CHAIN_ID = new RuleChainId(UUID.fromString("b87c4123-f9f2-41a6-9a09-e3a5b6580b11")); |
|||
private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("1ca5e2ef-1309-41d9-bafa-709e9df0e2a6")); |
|||
|
|||
@Test |
|||
void givenEmptyStack_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithDifferentEntry_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithMatchingEntry_whenIsAlreadyInStack_thenReturnTrue() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithMatchingEntryAmongOthers_whenIsAlreadyInStack_thenReturnTrue() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithSameChainButDifferentNode_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithSameNodeButDifferentChain_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), RULE_NODE_ID); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithEntryThenPopped_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
ctx.pop(); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,247 @@ |
|||
/** |
|||
* 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.common.util; |
|||
|
|||
import lombok.AccessLevel; |
|||
import lombok.NoArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
import java.net.InetAddress; |
|||
import java.net.URI; |
|||
import java.net.UnknownHostException; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
|
|||
@Slf4j |
|||
@NoArgsConstructor(access = AccessLevel.PRIVATE) |
|||
public class SsrfProtectionValidator { |
|||
|
|||
private static volatile boolean enabled; |
|||
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https"); |
|||
private static final Set<String> BLOCKED_HOSTNAMES = Set.of("localhost"); |
|||
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local"); |
|||
|
|||
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY; |
|||
|
|||
// Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal)
|
|||
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of( |
|||
CidrRange.of("100.64.0.0", 10), // Carrier-Grade NAT (RFC 6598); Alibaba Cloud and Tencent Cloud metadata
|
|||
CidrRange.of("192.0.0.0", 24), // IANA reserved (RFC 6890); Oracle Cloud alternate metadata endpoint
|
|||
CidrRange.of("168.63.129.16", 32) // Azure WireServer
|
|||
); |
|||
|
|||
public static void validateUri(URI uri) { |
|||
validateUri(uri, enabled); |
|||
} |
|||
|
|||
static void validateUri(URI uri, boolean ssrfProtectionEnabled) { |
|||
if (!ssrfProtectionEnabled) { |
|||
return; |
|||
} |
|||
|
|||
String scheme = uri.getScheme(); |
|||
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) { |
|||
throw new RuntimeException("URI is invalid: only HTTP and HTTPS schemes are allowed, got: " + scheme); |
|||
} |
|||
|
|||
String host = uri.getHost(); |
|||
if (host == null || host.isEmpty()) { |
|||
throw new RuntimeException("URI is invalid: hostname is missing"); |
|||
} |
|||
|
|||
String hostLower = host.toLowerCase(); |
|||
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
for (String suffix : BLOCKED_HOSTNAME_SUFFIXES) { |
|||
if (hostLower.endsWith(suffix)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
} |
|||
// Block IPv6 loopback literal in URL (e.g. http://[::1]/)
|
|||
if ("[::1]".equals(host) || "::1".equals(host)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
|
|||
validateResolvedAddresses(host); |
|||
} |
|||
|
|||
private static void validateResolvedAddresses(String host) { |
|||
InetAddress[] addresses; |
|||
try { |
|||
addresses = InetAddress.getAllByName(host); |
|||
} catch (UnknownHostException e) { |
|||
throw new RuntimeException("URI is invalid: unable to resolve hostname '" + host + "'", e); |
|||
} |
|||
|
|||
for (InetAddress address : addresses) { |
|||
if (isBlockedAddress(address)) { |
|||
log.debug("Blocked request to host '{}' resolved to '{}'", host, address.getHostAddress()); |
|||
throwBlockedHost(host); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static boolean isBlockedAddress(InetAddress address) { |
|||
// Covers 127.0.0.0/8 and ::1
|
|||
if (address.isLoopbackAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|||
if (address.isSiteLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 169.254.0.0/16 and fe80::/10
|
|||
if (address.isLinkLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 0.0.0.0
|
|||
if (address.isAnyLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Additional check for IPv6 unique local addresses (fc00::/7)
|
|||
byte[] addr = address.getAddress(); |
|||
if (addr.length == 16) { |
|||
int firstByte = addr[0] & 0xFF; |
|||
// fc00::/7 means first 7 bits are 1111110, so first byte is 0xFC or 0xFD
|
|||
if (firstByte == 0xFC || firstByte == 0xFD) { |
|||
return true; |
|||
} |
|||
} |
|||
for (CidrRange cidr : CLOUD_METADATA_RANGES) { |
|||
if (cidr.contains(address)) { |
|||
return true; |
|||
} |
|||
} |
|||
// Check additional configured CIDR ranges
|
|||
for (CidrRange cidr : additionalBlocked.cidrRanges) { |
|||
if (cidr.contains(address)) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private static void throwBlockedHost(String host) { |
|||
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed"); |
|||
} |
|||
|
|||
public static void setEnabled(boolean enabled) { |
|||
SsrfProtectionValidator.enabled = enabled; |
|||
} |
|||
|
|||
public static void setAdditionalBlockedHosts(List<String> entries) { |
|||
if (entries == null || entries.isEmpty()) { |
|||
additionalBlocked = AdditionalBlockedHosts.EMPTY; |
|||
return; |
|||
} |
|||
List<CidrRange> cidrRanges = new ArrayList<>(); |
|||
Set<String> hostnames = new HashSet<>(); |
|||
for (String entry : entries) { |
|||
String trimmed = entry.trim(); |
|||
if (trimmed.isEmpty()) { |
|||
continue; |
|||
} |
|||
if (trimmed.contains("/") || isIpLiteral(trimmed)) { |
|||
try { |
|||
cidrRanges.add(CidrRange.parse(trimmed)); |
|||
} catch (Exception e) { |
|||
log.warn("Failed to parse CIDR/IP entry '{}': {}", trimmed, e.getMessage()); |
|||
} |
|||
} else { |
|||
hostnames.add(trimmed.toLowerCase()); |
|||
} |
|||
} |
|||
additionalBlocked = new AdditionalBlockedHosts( |
|||
Collections.unmodifiableList(cidrRanges), |
|||
Collections.unmodifiableSet(hostnames)); |
|||
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size()); |
|||
} |
|||
|
|||
private static boolean isIpLiteral(String entry) { |
|||
// IPv4 starts with a digit, IPv6 contains ':'
|
|||
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); |
|||
} |
|||
|
|||
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) { |
|||
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet()); |
|||
} |
|||
|
|||
record CidrRange(byte[] network, int prefixLength) { |
|||
|
|||
static CidrRange of(String ip, int prefixLength) { |
|||
try { |
|||
byte[] addr = InetAddress.getByName(ip).getAddress(); |
|||
if (prefixLength < 0 || prefixLength > addr.length * 8) { |
|||
throw new IllegalArgumentException("Invalid prefix length: " + prefixLength + " for " + ip); |
|||
} |
|||
return new CidrRange(addr, prefixLength); |
|||
} catch (UnknownHostException e) { |
|||
throw new IllegalArgumentException("Invalid IP: " + ip, e); |
|||
} |
|||
} |
|||
|
|||
static CidrRange parse(String entry) throws UnknownHostException { |
|||
int slashIndex = entry.indexOf('/'); |
|||
if (slashIndex >= 0) { |
|||
String ip = entry.substring(0, slashIndex); |
|||
int prefix = Integer.parseInt(entry.substring(slashIndex + 1)); |
|||
byte[] addr = InetAddress.getByName(ip).getAddress(); |
|||
if (prefix < 0 || prefix > addr.length * 8) { |
|||
throw new IllegalArgumentException("Invalid prefix length: " + prefix + " for " + entry); |
|||
} |
|||
return new CidrRange(addr, prefix); |
|||
} else { |
|||
byte[] addr = InetAddress.getByName(entry).getAddress(); |
|||
return new CidrRange(addr, addr.length * 8); |
|||
} |
|||
} |
|||
|
|||
boolean contains(InetAddress address) { |
|||
byte[] addr = address.getAddress(); |
|||
if (addr.length != network.length) { |
|||
return false; |
|||
} |
|||
int fullBytes = prefixLength / 8; |
|||
int remainingBits = prefixLength % 8; |
|||
for (int i = 0; i < fullBytes; i++) { |
|||
if (addr[i] != network[i]) { |
|||
return false; |
|||
} |
|||
} |
|||
if (remainingBits > 0 && fullBytes < addr.length) { |
|||
int mask = 0xFF << (8 - remainingBits); |
|||
if ((addr[fullBytes] & mask) != (network[fullBytes] & mask)) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
try { |
|||
return InetAddress.getByAddress(network).getHostAddress() + "/" + prefixLength; |
|||
} catch (UnknownHostException e) { |
|||
return "invalid/" + prefixLength; |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,338 @@ |
|||
/** |
|||
* 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.common.util; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.parallel.ResourceLock; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.ValueSource; |
|||
|
|||
import java.net.URI; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThatNoException; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
|
|||
public class SsrfProtectionValidatorTest { |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://example.com", |
|||
"https://example.com:8443/path", |
|||
"https://8.8.8.8/dns-query" |
|||
}) |
|||
void testAllowedUrls(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://127.0.0.1", |
|||
"http://127.0.0.1:8080/path", |
|||
"http://127.1.2.3" |
|||
}) |
|||
void testBlockedLoopbackIpv4(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedLocalhost() { |
|||
URI uri = URI.create("http://localhost/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedIpv6Loopback() { |
|||
URI uri = URI.create("http://[::1]/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://169.254.169.254/latest/meta-data/", |
|||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/", |
|||
"http://169.254.1.1" |
|||
}) |
|||
void testBlockedLinkLocalImds(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://10.0.0.1", |
|||
"http://10.255.255.255", |
|||
"http://172.16.0.1", |
|||
"http://172.31.255.255", |
|||
"http://192.168.1.1", |
|||
"http://192.168.0.100:8080/api" |
|||
}) |
|||
void testBlockedPrivateRfc1918(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
// 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598): Alibaba Cloud metadata (100.100.100.200), Tencent Cloud (100.88.222.5)
|
|||
"http://100.100.100.200", |
|||
"http://100.88.222.5", |
|||
"http://100.64.0.1", |
|||
"http://100.127.255.255", |
|||
// 192.0.0.0/24 — IANA reserved (RFC 6890): Oracle Cloud alternate metadata (192.0.0.192)
|
|||
"http://192.0.0.192", |
|||
"http://192.0.0.1", |
|||
// 168.63.129.16 — Azure WireServer
|
|||
"http://168.63.129.16" |
|||
}) |
|||
void testBlockedCloudMetadataEndpoints(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
// Just outside 100.64.0.0/10
|
|||
"http://100.128.0.1", |
|||
// Just outside 192.0.0.0/24
|
|||
"http://192.0.1.1", |
|||
// Adjacent to Azure WireServer
|
|||
"http://168.63.129.17" |
|||
}) |
|||
void testAllowedNearCloudMetadataBoundaries(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"file:///etc/passwd", |
|||
"ftp://internal.host/file" |
|||
}) |
|||
void testBlockedSchemes(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("only HTTP and HTTPS schemes are allowed"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedZeroAddress() { |
|||
URI uri = URI.create("http://0.0.0.0"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://server.internal", |
|||
"http://app.local" |
|||
}) |
|||
void testBlockedHostnameSuffixes(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedNullScheme() { |
|||
URI uri = URI.create("//example.com/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("only HTTP and HTTPS schemes are allowed"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedEmptyHost() { |
|||
URI uri = URI.create("http:///path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("hostname is missing"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedUnresolvableHostname() { |
|||
URI uri = URI.create("http://host.invalid.tld.that.does.not.exist/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("unable to resolve hostname"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedLocalhostCaseInsensitive() { |
|||
URI uri = URI.create("http://LOCALHOST/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testDisabledAllowsPrivateAddresses() { |
|||
URI uri = URI.create("http://127.0.0.1"); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, false)); |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedSingleIp() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.8")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8/dns-query"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Adjacent IP is not blocked
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrSlash10() { |
|||
try { |
|||
// Use 44.0.0.0/10 (not blocked by default) to verify CIDR /10 matching
|
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("44.0.0.0/10")); |
|||
// Inside the range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.0.1.1"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Last address in the range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.63.255.255"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Outside the range
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.64.0.1"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrSlash24() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("198.51.100.0/24")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.0"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.255"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Outside the range
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.101.0"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedHostnameViaValidateUri() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("evil.corp")); |
|||
URI uri = URI.create("http://evil.corp/api"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedHostnameCaseInsensitive() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("My-Service.Corp")); |
|||
URI uri = URI.create("http://my-service.corp/api"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testSetAdditionalBlockedHostsEmptyAndNull() { |
|||
// Should not throw
|
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(null); |
|||
} |
|||
|
|||
@Test |
|||
void testCidrRangeInvalidPrefixLength() { |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/999")) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessageContaining("Invalid prefix length"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/-1")) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessageContaining("Invalid prefix length"); |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrViaValidateUri() { |
|||
// 203.0.113.0/24 (TEST-NET-3) is not blocked by default
|
|||
URI uri = URI.create("http://203.0.113.1"); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedMixedConfig() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24", "evil.corp", "8.8.8.8")); |
|||
// CIDR range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://203.0.113.50"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Hostname
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://evil.corp/api"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Single IP
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Not in any additional block list
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://1.1.1.1"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -1,154 +0,0 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.service.timeseries.sql; |
|||
|
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import com.google.common.util.concurrent.ListeningExecutorService; |
|||
import com.google.common.util.concurrent.MoreExecutors; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.apache.commons.lang3.RandomStringUtils; |
|||
import org.junit.After; |
|||
import org.junit.Assert; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.server.common.data.Tenant; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BooleanDataEntry; |
|||
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
|||
import org.thingsboard.server.common.data.kv.LongDataEntry; |
|||
import org.thingsboard.server.common.data.kv.StringDataEntry; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.dao.service.AbstractServiceTest; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Random; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
@DaoSqlTest |
|||
@Slf4j |
|||
public class LatestTimeseriesPerformanceTest extends AbstractServiceTest { |
|||
|
|||
private static final String STRING_KEY = "stringKey"; |
|||
private static final String LONG_KEY = "longKey"; |
|||
private static final String DOUBLE_KEY = "doubleKey"; |
|||
private static final String BOOLEAN_KEY = "booleanKey"; |
|||
private static final int AMOUNT_OF_UNIQ_KEY = 10000; |
|||
private static final int TIMEOUT = 100; |
|||
|
|||
private final Random random = new Random(); |
|||
|
|||
@Autowired |
|||
private TimeseriesLatestDao timeseriesLatestDao; |
|||
|
|||
private ListeningExecutorService testExecutor; |
|||
|
|||
private EntityId entityId; |
|||
|
|||
private AtomicLong saveCounter; |
|||
|
|||
@Before |
|||
public void before() { |
|||
Tenant tenant = new Tenant(); |
|||
tenant.setTitle("My tenant"); |
|||
Tenant savedTenant = tenantService.saveTenant(tenant); |
|||
Assert.assertNotNull(savedTenant); |
|||
tenantId = savedTenant.getId(); |
|||
entityId = new DeviceId(UUID.randomUUID()); |
|||
saveCounter = new AtomicLong(0); |
|||
testExecutor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(200, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope"))); |
|||
} |
|||
|
|||
@After |
|||
public void after() { |
|||
tenantService.deleteTenant(tenantId); |
|||
if (testExecutor != null) { |
|||
testExecutor.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void test_save_latest_timeseries() throws Exception { |
|||
warmup(); |
|||
saveCounter.set(0); |
|||
|
|||
long startTime = System.currentTimeMillis(); |
|||
List<ListenableFuture<?>> futures = new ArrayList<>(); |
|||
for (int i = 0; i < 25_000; i++) { |
|||
futures.add(save(generateStrEntry(getRandomKey()))); |
|||
futures.add(save(generateLngEntry(getRandomKey()))); |
|||
futures.add(save(generateDblEntry(getRandomKey()))); |
|||
futures.add(save(generateBoolEntry(getRandomKey()))); |
|||
} |
|||
Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); |
|||
long endTime = System.currentTimeMillis(); |
|||
|
|||
long totalTime = endTime - startTime; |
|||
|
|||
log.info("Total time: {}", totalTime); |
|||
log.info("Saved count: {}", saveCounter.get()); |
|||
log.warn("Saved per 1 sec: {}", saveCounter.get() * 1000 / totalTime); |
|||
} |
|||
|
|||
private void warmup() throws Exception { |
|||
List<ListenableFuture<?>> futures = new ArrayList<>(); |
|||
for (int i = 0; i < AMOUNT_OF_UNIQ_KEY; i++) { |
|||
futures.add(save(generateStrEntry(i))); |
|||
futures.add(save(generateLngEntry(i))); |
|||
futures.add(save(generateDblEntry(i))); |
|||
futures.add(save(generateBoolEntry(i))); |
|||
} |
|||
Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); |
|||
} |
|||
|
|||
private ListenableFuture<?> save(TsKvEntry tsKvEntry) { |
|||
return Futures.transformAsync(testExecutor.submit(() -> timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)), result -> { |
|||
saveCounter.incrementAndGet(); |
|||
return result; |
|||
}, testExecutor); |
|||
} |
|||
|
|||
private TsKvEntry generateStrEntry(int keyIndex) { |
|||
return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(STRING_KEY + keyIndex, RandomStringUtils.random(10))); |
|||
} |
|||
|
|||
private TsKvEntry generateLngEntry(int keyIndex) { |
|||
return new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(LONG_KEY + keyIndex, random.nextLong())); |
|||
} |
|||
|
|||
private TsKvEntry generateDblEntry(int keyIndex) { |
|||
return new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry(DOUBLE_KEY + keyIndex, random.nextDouble())); |
|||
} |
|||
|
|||
private TsKvEntry generateBoolEntry(int keyIndex) { |
|||
return new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(BOOLEAN_KEY + keyIndex, random.nextBoolean())); |
|||
} |
|||
|
|||
private int getRandomKey() { |
|||
return random.nextInt(AMOUNT_OF_UNIQ_KEY); |
|||
} |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue