Збір конфігів перестав залежати від того, чи згадає людина натиснути кнопку. Свій парсер cron (internal/cronx) замість залежності: бітові маски uint64 на поле, пошук наступного запуску покроково по хвилинах із запобіжником у чотири роки. Правило dom/dow — об'єднання, як у справжнього cron. Неможливий розклад (30 лютого) чесно відмовляє замість зациклення. Планувальник тікає раз на хвилину під advisory-блокуванням, тож у кластері розклад розкручує рівно один екземпляр. Спершу переноситься next_backup_at, потім ставиться завдання: падіння між кроками коштує одного пропущеного бекапу, зворотний порядок дав би нескінченну чергу. Форма розкладу: чотири пресети плюс довільний cron. Виправлено помилку, через яку пункт «свій розклад…» нічого не робив — обробник select відсікав порожнє значення, тобто саме той випадок, заради якого пункт існує. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
432 lines
14 KiB
Go
432 lines
14 KiB
Go
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{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds})
|
||
}
|
||
|
||
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"`
|
||
}
|
||
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,
|
||
})
|
||
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})
|
||
}
|