П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
165 lines
6.4 KiB
Go
165 lines
6.4 KiB
Go
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)
|
||
}
|