Netpulse_SasS/server/internal/httpapi/agents_manage.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

165 lines
6.4 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"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// Модулі, які вміє зонд. Дублює перелік у бінарнику навмисно: увімкнути
// в UI те, чого агент не вміє, означає мовчки не отримати даних.
// validAgentModules — те, що зонд справді вміє.
//
// Перелік дублює реальність зонда, і тому легко відстає від неї. Саме
// це й сталося з `traps`: модуль написали, у compose увімкнули, на
// живому залізі перевірили — а сюди не дописали. Наслідок був не
// «трапи не працюють», а «зонд неможливо зареєструвати взагалі»:
// установник просив traps і отримував 400 на останньому кроці.
// Знайшла пісочниця установника з другої спроби.
//
// Джерело правди — Key() модулів у agent/internal/modules/* плюс двоє,
// які не є модулями реєстру (syslog і traps — вони лише слухають порт,
// не мають ані задач, ані розкладу) і `ncm`, який живе в сесії зонда.
// Дописуючи модуль на зонді, допишіть його тут.
var validAgentModules = map[string]bool{
"icmp": true, "snmp": true, "topology": true,
"ncm": true, "syslog": true, "http": true,
"traps": true, "filecfg": true,
}
func (s *Server) handleListEnrollments(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:read") {
return
}
list, err := s.store.ListEnrollments(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "запрошення зондів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"enrollments": list})
}
// handleCreateEnrollment видає одноразовий токен.
//
// Токен повертається рівно тут і більше ніде: у базі лежить лише його
// sha256. Якщо людина закрила вікно, не скопіювавши команду — простіше
// створити нове запрошення, ніж тримати чинний доступ у базі заради
// такого випадку.
func (s *Server) handleCreateEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
var in struct {
NameHint string `json:"name_hint"`
Modules []string `json:"modules"`
TTLHours int `json:"ttl_hours"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for _, m := range in.Modules {
if !validAgentModules[m] {
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
return
}
}
ttl := time.Duration(in.TTLHours) * time.Hour
if in.TTLHours <= 0 {
ttl = store.EnrollmentTTL
}
// Тиждень — стеля. Запрошення, що живе місяць, перестає бути
// запрошенням і стає постійним ключем у чиємусь чаті.
if ttl > 7*24*time.Hour {
ttl = 7 * 24 * time.Hour
}
e, err := s.store.CreateEnrollment(r.Context(), p.TenantID, p.UserID,
strings.TrimSpace(in.NameHint), in.Modules, ttl)
if err != nil {
s.writeStoreError(w, "створення запрошення", err)
return
}
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleDeleteEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
if err := s.store.DeleteEnrollment(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення запрошення", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Керування зондом
// ---------------------------------------------------------------------
func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
var in struct {
Name string `json:"name"`
Modules []string `json:"modules"`
Limits *struct {
MaxConcurrency int `json:"max_concurrency"`
IcmpRatePPS int `json:"icmp_rate_pps"`
BatchSize int `json:"batch_size"`
MaxInFlight int `json:"max_in_flight"`
} `json:"limits"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for _, m := range in.Modules {
if !validAgentModules[m] {
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
return
}
}
upd := store.AgentUpdate{Name: strings.TrimSpace(in.Name), Modules: in.Modules}
if in.Limits != nil {
upd.Limits = &store.Limits{
MaxConcurrency: in.Limits.MaxConcurrency,
IcmpRatePPS: in.Limits.IcmpRatePPS,
BatchSize: in.Limits.BatchSize,
MaxInFlight: in.Limits.MaxInFlight,
}
}
if err := s.store.UpdateAgent(r.Context(), p.TenantID, r.PathValue("id"), upd); err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "зонд із такою назвою вже є")
return
}
s.writeStoreError(w, "оновлення зонда", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteAgent прибирає зонд.
//
// Хости, які він опитував, лишаються — просто без зонда. Видаляти їх
// разом означало б втратити історію через заміну заліза, на якому
// стояв агент.
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
if err := s.store.DeleteAgent(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення зонда", err)
return
}
w.WriteHeader(http.StatusNoContent)
}