Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД → диспетчер у AgentService → зонд по SSH/Telnet → вивантаження стрімом → звірка sha256 і дедуплікація. Агент (internal/ncmx): - усе побудовано навколо пошуку промпту: у консолі немає ані коду завершення, ані довжини відповіді - промпт шукається лише в хвості 512 байтів, інакше "banner motd #" обривав би збір на середині конфігу - дедлайн на паузу між байтами, а не на всю операцію: збір із шасі триває хвилини, а тиша означає завислий пристрій або хибний regex - ключі SSH обладнання не звіряються свідомо: залізо міняє їх з кожною прошивкою, і known_hosts на сотні пристроїв означав би не збирати конфіги зовсім Сервер: черга ncm.jobs із FOR UPDATE SKIP LOCKED (два екземпляри не надішлють одне завдання двічі), диспетчер, прибирання завислих. Знайдено живим прогоном: - сервер ігнорував поле encoding: агент стискав gzip і рахував sha256 від оригіналу, сервер рахував від стиснених байтів і відхиляв кожен бекап. Поле в контракті з Етапу 2, реалізації не було — інтеграційний тест користувався "none" і повз дірку проходив - промпт обрізався не по рядку: шаблон [>#]\s*$ ловить лише символ, і в конфізі лишалось ім'я пристрою окремим рядком - у конфіг потрапляли escape-послідовності bash і подвоєні \r\r\n від PTY: 295 байтів / 12 рядків замість 286 / 10. Після виправлення — 285 / 10, різниця лише у фінальному переводі рядка Живий прогін проти стенду: success, конфіг 285 байтів, повторний збір — unchanged без другої версії. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
7.5 KiB
Go
233 lines
7.5 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"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})
|
||
}
|