Merge branch 'master' into fix/008-questdb-sql-injection

This commit is contained in:
Duansg
2026-08-10 00:01:14 +08:00
committed by GitHub
73 changed files with 2915 additions and 227 deletions
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -143,7 +144,7 @@ public class SopController {
.status("FAILED")
.error("SOP skill not found: " + skillName)
.build();
return ResponseEntity.notFound().build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResult);
}
Map<String, Object> inputParams = params != null ? params : new HashMap<>();
@@ -105,9 +105,8 @@ public class SopScheduleExecutor {
// Check if skill exists
var definition = skillRegistry.getSkill(schedule.getSopName());
if (definition == null) {
log.warn("Skill {} not found, skipping schedule {}",
schedule.getSopName(), schedule.getId());
return;
// Do not silently skip an invalid schedule because its execution time will still be advanced.
throw new IllegalStateException("SOP skill not found: " + schedule.getSopName());
}
// Parse parameters
@@ -148,10 +148,17 @@ public class SopScheduleServiceImpl implements SopScheduleService {
private LocalDateTime calculateNextRunTime(String cronExpression) {
try {
CronExpression cron = CronExpression.parse(cronExpression);
return cron.next(LocalDateTime.now());
} catch (Exception e) {
log.error("Failed to calculate next run time for cron: {}", cronExpression, e);
return null;
LocalDateTime nextRunTime = cron.next(LocalDateTime.now());
if (nextRunTime == null) {
// Expressions such as February 31 are syntactically valid but can never be triggered.
throw new IllegalArgumentException(
"Cron expression has no future execution time: " + cronExpression);
}
return nextRunTime;
} catch (IllegalArgumentException e) {
throw e;
} catch (RuntimeException e) {
throw new IllegalArgumentException("Failed to calculate next run time: " + cronExpression, e);
}
}
}
@@ -30,6 +30,9 @@ import org.apache.hertzbeat.ai.tools.DatabaseTools;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.util.AesUtil;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.IpDomainUtil;
import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.hertzbeat.manager.pojo.dto.MonitorDto;
import org.apache.hertzbeat.manager.service.MonitorService;
import org.springframework.ai.tool.annotation.Tool;
@@ -240,11 +243,21 @@ public class DatabaseToolsImpl implements DatabaseTools {
private String buildJdbcUrl(String platform, String host, String port, String database) {
String effectivePort = (port == null || port.isEmpty()) ? "3306" : port;
String effectiveDb = (database == null || database.isEmpty()) ? "" : database;
return "jdbc:mysql://" + host + ":" + effectivePort + "/" + effectiveDb
// host, port and database come from monitor parameters and are concatenated into the url,
// so they must not carry url syntax of their own
String effectiveDb = JdbcUrlSafetyUtil.requireSafeDatabaseName(database);
if (!IpDomainUtil.validateIpDomain(host)) {
throw new IllegalArgumentException("Invalid database host: " + host);
}
if (!CommonUtil.isNumeric(effectivePort)) {
throw new IllegalArgumentException("Invalid database port: " + effectivePort);
}
String url = "jdbc:mysql://" + host + ":" + effectivePort + "/" + effectiveDb
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false"
+ "&allowPublicKeyRetrieval=true&connectTimeout=5000";
JdbcUrlSafetyUtil.requireSafeJdbcUrl(url);
return url;
}
private String executeAndFormat(String url, String username, String password,
@@ -125,12 +125,16 @@ public class SkillToolsImpl implements SkillTools {
// Parse parameters
Map<String, Object> params = parseParams(paramsJson);
if (params == null) {
return "Error: Skill parameters must be a valid JSON object.";
}
// Validate required parameters
if (skill.getParameters() != null) {
for (SopParameter paramDef : skill.getParameters()) {
if (paramDef.isRequired()) {
if (!params.containsKey(paramDef.getName()) || params.get(paramDef.getName()) == null) {
Object value = params.get(paramDef.getName());
if (value == null || value instanceof String text && text.isBlank()) {
return "Error: Required parameter '" + paramDef.getName() + "' is missing. "
+ "Description: " + paramDef.getDescription();
}
@@ -143,7 +147,9 @@ public class SkillToolsImpl implements SkillTools {
SopResult result = sopEngine.executeSync(skill, params);
// Check output type
if (result.getOutputType() == OutputType.REPORT) {
if ("SUCCESS".equals(result.getStatus())
&& result.getOutputType() == OutputType.REPORT
&& result.getContent() != null) {
// Report type: return with marker for direct display to user
log.info("Skill {} returned report-type output, marking for direct display", skillName);
return SKILL_REPORT_MARKER + "\n" + result.getContent();
@@ -167,8 +173,8 @@ public class SkillToolsImpl implements SkillTools {
try {
return JsonUtil.fromJson(paramsJson, Map.class);
} catch (Exception e) {
log.warn("Failed to parse params JSON: {}, returning empty map", paramsJson);
return new HashMap<>();
log.warn("Failed to parse params JSON: {}", paramsJson);
return null;
}
}
}
@@ -0,0 +1,61 @@
/*
* 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.ai.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Verifies that synchronous SOP failures include a diagnostic response body.
*/
@ExtendWith(MockitoExtension.class)
class SopControllerTest {
@Mock
private SkillRegistry skillRegistry;
@Mock
private SopEngine sopEngine;
@InjectMocks
private SopController controller;
@Test
void executeSopSyncShouldReturnFailureBodyWhenSkillDoesNotExist() {
when(skillRegistry.getSkill("missing")).thenReturn(null);
ResponseEntity<SopResult> response = controller.executeSopSync("missing", null);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("FAILED", response.getBody().getStatus());
assertEquals("SOP skill not found: missing", response.getBody().getError());
}
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.ai.schedule;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doThrow;
@@ -37,6 +38,7 @@ import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -100,6 +102,21 @@ class SopScheduleExecutorTest {
verify(scheduleService).updateAfterExecution(1L);
}
@Test
void checkShouldPushErrorWhenScheduledSkillNoLongerExists() {
SopSchedule schedule = schedule(1L, null);
when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
when(skillRegistry.getSkill("daily_inspection")).thenReturn(null);
executor.checkAndExecuteDueSchedules();
ArgumentCaptor<ChatMessage> messageCaptor = ArgumentCaptor.forClass(ChatMessage.class);
verify(chatMessageDao).save(messageCaptor.capture());
assertTrue(messageCaptor.getValue().getContent().contains("SOP skill not found: daily_inspection"));
verifyNoInteractions(sopEngine);
verify(scheduleService).updateAfterExecution(1L);
}
private SopSchedule schedule(Long id, String params) {
return SopSchedule.builder()
.id(id)
@@ -0,0 +1,58 @@
/*
* 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.ai.service.impl;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that SOP schedules with no future execution time are not persisted.
*/
@ExtendWith(MockitoExtension.class)
class SopScheduleServiceImplTest {
@Mock
private SopScheduleDao sopScheduleDao;
@InjectMocks
private SopScheduleServiceImpl scheduleService;
@Test
void createScheduleShouldRejectCronWithoutFutureExecutionTime() {
SopSchedule schedule = SopSchedule.builder()
.conversationId(1L)
.sopName("daily_inspection")
.cronExpression("0 0 0 31 2 *")
.build();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class, () -> scheduleService.createSchedule(schedule));
assertTrue(exception.getMessage().contains("no future execution time"));
verifyNoInteractions(sopScheduleDao);
}
}
@@ -0,0 +1,124 @@
/*
* 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.ai.tools.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.OutputType;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that skill parameter errors and report execution failures return accurate, identifiable results.
*/
@ExtendWith(MockitoExtension.class)
class SkillToolsImplTest {
private static final String SKILL_NAME = "diagnose";
@Mock
private SkillRegistry skillRegistry;
@Mock
private SopEngine sopEngine;
private SkillToolsImpl skillTools;
@BeforeEach
void setUp() {
skillTools = new SkillToolsImpl(skillRegistry, sopEngine);
}
@Test
void executeSkillShouldRejectInvalidJsonObject() {
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill());
String response = skillTools.executeSkill(SKILL_NAME, "not-json");
assertEquals("Error: Skill parameters must be a valid JSON object.", response);
verifyNoInteractions(sopEngine);
}
@Test
void executeSkillShouldRejectBlankRequiredParameter() {
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill());
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":\" \"}");
assertTrue(response.contains("Required parameter 'monitorId' is missing"));
verifyNoInteractions(sopEngine);
}
@Test
void executeSkillShouldNotMarkFailedReportForDirectDisplay() {
SopDefinition skill = skill();
SopResult failedResult = SopResult.builder()
.sopName(SKILL_NAME)
.status("FAILED")
.outputType(OutputType.REPORT)
.error("database unavailable")
.build();
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill);
when(sopEngine.executeSync(skill, java.util.Map.of("monitorId", 1))).thenReturn(failedResult);
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":1}");
assertFalse(response.startsWith(SkillToolsImpl.SKILL_REPORT_MARKER));
assertTrue(response.contains("database unavailable"));
}
@Test
void executeSkillShouldMarkSuccessfulReportForDirectDisplay() {
SopDefinition skill = skill();
SopResult successResult = SopResult.builder()
.status("SUCCESS")
.outputType(OutputType.REPORT)
.content("diagnostic report")
.build();
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill);
when(sopEngine.executeSync(skill, java.util.Map.of("monitorId", 1))).thenReturn(successResult);
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":1}");
assertEquals(SkillToolsImpl.SKILL_REPORT_MARKER + "\ndiagnostic report", response);
}
private SopDefinition skill() {
return SopDefinition.builder()
.name(SKILL_NAME)
.parameters(List.of(SopParameter.builder()
.name("monitorId")
.required(true)
.description("Monitor ID")
.build()))
.build();
}
}
@@ -18,10 +18,16 @@
package org.apache.hertzbeat.alert.notice;
import com.google.common.collect.Maps;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.config.AlertSseManager;
@@ -29,7 +35,9 @@ import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.plugin.PostAlertPlugin;
import org.apache.hertzbeat.plugin.Plugin;
@@ -124,6 +132,7 @@ public class AlertNoticeDispatch {
private void sendNotify(GroupAlert alert) {
matchNoticeRulesByAlert(alert).ifPresent(noticeRules -> noticeRules.forEach(rule -> {
NoticeTemplate noticeTemplate = getOneTemplateById(rule.getTemplateId());
GroupAlert noticeAlert = scopeAlertToRule(alert, rule);
rule.getReceiverId().forEach(receiverId -> {
NoticeReceiver receiver = getOneReceiverById(receiverId);
if (receiver == null || receiver.getType() == null) {
@@ -133,7 +142,7 @@ public class AlertNoticeDispatch {
try {
workerPool.executeNotify(receiver.getType(), () -> {
try {
sendNoticeMsg(receiver, noticeTemplate, alert);
sendNoticeMsg(receiver, noticeTemplate, noticeAlert);
} catch (AlertNoticeException e) {
log.warn("DispatchTask sendNoticeMsg error, message: {}", e.getMessage());
}
@@ -145,4 +154,100 @@ public class AlertNoticeDispatch {
});
}));
}
private GroupAlert scopeAlertToRule(GroupAlert alert, NoticeRule rule) {
if (rule.isFilterAll() || rule.getLabels() == null || rule.getLabels().isEmpty()
|| alert.getAlerts() == null) {
return alert;
}
List<SingleAlert> matchingAlerts = alert.getAlerts().stream()
.filter(singleAlert -> singleAlert.getLabels() != null
&& rule.getLabels().entrySet().stream().allMatch(label ->
Objects.equals(label.getValue(), singleAlert.getLabels().get(label.getKey()))))
.toList();
Map<String, String> commonLabels = extractCommonAttributes(matchingAlerts, SingleAlert::getLabels);
Map<String, String> commonAnnotations =
extractCommonAttributes(matchingAlerts, SingleAlert::getAnnotations);
Map<String, String> groupLabels = extractGroupLabels(alert.getGroupLabels(), commonLabels);
String groupKey = Objects.equals(groupLabels, alert.getGroupLabels())
? alert.getGroupKey() : generateGroupKey(groupLabels);
return GroupAlert.builder()
.id(alert.getId())
.groupKey(groupKey)
.status(determineGroupStatus(matchingAlerts))
.groupLabels(groupLabels)
.commonLabels(commonLabels)
.commonAnnotations(commonAnnotations)
.alertFingerprints(matchingAlerts.stream()
.map(SingleAlert::getFingerprint)
.filter(Objects::nonNull)
.toList())
.creator(alert.getCreator())
.modifier(alert.getModifier())
.gmtCreate(firstTime(matchingAlerts, SingleAlert::getGmtCreate, alert.getGmtCreate()))
.gmtUpdate(lastTime(matchingAlerts, SingleAlert::getGmtUpdate, alert.getGmtUpdate()))
.alerts(matchingAlerts)
.build();
}
private Map<String, String> extractCommonAttributes(
Collection<SingleAlert> alerts,
Function<SingleAlert, Map<String, String>> attributes) {
if (alerts.isEmpty()) {
return new HashMap<>(0);
}
Map<String, String> firstAttributes = attributes.apply(alerts.iterator().next());
Map<String, String> common =
firstAttributes == null ? new HashMap<>(0) : new HashMap<>(firstAttributes);
for (SingleAlert alert : alerts) {
Map<String, String> current = attributes.apply(alert);
common.keySet().removeIf(key ->
current == null || !current.containsKey(key)
|| !Objects.equals(common.get(key), current.get(key)));
}
return common;
}
private Map<String, String> extractGroupLabels(
Map<String, String> originalGroupLabels,
Map<String, String> commonLabels) {
Map<String, String> groupLabels = new HashMap<>();
if (originalGroupLabels != null) {
originalGroupLabels.keySet().forEach(key -> {
if (commonLabels.containsKey(key)) {
groupLabels.put(key, commonLabels.get(key));
}
});
}
return groupLabels;
}
private String generateGroupKey(Map<String, String> groupLabels) {
return groupLabels.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + ":" + entry.getValue())
.collect(Collectors.joining(","));
}
private String determineGroupStatus(List<SingleAlert> alerts) {
return alerts.stream().anyMatch(alert ->
CommonConstants.ALERT_STATUS_FIRING.equals(alert.getStatus()))
? CommonConstants.ALERT_STATUS_FIRING : CommonConstants.ALERT_STATUS_RESOLVED;
}
private LocalDateTime firstTime(
List<SingleAlert> alerts,
Function<SingleAlert, LocalDateTime> time,
LocalDateTime fallback) {
return alerts.stream().map(time).filter(Objects::nonNull)
.min(LocalDateTime::compareTo).orElse(fallback);
}
private LocalDateTime lastTime(
List<SingleAlert> alerts,
Function<SingleAlert, LocalDateTime> time,
LocalDateTime fallback) {
return alerts.stream().map(time).filter(Objects::nonNull)
.max(LocalDateTime::compareTo).orElse(fallback);
}
}
@@ -42,6 +42,10 @@ import org.springframework.stereotype.Component;
@Slf4j
final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
static final int LOCK_STRIPE_COUNT = 256;
private static final Object[] KEY_LOCKS = createKeyLocks();
private final GroupAlertDao groupAlertDao;
private final SingleAlertDao singleAlertDao;
@@ -58,7 +62,7 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
List<SingleAlert> newAlerts = new ArrayList<>();
for (SingleAlert singleAlert : originalAlerts) {
synchronized (singleAlert.getFingerprint().intern()) {
synchronized (lockFor(singleAlert.getFingerprint())) {
SingleAlert existAlert = singleAlertDao.findByFingerprint(singleAlert.getFingerprint());
if (existAlert != null) {
// Update the existing alert with the ID and creation time from the database
@@ -90,7 +94,7 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
}
groupAlert.setAlerts(newAlerts);
// Find existing alert group
synchronized (groupAlert.getGroupKey().intern()) {
synchronized (lockFor(groupAlert.getGroupKey())) {
GroupAlert existGroupAlert = groupAlertDao.findByGroupKey(groupAlert.getGroupKey());
// Process resolved alerts
if (existGroupAlert != null) {
@@ -132,4 +136,16 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
return savedGroupAlert;
}
}
static Object lockFor(String key) {
return KEY_LOCKS[Math.floorMod(key.hashCode(), LOCK_STRIPE_COUNT)];
}
private static Object[] createKeyLocks() {
Object[] locks = new Object[LOCK_STRIPE_COUNT];
for (int index = 0; index < locks.length; index++) {
locks[index] = new Object();
}
return locks;
}
}
@@ -26,18 +26,14 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.common.util.LogUtil;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -65,7 +61,6 @@ public class AlibabaSmsClientImpl implements SmsClient {
private final String accessKeySecret;
private final String signName;
private final String templateCode;
private static final Logger logger = LoggerFactory.getLogger(AlibabaSmsClientImpl.class);
public AlibabaSmsClientImpl(AlibabaSmsProperties config) {
if (config != null) {
@@ -154,31 +149,37 @@ public class AlibabaSmsClientImpl implements SmsClient {
httpPost.setHeader("x-acs-content-sha256",
CryptoUtils.sha256Hex(""));
log.info("Sending Alibaba SMS request to {}", url + ", params: " + templateParam + "headers: " + Arrays.toString(httpPost.getAllHeaders()));
log.debug("Sending SMS request via Alibaba Cloud");
// Send request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Alibaba Cloud SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("Alibaba Cloud SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null || jsonResponse.get("Code") == null) {
throw SmsFailureMessages.invalidResponse("Alibaba Cloud SMS");
}
String code = jsonResponse.get("Code").asText();
if (!"OK".equals(code)) {
String message = jsonResponse.get("Message").asText();
throw new SendMessageException(code + ":" + message);
throw SmsFailureMessages.providerCode("Alibaba Cloud SMS", code);
}
log.info("Successfully sent SMS to phone: {}", phoneNumber);
log.info("Successfully sent SMS via Alibaba Cloud");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Alibaba Cloud");
throw e;
} catch (Exception e) {
LogUtil.warn(logger, "Failed to send SMS: {0}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Alibaba Cloud, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Alibaba Cloud SMS");
}
}
@@ -196,7 +197,8 @@ public class AlibabaSmsClientImpl implements SmsClient {
// Step 4: Build authorization header
return ALGORITHM + " Credential=" + accessKeyId + ",SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;" + "x-acs-signature-nonce;x-acs-version,Signature=" + signature;
} catch (Exception e) {
LogUtil.warn(logger, "Failed to calculate authorization {0}", e.getMessage());
log.warn("Failed to calculate Alibaba Cloud authorization, failure type: {}",
e.getClass().getSimpleName());
throw new RuntimeException("Failed to calculate authorization", e);
}
}
@@ -40,7 +40,6 @@ import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -116,11 +115,14 @@ public class AwsSmsClientImpl implements SmsClient {
URI requestUri = new URI(endpoint);
HttpPost httpPost = createHttpPost(requestUri, amzDate, payloadInString);
log.info("Sending AWS SMS request to {}", requestUri + "," + "headers: " + Arrays.toString(httpPost.getAllHeaders()));
executeRequest(httpClient, httpPost, phoneNumber);
log.debug("Sending SMS request via AWS");
executeRequest(httpClient, httpPost);
} catch (SendMessageException e) {
log.warn("Failed to send SMS via AWS");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via AWS, failure type: {}", e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("AWS SMS");
}
}
@@ -149,28 +151,27 @@ public class AwsSmsClientImpl implements SmsClient {
return httpPost;
}
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost, String phoneNumber) throws Exception {
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost) throws Exception {
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("AWS SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("AWS SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("AWS SMS");
}
JsonNode responseNode = jsonResponse.get("MessageId");
if (responseNode == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("AWS SMS");
}
String messageId = responseNode.asText();
log.info("Successfully sent SMS to phone: {}, messageId: {}", phoneNumber, messageId);
log.info("Successfully sent SMS via AWS");
}
}
@@ -285,5 +286,3 @@ public class AwsSmsClientImpl implements SmsClient {
}
}
@@ -246,11 +246,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
CacheFactory.setNoticeCache(rules);
}
// The temporary rule is to forward all, and then implement more matching rules: alarm status selection, monitoring type selection, etc.
// TODO: This matches an already-grouped alert against notice rules (group-then-route). It cannot fully
// separate alerts that were grouped together but should reach different receivers, so a rule matched by
// one alert still notifies the whole group. The ideal design is route-then-group (like Alertmanager):
// route each single alert by its labels first, then group per receiver. Tracked as a follow-up to #3852.
// Match grouped alerts here; dispatch scopes each notification to the single alerts matching its rule.
return rules.stream()
.filter(rule -> {
if (!rule.isFilterAll()) {
@@ -0,0 +1,55 @@
/*
* 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.alert.service.impl;
import java.util.regex.Pattern;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
/**
* Builds bounded SMS failures without copying provider-controlled response
* bodies, request URLs, or transport exception messages.
*/
final class SmsFailureMessages {
private static final Pattern SAFE_PROVIDER_CODE = Pattern.compile("[-A-Za-z0-9_.]{1,64}");
private static final String UNKNOWN_PROVIDER_CODE = "UNKNOWN_PROVIDER_ERROR";
private SmsFailureMessages() {
}
static SendMessageException requestFailed(String providerLabel) {
return new SendMessageException(providerLabel + " request failed");
}
static SendMessageException httpStatus(String providerLabel, int statusCode) {
return new SendMessageException(
providerLabel + " request failed with HTTP status " + statusCode);
}
static SendMessageException providerCode(String providerLabel, String code) {
String safeCode = code != null && SAFE_PROVIDER_CODE.matcher(code).matches()
? code
: UNKNOWN_PROVIDER_CODE;
return new SendMessageException(
providerLabel + " request failed (code: " + safeCode + ")");
}
static SendMessageException invalidResponse(String providerLabel) {
return new SendMessageException(providerLabel + " provider returned an invalid response");
}
}
@@ -61,7 +61,7 @@ public class SmsLocalSmsClientImpl implements SmsClient {
@Override
public void sendMessage(NoticeReceiver receiver, NoticeTemplate noticeTemplate, GroupAlert alert) {
if (Objects.isNull(receiver) || Objects.isNull(alert)) {
log.warn("receiver and alert can not be null! receiver: {}, alert:{}", receiver, alert);
log.warn("SMSLocal receiver and alert cannot be null");
return;
}
@@ -79,36 +79,42 @@ public class SmsLocalSmsClientImpl implements SmsClient {
httpPost.setHeader("Token", config.getApiKey());
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.debug("Sending SMS request to {}, payload: {}", httpPost.getURI(), payload);
log.debug("Sending SMS request via SMSLocal");
// send http request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.debug("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("SMSLocal response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode);
throw SmsFailureMessages.httpStatus("SMSLocal", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
JsonNode jsonNode = jsonResponse.get(0);
if (Objects.isNull(jsonNode)) {
log.warn("jsonResponse parse errorCode failed: {}", jsonResponse);
return;
if (jsonResponse == null || !jsonResponse.isArray() || jsonResponse.isEmpty()) {
throw SmsFailureMessages.invalidResponse("SMSLocal");
}
String errorCode = jsonNode.get("errorCode").asText();
JsonNode jsonNode = jsonResponse.get(0);
JsonNode errorCodeNode = jsonNode.get("errorCode");
if (errorCodeNode == null) {
throw SmsFailureMessages.invalidResponse("SMSLocal");
}
String errorCode = errorCodeNode.asText();
if (!SUCCESS_CODE.equals(errorCode)) {
String msgid = jsonNode.get("id").asText();
throw new SendMessageException(errorCode + ":" + msgid);
throw SmsFailureMessages.providerCode("SMSLocal", errorCode);
}
log.info("Successfully sent SMS to phone: {}", receiver.getPhone());
log.info("Successfully sent SMS via SMSLocal");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via SMSLocal");
throw e;
} catch (Exception e) {
log.error("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via SMSLocal, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("SMSLocal");
}
}
@@ -121,7 +127,7 @@ public class SmsLocalSmsClientImpl implements SmsClient {
@Override
public boolean checkConfig() {
if (Objects.isNull(config) || Objects.isNull(config.getApiKey()) || config.getApiKey().isBlank()) {
log.warn("smslocal properties can not be null: {}", config);
log.warn("SMSLocal properties cannot be null or blank");
return false;
}
return true;
@@ -134,41 +134,56 @@ public class TencentSmsClientImpl implements SmsClient {
httpPost.setHeader("Authorization", authorization);
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.debug("Sending SMS request to {}, payload: {}", httpPost.getURI(), payload);
log.debug("Sending SMS request via Tencent Cloud");
// send http request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.debug("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Tencent Cloud SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode);
throw SmsFailureMessages.httpStatus("Tencent Cloud SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
JsonNode responseNode = jsonResponse.get("Response");
if (responseNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
JsonNode error = responseNode.get("Error");
if (error != null) {
String code = error.get("Code").asText();
String message = error.get("Message").asText();
throw new SendMessageException(code + ":" + message);
JsonNode codeNode = error.get("Code");
if (codeNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
throw SmsFailureMessages.providerCode("Tencent Cloud SMS", codeNode.asText());
}
JsonNode sendStatusSet = responseNode.get("SendStatusSet");
if (sendStatusSet != null && sendStatusSet.isArray() && sendStatusSet.size() > 0) {
JsonNode firstStatus = sendStatusSet.get(0);
String code = firstStatus.get("Code").asText();
String message = firstStatus.get("Message").asText();
if (!RESPONSE_OK.equals(code)) {
throw new SendMessageException(code + ":" + message);
}
if (sendStatusSet == null || !sendStatusSet.isArray() || sendStatusSet.isEmpty()) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
log.info("Successfully sent SMS to phones: {}", String.join(",", phones));
JsonNode codeNode = sendStatusSet.get(0).get("Code");
if (codeNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
String code = codeNode.asText();
if (!RESPONSE_OK.equals(code)) {
throw SmsFailureMessages.providerCode("Tencent Cloud SMS", code);
}
log.info("Successfully sent SMS via Tencent Cloud");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Tencent Cloud");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Tencent Cloud, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Tencent Cloud SMS");
}
}
@@ -97,11 +97,15 @@ public class TwilioSmsClientImpl implements SmsClient {
URI requestUri = new URI(endpoint);
HttpPost httpPost = createHttpPost(requestUri, phoneNumber, message);
log.info("Sending Twilio SMS request to {}", requestUri);
executeRequest(httpClient, httpPost, phoneNumber);
log.debug("Sending SMS request via Twilio");
executeRequest(httpClient, httpPost);
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Twilio");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Twilio, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Twilio SMS");
}
}
@@ -121,41 +125,36 @@ public class TwilioSmsClientImpl implements SmsClient {
httpPost.setEntity(new UrlEncodedFormEntity(parameters));
return httpPost;
} catch (Exception e) {
log.error("Failed to create HTTP request: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to create Twilio SMS request, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Twilio SMS");
}
}
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost, String phoneNumber)
throws Exception {
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost) throws Exception {
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Twilio SMS response status: {}", statusCode);
if (statusCode < 200 || statusCode >= 300) {
if (responseBody.contains("21608")) {
throw new SendMessageException(
"The Twilio trial account can only send SMS to verified phone numbers");
} else {
throw new SendMessageException(
"HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.providerCode("Twilio SMS", "21608");
}
throw SmsFailureMessages.httpStatus("Twilio SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("Twilio SMS");
}
JsonNode sidNode = jsonResponse.get("sid");
if (sidNode == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("Twilio SMS");
}
String sid = sidNode.asText();
log.info("Successfully sent SMS to phone: {}, sid: {}", phoneNumber, sid);
log.info("Successfully sent SMS via Twilio");
}
}
@@ -102,14 +102,17 @@ public class UniSmsClientImpl implements SmsClient {
String payload = JsonUtil.toJson(params);
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.info("Sending SMS request to UniSMS, payload: {}, url: {}", payload, url);
log.debug("Sending SMS request via UniSMS");
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
handleResponse(response, receiver.getPhone());
handleResponse(response);
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via UniSMS");
throw e;
} catch (Exception e) {
log.error("Failed to send SMS via UniSMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via UniSMS, failure type: {}", e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("UniSMS");
}
}
@@ -145,24 +148,26 @@ public class UniSmsClientImpl implements SmsClient {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
private void handleResponse(CloseableHttpResponse response, String phone) throws IOException {
private void handleResponse(CloseableHttpResponse response) throws IOException {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("UniSMS response status: {}, body: {}", statusCode, responseBody);
log.debug("UniSMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("UniSMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null || jsonResponse.get("code") == null) {
throw SmsFailureMessages.invalidResponse("UniSMS");
}
String code = jsonResponse.get("code").asText();
if (!SUCCESS_CODE.equals(code)) {
String message = jsonResponse.get("message").asText();
throw new SendMessageException(code + ":" + message);
throw SmsFailureMessages.providerCode("UniSMS", code);
}
log.info("Successfully sent SMS to phone: {}", phone);
log.info("Successfully sent SMS via UniSMS");
}
@Override
@@ -42,6 +42,7 @@ public class ZabbixExternAlertServiceImpl implements ExternAlertService {
log.warn("parse extern alert content failed! content: {}", content);
return;
}
alert.setId(null);
alarmCommonReduce.reduceAndSendAlarm(alert);
}
@@ -17,10 +17,8 @@
package org.apache.hertzbeat.alert.util;
import java.util.Arrays;
import java.util.List;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
/**
* alert util
@@ -28,13 +26,31 @@ import java.util.Objects;
public class AlertUtil {
/**
* calculate fingerprint
* @param fingerPrints finger prints
* Calculate an in-memory alert cache coordinate.
*
* <p>This value is rebuilt from persisted alert labels when the process
* starts. It is not the durable {@code SingleAlert.fingerprint} used by
* persistence, grouping, silence, or inhibition.</p>
*
* @param fingerPrints labels used by the calculator cache
* @return deterministic cache coordinate
*/
public static String calculateFingerprint(Map<String, String> fingerPrints) {
List<String> keyList = fingerPrints.keySet().stream().filter(Objects::nonNull).sorted().toList();
List<String> valueList = fingerPrints.values().stream().filter(Objects::nonNull).sorted().toList();
return Arrays.hashCode(keyList.toArray(new String[0])) + "-"
+ Arrays.hashCode(valueList.toArray(new String[0]));
StringBuilder canonicalLabels = new StringBuilder();
fingerPrints.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.nullsFirst(Comparator.naturalOrder())))
.forEach(entry -> {
appendLengthPrefixed(canonicalLabels, entry.getKey());
appendLengthPrefixed(canonicalLabels, entry.getValue());
});
return CryptoUtils.sha256Hex(canonicalLabels.toString());
}
private static void appendLengthPrefixed(StringBuilder target, String value) {
if (value == null) {
target.append("-1:");
return;
}
target.append(value.length()).append(':').append(value);
}
}
@@ -34,6 +34,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.when;
/**
@@ -132,4 +133,28 @@ public class AlarmCacheManagerTest {
historicalSingleAlert = alarmCacheManager.getFiring(4L, fingerprint);
assertNull(historicalSingleAlert);
}
}
@Test
void restartShouldRebuildCacheKeyWithoutChangingPersistedFingerprint() {
Map<String, String> labels = Map.of(
CommonConstants.LABEL_DEFINE_ID, "7",
CommonConstants.LABEL_ALERT_NAME, "disk_full",
"instance", "db-1");
SingleAlert persistedAlert = SingleAlert.builder()
.id(99L)
.fingerprint("alertname:disk_full,define_id:7,instance:db-1")
.labels(labels)
.status(CommonConstants.ALERT_STATUS_FIRING)
.build();
when(singleAlertDao.querySingleAlertsByStatus(CommonConstants.ALERT_STATUS_FIRING))
.thenReturn(Collections.singletonList(persistedAlert));
alarmCacheManager = new AlarmCacheManager(singleAlertDao);
String rebuiltCacheKey = AlertUtil.calculateFingerprint(labels);
SingleAlert resolved = alarmCacheManager.removeFiring(7L, rebuiltCacheKey);
assertSame(persistedAlert, resolved);
assertEquals("alertname:disk_full,define_id:7,instance:db-1", resolved.getFingerprint());
assertNull(alarmCacheManager.getFiring(7L, rebuiltCacheKey));
}
}
@@ -17,31 +17,38 @@
package org.apache.hertzbeat.alert.notice;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyByte;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.config.AlertSseManager;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.plugin.runner.PluginRunner;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
/**
* Test case for Alert Notice Dispatch
@@ -180,4 +187,155 @@ class AlertNoticeDispatchTest {
verify(alertNotifyHandler).send(eq(receiver), eq(template), eq(alert));
verify(emitterManager).broadcast(any(String.class));
}
@Test
void testDispatchAlarmRecomputesNoticeFromAlertsMatchingRuleLabels() {
LocalDateTime matchingCreated = LocalDateTime.of(2026, 7, 30, 10, 0);
LocalDateTime matchingUpdated = LocalDateTime.of(2026, 7, 30, 10, 5);
SingleAlert matchingAlert = SingleAlert.builder()
.fingerprint("matching")
.labels(Map.of("department", "algorithm", "service", "checkout", "severity", "warning"))
.annotations(Map.of("summary", "algorithm summary", "runbook", "shared runbook"))
.content("matching-content")
.status("resolved")
.gmtCreate(matchingCreated)
.gmtUpdate(matchingUpdated)
.build();
SingleAlert unrelatedAlert = SingleAlert.builder()
.fingerprint("unrelated")
.labels(Map.of("department", "infra", "service", "checkout", "severity", "critical"))
.annotations(Map.of("summary", "infra summary", "runbook", "shared runbook"))
.content("unrelated-content")
.status("firing")
.gmtCreate(matchingCreated.minusHours(1))
.gmtUpdate(matchingUpdated.plusHours(1))
.build();
GroupAlert groupedAlert = GroupAlert.builder()
.id(2L)
.groupKey("department:infra,service:checkout")
.status("firing")
.groupLabels(Map.of("department", "infra", "service", "checkout"))
.commonLabels(Map.of("service", "checkout"))
.commonAnnotations(Map.of("runbook", "shared runbook"))
.alertFingerprints(List.of("matching", "unrelated"))
.gmtCreate(matchingCreated.minusHours(1))
.gmtUpdate(matchingUpdated.plusHours(1))
.alerts(List.of(matchingAlert, unrelatedAlert))
.build();
NoticeTemplate template = NoticeTemplate.builder().id(1L).build();
NoticeRule rule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "algorithm"))
.receiverId(List.of(1L))
.templateId(1L)
.build();
when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
when(noticeConfigService.getReceiverFilterRule(groupedAlert)).thenReturn(List.of(rule));
when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
when(noticeConfigService.getOneTemplateById(1L)).thenReturn(template);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
return null;
}).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
alertNoticeDispatch.dispatchAlarm(groupedAlert);
ArgumentCaptor<GroupAlert> noticeAlert = ArgumentCaptor.forClass(GroupAlert.class);
verify(alertNotifyHandler).send(eq(receiver), eq(template), noticeAlert.capture());
GroupAlert scopedAlert = noticeAlert.getValue();
assertAll(
() -> assertEquals(List.of(matchingAlert), scopedAlert.getAlerts()),
() -> assertEquals(List.of("matching"), scopedAlert.getAlertFingerprints()),
() -> assertEquals("resolved", scopedAlert.getStatus()),
() -> assertEquals(
Map.of("department", "algorithm", "service", "checkout"),
scopedAlert.getGroupLabels()),
() -> assertEquals(
Map.of("department", "algorithm", "service", "checkout", "severity", "warning"),
scopedAlert.getCommonLabels()),
() -> assertEquals(
Map.of("summary", "algorithm summary", "runbook", "shared runbook"),
scopedAlert.getCommonAnnotations()),
() -> assertEquals("department:algorithm,service:checkout", scopedAlert.getGroupKey()),
() -> assertEquals(matchingCreated, scopedAlert.getGmtCreate()),
() -> assertEquals(matchingUpdated, scopedAlert.getGmtUpdate()),
() -> assertEquals(2, groupedAlert.getAlerts().size()),
() -> assertEquals("firing", groupedAlert.getStatus()),
() -> assertEquals(Map.of("service", "checkout"), groupedAlert.getCommonLabels()));
}
@Test
void testDispatchAlarmScopesMultipleRulesForTheSameReceiverIndependently() {
SingleAlert algorithmAlert = SingleAlert.builder()
.fingerprint("algorithm")
.labels(Map.of("department", "algorithm", "service", "checkout"))
.annotations(Map.of("summary", "algorithm firing", "runbook", "algorithm runbook"))
.status("firing")
.build();
SingleAlert algorithmResolvedAlert = SingleAlert.builder()
.fingerprint("algorithm-resolved")
.labels(Map.of("department", "algorithm", "service", "checkout"))
.annotations(Map.of("summary", "algorithm resolved", "runbook", "algorithm runbook"))
.status("resolved")
.build();
SingleAlert infrastructureAlert = SingleAlert.builder()
.fingerprint("infra")
.labels(Map.of("department", "infra", "service", "checkout"))
.annotations(Map.of("summary", "infra summary"))
.status("resolved")
.build();
GroupAlert groupedAlert = GroupAlert.builder()
.status("firing")
.groupLabels(Map.of("service", "checkout"))
.alerts(List.of(algorithmAlert, algorithmResolvedAlert, infrastructureAlert))
.build();
NoticeTemplate algorithmTemplate = NoticeTemplate.builder().id(1L).build();
NoticeTemplate infrastructureTemplate = NoticeTemplate.builder().id(2L).build();
NoticeRule algorithmRule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "algorithm"))
.receiverId(List.of(1L))
.templateId(1L)
.build();
NoticeRule infrastructureRule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "infra"))
.receiverId(List.of(1L))
.templateId(2L)
.build();
when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
when(noticeConfigService.getReceiverFilterRule(groupedAlert))
.thenReturn(List.of(algorithmRule, infrastructureRule));
when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
when(noticeConfigService.getOneTemplateById(1L)).thenReturn(algorithmTemplate);
when(noticeConfigService.getOneTemplateById(2L)).thenReturn(infrastructureTemplate);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
return null;
}).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
alertNoticeDispatch.dispatchAlarm(groupedAlert);
ArgumentCaptor<NoticeTemplate> templates = ArgumentCaptor.forClass(NoticeTemplate.class);
ArgumentCaptor<GroupAlert> alerts = ArgumentCaptor.forClass(GroupAlert.class);
verify(alertNotifyHandler, times(2)).send(eq(receiver), templates.capture(), alerts.capture());
assertAll(
() -> assertEquals(List.of(algorithmTemplate, infrastructureTemplate), templates.getAllValues()),
() -> assertEquals(
List.of("algorithm", "algorithm-resolved"),
alerts.getAllValues().get(0).getAlertFingerprints()),
() -> assertEquals(List.of("infra"), alerts.getAllValues().get(1).getAlertFingerprints()),
() -> assertEquals("firing", alerts.getAllValues().get(0).getStatus()),
() -> assertEquals("resolved", alerts.getAllValues().get(1).getStatus()),
() -> assertEquals(
Map.of("runbook", "algorithm runbook"),
alerts.getAllValues().get(0).getCommonAnnotations()),
() -> assertEquals(
Map.of("summary", "infra summary"),
alerts.getAllValues().get(1).getCommonAnnotations()));
}
}
@@ -18,6 +18,8 @@
package org.apache.hertzbeat.alert.notice.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -33,7 +35,9 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Test case for {@link DbAlertStoreHandlerImpl}
@@ -122,4 +126,17 @@ class DbAlertStoreHandlerImplTest {
assertEquals(1L, groupAlert.getId());
}
@Test
void usesBoundedLocksForExternalKeys() {
int stripeCount = DbAlertStoreHandlerImpl.LOCK_STRIPE_COUNT;
Object firstLock = DbAlertStoreHandlerImpl.lockFor("same-key");
assertSame(firstLock, DbAlertStoreHandlerImpl.lockFor("same-key"));
Set<Object> locks = new HashSet<>();
for (int index = 0; index < stripeCount * 4; index++) {
locks.add(DbAlertStoreHandlerImpl.lockFor("external-key-" + index));
}
assertTrue(locks.size() <= stripeCount);
}
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -28,6 +29,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.config.VirtualThreadProperties;
import org.junit.jupiter.api.AfterEach;
@@ -85,6 +87,28 @@ class AlarmCommonReduceTest {
assertTrue(virtualThread.get());
}
@Test
void durableFingerprintShouldRemainIndependentFromCalculatorCacheKey() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> durableFingerprint = new AtomicReference<>();
doAnswer(invocation -> {
durableFingerprint.set(invocation.getArgument(0, SingleAlert.class).getFingerprint());
latch.countDown();
return null;
}).when(alarmGroupReduce).processGroupAlert(any(SingleAlert.class));
SingleAlert alert = SingleAlert.builder()
.labels(new HashMap<>(Map.of(
"instance", "db-1",
"alertname", "disk_full",
"timestamp", "not-part-of-identity")))
.build();
alarmCommonReduce.reduceAndSendAlarm(alert);
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertEquals("alertname:disk_full,instance:db-1", durableFingerprint.get());
}
@Test
void testReduceAndSendAlarmQueuesWhenConcurrencyLimitReached() throws Exception {
VirtualThreadProperties properties = new VirtualThreadProperties(
@@ -0,0 +1,300 @@
/*
* 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.alert.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Map;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.dto.sms.AlibabaSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.AwsSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.SmslocalSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.TencentSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.TwilioSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.UniSmsProperties;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
/**
* Verifies that SMS clients do not write request credentials or message data to logs.
*/
@ExtendWith(OutputCaptureExtension.class)
class SmsClientLoggingTest {
private static final String ACCESS_KEY = "access-key-log-sentinel";
private static final String PHONE = "15555550123";
private static final String ALERT_CONTENT = "alert-content-log-sentinel";
private static final String PROVIDER_BODY = "provider-body-log-sentinel";
private static final String SIGNED_URL = "https://provider.invalid/send?Signature=signed-url-log-sentinel";
@Test
void requestCredentialsAndMessageDataShouldNotBeLogged(CapturedOutput output) throws Exception {
NoticeReceiver receiver = receiver();
GroupAlert alert = alert();
AwsSmsProperties awsProperties = new AwsSmsProperties();
awsProperties.setAccessKeyId(ACCESS_KEY);
awsProperties.setAccessKeySecret("aws-secret");
awsProperties.setRegion("us-east-1");
withSuccessfulResponse("{\"MessageId\":\"message-id\"}",
() -> new AwsSmsClientImpl(awsProperties).sendMessage(receiver, null, alert));
AlibabaSmsProperties alibabaProperties =
new AlibabaSmsProperties(ACCESS_KEY, "alibaba-secret", "sign", "template");
withSuccessfulResponse("{\"Code\":\"OK\"}",
() -> new AlibabaSmsClientImpl(alibabaProperties).sendMessage(receiver, null, alert));
UniSmsProperties uniProperties =
new UniSmsProperties(ACCESS_KEY, "unisms-secret", "sign", "template", "hmac");
withSuccessfulResponse("{\"code\":\"0\"}",
() -> new UniSmsClientImpl(uniProperties).sendMessage(receiver, null, alert));
withSuccessfulResponse("{\"sid\":\"message-id\"}",
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver, null, alert));
withSuccessfulResponse("{\"Response\":{\"SendStatusSet\":[{\"Code\":\"Ok\"}]}}",
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver, null, alert));
withSuccessfulResponse("[{\"errorCode\":\"200\",\"id\":\"message-id\"}]",
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver, null, alert));
String logs = output.getAll();
assertFalse(logs.contains(ACCESS_KEY));
assertFalse(logs.contains(PHONE));
assertFalse(logs.contains(ALERT_CONTENT));
assertFalse(logs.contains("Authorization"));
assertFalse(logs.contains("Signature="));
}
@Test
void failedResponsesExposeOnlyProviderAndHttpStatus(CapturedOutput output) throws Exception {
String body = "{\"message\":\"" + PROVIDER_BODY + "\",\"phone\":\"" + PHONE + "\"}";
SendMessageException awsFailure = withResponse(503, body,
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException alibabaFailure = withResponse(502, body,
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withResponse(429, body,
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withResponse(429, body,
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withResponse(429, body,
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withResponse(429, body,
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("AWS SMS request failed with HTTP status 503", awsFailure.getMessage());
assertEquals("Alibaba Cloud SMS request failed with HTTP status 502", alibabaFailure.getMessage());
assertEquals("UniSMS request failed with HTTP status 429", uniFailure.getMessage());
assertEquals("Twilio SMS request failed with HTTP status 429", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed with HTTP status 429", tencentFailure.getMessage());
assertEquals("SMSLocal request failed with HTTP status 429", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ awsFailure.getMessage()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
@Test
void providerErrorsDoNotExposeProviderMessages(CapturedOutput output) throws Exception {
SendMessageException alibabaFailure = withResponse(
200,
"{\"Code\":\"THROTTLED\",\"Message\":\"" + PROVIDER_BODY + "\"}",
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withResponse(
200,
"{\"code\":\"RATE_LIMITED\",\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException awsFailure = withResponse(
200,
"{\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withResponse(
400,
"{\"code\":21608,\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withResponse(
200,
"{\"Response\":{\"Error\":{\"Code\":\"THROTTLED\",\"Message\":\""
+ PROVIDER_BODY + "\"}}}",
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withResponse(
200,
"[{\"errorCode\":\"RATE_LIMITED\",\"id\":\"" + PROVIDER_BODY + "\"}]",
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("Alibaba Cloud SMS request failed (code: THROTTLED)", alibabaFailure.getMessage());
assertEquals("UniSMS request failed (code: RATE_LIMITED)", uniFailure.getMessage());
assertEquals("AWS SMS provider returned an invalid response", awsFailure.getMessage());
assertEquals("Twilio SMS request failed (code: 21608)", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed (code: THROTTLED)", tencentFailure.getMessage());
assertEquals("SMSLocal request failed (code: RATE_LIMITED)", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ awsFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
@Test
void networkExceptionsDoNotExposeSignedUrls(CapturedOutput output) throws Exception {
SendMessageException awsFailure = withNetworkFailure(
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException alibabaFailure = withNetworkFailure(
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withNetworkFailure(
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withNetworkFailure(
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withNetworkFailure(
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withNetworkFailure(
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("AWS SMS request failed", awsFailure.getMessage());
assertEquals("Alibaba Cloud SMS request failed", alibabaFailure.getMessage());
assertEquals("UniSMS request failed", uniFailure.getMessage());
assertEquals("Twilio SMS request failed", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed", tencentFailure.getMessage());
assertEquals("SMSLocal request failed", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ awsFailure.getMessage()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
private void withSuccessfulResponse(String responseBody, Runnable operation) throws Exception {
withResponse(200, responseBody, operation, false);
}
private SendMessageException withResponse(int statusCode, String responseBody, Runnable operation)
throws Exception {
return withResponse(statusCode, responseBody, operation, true);
}
private SendMessageException withResponse(
int statusCode,
String responseBody,
Runnable operation,
boolean expectsFailure) throws Exception {
CloseableHttpClient httpClient = mock(CloseableHttpClient.class);
CloseableHttpResponse response = mock(CloseableHttpResponse.class);
StatusLine statusLine = mock(StatusLine.class);
when(statusLine.getStatusCode()).thenReturn(statusCode);
when(response.getStatusLine()).thenReturn(statusLine);
when(response.getEntity()).thenReturn(new StringEntity(responseBody, ContentType.APPLICATION_JSON));
when(httpClient.execute(any(HttpPost.class))).thenReturn(response);
try (MockedStatic<HttpClients> httpClients = mockStatic(HttpClients.class)) {
httpClients.when(HttpClients::createDefault).thenReturn(httpClient);
if (expectsFailure) {
return assertThrows(SendMessageException.class, operation::run);
}
operation.run();
return null;
}
}
private SendMessageException withNetworkFailure(Runnable operation) throws Exception {
CloseableHttpClient httpClient = mock(CloseableHttpClient.class);
when(httpClient.execute(any(HttpPost.class)))
.thenThrow(new IOException(SIGNED_URL + "&phone=" + PHONE + "&body=" + PROVIDER_BODY));
try (MockedStatic<HttpClients> httpClients = mockStatic(HttpClients.class)) {
httpClients.when(HttpClients::createDefault).thenReturn(httpClient);
return assertThrows(SendMessageException.class, operation::run);
}
}
private NoticeReceiver receiver() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setPhone(PHONE);
return receiver;
}
private GroupAlert alert() {
GroupAlert alert = new GroupAlert();
alert.setGroupKey("instance");
alert.setCommonLabels(Map.of());
alert.setCommonAnnotations(Map.of("summary", ALERT_CONTENT, "description", ALERT_CONTENT));
return alert;
}
private AwsSmsProperties awsProperties() {
AwsSmsProperties properties = new AwsSmsProperties();
properties.setAccessKeyId(ACCESS_KEY);
properties.setAccessKeySecret("aws-secret");
properties.setRegion("us-east-1");
return properties;
}
private AlibabaSmsProperties alibabaProperties() {
return new AlibabaSmsProperties(ACCESS_KEY, "alibaba-secret", "sign", "template");
}
private UniSmsProperties uniProperties() {
return new UniSmsProperties(ACCESS_KEY, "unisms-secret", "sign", "template", "hmac");
}
private TwilioSmsProperties twilioProperties() {
return new TwilioSmsProperties(ACCESS_KEY, "twilio-secret", "twilio-phone");
}
private TencentSmsProperties tencentProperties() {
return new TencentSmsProperties(ACCESS_KEY, "tencent-secret", "app-id", "sign", "template");
}
private SmslocalSmsProperties smslocalProperties() {
return new SmslocalSmsProperties(ACCESS_KEY);
}
private void assertNoSensitiveSentinels(String text) {
assertFalse(text.contains(ACCESS_KEY));
assertFalse(text.contains(PHONE));
assertFalse(text.contains(ALERT_CONTENT));
assertFalse(text.contains(PROVIDER_BODY));
assertFalse(text.contains(SIGNED_URL));
assertFalse(text.contains("signed-url-log-sentinel"));
}
}
@@ -0,0 +1,58 @@
/*
* 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.alert.service.impl;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.verify;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link ZabbixExternAlertServiceImpl}.
*/
@ExtendWith(MockitoExtension.class)
class ZabbixExternAlertServiceImplTest {
@Mock
private AlarmCommonReduce alarmCommonReduce;
@InjectMocks
private ZabbixExternAlertServiceImpl externAlertService;
@Test
void ignoresExternalPersistenceIdentity() {
SingleAlert incoming = SingleAlert.builder()
.id(123L)
.fingerprint("zabbix-alert")
.build();
externAlertService.addExternAlert(JsonUtil.toJson(incoming));
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(alertCaptor.capture());
assertNull(alertCaptor.getValue().getId());
}
}
@@ -0,0 +1,61 @@
/*
* 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.alert.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Test case for {@link AlertUtil}.
*/
class AlertUtilTest {
@Test
void calculateFingerprintPreservesLabelPairing() {
Map<String, String> first = new LinkedHashMap<>();
first.put("environment", "production");
first.put("team", "payments");
Map<String, String> swapped = new LinkedHashMap<>();
swapped.put("environment", "payments");
swapped.put("team", "production");
assertNotEquals(
AlertUtil.calculateFingerprint(first),
AlertUtil.calculateFingerprint(swapped));
}
@Test
void calculateFingerprintIsIndependentOfMapIterationOrder() {
Map<String, String> first = new LinkedHashMap<>();
first.put("environment", "production");
first.put("team", "payments");
Map<String, String> reversed = new LinkedHashMap<>();
reversed.put("team", "payments");
reversed.put("environment", "production");
assertEquals(
AlertUtil.calculateFingerprint(first),
AlertUtil.calculateFingerprint(reversed));
}
}
@@ -46,6 +46,7 @@ import org.apache.hertzbeat.common.entity.job.SshTunnel;
import org.apache.hertzbeat.common.entity.job.protocol.JdbcProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.exception.SshChannelOpenException;
import org.postgresql.util.PSQLException;
@@ -605,28 +606,33 @@ public class JdbcCommonCollect extends AbstractCollect {
return url;
}
assert jdbcProtocol.getPlatform() != null;
return switch (jdbcProtocol.getPlatform()) {
// the database name is concatenated into the url below, so it must not carry url syntax
String database = JdbcUrlSafetyUtil.requireSafeDatabaseName(jdbcProtocol.getDatabase());
String constructedUrl = switch (jdbcProtocol.getPlatform()) {
case "mysql", "mariadb" -> "jdbc:mysql://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase())
+ "/" + database
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false";
case "xugu" -> "jdbc:xugu://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "postgresql" -> "jdbc:postgresql://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "clickhouse" -> "jdbc:clickhouse://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "sqlserver" -> "jdbc:sqlserver://" + host + ":" + port
+ ";" + (jdbcProtocol.getDatabase() == null ? "" : "DatabaseName=" + jdbcProtocol.getDatabase())
+ ";" + (database.isEmpty() ? "" : "DatabaseName=" + database)
+ ";trustServerCertificate=true;";
case "oracle" -> "jdbc:oracle:thin:@" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "dm" -> "jdbc:dm://" + host + ":" + port;
case "db2" -> "jdbc:db2://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "testcontainers" -> "jdbc:tc:" + host + ":" + port
+ ":///" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase()) + "?user=root&password=root";
+ ":///" + database + "?user=root&password=root";
default -> throw new IllegalArgumentException("Not support database platform: " + jdbcProtocol.getPlatform());
};
// fail closed if any concatenated value still smuggled a driver property through
JdbcUrlSafetyUtil.requireSafeJdbcUrl(constructedUrl);
return constructedUrl;
}
private static final class ResultSetJdbcQueryRowSet implements JdbcQueryRowSet {
@@ -185,6 +185,29 @@ class JdbcCommonCollectTest {
assertEquals("Not support database platform: invalid", exception.getMessage());
}
/**
* The url blacklist only guards a user supplied url. Driver properties smuggled through the
* database name reach the very same connection, so they have to be rejected too.
*/
@Test
void testConstructDatabaseUrlRejectsDriverPropertiesInDatabaseName() {
String[] payloads = {
"test?allowLoadLocalInfile=true&z=",
"test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&z=",
"test&useSSL=false",
};
for (String payload : payloads) {
JdbcProtocol jdbcProtocol = JdbcProtocol.builder()
.platform("mysql")
.database(payload)
.build();
assertThrows(IllegalArgumentException.class,
() -> constructDatabaseUrl(jdbcCommonCollect, jdbcProtocol, "localhost", "3306"),
"database name should be rejected: " + payload);
}
}
@Test
void testCloseConnectionWhenCreateStatementFails() throws Exception {
String url = "jdbc:postgresql://localhost:5432/hertzbeat";
@@ -46,21 +46,23 @@ public class MysqlR2dbcQueryExecutor implements MysqlQueryExecutor {
@Override
public QueryResult execute(String sql, QueryOptions options) {
String normalizedSql = sqlGuard.normalizeAndValidate(sql);
QueryResult firstAttempt = executeOnce(normalizedSql, options, SslMode.PREFERRED);
if (!firstAttempt.hasError() || !shouldRetryWithoutSsl(firstAttempt.getError())) {
return firstAttempt;
// A single PREFERRED attempt negotiates TLS when the server supports it and
// falls back to plaintext only through the protocol-level handshake when the
// server advertises no SSL capability. It must NOT be followed by a second
// SslMode.DISABLED attempt on handshake *failures*: a handshake failure is
// ambiguous (a MitM can induce it to force a downgrade), and retrying with
// SSL disabled would then ship the monitoring credentials in the clear.
QueryResult result = executeOnce(normalizedSql, options, SslMode.PREFERRED);
if (!result.hasError()) {
return result;
}
QueryResult fallbackAttempt = executeOnce(normalizedSql, options, SslMode.DISABLED);
if (!fallbackAttempt.hasError()) {
return fallbackAttempt;
}
if (requiresSslCompatibleAuth(fallbackAttempt.getError())) {
if (requiresSslCompatibleAuth(result.getError())) {
return QueryResult.builder()
.error(fallbackAttempt.getError()
.error(result.getError()
+ ". This route currently needs a TLS-compatible runtime or a mysql_native_password monitoring user.")
.build();
}
return fallbackAttempt;
return result;
}
private QueryResult executeOnce(String sql, QueryOptions options, SslMode sslMode) {
@@ -128,17 +130,6 @@ public class MysqlR2dbcQueryExecutor implements MysqlQueryExecutor {
return false;
}
private boolean shouldRetryWithoutSsl(String error) {
if (error == null) {
return false;
}
String normalized = error.toLowerCase(Locale.ROOT);
return normalized.contains("handshake_failure")
|| normalized.contains("ssl/tls handshake")
|| normalized.contains("closedchannelexception")
|| normalized.contains("connection unexpectedly closed");
}
private boolean requiresSslCompatibleAuth(String error) {
if (error == null) {
return false;
@@ -25,6 +25,7 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedDeque;
@@ -110,9 +111,21 @@ public class Job {
*/
private long defaultInterval = 600L;
/**
* Refresh time list for one cycle of the job.
* Explicit refresh delays used for one-time overrides and fallback schedules.
*/
private ConcurrentLinkedDeque<Long> intervals;
/**
* Collector-only periods used to generate the next refresh delay lazily.
*/
@JsonIgnore
private transient long[] metricsSchedulePeriods;
/**
* Collector-only time remaining until each metric period is due.
*/
@JsonIgnore
private transient long[] metricsScheduleRemaining;
/**
* Whether it is a recurring periodic task true is yes, false is no.
*/
@@ -328,7 +341,7 @@ public class Job {
if (array != null && !array.isEmpty()) {
long result = array.get(0);
for (int i = 1; i < array.size(); i++) {
result = result / gcd(result, array.get(i)) * array.get(i);
result = Math.multiplyExact(result / gcd(result, array.get(i)), array.get(i));
}
return result;
}
@@ -340,29 +353,50 @@ public class Job {
* Generate a list of refresh intervals for metric collection.
*/
public synchronized void generateMetricsIntervals(List<Long> metricsIntervals) {
// 1. To find the least common multiple (LCM) of all metric refresh intervals
long lcm = lcm(metricsIntervals);
List<Long> refreshTimes = new LinkedList<>();
// 2. Calculate all possible refresh intervals in one round
for (long interval : metricsIntervals) {
for (long t = interval; t <= lcm; t += interval) {
if (!refreshTimes.contains(t)) {
refreshTimes.add(t);
}
}
long[] periods = metricsIntervals == null
? new long[0]
: metricsIntervals.stream()
.filter(Objects::nonNull)
.mapToLong(Long::longValue)
.filter(interval -> interval > 0)
.distinct()
.sorted()
.toArray();
if (periods.length == 0) {
metricsSchedulePeriods = null;
metricsScheduleRemaining = null;
intervals = new ConcurrentLinkedDeque<>(List.of(Math.max(1L, defaultInterval)));
return;
}
// 3. Sort from smallest to largest
Collections.sort(refreshTimes);
// 4. Calculate the refresh interval list for Job's cycle
LinkedList<Long> intervals = new LinkedList<>();
intervals.add(refreshTimes.get(0));
for (int i = 1; i < refreshTimes.size(); i++) {
intervals.add(refreshTimes.get(i) - refreshTimes.get(i - 1));
}
setIntervals(new ConcurrentLinkedDeque<>(intervals));
metricsSchedulePeriods = periods;
metricsScheduleRemaining = periods.clone();
intervals = null;
}
public synchronized void setIntervals(ConcurrentLinkedDeque<Long> intervals) {
this.intervals = intervals;
metricsSchedulePeriods = null;
metricsScheduleRemaining = null;
}
public synchronized long getInterval() {
if (metricsScheduleRemaining != null && metricsSchedulePeriods != null
&& metricsScheduleRemaining.length == metricsSchedulePeriods.length
&& metricsScheduleRemaining.length > 0) {
long nextInterval = Long.MAX_VALUE;
for (long remaining : metricsScheduleRemaining) {
nextInterval = Math.min(nextInterval, remaining);
}
if (nextInterval > 0) {
for (int i = 0; i < metricsScheduleRemaining.length; i++) {
metricsScheduleRemaining[i] -= nextInterval;
if (metricsScheduleRemaining[i] == 0) {
metricsScheduleRemaining[i] = metricsSchedulePeriods[i];
}
}
return nextInterval;
}
}
if (this.intervals != null && !this.intervals.isEmpty()) {
Long interval = this.intervals.removeFirst();
if (interval != null) {
@@ -285,8 +285,9 @@ public final class CollectRep {
Row row = iterator.next();
ValueRow valueRow = ValueRow.newBuilder()
.setColumns(fieldNames.stream()
.map(fieldName -> new String(((VarCharVector)
table.getVector(fieldName)).get(row.getRowNumber())))
.map(fieldName -> new String(
((VarCharVector) table.getVector(fieldName)).get(row.getRowNumber()),
StandardCharsets.UTF_8))
.collect(Collectors.toList()))
.build();
values.add(valueRow);
@@ -443,8 +444,8 @@ public final class CollectRep {
fieldIndex < row.getColumnsList().size()) {
String value = row.getColumns(fieldIndex);
if (value != null) {
// Check byte array size, Arrow buffer size is 32768 bytes
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
// setSafe grows the variable-width data buffer beyond its initial allocation.
vector.setSafe(rowIndex, bytes);
}
}
@@ -464,7 +465,7 @@ public final class CollectRep {
throw e;
}
}
public long getId() {
return Long.parseLong(metadata.getOrDefault(MetricDataConstants.ID, "0"));
}
@@ -69,13 +69,11 @@ public class RedisMetricsDataCodec implements RedisCodec<String, CollectRep.Metr
try (ByteArrayInputStream in = new ByteArrayInputStream(bytes);
ArrowStreamReader reader = new ArrowStreamReader(
Channels.newChannel(in), allocator)) {
reader.loadNextBatch();
VectorSchemaRoot root = reader.getVectorSchemaRoot();
if (root == null || root.getRowCount() == 0) {
log.warn("Empty data received");
if (!reader.loadNextBatch()) {
log.warn("No record batch in metrics data stream, discarding");
return null;
}
return new CollectRep.MetricsData(root);
return new CollectRep.MetricsData(reader.getVectorSchemaRoot());
}
} catch (Exception e) {
log.error("Failed to decode metrics data", e);
@@ -0,0 +1,99 @@
/*
* 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.common.util;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Safety helpers for building JDBC connection urls from user supplied monitor parameters.
*
* <p>Connection parameters such as the database name are concatenated into the jdbc url. Without
* restriction a value like {@code db?allowLoadLocalInfile=true} injects arbitrary driver properties,
* which a malicious database server turns into local file disclosure or deserialization on the
* connecting jvm. The url blacklist only guards the url a user types in directly, so every other
* value that reaches the url has to be constrained here.
*/
public final class JdbcUrlSafetyUtil {
/**
* Identifier characters accepted in a database or schema name. Deliberately excludes the
* characters that carry meaning inside a jdbc url: {@code ? & = : / \ ; # space}.
*/
private static final Pattern DATABASE_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_$][A-Za-z0-9_$.\\-]{0,63}$");
/**
* Driver properties that turn a connection into a client side attack. Checked against the
* assembled url so a concatenation mistake anywhere still fails closed.
*/
private static final String[] DANGEROUS_URL_PROPERTIES = {
// file IO - lets a malicious server read files from the connecting host
"allowloadlocalinfile", "allowloadlocalinfileinpath", "uselocalinfile",
// code execution and deserialization
"autodeserialize", "detectcustomcollations", "queryinterceptors", "statementinterceptors",
"exceptioninterceptors", "javaobjectserializer", "serverstatusdiffinterceptor",
"socketfactory", "init=", "runscript",
// multi statement execution
"allowmultiqueries",
// remote object lookup
"jndi:", "ldap:", "rmi:",
};
private JdbcUrlSafetyUtil() {
}
/**
* Validate a database or schema name that is about to be concatenated into a jdbc url.
*
* @param database database name, may be null or empty
* @return the database name, or an empty string when nothing was supplied
* @throws IllegalArgumentException when the name contains jdbc url syntax
*/
public static String requireSafeDatabaseName(String database) {
if (database == null || database.isEmpty()) {
return "";
}
if (!DATABASE_NAME_PATTERN.matcher(database).matches()) {
throw new IllegalArgumentException("Invalid database name: only letters, digits, "
+ "'_', '$', '.' and '-' are allowed, up to 64 characters");
}
return database;
}
/**
* Reject an assembled jdbc url that carries a driver property known to be attacker useful.
*
* <p>Applies to urls this project builds itself. Urls typed in by a user go through the wider
* platform aware checks in the collector before reaching here.
*
* @param url assembled jdbc url
* @throws IllegalArgumentException when a dangerous property is present
*/
public static void requireSafeJdbcUrl(String url) {
if (url == null || url.isEmpty()) {
return;
}
String normalized = url.toLowerCase(Locale.ROOT).replaceAll("[\\x00-\\x1F\\x7F]", "");
for (String property : DANGEROUS_URL_PROPERTIES) {
if (normalized.contains(property)) {
throw new IllegalArgumentException(
"Invalid JDBC URL: contains potentially malicious parameter: " + property);
}
}
}
}
@@ -0,0 +1,100 @@
/*
* 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.common.entity.job;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedDeque;
import org.junit.jupiter.api.Test;
/**
* Test case for {@link Job}.
*/
class JobTest {
@Test
void schedulesCoprimeIntervalsWithoutMaterializingTheirWholeCycle() {
Job job = new Job();
job.generateMetricsIntervals(List.of(1009L, 1013L));
assertTrue(job.getIntervals() == null || job.getIntervals().size() <= 2);
assertEquals(
List.of(1009L, 4L, 1005L, 8L, 1001L, 12L, 997L, 16L),
List.of(
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval()));
}
@Test
void repeatsTheSameScheduleForOrdinaryIntervals() {
Job job = new Job();
job.generateMetricsIntervals(List.of(4L, 6L));
assertEquals(
List.of(4L, 2L, 2L, 4L, 4L, 2L, 2L, 4L),
List.of(
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval(),
job.getInterval()));
}
@Test
void explicitIntervalsReplaceGeneratedSchedule() {
Job job = new Job();
job.generateMetricsIntervals(List.of(4L, 6L));
job.setIntervals(new ConcurrentLinkedDeque<>(List.of(0L)));
assertEquals(0L, job.getInterval());
assertEquals(0L, job.getInterval());
}
@Test
void usesDefaultIntervalWhenNoValidMetricIntervalExists() {
Job job = new Job();
job.setDefaultInterval(15L);
job.generateMetricsIntervals(List.of(0L, -1L));
assertEquals(15L, job.getInterval());
assertEquals(15L, job.getInterval());
}
@Test
void rejectsLeastCommonMultipleOverflow() {
assertThrows(
ArithmeticException.class,
() -> Job.lcm(List.of(Long.MAX_VALUE, Long.MAX_VALUE - 1)));
}
}
@@ -19,10 +19,16 @@
package org.apache.hertzbeat.common.entity.message;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.params.provider.Arguments.arguments;
/**
* Test case for {@link CollectRep}
@@ -43,4 +49,41 @@ public class CollectRepTest {
assertEquals(field1.equals(field2), result);
}
@ParameterizedTest(name = "{0}")
@MethodSource("largeMetricValues")
void preservesArrowStringValue(String description, String value) {
CollectRep.Field field = CollectRep.Field.newBuilder()
.setName("payload")
.setType(1)
.build();
CollectRep.ValueRow row = new CollectRep.ValueRow(List.of(value));
try (CollectRep.MetricsData metricsData = CollectRep.MetricsData.newBuilder()
.addField(field)
.addValueRow(row)
.build()) {
String storedValue = metricsData.getValues().getFirst().getColumns(0);
assertEquals(
value.getBytes(StandardCharsets.UTF_8).length,
storedValue.getBytes(StandardCharsets.UTF_8).length);
assertEquals(value, storedValue);
}
}
private static Stream<Arguments> largeMetricValues() {
String ideograph = "\u4e2d";
String emoji = new String(Character.toChars(0x1F600));
return Stream.of(
arguments("ASCII before previous boundary", "a".repeat(32_699)),
arguments("ASCII at previous boundary", "a".repeat(32_700)),
arguments("ASCII after previous boundary", "a".repeat(32_701)),
arguments("large ASCII value", "a".repeat(100_000)),
arguments("multibyte before previous boundary", ideograph.repeat(10_899)),
arguments("multibyte at previous boundary", ideograph.repeat(10_900)),
arguments("multibyte after previous boundary", ideograph.repeat(10_901)),
arguments("emoji before previous boundary", emoji.repeat(8_174)),
arguments("emoji at previous boundary", emoji.repeat(8_175)),
arguments("emoji after previous boundary", emoji.repeat(8_176)));
}
}
@@ -23,6 +23,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.arrow.vector.VectorSchemaRoot;
import org.apache.arrow.vector.ipc.ArrowStreamWriter;
@@ -102,6 +104,29 @@ class KafkaMetricsDataSerializerTest {
assertArrayEquals(expectedBytes, bytes);
}
@Test
void preservesLargeMetricValueThroughArrowIpc() {
String value = "a".repeat(100_000);
CollectRep.Field field = CollectRep.Field.newBuilder()
.setName("payload")
.setType(1)
.build();
CollectRep.MetricsData source = CollectRep.MetricsData.newBuilder()
.addField(field)
.addValueRow(new CollectRep.ValueRow(List.of(value)))
.build();
byte[] bytes = serializer.serialize("topic", source);
KafkaMetricsDataDeserializer deserializer = new KafkaMetricsDataDeserializer();
try (CollectRep.MetricsData restored = deserializer.deserialize("topic", bytes)) {
assertArrayEquals(
value.getBytes(StandardCharsets.UTF_8),
restored.getValues().getFirst().getColumns(0)
.getBytes(StandardCharsets.UTF_8));
}
}
@Test
void testClose() {
@@ -0,0 +1,104 @@
/*
* 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.common.serialize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.util.List;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.VectorSchemaRoot;
import org.apache.arrow.vector.ipc.ArrowStreamWriter;
import org.apache.arrow.vector.types.pojo.ArrowType;
import org.apache.arrow.vector.types.pojo.Field;
import org.apache.arrow.vector.types.pojo.Schema;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Test case for {@link RedisMetricsDataCodec}
*/
class RedisMetricsDataCodecTest {
private RedisMetricsDataCodec codec;
@BeforeEach
void setUp() {
codec = new RedisMetricsDataCodec();
}
@Test
void encodeDecodePreservesRows() {
CollectRep.MetricsData data = CollectRep.MetricsData.newBuilder()
.setId(123L)
.setApp("linux")
.setMetrics("cpu")
.setCode(CollectRep.Code.SUCCESS)
.addField(CollectRep.Field.newBuilder().setName("usage").setType(0).build())
.addValueRow(CollectRep.ValueRow.newBuilder().addColumn("42").build())
.build();
try (CollectRep.MetricsData decoded = codec.decodeValue(codec.encodeValue(data))) {
assertNotNull(decoded);
assertEquals(123L, decoded.getId());
assertEquals(CollectRep.Code.SUCCESS, decoded.getCode());
assertEquals(1, decoded.getValues().size());
assertEquals("42", decoded.getValues().get(0).getColumns(0));
}
}
@Test
void encodeDecodeKeepsZeroRowTimeout() {
CollectRep.MetricsData timeout = CollectRep.MetricsData.newBuilder()
.setId(456L)
.setApp("linux")
.setMetrics("cpu")
.setPriority(0)
.setCode(CollectRep.Code.TIMEOUT)
.setMsg("Collect Timeout No Response")
.build();
try (CollectRep.MetricsData decoded = codec.decodeValue(codec.encodeValue(timeout))) {
assertNotNull(decoded);
assertEquals(456L, decoded.getId());
assertEquals(CollectRep.Code.TIMEOUT, decoded.getCode());
assertEquals("Collect Timeout No Response", decoded.getMsg());
assertEquals(0, decoded.getPriority());
assertEquals(0, decoded.rowCount());
}
}
@Test
void decodeSchemaOnlyStreamReturnsNull() throws Exception {
Schema schema = new Schema(List.of(Field.nullable("usage", new ArrowType.Utf8())));
try (BufferAllocator allocator = new RootAllocator();
VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ArrowStreamWriter writer = new ArrowStreamWriter(root, null, Channels.newChannel(out))) {
writer.start();
writer.end();
assertNull(codec.decodeValue(ByteBuffer.wrap(out.toByteArray())));
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/**
* Test case for {@link JdbcUrlSafetyUtil}
*/
class JdbcUrlSafetyUtilTest {
@Test
void testAcceptsOrdinaryDatabaseNames() {
assertEquals("hertzbeat", JdbcUrlSafetyUtil.requireSafeDatabaseName("hertzbeat"));
assertEquals("my_db-1", JdbcUrlSafetyUtil.requireSafeDatabaseName("my_db-1"));
assertEquals("orcl.example.com", JdbcUrlSafetyUtil.requireSafeDatabaseName("orcl.example.com"));
assertEquals("", JdbcUrlSafetyUtil.requireSafeDatabaseName(null));
assertEquals("", JdbcUrlSafetyUtil.requireSafeDatabaseName(""));
}
@ValueSource(strings = {
// the payload that turns a monitor into local file disclosure on the collector
"test?allowLoadLocalInfile=true&z=",
"test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&z=",
// any character that carries jdbc url meaning has to be refused
"db&user=root",
"db=x",
"db/../other",
"db;DatabaseName=other",
"db:1234",
"db#fragment",
"db name",
"?leadingQuestion",
})
@ParameterizedTest
void testRejectsDatabaseNamesCarryingUrlSyntax(String database) {
assertThrows(IllegalArgumentException.class,
() -> JdbcUrlSafetyUtil.requireSafeDatabaseName(database));
}
@Test
void testRejectsDatabaseNameOverLength() {
assertThrows(IllegalArgumentException.class,
() -> JdbcUrlSafetyUtil.requireSafeDatabaseName("a".repeat(65)));
}
@Test
void testAcceptsUrlsThisProjectBuilds() {
assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
"jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=false"));
assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
"jdbc:sqlserver://localhost:1433;DatabaseName=test;trustServerCertificate=true;"));
assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(
"jdbc:oracle:thin:@localhost:1521/orcl"));
assertDoesNotThrow(() -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(null));
}
@ValueSource(strings = {
"jdbc:mysql://localhost:3306/test?allowLoadLocalInfile=true",
"jdbc:mysql://localhost:3306/test?autoDeserialize=true",
"jdbc:mysql://localhost:3306/test?queryInterceptors=x",
"jdbc:mysql://localhost:3306/test?allowMultiQueries=true",
"jdbc:postgresql://localhost:5432/test?socketFactory=x",
"jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://evil/x.sql'",
})
@ParameterizedTest
void testRejectsUrlsCarryingDangerousDriverProperties(String url) {
assertThrows(IllegalArgumentException.class, () -> JdbcUrlSafetyUtil.requireSafeJdbcUrl(url));
}
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.common.entity.manager;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
@@ -27,6 +28,7 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -50,12 +52,14 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@Table(name = "hzb_bulletin")
@Table(name = "hzb_bulletin", uniqueConstraints = {
@UniqueConstraint(name = "uk_bulletin_name", columnNames = "name")
})
public class Bulletin {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Schema(description = "Bulletin ID", example = "1")
@Schema(description = "Bulletin ID", example = "1", accessMode = READ_ONLY)
private Long id;
@Schema(description = "Bulletin Name", example = "Bulletin1", accessMode = READ_WRITE)
@@ -74,19 +78,19 @@ public class Bulletin {
@Convert(converter = JsonMapListAttributeConverter.class)
private Map<String, List<String>> fields;
@Schema(title = "The creator of this record", example = "tom", accessMode = READ_WRITE)
@Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY)
@CreatedBy
private String creator;
@Schema(title = "The modifier of this record", example = "tom", accessMode = READ_WRITE)
@Schema(title = "The modifier of this record", example = "tom", accessMode = READ_ONLY)
@LastModifiedBy
private String modifier;
@Schema(title = "Record create time", example = "2024-07-02T20:09:34.903217", accessMode = READ_WRITE)
@Schema(title = "Record create time", example = "2024-07-02T20:09:34.903217", accessMode = READ_ONLY)
@CreatedDate
private LocalDateTime gmtCreate;
@Schema(title = "Record modify time", example = "2024-07-02T20:09:34.903217", accessMode = READ_WRITE)
@Schema(title = "Record modify time", example = "2024-07-02T20:09:34.903217", accessMode = READ_ONLY)
@LastModifiedDate
private LocalDateTime gmtUpdate;
}
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -70,6 +76,10 @@ resourceRole:
- /api/sse/**===post===[admin,user]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin,user]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
@@ -44,7 +44,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.support.BasicAuthenticationInterceptor;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@@ -98,7 +97,6 @@ public class ServiceAccountService {
String endpoint = String.format(prefix + CREATE_SERVICE_ACCOUNT_API, url);
HttpHeaders headers = createHeaders();
String body = String.format("{\"name\":\"%s\",\"role\":\"%s\",\"isDisabled\":false}", ACCOUNT_NAME, ACCOUNT_ROLE);
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(username, password));
HttpEntity<String> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(endpoint, request, String.class);
@@ -128,7 +126,6 @@ public class ServiceAccountService {
String endpoint = String.format(prefix + CREATE_SERVICE_TOKEN_API, url, accountId);
HttpHeaders headers = createHeaders();
String body = String.format("{\"name\":\"%s\"}", CommonUtil.generateRandomWord(6));
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(username, password));
HttpEntity<String> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(endpoint, request, String.class);
@@ -178,7 +175,6 @@ public class ServiceAccountService {
public ResponseEntity<String> getAccounts() {
String endpoint = String.format(prefix + GET_SERVICE_ACCOUNTS_API, url);
HttpHeaders headers = createHeaders();
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(username, password));
HttpEntity<String> request = new HttpEntity<>(headers);
try {
ResponseEntity<String> response = restTemplate.exchange(endpoint, HttpMethod.GET, request, String.class);
@@ -0,0 +1,90 @@
/*
* 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.grafana.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.hertzbeat.common.constants.NetworkConstants;
import org.apache.hertzbeat.grafana.config.GrafanaProperties;
import org.apache.hertzbeat.grafana.dao.GrafanaConfigDao;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
/**
* Test case for {@link ServiceAccountService}.
*/
@ExtendWith(MockitoExtension.class)
class ServiceAccountServiceTest {
@Mock
private GrafanaProperties grafanaProperties;
@Mock
private GrafanaConfigDao grafanaConfigDao;
@Mock
private RestTemplate restTemplate;
private ServiceAccountService serviceAccountService;
@BeforeEach
void setUp() {
when(grafanaProperties.getPrefix()).thenReturn("https://");
when(grafanaProperties.getUrl()).thenReturn("grafana.example");
when(grafanaProperties.username()).thenReturn("admin");
when(grafanaProperties.password()).thenReturn("password");
serviceAccountService = new ServiceAccountService(grafanaProperties, grafanaConfigDao, restTemplate);
serviceAccountService.init();
}
@Test
void keepsGrafanaAuthenticationScopedToTheRequest() {
when(restTemplate.exchange(
eq("https://grafana.example/api/serviceaccounts/search"),
eq(HttpMethod.GET),
any(HttpEntity.class),
eq(String.class)))
.thenReturn(ResponseEntity.ok("{\"serviceAccounts\":[]}"));
serviceAccountService.getAccounts();
verify(restTemplate, never()).getInterceptors();
ArgumentCaptor<HttpEntity<String>> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).exchange(
eq("https://grafana.example/api/serviceaccounts/search"),
eq(HttpMethod.GET),
requestCaptor.capture(),
eq(String.class));
assertEquals(
"Basic YWRtaW46cGFzc3dvcmQ=",
requestCaptor.getValue().getHeaders().getFirst(NetworkConstants.AUTHORIZATION));
}
}
@@ -62,7 +62,7 @@ public class BulletinController {
@PostMapping
public ResponseEntity<Message<Void>> addNewBulletin(@Valid @RequestBody Bulletin bulletin) {
try {
bulletinService.validate(bulletin);
bulletinService.validate(bulletin, false);
bulletinService.addBulletin(bulletin);
} catch (Exception e) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Add failed! " + e.getMessage()));
@@ -74,7 +74,7 @@ public class BulletinController {
@PutMapping
public ResponseEntity<Message<Void>> editBulletin(@Valid @RequestBody Bulletin bulletin) {
try {
bulletinService.validate(bulletin);
bulletinService.validate(bulletin, true);
bulletinService.editBulletin(bulletin);
} catch (Exception e) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Edit failed! " + e.getMessage()));
@@ -23,11 +23,14 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.constants.GeneralConfigTypeEnum;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.ResponseUtil;
import org.apache.hertzbeat.manager.pojo.dto.TemplateConfig;
import org.apache.hertzbeat.manager.service.ConfigService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -61,6 +64,18 @@ public class GeneralConfigController {
private static final Set<String> ZONE_IDS = ZoneId.getAvailableZoneIds();
/**
* Config types that must never travel over the rest api.
*
* <p>The {@code secret} config holds the jwt signing key and the aes key that protects
* every stored monitor credential. Both are loaded straight from the persistence layer
* while the application boots and no part of the ui reads them, so handing them out
* over http has no legitimate use and would let a reader mint admin tokens and decrypt
* stored credentials. Refusing the read here keeps the guarantee even if the rbac rules
* for this route are ever loosened again.
*/
private static final Set<String> NON_READABLE_TYPES = Set.of(GeneralConfigTypeEnum.secret.name());
@Resource
private ConfigService configService;
@@ -80,6 +95,12 @@ public class GeneralConfigController {
public ResponseEntity<Message<Object>> getConfig(
@Parameter(description = "Config Type", example = "email")
@PathVariable("type") @NotNull final String type) {
if (NON_READABLE_TYPES.contains(type)) {
log.warn("Refused to serve the {} config over the rest api", type);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Message.fail(CommonConstants.FAIL_CODE,
"The " + type + " config can not be read through the rest api."));
}
return ResponseUtil.handle(() -> configService.getConfig(type));
}
@@ -31,7 +31,7 @@ public interface BulletinService {
/**
* validate Bulletin
*/
void validate(Bulletin bulletin) throws IllegalArgumentException;
void validate(Bulletin bulletin, boolean isModify) throws IllegalArgumentException;
/**
* Get Bulletin by id
@@ -65,7 +65,7 @@ public class BulletinServiceImpl implements BulletinService {
* validate Bulletin
*/
@Override
public void validate(Bulletin bulletin) throws IllegalArgumentException {
public void validate(Bulletin bulletin, boolean isModify) throws IllegalArgumentException {
if (bulletin == null) {
throw new IllegalArgumentException("Bulletin cannot be null");
}
@@ -78,8 +78,11 @@ public class BulletinServiceImpl implements BulletinService {
if (bulletin.getMonitorIds() == null || bulletin.getMonitorIds().isEmpty()) {
throw new IllegalArgumentException("Bulletin monitorIds cannot be null or empty");
}
if (isModify && bulletin.getId() == null) {
throw new IllegalArgumentException("Bulletin id cannot be null when editing");
}
Bulletin existBulletin = bulletinDao.findByName(bulletin.getName());
if (existBulletin != null && !existBulletin.getId().equals(bulletin.getId())) {
if (existBulletin != null && (!isModify || !existBulletin.getId().equals(bulletin.getId()))) {
throw new IllegalArgumentException("Bulletin name duplicated");
}
}
@@ -94,7 +97,12 @@ public class BulletinServiceImpl implements BulletinService {
if (optional.isEmpty()) {
throw new IllegalArgumentException("Bulletin not found");
}
bulletinDao.save(bulletin);
Bulletin storedBulletin = optional.get();
storedBulletin.setName(bulletin.getName());
storedBulletin.setMonitorIds(bulletin.getMonitorIds());
storedBulletin.setApp(bulletin.getApp());
storedBulletin.setFields(bulletin.getFields());
bulletinDao.save(storedBulletin);
}
/**
@@ -103,7 +111,13 @@ public class BulletinServiceImpl implements BulletinService {
@Override
@Transactional(rollbackFor = Exception.class)
public void addBulletin(Bulletin bulletin) {
bulletinDao.save(bulletin);
Bulletin newBulletin = Bulletin.builder()
.name(bulletin.getName())
.monitorIds(bulletin.getMonitorIds())
.app(bulletin.getApp())
.fields(bulletin.getFields())
.build();
bulletinDao.save(newBulletin);
}
/**
@@ -62,7 +62,7 @@ class BulletinControllerTest {
Bulletin bulletinDto = new Bulletin();
doAnswer(invocation -> {
throw new IllegalArgumentException("Invalid bulletin");
}).when(bulletinService).validate(bulletinDto);
}).when(bulletinService).validate(bulletinDto, false);
this.mockMvc.perform(MockMvcRequestBuilders.post("/api/bulletin")
.contentType("application/json")
@@ -72,7 +72,7 @@ class BulletinControllerTest {
doAnswer(invocation -> {
return null;
}).when(bulletinService).validate(bulletinDto);
}).when(bulletinService).validate(bulletinDto, false);
doAnswer(invocation -> {
return null;
}).when(bulletinService).addBulletin(bulletinDto);
@@ -88,7 +88,7 @@ class BulletinControllerTest {
Bulletin bulletinDto = new Bulletin();
doAnswer(invocation -> {
throw new IllegalArgumentException("Invalid bulletin");
}).when(bulletinService).validate(bulletinDto);
}).when(bulletinService).validate(bulletinDto, true);
this.mockMvc.perform(MockMvcRequestBuilders.put("/api/bulletin")
.contentType("application/json")
@@ -98,7 +98,7 @@ class BulletinControllerTest {
doAnswer(invocation -> {
return null;
}).when(bulletinService).validate(bulletinDto);
}).when(bulletinService).validate(bulletinDto, true);
doAnswer(invocation -> {
return null;
}).when(bulletinService).editBulletin(bulletinDto);
@@ -20,6 +20,8 @@ package org.apache.hertzbeat.manager.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -84,6 +86,18 @@ class GeneralConfigControllerTest {
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE));
}
@Test
public void testGetSecretConfigIsRefused() throws Exception {
mockMvc.perform(get("/api/config/secret")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE));
// the jwt signing key and the aes key must not even be loaded for a rest read
verify(configService, never()).getConfig(anyString());
}
@Test
public void testUpdateTemplateAppConfig() throws Exception {
@@ -24,6 +24,7 @@ import org.apache.hertzbeat.manager.service.impl.BulletinServiceImpl;
import org.apache.hertzbeat.warehouse.store.realtime.RealTimeDataReader;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -32,19 +33,24 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -67,17 +73,17 @@ public class BulletinServiceTest {
@Test
public void testValidate() throws Exception {
assertThrows(IllegalArgumentException.class, () -> {
bulletinService.validate(null);
bulletinService.validate(null, false);
});
assertThrows(IllegalArgumentException.class, () -> {
bulletinService.validate(new Bulletin());
bulletinService.validate(new Bulletin(), false);
});
assertThrows(IllegalArgumentException.class, () -> {
Bulletin obj = new Bulletin();
obj.setApp("app");
bulletinService.validate(obj);
bulletinService.validate(obj, false);
});
assertThrows(IllegalArgumentException.class, () -> {
@@ -87,7 +93,7 @@ public class BulletinServiceTest {
Bulletin obj = new Bulletin();
obj.setApp("app");
obj.setFields(fields);
bulletinService.validate(obj);
bulletinService.validate(obj, false);
});
assertDoesNotThrow(() -> {
@@ -101,7 +107,7 @@ public class BulletinServiceTest {
obj.setApp("app");
obj.setFields(fields);
obj.setMonitorIds(ids);
bulletinService.validate(obj);
bulletinService.validate(obj, false);
});
}
@@ -119,6 +125,75 @@ public class BulletinServiceTest {
});
}
@Test
void validateCreateRejectsDuplicateNameEvenWhenClientSubmitsExistingId() {
Bulletin stored = Bulletin.builder().id(7L).name("duplicate-name").build();
Bulletin submitted = Bulletin.builder()
.id(7L)
.name("duplicate-name")
.app("app")
.fields(Map.of("metric", List.of("field")))
.monitorIds(List.of(1L))
.build();
when(bulletinDao.findByName("duplicate-name")).thenReturn(stored);
assertThrows(IllegalArgumentException.class, () -> bulletinService.validate(submitted, false));
}
@Test
void addBulletinIgnoresClientManagedFields() {
Bulletin bulletin = new Bulletin();
bulletin.setId(7L);
bulletin.setCreator("submitted-creator");
bulletin.setModifier("submitted-modifier");
bulletin.setGmtCreate(LocalDateTime.of(2020, 1, 1, 0, 0));
bulletin.setGmtUpdate(LocalDateTime.of(2020, 1, 2, 0, 0));
bulletinService.addBulletin(bulletin);
ArgumentCaptor<Bulletin> saved = ArgumentCaptor.forClass(Bulletin.class);
verify(bulletinDao).save(saved.capture());
assertNull(saved.getValue().getId());
assertNull(saved.getValue().getCreator());
assertNull(saved.getValue().getModifier());
assertNull(saved.getValue().getGmtCreate());
assertNull(saved.getValue().getGmtUpdate());
}
@Test
void editBulletinPreservesStoredManagedFields() {
LocalDateTime createdAt = LocalDateTime.of(2020, 1, 1, 0, 0);
LocalDateTime updatedAt = LocalDateTime.of(2020, 1, 2, 0, 0);
Bulletin stored = Bulletin.builder()
.id(7L)
.name("old-name")
.creator("stored-creator")
.modifier("stored-modifier")
.gmtCreate(createdAt)
.gmtUpdate(updatedAt)
.build();
Bulletin submitted = Bulletin.builder()
.id(7L)
.name("new-name")
.creator("submitted-creator")
.modifier("submitted-modifier")
.gmtCreate(createdAt.minusYears(1))
.gmtUpdate(updatedAt.minusYears(1))
.build();
when(bulletinDao.findById(7L)).thenReturn(Optional.of(stored));
bulletinService.editBulletin(submitted);
ArgumentCaptor<Bulletin> saved = ArgumentCaptor.forClass(Bulletin.class);
verify(bulletinDao).save(saved.capture());
assertSame(stored, saved.getValue());
assertEquals("new-name", saved.getValue().getName());
assertEquals("stored-creator", saved.getValue().getCreator());
assertEquals("stored-modifier", saved.getValue().getModifier());
assertEquals(createdAt, saved.getValue().getGmtCreate());
assertEquals(updatedAt, saved.getValue().getGmtUpdate());
}
@Test
public void testGetBulletins() throws Exception {
Bulletin bulletin = new Bulletin();
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -70,6 +76,10 @@ resourceRole:
- /api/sse/**===post===[admin,user]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin,user]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,6 +86,10 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
@@ -79,6 +99,8 @@ resourceRole:
- /api/account/token===get===[admin]
- /api/account/token/**===post===[admin]
- /api/account/token/**===delete===[admin]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
@@ -0,0 +1,86 @@
/*
* 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.startup.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import com.usthe.sureness.matcher.util.TirePathTree;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* Guards the rbac rule covering the spring boot actuator endpoints.
*
* <p>`application.yml` exposes `metrics`, `health` and `prometheus`, but `sureness.yml`
* neither listed nor excluded `/actuator/**`. A route with no rule leaves `supportRoles`
* null and `BaseProcessor.authorized` returns early, so every authenticated account
* including {@code guest} could read jvm heap, thread and gc counters, http call
* statistics and datasource health - useful for internal reconnaissance and as a
* feedback channel while probing for resource exhaustion.
*/
class SurenessActuatorRuleTest {
private static final String SEPARATOR = "===";
private static TirePathTree roleTree;
private static TirePathTree excludeTree;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadSurenessConfig() throws IOException {
List<String> resourceRole;
List<String> excludedResource;
try (InputStream in = SurenessActuatorRuleTest.class.getResourceAsStream("/sureness.yml")) {
assertNotNull(in, "sureness.yml must be on the classpath");
Map<String, Object> document = new Yaml().load(in);
resourceRole = (List<String>) document.get("resourceRole");
excludedResource = (List<String>) document.get("excludedResource");
}
assertNotNull(resourceRole, "resourceRole must be present");
assertNotNull(excludedResource, "excludedResource must be present");
roleTree = new TirePathTree();
roleTree.buildTree(new LinkedHashSet<>(resourceRole));
excludeTree = new TirePathTree();
excludeTree.buildTree(new LinkedHashSet<>(excludedResource));
}
@Test
void actuatorEndpointsAreRestrictedToAdmin() {
assertEquals("[admin]", roleTree.searchPathFilterRoles("/actuator/prometheus" + SEPARATOR + "get"));
assertEquals("[admin]", roleTree.searchPathFilterRoles("/actuator/health" + SEPARATOR + "get"));
assertEquals("[admin]", roleTree.searchPathFilterRoles("/actuator/metrics" + SEPARATOR + "get"));
}
/**
* Sureness evaluates the exclusion tree before any credential check, so an actuator
* path landing there would hand these internals to anonymous callers instead.
*/
@Test
void actuatorEndpointsAreNotExcludedFromAuthentication() {
assertNull(excludeTree.searchPathFilterRoles("/actuator/prometheus" + SEPARATOR + "get"));
assertNull(excludeTree.searchPathFilterRoles("/actuator/health" + SEPARATOR + "get"));
}
}
@@ -0,0 +1,97 @@
/*
* 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.startup.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import com.usthe.sureness.matcher.util.TirePathTree;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* Guards the rbac rules covering {@code /api/config/**}.
*
* <p>A route missing from {@code sureness.yml} carries no role requirement, and
* {@code BaseProcessor.authorized} returns early when no role is required, so any
* authenticated caller reaches it. The config routes used to be absent entirely, which
* exposed {@code GET /api/config/secret} - the jwt signing key and the aes key that
* protects stored monitor credentials - to every account including {@code guest}.
*/
class SurenessConfigRuleTest {
private static final String SEPARATOR = "===";
private static TirePathTree roleTree;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadSurenessConfig() throws IOException {
List<String> resourceRole;
try (InputStream in = SurenessConfigRuleTest.class.getResourceAsStream("/sureness.yml")) {
assertNotNull(in, "sureness.yml must be on the classpath");
Map<String, Object> document = new Yaml().load(in);
resourceRole = (List<String>) document.get("resourceRole");
}
assertNotNull(resourceRole, "resourceRole must be present");
roleTree = new TirePathTree();
roleTree.buildTree(new LinkedHashSet<>(resourceRole));
}
private static String rolesFor(String path, String method) {
return roleTree.searchPathFilterRoles(path + SEPARATOR + method);
}
@Test
void readingTheSecretConfigIsRestrictedToAdmin() {
assertEquals("[admin]", rolesFor("/api/config/secret", "get"));
}
@Test
void writingTheSecretConfigIsRestrictedToAdmin() {
assertEquals("[admin]", rolesFor("/api/config/secret", "post"));
}
@Test
void writingAnyOtherConfigIsRestrictedToAdmin() {
assertEquals("[admin]", rolesFor("/api/config/email", "post"));
assertEquals("[admin]", rolesFor("/api/config/oss", "post"));
assertEquals("[admin]", rolesFor("/api/config/template/linux", "put"));
}
/**
* The notification widget in the top bar lets every signed in user flip the mute flag,
* so this one write has to stay reachable by all roles.
*/
@Test
void togglingMuteStaysOpenToEveryRole() {
assertEquals("[admin,user,guest]", rolesFor("/api/config/mute", "post"));
assertEquals("[admin,user,guest]", rolesFor("/api/config/mute", "get"));
}
@Test
void readingNonSecretConfigStaysOpenToEveryRole() {
assertEquals("[admin,user,guest]", rolesFor("/api/config/system", "get"));
assertEquals("[admin,user,guest]", rolesFor("/api/config/timezones", "get"));
}
}
@@ -0,0 +1,227 @@
/*
* 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.startup.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.usthe.sureness.matcher.util.TirePathTree;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* Guards the rbac rules shipped in {@code sureness.yml}.
*
* <p>Sureness splits every rule on {@code ===} and silently drops any line that does not
* yield exactly three segments, so a rule with a mistyped separator disappears from the
* match tree instead of failing loudly. A dropped rule leaves its endpoint with no role
* requirement at all, which sureness treats as "no restriction" for an authenticated
* caller. These tests assert both that every rule is well formed and that the rules
* protecting destructive endpoints really resolve to the intended roles.
*/
class SurenessResourceRuleTest {
private static final String SEPARATOR = "===";
private static final int RESOURCE_ROLE_SEGMENTS = 3;
private static final int EXCLUDED_RESOURCE_SEGMENTS = 2;
private static final Path SCRIPT_DIR = Path.of("..", "script");
private static List<String> resourceRole;
private static List<String> excludedResource;
private static TirePathTree roleTree;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadSurenessConfig() throws IOException {
try (InputStream in = SurenessResourceRuleTest.class.getResourceAsStream("/sureness.yml")) {
assertNotNull(in, "sureness.yml must be on the classpath");
Map<String, Object> document = new Yaml().load(in);
resourceRole = (List<String>) document.get("resourceRole");
excludedResource = (List<String>) document.get("excludedResource");
}
assertNotNull(resourceRole, "resourceRole must be present");
assertNotNull(excludedResource, "excludedResource must be present");
roleTree = new TirePathTree();
roleTree.buildTree(new LinkedHashSet<>(resourceRole));
}
@Test
void everyResourceRoleRuleIsWellFormed() {
for (String rule : resourceRole) {
assertEquals(RESOURCE_ROLE_SEGMENTS, rule.split(SEPARATOR, -1).length,
"resourceRole rule is silently dropped by sureness, it needs exactly two '"
+ SEPARATOR + "' separators: " + rule);
}
}
@Test
void everyExcludedResourceRuleIsWellFormed() {
for (String rule : excludedResource) {
assertEquals(EXCLUDED_RESOURCE_SEGMENTS, rule.split(SEPARATOR, -1).length,
"excludedResource rule is silently dropped by sureness, it needs exactly one '"
+ SEPARATOR + "' separator: " + rule);
}
}
@Test
void everyRuleReachesTheMatchTree() {
assertEquals(resourceRole.size(), roleTree.getResourceNum(),
"a rule was dropped while building the match tree, leaving its endpoint unprotected");
}
@Test
void deletingOneMonitorIsRestrictedToAdmin() {
assertEquals("[admin]", roleTree.searchPathFilterRoles("/api/monitor/1" + SEPARATOR + "delete"));
}
@Test
void deletingMonitorsIsRestrictedToAdmin() {
assertEquals("[admin]", roleTree.searchPathFilterRoles("/api/monitors/1" + SEPARATOR + "delete"));
}
@Test
void readingMonitorsStaysOpenToEveryRole() {
assertEquals("[admin,user,guest]", roleTree.searchPathFilterRoles("/api/monitors" + SEPARATOR + "get"));
}
/**
* Only the {@code get} verb under {@code /api/apps} used to be listed, so the monitoring
* template writes behind {@code AppController} reached any authenticated caller, down to
* a {@code guest}.
*
* <p>The write verbs are not equally dangerous, and the rules deliberately differ.
* {@code post} refuses to overwrite an existing template, so it only adds a type that
* nobody is obliged to use, which puts it on the same footing as creating a monitor.
* {@code put} overwrites any template including the built-in ones and then hands the new
* definition to {@code updateAppCollectJob}, retroactively changing what every existing
* monitor of that type collects for every account, so it stays admin only.
*/
@Test
void addingMonitorTemplatesIsOpenToUsers() {
assertEquals("[admin,user]", roleTree.searchPathFilterRoles("/api/apps/define/yml" + SEPARATOR + "post"));
}
@Test
void overwritingMonitorTemplatesIsRestrictedToAdmin() {
assertEquals("[admin]", roleTree.searchPathFilterRoles("/api/apps/define/yml" + SEPARATOR + "put"));
}
@Test
void deletingMonitorTemplatesIsRestrictedToAdmin() {
assertEquals("[admin]", roleTree.searchPathFilterRoles("/api/apps/linux/define/yml" + SEPARATOR + "delete"));
}
@Test
void readingMonitorTemplatesStaysOpenToEveryRole() {
assertEquals("[admin,user,guest]", roleTree.searchPathFilterRoles("/api/apps/linux/define/yml" + SEPARATOR + "get"));
}
/**
* The deployment scripts ship their own copies of {@code sureness.yml}; a rule fixed only
* in the packaged file would still leave every container deployment exposed.
*
* <p>Each section is checked against its own shape rather than against "either shape".
* The two sections are not interchangeable: {@code resourceRole} takes
* {@code api===method===roles} and {@code excludedResource} takes {@code api===method},
* and sureness consults the exclusion tree before it authenticates. A rule carrying
* roles that lands under {@code excludedResource} therefore matches nothing and quietly
* leaves its endpoint unruled, which a check accepting either shape anywhere would
* wave through.
*/
@Test
void deploymentCopiesCarryWellFormedRules() throws IOException {
for (Path copy : deploymentCopies()) {
List<String> copyResourceRole = sectionOf(copy, "resourceRole");
List<String> copyExcludedResource = sectionOf(copy, "excludedResource");
for (String rule : copyResourceRole) {
assertEquals(RESOURCE_ROLE_SEGMENTS, rule.split(SEPARATOR, -1).length,
"resourceRole rule is silently dropped by sureness in " + copy
+ ", it needs exactly two '" + SEPARATOR + "' separators: " + rule);
}
for (String rule : copyExcludedResource) {
assertEquals(EXCLUDED_RESOURCE_SEGMENTS, rule.split(SEPARATOR, -1).length,
"excludedResource rule is silently dropped by sureness in " + copy
+ ", it needs exactly one '" + SEPARATOR + "' separator: " + rule);
}
}
}
/**
* Well formed is not the same as correct: a copy that simply never listed the write verbs
* passes every shape check above while leaving the monitoring template writes unruled. The
* copies are edited by hand, one per deployment flavour, so the roles themselves are pinned
* here too and a missed copy fails instead of shipping.
*/
@Test
void deploymentCopiesRestrictMonitorTemplateWrites() throws IOException {
for (Path copy : deploymentCopies()) {
TirePathTree copyTree = new TirePathTree();
copyTree.buildTree(new LinkedHashSet<>(sectionOf(copy, "resourceRole")));
assertEquals("[admin,user]", copyTree.searchPathFilterRoles("/api/apps/define/yml" + SEPARATOR + "post"),
"adding a monitoring template is unruled or over-granted in " + copy);
assertEquals("[admin]", copyTree.searchPathFilterRoles("/api/apps/define/yml" + SEPARATOR + "put"),
"overwriting a monitoring template is unruled or over-granted in " + copy);
assertEquals("[admin]", copyTree.searchPathFilterRoles("/api/apps/linux/define/yml" + SEPARATOR + "delete"),
"deleting a monitoring template is unruled or over-granted in " + copy);
}
}
/**
* @return the {@code sureness.yml} copies shipped by the deployment scripts
*/
private static Set<Path> deploymentCopies() throws IOException {
assumeTrue(Files.isDirectory(SCRIPT_DIR),
"running outside the source tree, the packaged file asserted above is all we can see");
try (Stream<Path> paths = Files.walk(SCRIPT_DIR)) {
Set<Path> copies = paths.filter(path -> path.getFileName().toString().equals("sureness.yml"))
.collect(Collectors.toCollection(LinkedHashSet::new));
assertFalse(copies.isEmpty(), "expected the deployment scripts to ship sureness.yml copies");
return copies;
}
}
@SuppressWarnings("unchecked")
private static List<String> sectionOf(Path copy, String section) throws IOException {
Map<String, Object> document;
try (InputStream in = Files.newInputStream(copy)) {
document = new Yaml().load(in);
}
List<String> rules = (List<String>) document.get(section);
assertNotNull(rules, section + " must be present in " + copy);
return rules;
}
}
@@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.constants.MetricDataConstants;
@@ -45,6 +46,38 @@ import org.springframework.stereotype.Service;
@Service
public class MetricsDataServiceImpl implements MetricsDataService {
/**
* The history range is interpolated straight into the time predicate of the generated
* query - {@code WHERE ts >= now - %s} for tdengine, the equivalent for influxdb, iotdb
* and victoria metrics - with no quoting around it, which makes it the widest opening
* of the four inputs: anything after the range escapes the predicate and continues the
* statement.
*
* <p>A count followed by a single unit letter is the whole language the ui speaks
* ({@code 1h}, {@code 6h}, {@code 1D}, {@code 1W}, {@code 4W}, {@code 12W}) and cannot
* carry a quote, a separator or a comment. Both cases are accepted because the storages
* differ on it: questdb lowercases the unit while `TimePeriodUtil` reads an uppercase
* unit as a calendar period. Which units a given storage actually supports is left to
* that storage, so this rejects without changing what already worked.
*/
private static final Pattern HISTORY_RANGE = Pattern.compile("\\d{1,6}[smhdwy]", Pattern.CASE_INSENSITIVE);
/**
* App, metrics group and metric names reach the storages as table and column
* identifiers, quoted with backticks in tdengine and double quotes in questdb, and as
* promql label values in victoria metrics. None of the characters allowed here can
* close any of those, and every monitoring template shipped with hertzbeat names its
* apps, metric groups and fields from this set.
*/
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z0-9_-]{1,200}");
/**
* The instance is commonly an address, so it additionally allows the punctuation an
* address carries. The storages that build a table name from it already fold {@code .},
* {@code :}, {@code [} and {@code ]} into underscores.
*/
private static final Pattern INSTANCE = Pattern.compile("[A-Za-z0-9_\\-.:\\[\\]]{1,200}");
private final RealTimeDataReader realTimeDataReader;
private final Optional<HistoryDataReader> historyDataReader;
@@ -111,6 +144,11 @@ public class MetricsDataServiceImpl implements MetricsDataService {
if (history == null) {
history = "6h";
}
validateHistoryRange(history);
validateIdentifier(app, "app");
validateIdentifier(metrics, "metrics");
validateIdentifier(metric, "metric");
validateInstance(instance);
Map<String, List<Value>> instanceValuesMap;
if (interval == null || !interval) {
instanceValuesMap = historyDataReader.get().getHistoryMetricData(instance, app, metrics, metric, history);
@@ -126,4 +164,23 @@ public class MetricsDataServiceImpl implements MetricsDataService {
.field(Field.builder().name(metric).type(CommonConstants.TYPE_NUMBER).build())
.build();
}
private static void validateHistoryRange(String history) {
if (!HISTORY_RANGE.matcher(history).matches()) {
throw new IllegalArgumentException("history range: " + history
+ " is illegal, expected a count followed by a unit such as 6h or 1W.");
}
}
private static void validateIdentifier(String value, String name) {
if (value == null || !IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException(name + ": " + value + " is illegal.");
}
}
private static void validateInstance(String instance) {
if (instance == null || !INSTANCE.matcher(instance).matches()) {
throw new IllegalArgumentException("instance: " + instance + " is illegal.");
}
}
}
@@ -0,0 +1,150 @@
/*
* 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.warehouse.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.hertzbeat.common.entity.dto.MetricsHistoryData;
import org.apache.hertzbeat.common.entity.dto.Value;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.apache.hertzbeat.warehouse.store.realtime.RealTimeDataReader;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link MetricsDataServiceImpl}.
*
* <p>The history query inputs arrive from rest path variables and a query parameter and are
* interpolated into native queries by every time series storage: the range lands unquoted
* in the time predicate, while the app, metrics group, metric and instance land as table
* and column identifiers or as promql label values. The only storage that parses the range
* instead of interpolating it is questdb, and the default duckdb storage uses prepared
* statements, so validating here is what covers tdengine, influxdb, iotdb and victoria
* metrics at once.
*/
@ExtendWith(MockitoExtension.class)
class MetricsDataServiceImplTest {
@Mock
private RealTimeDataReader realTimeDataReader;
@Mock
private HistoryDataReader historyDataReader;
private MetricsDataServiceImpl metricsDataService;
@BeforeEach
void setUp() {
metricsDataService = new MetricsDataServiceImpl(realTimeDataReader, Optional.of(historyDataReader));
}
@Test
void testHistoryRangeBreakingOutOfTheTimePredicateIsRejected() {
// reproduces the reported payload: the range closes `ts >= now - ?` and continues the statement
assertRejected("1s union all select ts, metric_labels, `usage` from `other_table` where ts>=now-1w");
assertRejected("1h; drop table cpu");
assertRejected("1h' or '1'='1");
assertRejected("1h)--");
}
@Test
void testMalformedHistoryRangeIsRejected() {
assertRejected("");
assertRejected("6");
assertRejected("hh");
assertRejected("-1h");
assertRejected("1 h");
}
@Test
void testRangesTheUiSendsAreAccepted() {
// the periods behind the chart buttons, plus the default applied when none is given
for (String range : List.of("1h", "6h", "1D", "1W", "4W", "12W")) {
when(historyDataReader.getHistoryMetricData("127.0.0.1", "linux", "cpu", "usage", range))
.thenReturn(Map.of("", List.of(new Value("1", 1L))));
MetricsHistoryData data = metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux", "cpu", "usage", range, false);
assertEquals("cpu", data.getMetrics());
}
}
@Test
void testMissingRangeFallsBackToTheDefault() {
when(historyDataReader.getHistoryMetricData("127.0.0.1", "linux", "cpu", "usage", "6h"))
.thenReturn(Map.of("", List.of(new Value("1", 1L))));
metricsDataService.getMetricHistoryData("127.0.0.1", "linux", "cpu", "usage", null, false);
verify(historyDataReader).getHistoryMetricData("127.0.0.1", "linux", "cpu", "usage", "6h");
}
@Test
void testIdentifierEscapingTheQuotingIsRejected() {
// a backtick closes a tdengine identifier, a double quote closes a questdb one
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux`,(select 1) `x", "cpu", "usage", "6h", false));
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux", "cpu\" or \"1\"=\"1", "usage", "6h", false));
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux", "cpu", "usage`", "6h", false));
// the instance lands inside a promql label selector in victoria metrics
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1\",__name__=~\".*", "linux", "cpu", "usage", "6h", false));
verify(historyDataReader, never()).getHistoryMetricData(anyString(), anyString(), anyString(),
anyString(), anyString());
}
@Test
void testNamesUsedByTheShippedTemplatesAreAccepted() {
// dashes appear in template field names, an instance is usually an address
when(historyDataReader.getHistoryMetricData("[::1]:8080", "hugegraph", "cache", "edge-hugegraph-hits", "6h"))
.thenReturn(Map.of("", List.of(new Value("1", 1L))));
MetricsHistoryData data = metricsDataService.getMetricHistoryData(
"[::1]:8080", "hugegraph", "cache", "edge-hugegraph-hits", "6h", false);
assertEquals("cache", data.getMetrics());
}
@Test
void testIntervalQueriesAreValidatedTheSameWay() {
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux", "cpu", "usage", "1h; drop table cpu", true));
verify(historyDataReader, never()).getHistoryIntervalMetricData(anyString(), anyString(), anyString(),
anyString(), anyString());
}
private void assertRejected(String history) {
assertThrows(IllegalArgumentException.class, () -> metricsDataService.getMetricHistoryData(
"127.0.0.1", "linux", "cpu", "usage", history, false), "expected rejection of: " + history);
}
}
+7 -3
View File
@@ -130,14 +130,18 @@ More details see&emsp;&#x1F449;&emsp;[Alarm grouping](alarm_group) <br />
> After setting the receiver, you need to set the associated alarm notification strategy to configure which alarm information is sent to which receiver.
&emsp;&#x1F449;&emsp;[Configure Email Notification](alert_email) <br />
&emsp;&#x1F449;&emsp;[Configure Discord Notification](alert_webhook) <br />
&emsp;&#x1F449;&emsp;[Configure Slack Notification](alert_webhook) <br />
&emsp;&#x1F449;&emsp;[Configure Telegram Notification](alert_webhook) <br />
&emsp;&#x1F449;&emsp;[Configure SMS Notification](alert_sms) <br />
&emsp;&#x1F449;&emsp;[Configure WebHook Notification](alert_webhook) <br />
&emsp;&#x1F449;&emsp;[Configure Discord Notification](alert_discord) <br />
&emsp;&#x1F449;&emsp;[Configure Slack Notification](alert_slack) <br />
&emsp;&#x1F449;&emsp;[Configure Telegram Notification](alert_telegram) <br />
&emsp;&#x1F449;&emsp;[Configure enterprise WeChat Robot Notification](alert_wework) <br />
&emsp;&#x1F449;&emsp;[Configure enterprise WeChat App Notification](alert_enterprise_wechat_app) <br />
&emsp;&#x1F449;&emsp;[Configure DingDing Robot Notification](alert_dingtalk) <br />
&emsp;&#x1F449;&emsp;[Configure FeiShu Robot Notification](alert_feishu) <br />
&emsp;&#x1F449;&emsp;[Configure FeiShu App Notification](alert_feishu_app) <br />
&emsp;&#x1F449;&emsp;[Configure Huawei Cloud SMN Notification](alert_smn) <br />
&emsp;&#x1F449;&emsp;[Notification Template](alert_notification_template) <br />
### Plugins
@@ -64,7 +64,7 @@ mysql Ver 8.0.25 for Linux on x86_64 (MySQL Community Server - GPL)
修改位于 `hertzbeat/config/application.yml` 的配置文件
注意⚠️docker容器方式需要将application.yml文件挂载到主机本地,安装包方式解压修改位于 `hertzbeat/config/application.yml` 即可
替换里面的`spring.database`数据源参数,IP端口账户密码驱动
⚠️注意`application.yml`文件内容需完整,除下方修改内容外其他参数需保留,完整内容见[/script/application.yml](https://github.com/hertzbeat/hertzbeat/raw/master/script/application.yml)
⚠️注意`application.yml`文件内容需完整,除下方修改内容外其他参数需保留,完整内容见[/script/application.yml](https://raw.githubusercontent.com/apache/hertzbeat/master/script/application.yml)
需修改部分原参数:
@@ -55,7 +55,7 @@ PostgreSQL 是一个功能强大,开源的关系型数据库管理系统(RDB
修改位于 `hertzbeat/config/application.yml` 的配置文件
注意⚠️ docker 容器方式需要将 application.yml 文件挂载到主机本地,安装包方式解压修改位于 `hertzbeat/config/application.yml` 即可
替换里面的 `spring.database` 数据源参数,IP 端口账户密码驱动
⚠️注意 `application.yml` 文件内容需完整,除下方修改内容外其他参数需保留,完整内容见[/script/application.yml](https://github.com/hertzbeat/hertzbeat/raw/master/script/application.yml)
⚠️注意 `application.yml` 文件内容需完整,除下方修改内容外其他参数需保留,完整内容见[/script/application.yml](https://raw.githubusercontent.com/apache/hertzbeat/master/script/application.yml)
```yaml
spring:
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,8 +86,14 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,8 +86,14 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,8 +86,14 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,12 +86,18 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
- /api/logs/**===get===[admin,user,guest]
- /api/traces/**===get===[admin,user,guest]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
@@ -32,8 +32,11 @@ services:
timeout: 5s
retries: 5
start_period: 30s
# Bind data-store ports to localhost only: these services carry default
# credentials (see POSTGRES_PASSWORD / the greptime static user provider)
# and are only meant for the operator on the host, not for remote access.
ports:
- '15432:5432'
- '127.0.0.1:15432:5432'
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: 123456
@@ -58,11 +61,14 @@ services:
start_period: 30s
environment:
TZ: Asia/Shanghai
# Bind data-store ports to localhost only: these services use the default
# static user provider (greptime=greptime) and are only meant for the
# operator on the host, not for remote access.
ports:
- "14000:4000"
- "14001:4001"
- "14002:4002"
- "14003:4003"
- "127.0.0.1:14000:4000"
- "127.0.0.1:14001:4001"
- "127.0.0.1:14002:4002"
- "127.0.0.1:14003:4003"
volumes:
- greptime-tsdb-data:/greptimedb_data
command:
@@ -22,10 +22,16 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
- /api/monitor/**===delete==[admin]
- /api/monitor/**===delete===[admin]
- /api/monitors/**===get===[admin,user,guest]
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,8 +86,14 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
+22
View File
@@ -22,6 +22,12 @@
resourceRole:
- /api/account/auth/refresh===post===[admin,user,guest]
- /api/apps/**===get===[admin,user,guest]
# the define yml routes persist the global collection templates
# post can only add a new type, put overwrites an existing one and immediately
# redispatches its collect job to every monitor already using it - hence admin only
- /api/apps/**===post===[admin,user]
- /api/apps/**===put===[admin]
- /api/apps/**===delete===[admin]
- /api/monitor/**===get===[admin,user,guest]
- /api/monitor/**===post===[admin,user]
- /api/monitor/**===put===[admin,user]
@@ -54,6 +60,16 @@ resourceRole:
- /api/collector/**===post===[admin,user]
- /api/collector/**===put===[admin,user]
- /api/collector/**===delete===[admin]
# the secret config holds the jwt signing key and the aes key protecting stored
# credentials, so it stays admin only and is additionally refused by the controller
- /api/config/secret===get===[admin]
- /api/config/secret===post===[admin]
# the mute toggle sits in the notification widget every signed in user sees
- /api/config/mute===post===[admin,user,guest]
- /api/config/**===get===[admin,user,guest]
- /api/config/**===post===[admin]
- /api/config/**===put===[admin]
- /api/config/**===delete===[admin]
- /api/status/page/**===get===[admin,user,guest]
- /api/status/page/**===post===[admin,user]
- /api/status/page/**===put===[admin,user]
@@ -70,12 +86,18 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/ai/**===get===[admin]
- /api/ai/**===post===[admin]
- /api/ai/**===put===[admin]
- /api/ai/**===delete===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
- /api/logs/**===get===[admin,user,guest]
- /api/traces/**===get===[admin,user,guest]
# spring boot actuator exposes jvm, http and datasource internals for scraping
- /actuator/**===get===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
+3
View File
@@ -301,6 +301,9 @@
"alert.setting.period": "Execution Period",
"alert.setting.period.placeholder": "Please enter execution period, minimum 60 seconds",
"alert.setting.period.tip": "Time interval for periodic threshold calculation, in seconds, minimum 60 seconds",
"alert.setting.window": "Calculation Window",
"alert.setting.window.placeholder": "Please enter calculation window time",
"alert.setting.window.tip": "Time window for real-time threshold calculation, in seconds",
"alert.setting.preview.expr": "Preview Expr",
"alert.setting.priority.tip": "The alarm level that triggers the threshold, from low to high:WarningCriticalEmergency",
"alert.setting.promql.tip": "PromQL Query: sum(rate(process_cpu_seconds_total[5m])) > 0.8",
+3
View File
@@ -285,6 +285,9 @@
"alert.setting.period": "実行期間",
"alert.setting.period.placeholder": "実行期間を入力してください。最小60秒",
"alert.setting.period.tip": "周期的な閾値計算の時間間隔(秒単位)、最小60秒",
"alert.setting.window": "計算ウィンドウ",
"alert.setting.window.placeholder": "計算ウィンドウ時間を入力してください",
"alert.setting.window.tip": "リアルタイム閾値計算のウィンドウ時間(秒単位)",
"alert.setting.preview.expr": "式をプレビュー",
"alert.setting.priority.tip": "閾値をトリガーするアラームレベル。低から高へ:警告、クリティカル、緊急",
"alert.setting.promql.tip": "PromQLクエリ:sum(rate(process_cpu_seconds_total[5m])) > 0.8",
+3
View File
@@ -301,6 +301,9 @@
"alert.setting.period": "실행 주기",
"alert.setting.period.placeholder": "실행 주기를 입력하세요. 최소 60초입니다",
"alert.setting.period.tip": "주기적 임계값 계산 시간 간격(초)으로, 최소 60초입니다",
"alert.setting.window": "계산 윈도우",
"alert.setting.window.placeholder": "계산 윈도우 시간을 입력하세요",
"alert.setting.window.tip": "실시간 임계값 계산 윈도우 시간(초)입니다",
"alert.setting.preview.expr": "표현식 미리보기",
"alert.setting.priority.tip": "임계값 발생 시의 알람 등급으로, 낮은 순서부터 경고, 심각, 긴급입니다",
"alert.setting.promql.tip": "PromQL 쿼리: sum(rate(process_cpu_seconds_total[5m])) > 0.8",
+3
View File
@@ -271,6 +271,9 @@
"alert.setting.name.tip": "O nome da regra de limite precisa ser exclusivo",
"alert.setting.period": "Ciclo de execução",
"alert.setting.period.placeholder": "Insira o período de execução, mínimo de 60 segundos",
"alert.setting.window": "Janela de cálculo",
"alert.setting.window.placeholder": "Insira o tempo da janela de cálculo",
"alert.setting.window.tip": "Janela de tempo para cálculo de limite em tempo real, em segundos",
"alert.setting.bind.available": "Monitoramento opcional",
"alert.setting.bind.manage": "Monitoramento relacionado",
"alert.setting.bind.monitors": "Monitoramento relacionado",
+3
View File
@@ -280,6 +280,9 @@
"alert.setting.period": "執行週期",
"alert.setting.period.placeholder": "請輸入執行週期,最小60秒",
"alert.setting.period.tip": "週期性執行閾值計算的時間間隔,單位秒,最小60秒",
"alert.setting.window": "計算窗口",
"alert.setting.window.placeholder": "請輸入計算窗口時間",
"alert.setting.window.tip": "即時閾值計算的窗口時間,單位秒",
"alert.setting.preview.expr": "表达式预览",
"alert.setting.priority.tip": "觸發阈值的告警級別,從低到高依次爲:警告-warning,嚴重-critical,緊急-emergency",
"alert.setting.promql.tip": "PromQL查询语句: sum(rate(process_cpu_seconds_total[5m])) > 0.8",