Browse Source

Merge pull request #15578 from thingsboard/rc

Merge rc into master
pull/15580/head
Viacheslav Klimov 5 months ago
committed by GitHub
parent
commit
9733a0ca7e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  2. 9
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  3. 69
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  4. 78
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  5. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
  6. 2
      common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
  7. 26
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java
  8. 25
      msa/js-executor/pom.xml
  9. 11
      msa/pom.xml
  10. 2
      msa/web-ui/pom.xml
  11. 2
      ui-ngx/pom.xml

8
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java

@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.TextContent;
@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override @Override
public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) { public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) {
ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); ChatModel langChainChatModel;
try {
langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
} catch (Throwable t) {
return FluentFuture.from(Futures.immediateFailedFuture(t));
}
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest); chatRequest = prepareGithubChatRequest(chatRequest);
} }

9
application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java

@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Base64; import java.util.Base64;
@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl()) .baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey()) .apiKey(chatModelConfig.providerConfig().apiKey())
@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
validateBaseUrl(providerConfig.endpoint());
return AzureOpenAiChatModel.builder() return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint()) .endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion()) .serviceVersion(providerConfig.serviceVersion())
@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
var builder = OllamaChatModel.builder() var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl()) .baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId()) .modelName(chatModelConfig.modelId())
@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
return builder.build(); return builder.build();
} }
private static void validateBaseUrl(String url) {
SsrfProtectionValidator.validateUri(URI.create(url));
}
private static Duration toDuration(Integer timeoutSeconds) { private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
} }

69
application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java

@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test; import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.dto.TbChatRequest;
import org.thingsboard.server.common.data.ai.dto.TbChatResponse;
import org.thingsboard.server.common.data.ai.dto.TbContent;
import org.thingsboard.server.common.data.ai.dto.TbUserMessage;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest {
assertThat(updatedModel.getExternalId()).isNull(); assertThat(updatedModel.getExternalId()).isNull();
} }
@Test
public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception {
// GIVEN
loginTenantAdmin();
SsrfProtectionValidator.setEnabled(true);
try {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:22/")
.apiKey("test-api-key")
.build())
.modelId("gpt-4o")
.build();
AiModel model = AiModel.builder()
.tenantId(tenantId)
.name("SSRF test model")
.configuration(modelConfig)
.build();
// WHEN
ResultActions result = doPost("/api/ai/model", model);
// THEN
result.andExpect(status().isBadRequest());
} finally {
SsrfProtectionValidator.setEnabled(false);
}
}
@Test
public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception {
// GIVEN
loginTenantAdmin();
SsrfProtectionValidator.setEnabled(true);
try {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://10.0.0.1:8080/")
.apiKey("test-api-key")
.build())
.modelId("gpt-4o")
.build();
var chatRequest = new TbChatRequest(
null,
new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))),
modelConfig);
// WHEN
TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk());
// THEN
assertThat(response).isInstanceOf(TbChatResponse.Failure.class);
assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid");
} finally {
SsrfProtectionValidator.setEnabled(false);
}
}
/* --- Get by ID API tests --- */ /* --- Get by ID API tests --- */
@Test @Test

78
application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java

@ -17,13 +17,25 @@ package org.thingsboard.server.service.ai;
import com.google.cloud.vertexai.api.GenerationConfig; import com.google.cloud.vertexai.api.GenerationConfig;
import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.ChatModel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidator")
class Langchain4jChatModelConfigurerImplTest { class Langchain4jChatModelConfigurerImplTest {
private static final String TEST_SERVICE_ACCOUNT_KEY = """ private static final String TEST_SERVICE_ACCOUNT_KEY = """
@ -41,6 +53,72 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
@BeforeEach
void enableSsrfProtection() {
SsrfProtectionValidator.setEnabled(true);
}
@AfterEach
void disableSsrfProtection() {
SsrfProtectionValidator.setEnabled(false);
}
@Test
void configureChatModel_openAi_withPrivateIp_shouldThrow() {
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/")
.apiKey("test")
.build())
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/")
.apiKey("test")
.build())
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_ollama_withPrivateIp_shouldThrow() {
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.build();
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test @Test
void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
// GIVEN // GIVEN

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java

@ -48,7 +48,7 @@ import java.util.UUID;
@DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class)
}) })
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class)
@JsonSubTypes({ @JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), @Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class),

