Логін: - 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>
461 lines
15 KiB
Go
461 lines
15 KiB
Go
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")
|
||
}
|