Netpulse_SasS/server/internal/httpapi/alerts.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

461 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// maxMute — стеля ручного заглушення.
//
// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень
// достатньо для будь-яких робіт і замало, щоб забути назовсім.
const maxMute = 7 * 24 * time.Hour
func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
f := store.AlertFilter{
MinSeverity: r.URL.Query().Get("min_severity"),
DeviceID: r.URL.Query().Get("device_id"),
Scope: p.Scope(),
}
if v := r.URL.Query().Get("state"); v != "" {
f.States = strings.Split(v, ",")
}
if v := r.URL.Query().Get("limit"); v != "" {
f.Limit, _ = strconv.Atoi(v)
}
alerts, err := s.store.ListAlerts(r.Context(), p.TenantID, f)
if err != nil {
s.log.Error("перелік алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if alerts == nil {
alerts = []store.Alert{}
}
counts, err := s.store.CountAlerts(r.Context(), p.TenantID, p.Scope())
if err != nil {
s.log.Error("лічильники алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts, "counts": counts})
}
func (s *Server) handleAckAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
Comment string `json:"comment"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
a, err := s.store.AckAlert(r.Context(), p.TenantID, r.PathValue("id"), p.UserID, req.Comment)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("підтвердження алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleCloseAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
err := s.store.CloseAlert(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("закриття алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
DeviceID string `json:"device_id"`
Minutes int `json:"minutes"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if req.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібен device_id")
return
}
if req.Minutes <= 0 {
req.Minutes = 60
}
if !p.Scope().CanWrite(req.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
if max := time.Now().Add(maxMute); until.After(max) {
until = max
}
if err := s.store.MuteDevice(r.Context(), p.TenantID, req.DeviceID, p.UserID, req.Reason, until); err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор пристрою")
return
}
s.log.Error("заглушення пристрою", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"until": until})
}
// ---------------------------------------------------------------------
// Правила
// ---------------------------------------------------------------------
func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
rules, err := s.store.ListRules(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік правил", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if rules == nil {
rules = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
}
// validSources і validSeverities дублюють ENUM у БД навмисно: помилка
// в назві джерела має давати 400 з зрозумілим текстом, а не 500 від
// невдалого приведення типу в глибині запиту.
var validSources = map[string]bool{
"metric": true, "icmp": true, "interface": true, "link": true,
"syslog": true, "trap": true, "ncm": true, "agent": true, "compliance": true,
}
var validSeverities = map[string]bool{
"info": true, "warning": true, "average": true, "high": true, "disaster": true,
}
func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Severity string `json:"severity"`
Selector json.RawMessage `json:"selector"`
Condition json.RawMessage `json:"condition"`
ForSeconds int `json:"for_seconds"`
DependsOnTopology *bool `json:"depends_on_topology"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила")
return
}
if !validSources[req.Source] {
writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело правила")
return
}
if req.Severity == "" {
req.Severity = "warning"
}
if !validSeverities[req.Severity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
if len(req.Condition) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова")
return
}
if req.ForSeconds <= 0 {
req.ForSeconds = 60
}
in := store.RuleInput{
Name: req.Name,
Description: req.Description,
Source: req.Source,
Severity: req.Severity,
Selector: jsonOr(req.Selector, "{}"),
Condition: string(req.Condition),
ForSeconds: req.ForSeconds,
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
Enabled: req.Enabled == nil || *req.Enabled,
}
id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
return
}
s.log.Error("створення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "очікується поле enabled")
return
}
err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("зміна правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Канали
// ---------------------------------------------------------------------
func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
// Секрети не розшифровуються: у переліку вони не потрібні, а
// найнадійніший спосіб не віддати токен назовні — не діставати його
// з бази взагалі.
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, nil)
if err != nil {
s.log.Error("перелік каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if channels == nil {
channels = []store.Channel{}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
}
var validChannelKinds = map[string]bool{
"email": true, "telegram": true, "webhook": true, "slack": true,
"discord": true, "mattermost": true,
}
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
var req struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
Template string `json:"template"`
MinSeverity string `json:"min_severity"`
Secret string `json:"secret"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва каналу")
return
}
if !validChannelKinds[req.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид каналу")
return
}
if req.MinSeverity == "" {
req.MinSeverity = "warning"
}
if !validSeverities[req.MinSeverity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
id, err := s.store.CreateChannel(r.Context(), p.TenantID, store.ChannelInput{
Kind: req.Kind,
Name: req.Name,
Config: jsonOr(req.Config, "{}"),
Template: req.Template,
MinSeverity: req.MinSeverity,
Secret: req.Secret,
Enabled: req.Enabled == nil || *req.Enabled,
}, s.keyring)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
return
}
s.log.Error("створення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTestChannel надсилає пробне повідомлення.
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.notifier == nil || s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_notifier",
"доставку сповіщень не налаштовано в цьому процесі")
return
}
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, s.keyring)
if err != nil {
s.log.Error("читання каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
id := r.PathValue("id")
for _, c := range channels {
if c.ID != id {
continue
}
if err := s.notifier.TestChannel(r.Context(), p.TenantID, c); err != nil {
// Помилка каналу — це відповідь на питання «чи працює», а не
// збій сервера: 200 з описом кращий за 500 без нього.
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
}
// ---------------------------------------------------------------------
func jsonOr(raw json.RawMessage, def string) string {
if len(raw) == 0 {
return def
}
return string(raw)
}
func isBadUUID(err error) bool {
return err != nil && strings.Contains(err.Error(), "invalid input syntax for type uuid")
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505")
}