2
common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java

@ -50,7 +50,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class)
@JsonSubTypes({ @JsonSubTypes({
@Type(name = "DEVICE", value = DeviceExportData.class), @Type(name = "DEVICE", value = DeviceExportData.class),
@Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "RULE_CHAIN", value = RuleChainExportData.class),

26
dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java

@ -17,13 +17,19 @@ package org.thingsboard.server.dao.service.validator;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.dao.ai.AiModelDao;
import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.tenant.TenantService;
import java.net.URI;
import java.util.Optional; import java.util.Optional;
@Component @Component
@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator<AiModel> {
if (!tenantService.tenantExists(tenantId)) { if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model reference a non-existent tenant!"); throw new DataValidationException("AI model reference a non-existent tenant!");
} }
// provider URL SSRF validation
if (model.getConfiguration() != null) {
AiProviderConfig providerConfig = model.getConfiguration().providerConfig();
String url = null;
if (providerConfig instanceof OpenAiProviderConfig c) {
url = c.baseUrl();
} else if (providerConfig instanceof AzureOpenAiProviderConfig c) {
url = c.endpoint();
} else if (providerConfig instanceof OllamaProviderConfig c) {
url = c.baseUrl();
}
if (url != null) {
try {
SsrfProtectionValidator.validateUri(URI.create(url));
} catch (Exception e) {
throw new DataValidationException("AI model provider URL is not allowed: " + e.getMessage());
}
}
}
} }
} }

25
msa/js-executor/pom.xml

@ -52,6 +52,29 @@
<type>exe</type> <type>exe</type>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!--
Reactor-only ordering dep (NOT a real classpath dependency).
Forces `mvn -T<n>` to serialize this module after web-ui so that
no two `yarn install` / `yarn run pkg` invocations can overlap on
the same agent. Concurrent yarn 1.x processes share ~/.cache/yarn
and have intermittently produced `tsc: not found` failures during
yarn pkg (incomplete typescript extraction in node_modules).
The chain is: ui-ngx -> msa/web-ui -> msa/js-executor.
type=pom + provided + wildcard exclusions keep nothing on the classpath.
-->
<dependency>
<groupId>org.thingsboard.msa</groupId>
<artifactId>web-ui</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies> </dependencies>
<build> <build>
@ -90,7 +113,7 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run pkg</arguments> <arguments>--mutex network run pkg</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

11
msa/pom.xml

@ -44,7 +44,16 @@
</properties> </properties>
<modules> <modules>
<!--Modules order is important to speedup parallel build and avoid yarn pgk parallel execution--> <!--
Module order below is informational only. Yarn-using modules
(web-ui, js-executor) are serialized via reactor-only
<dependency> entries in their own poms, forming the chain
ui-ngx -> web-ui -> js-executor. This prevents
concurrent yarn install / yarn run pkg invocations under `mvn -T<n>`,
which previously caused intermittent `tsc: not found` failures
(incomplete typescript extraction in node_modules from racing
yarn 1.x processes against the shared ~/.cache/yarn).
-->
<module>tb</module> <module>tb</module>
<module>web-ui</module> <module>web-ui</module>
<module>vc-executor</module> <module>vc-executor</module>

2
msa/web-ui/pom.xml

@ -99,7 +99,7 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run pkg</arguments> <arguments>--mutex network run pkg</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

2
ui-ngx/pom.xml

@ -106,7 +106,7 @@
<goal>yarn</goal> <goal>yarn</goal>
</goals> </goals>
<configuration> <configuration>
<arguments>run build:prod</arguments> <arguments>--mutex network run build:prod</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

Loading…
Cancel
Save