Netpulse_SasS/server/internal/httpapi/checks.go
byrsapty cd8d4c62ed Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.

Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.

Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.

Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.

Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.

Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.

Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.

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

561 lines
19 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"
"strings"
"github.com/netpulse/netpulse/server/internal/cronx"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListCheckTypes(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
types, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "типи перевірок", err)
return
}
if types == nil {
types = []store.CheckType{}
}
writeJSON(w, http.StatusOK, map[string]any{"check_types": types})
}
func (s *Server) handleListDeviceChecks(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
checks, err := s.store.ListDeviceChecks(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "перевірки хоста", err)
return
}
if checks == nil {
checks = []store.Check{}
}
writeJSON(w, http.StatusOK, map[string]any{"checks": checks})
}
// handleSetDeviceChecks приймає повний набір перевірок хоста.
func (s *Server) handleSetDeviceChecks(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
var in struct {
Checks []struct {
ID string `json:"id"`
CheckType string `json:"check_type"`
Params json.RawMessage `json:"params"`
IntervalSec int `json:"interval_sec"`
TimeoutMs int `json:"timeout_ms"`
Retries int `json:"retries"`
Enabled *bool `json:"enabled"`
} `json:"checks"`
}
if !decodeBody(w, r, &in) {
return
}
// Типи звіряємо з довідником: невідомий ключ інакше дійшов би до
// зонда й помер там як задача неіснуючому модулю — за кілометри від
// того місця, де його ввели.
known, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "типи перевірок", err)
return
}
ok := map[string]bool{}
for _, t := range known {
ok[t.Key] = true
}
out := make([]store.CheckInput, 0, len(in.Checks))
for _, c := range in.Checks {
if !ok[c.CheckType] {
writeError(w, http.StatusBadRequest, "bad_check_type",
"невідомий тип перевірки: "+c.CheckType)
return
}
out = append(out, store.CheckInput{
ID: c.ID,
CheckType: c.CheckType,
Params: jsonOr(c.Params, "{}"),
IntervalSec: c.IntervalSec,
TimeoutMs: c.TimeoutMs,
Retries: c.Retries,
Enabled: c.Enabled == nil || *c.Enabled,
})
}
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, out); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "збереження перевірок", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Доступи до обладнання
// ---------------------------------------------------------------------
func (s *Server) handleListCredentials(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
creds, err := s.store.ListCredentials(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік доступів", err)
return
}
if creds == nil {
creds = []store.Credential{}
}
// Скільки хостів користується кожним доступом — щоб попередження
// перед видаленням казало щось конкретне, а не «Ви впевнені?».
usage, err := s.store.CredentialUsage(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік доступів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds, "usage": usage})
}
var validCredProtos = map[string]bool{
"ssh": true, "telnet": true, "snmp_v2c": true, "snmp_v3": true,
"http": true, "https": true, "api": true, "modbus": true,
}
func (s *Server) handleCreateCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — зберегти доступ ніяк")
return
}
var in struct {
Name string `json:"name"`
Proto string `json:"proto"`
Username string `json:"username"`
Port int `json:"port"`
Secret string `json:"secret"`
Options json.RawMessage `json:"options"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва доступу")
return
}
if !validCredProtos[in.Proto] {
writeError(w, http.StatusBadRequest, "bad_proto", "невідомий протокол доступу")
return
}
id, err := s.store.CreateCredential(r.Context(), p.TenantID, strings.TrimSpace(in.Name),
in.Proto, in.Username, in.Port, in.Secret, jsonOr(in.Options, "{}"), s.keyring)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "доступ із такою назвою вже є")
return
}
s.writeStoreError(w, "створення доступу", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
// ---------------------------------------------------------------------
// Збір конфігу
// ---------------------------------------------------------------------
// handleCollectConfig ставить збір у чергу.
//
// Саме в чергу, а не одразу зонду: живу сесію тримає інший процес
// (AgentService), і черга в БД — єдиний чесний спосіб передати між ними
// завдання так, щоб воно пережило перезапуск будь-якого з них.
func (s *Server) handleCollectConfig(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
jobID, err := s.store.EnqueueConfigJob(r.Context(), p.TenantID, deviceID, "manual", p.UserID)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "постановка збору конфігу", err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}
// handleListConfigJobs — історія збору для хоста.
func (s *Server) handleListConfigJobs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
jobs, err := s.store.ListConfigJobs(r.Context(), p.TenantID, deviceID, 20)
if err != nil {
s.writeStoreError(w, "історія збору", err)
return
}
if jobs == nil {
jobs = []store.ConfigJobRow{}
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
}
// ---------------------------------------------------------------------
// Версії конфігу
// ---------------------------------------------------------------------
func (s *Server) handleListConfigs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
list, err := s.store.ListDeviceConfigs(r.Context(), p.TenantID, deviceID, 50)
if err != nil {
s.writeStoreError(w, "історія конфігів", err)
return
}
if list == nil {
list = []store.ConfigVersion{}
}
writeJSON(w, http.StatusOK, map[string]any{"configs": list})
}
// handleGetConfig віддає текст версії.
//
// Конфіг у БД зашифрований, тому без ключа процес чесно відмовляє, а не
// повертає порожній рядок: мовчазна порожнеча виглядала б як «пристрій
// віддав порожній конфіг».
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
body, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, r.PathValue("id"), s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "читання конфігу", err)
return
}
if !p.Scope().CanRead(meta.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
writeJSON(w, http.StatusOK, map[string]any{"config": meta, "body": body})
}
// handleDiffConfigs порівнює дві версії.
//
// Без параметра from береться попередня версія: саме це питання ставлять
// у 90% випадків — «що змінилось цього разу».
func (s *Server) handleDiffConfigs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
toID := r.PathValue("id")
_, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, toID, s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "читання конфігу", err)
return
}
if !p.Scope().CanRead(meta.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
fromID := r.URL.Query().Get("from")
if fromID == "" {
fromID = meta.PrevConfigID
}
if fromID == "" {
// Перша версія: порівнювати нема з чим, і це не помилка.
writeJSON(w, http.StatusOK, map[string]any{
"first": true, "hunks": []any{}, "lines_added": 0, "lines_removed": 0,
})
return
}
res, err := s.store.DiffConfigs(r.Context(), p.TenantID, fromID, toID, s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію для порівняння не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "порівняння конфігів", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// Розклад бекапу
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
pol, err := s.store.GetBackupPolicy(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "розклад бекапу", err)
return
}
// Політики немає — це не помилка, а «ще не налаштовано».
writeJSON(w, http.StatusOK, map[string]any{"policy": pol})
}
func (s *Server) handleSetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
var in struct {
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog *bool `json:"on_syslog"`
// true — повернути хост під глобальний розклад.
FollowsDefault *bool `json:"follows_default"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
// Розклад перевіряємо тут, а не в планувальнику: інакше людина
// дізнається про друкарську помилку через добу мовчання, а не
// одразу при збереженні.
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
err := s.store.SetBackupPolicy(r.Context(), p.TenantID, store.BackupPolicy{
DeviceID: deviceID,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
Enabled: in.Enabled == nil || *in.Enabled,
Cron: in.Cron,
OnSyslog: in.OnSyslog != nil && *in.OnSyslog,
// Збереження форми хоста типово означає «в мене свій розклад».
// Інакше найближча зміна глобального мовчки затерла б те, що
// людина щойно налаштувала руками.
FollowsDefault: in.FollowsDefault != nil && *in.FollowsDefault,
})
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "збереження розкладу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListProfiles — довідник профілів для форми розкладу.
func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
list, err := s.store.ListProfiles(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік профілів", err)
return
}
if list == nil {
list = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": list})
}
// ---------------------------------------------------------------------
// Глобальний розклад бекапів
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleSetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
var in struct {
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
// Підпорядкувати глобальному розкладу навіть ті хости, які
// налаштували окремо. Руйнівно, тому окремим прапорцем.
ApplyToAll bool `json:"apply_to_all"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
if err := s.store.SetBackupDefaults(r.Context(), p.TenantID, store.BackupDefaults{
Enabled: in.Enabled,
Cron: in.Cron,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
}, in.ApplyToAll); err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleUpdateCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Name string `json:"name"`
Username string `json:"username"`
Port int `json:"port"`
Secret string `json:"secret"`
Options json.RawMessage `json:"options"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва доступу")
return
}
if in.Secret != "" && s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — зберегти пароль ніяк")
return
}
err := s.store.UpdateCredential(r.Context(), p.TenantID, r.PathValue("id"),
strings.TrimSpace(in.Name), in.Username, in.Port, in.Secret,
jsonOr(in.Options, "{}"), s.keyring)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "доступ із такою назвою вже є")
return
}
s.writeStoreError(w, "оновлення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteCredential(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}