Compare commits

...
8 changed files with 441 additions and 12 deletions
@@ -63,6 +63,25 @@ public class JdbcCommonCollect extends AbstractCollect {
private static final String[] VULNERABLE_KEYWORDS = {"allowLoadLocalInfile", "allowLoadLocalInfileInPath", "useLocalInfile"};
private static final String[] BLACK_LIST = {
// dangerous SQL commands - may cause database structure damage or data leakage
"create trigger", "create alias", "runscript from", "shutdown", "drop table",
"drop database", "create function", "alter system", "grant all", "revoke all",
// file IO related - may cause server files to be read or written
"allowloadlocalinfile", "allowloadlocalinfileinpath", "uselocalinfile",
// code execution related - may result in remote code execution
"init=", "javaobjectserializer=", "runscript", "serverstatusdiffinterceptor",
"queryinterceptors=", "statementinterceptors=", "exceptioninterceptors=",
// multiple statement execution - may lead to SQL injection
"allowmultiqueries",
// deserialization related - may result in remote code execution
"autodeserialize", "detectcustomcollations",
};
private final GlobalConnectionCache connectionCommonCache = GlobalConnectionCache.getInstance();
@@ -331,17 +350,24 @@ public class JdbcCommonCollect extends AbstractCollect {
if (Objects.nonNull(jdbcProtocol.getUrl())
&& !Objects.equals("", jdbcProtocol.getUrl())
&& jdbcProtocol.getUrl().startsWith("jdbc")) {
// convert the URL to lowercase for case-insensitive checking
String url = jdbcProtocol.getUrl().toLowerCase();
// check whether the parameter is valid
if (url.contains("create trigger") || url.contains("create alias") || url.contains("runscript from")
|| url.contains("allowloadlocalinfile") || url.contains("allowloadlocalinfileinpath")
|| url.contains("uselocalinfile") || url.contains("autodeserialize") || url.contains("detectcustomcollations")
|| url.contains("serverstatusdiffinterceptor")) {
throw new IllegalArgumentException("Invalid JDBC URL: contains malicious characters.");
// limit url length
if (jdbcProtocol.getUrl().length() > 2048) {
throw new IllegalArgumentException("JDBC URL length exceeds maximum limit of 2048 characters");
}
// when has config jdbc url, use it
return jdbcProtocol.getUrl();
// remove special characters
String cleanedUrl = jdbcProtocol.getUrl().replaceAll("[\\x00-\\x1F\\x7F]", "");
String url = cleanedUrl.toLowerCase();
// backlist check
for (String keyword : BLACK_LIST) {
if (url.contains(keyword)) {
throw new IllegalArgumentException("Invalid JDBC URL: contains potentially malicious parameter: " + keyword);
}
}
// url format check
if (!url.matches("^jdbc:[a-zA-Z0-9]+://[^\\s]+$")) {
throw new IllegalArgumentException("Invalid JDBC URL format");
}
return cleanedUrl;
}
return switch (jdbcProtocol.getPlatform()) {
case "mysql", "mariadb" -> "jdbc:mysql://" + host + ":" + port
+27
View File
@@ -0,0 +1,27 @@
# Hertzbeat-MCP
## Hertzbeat-Log-MCP
Log MCP Service Based on GreptimeDB.
- GreptimeDB log writing needs to be enabled.
## Claude Desktop Integration (stdio)
```json
{
"mcpServers": {
"hertzbeat-mcp": {
"command": "java",
"args": [
"-Dspring.ai.mcp.server.stdio=true",
"-Dspring.main.web-application-type=none",
"-Dlogging.pattern.console=",
"-Dgreptime.url=http://${IP}:4000",
"-jar",
"${PATH}/hertzbeat-mcp-2.0-SNAPSHOT.jar"
]
}
}
}
```
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat</artifactId>
<version>2.0-SNAPSHOT</version>
</parent>
<artifactId>hertzbeat-mcp</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-webflux-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- json path parser-->
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>${java.version}</release>
<compilerArgs>
<compilerArg>-parameters</compilerArg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.apache.hertzbeat.mcp.server;
import org.apache.hertzbeat.mcp.server.service.LogService;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
/**
* MCP Server Application
*/
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
@Bean
public ToolCallbackProvider tools(
LogService logService) {
return MethodToolCallbackProvider.builder()
.toolObjects(logService)
.build();
}
}
@@ -0,0 +1,196 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.apache.hertzbeat.mcp.server.service;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.ReadContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* Log query service
*/
@Service
@Slf4j
public class LogService {
private static final String TIMESTAMP_COLUMN = "timestamp";
private static final String SEVERITY_TEXT_COLUMN = "severity_text";
private static final String BODY_COLUMN = "body";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final RestClient restClient;
public LogService(@Value("${greptime.url}") String greptimeUrl) {
this.restClient = RestClient.builder()
.baseUrl(greptimeUrl)
.defaultHeader("Accept", "application/json")
.defaultHeader("Content-Type", "application/x-www-form-urlencoded")
.build();
}
@Tool(description = "System log query tool that supports filtering by time, log level, and content")
public String getHertzbeatLog(
@ToolParam(description = """
Query system logs with support for filtering by time, log level, and content.
Usage:
1. Table name: hzb_log
2. Common query examples:
- Get latest 10 logs: SELECT * FROM hzb_log ORDER BY timestamp DESC LIMIT 10
- Query ERROR level logs: SELECT * FROM hzb_log WHERE severity_number=17
- Query specific time range: SELECT * FROM hzb_log WHERE timestamp > '2024-01-01 00:00:00'
Field descriptions:
1. severity_number (log level):
- 5: DEBUG
- 9: INFO
- 13: WARN
- 17: ERROR
2. timestamp: log timestamp
3. body: log content
""") String querySql) {
if (!isValidQuery(querySql)) {
return "Invalid query statement";
}
try {
String response = executeQuery(querySql);
return formatQueryResults(response);
} catch (Exception e) {
log.error("Failed to query logs", e);
return "Failed to query logs: " + e.getMessage();
}
}
private boolean isValidQuery(String sql) {
return sql != null && sql.toLowerCase().contains("hzb_log");
}
private String executeQuery(String sql) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("sql", sql);
log.debug("Executing SQL query: {}", sql);
return restClient.post()
.uri("/v1/sql?db=public")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(formData)
.retrieve()
.body(String.class);
}
private String formatQueryResults(String response) {
ReadContext ctx = JsonPath.parse(response);
List<Map<String, Object>> columnSchemas = ctx.read("$.output[0].records.schema.column_schemas");
List<List<Object>> rows = ctx.read("$.output[0].records.rows");
int totalRows = ctx.read("$.output[0].records.total_rows");
ColumnIndices indices = findColumnIndices(columnSchemas);
StringBuilder result = new StringBuilder()
.append("Query Results:\n\n")
.append("Log Time\t\t\tLog Level\tLog Content\n")
.append("----------------------------------------------------\n");
if (rows != null && !rows.isEmpty()) {
formatRows(rows, indices, result);
result.append("\nTotal ").append(totalRows).append(" records");
} else {
result.append("No data");
}
return result.toString();
}
private record ColumnIndices(int timestamp, int severityText, int body) {}
private ColumnIndices findColumnIndices(List<Map<String, Object>> columnSchemas) {
int timestampIndex = -1;
int severityTextIndex = -1;
int bodyIndex = -1;
for (int i = 0; i < columnSchemas.size(); i++) {
String columnName = (String) columnSchemas.get(i).get("name");
switch (columnName) {
case TIMESTAMP_COLUMN -> timestampIndex = i;
case SEVERITY_TEXT_COLUMN -> severityTextIndex = i;
case BODY_COLUMN -> bodyIndex = i;
default -> {
// Ignore other columns
}
}
}
return new ColumnIndices(timestampIndex, severityTextIndex, bodyIndex);
}
private void formatRows(List<List<Object>> rows, ColumnIndices indices, StringBuilder result) {
for (List<Object> row : rows) {
appendTimestamp(row, indices.timestamp(), result);
appendSeverity(row, indices.severityText(), result);
appendBody(row, indices.body(), result);
result.append("\n");
}
}
private void appendTimestamp(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
Object value = row.get(index);
if (value instanceof Number) {
long timestamp = ((Number) value).longValue();
LocalDateTime dateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(timestamp / 1_000_000),
ZoneId.systemDefault());
result.append(DATE_FORMATTER.format(dateTime)).append("\t");
return;
}
}
result.append("Unknown time\t");
}
private void appendSeverity(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
result.append(row.get(index)).append("\t");
} else {
result.append("Unknown\t");
}
}
private void appendBody(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
result.append(row.get(index));
} else {
result.append("No content");
}
}
}
@@ -0,0 +1,28 @@
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
spring:
main:
banner-mode: off
ai:
mcp:
server:
name: hertzbeat-log-analysis-server
version: 0.1
#
#logging:
# file:
# name:
@@ -98,9 +98,9 @@ public abstract class PromqlQueryExecutor implements QueryExecutor {
}
HttpEntity<Void> httpEntity = new HttpEntity<>(headers);
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(httpPromqlProperties.url);
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(httpPromqlProperties.url + QUERY_PATH);
uriComponentsBuilder.queryParam(HTTP_QUERY_PARAM, queryString);
URI uri = uriComponentsBuilder.build(true).toUri();
URI uri = uriComponentsBuilder.build().toUri();
ResponseEntity<PromQlQueryContent> responseEntity = restTemplate.exchange(uri,
HttpMethod.GET, httpEntity, PromQlQueryContent.class);
if (responseEntity.getStatusCode().is2xxSuccessful()) {
+1
View File
@@ -91,6 +91,7 @@
<module>hertzbeat-log</module>
<module>hertzbeat-e2e</module>
<module>hertzbeat-base</module>
<module>hertzbeat-mcp</module>
</modules>
<properties>