П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
398 lines
14 KiB
Go
398 lines
14 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи пристроїв
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListDeviceGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
groups, err := s.store.ListDeviceGroups(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "перелік груп", err)
|
||
return
|
||
}
|
||
if groups == nil {
|
||
groups = []store.DeviceGroup{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||
}
|
||
|
||
func (s *Server) handleCreateDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Color string `json:"color"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||
return
|
||
}
|
||
id, err := s.store.CreateDeviceGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Color)
|
||
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})
|
||
}
|
||
|
||
func (s *Server) handleDeleteDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteDeviceGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.writeStoreError(w, "видалення групи", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Хости
|
||
// ---------------------------------------------------------------------
|
||
|
||
var validDeviceKinds = map[string]bool{
|
||
"router": true, "switch": true, "firewall": true, "server": true,
|
||
"ap": true, "olt": true, "onu": true, "ups": true, "printer": true,
|
||
"camera": true, "sensor": true, "vm": true, "container": true, "other": true,
|
||
}
|
||
|
||
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
in, body, ok := decodeDevice(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
|
||
if err != nil {
|
||
if lim, ok := store.AsPlanLimit(err); ok {
|
||
// Текст складає БД — там, де перевірка й спрацювала, і з
|
||
// тими самими числами: «у тарифі Free дозволено 15 хостів,
|
||
// зараз 15». Числа їдуть іще й окремими полями, щоб форма
|
||
// могла показати їх поруч із кнопкою, а не лише в смужці
|
||
// помилки.
|
||
writeJSON(w, http.StatusPaymentRequired, map[string]any{
|
||
"error": map[string]any{
|
||
"code": "plan_limit",
|
||
"message": lim.Error(),
|
||
"limit": lim.Kind,
|
||
"plan": lim.Plan,
|
||
"allowed": lim.Allowed,
|
||
"used": lim.Used,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
s.writeStoreError(w, "створення хоста", err)
|
||
return
|
||
}
|
||
|
||
// Перевірки й доступи — частина створення, а не окремий крок.
|
||
// Хост без жодної перевірки просто лежить у списку й ніколи не
|
||
// опитується; вимагати від людини другого запиту означало б робити
|
||
// такий стан типовим.
|
||
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, id, body.checkInputs()); err != nil {
|
||
s.writeStoreError(w, "перевірки хоста", err)
|
||
return
|
||
}
|
||
if body.CredentialIDs != nil {
|
||
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, id, body.CredentialIDs); err != nil {
|
||
s.writeStoreError(w, "доступи хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.TemplateIDs != nil {
|
||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, id, body.TemplateIDs); err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleUpdateDevice(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
|
||
}
|
||
|
||
in, body, ok := decodeDevice(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.store.UpdateDevice(r.Context(), p.TenantID, deviceID, in); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "оновлення хоста", err)
|
||
return
|
||
}
|
||
if err := s.store.SetDeviceGroups(r.Context(), p.TenantID, deviceID, in.GroupIDs); err != nil {
|
||
s.writeStoreError(w, "групи хоста", err)
|
||
return
|
||
}
|
||
// nil означає «форма про це не говорила» — тоді не чіпаємо. Порожній
|
||
// масив означає «прибрати все», і це різні наміри.
|
||
if body.Checks != nil {
|
||
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, body.checkInputs()); err != nil {
|
||
s.writeStoreError(w, "перевірки хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.CredentialIDs != nil {
|
||
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, deviceID, body.CredentialIDs); err != nil {
|
||
s.writeStoreError(w, "доступи хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.TemplateIDs != nil {
|
||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, body.TemplateIDs); err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleDeleteDevice(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
|
||
}
|
||
if err := s.store.DeleteDevice(r.Context(), p.TenantID, deviceID); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "видалення хоста", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// deviceBody — усе, що приходить із форми хоста, разом із тим, ЯК його
|
||
// опитувати. Хост без перевірок не опитується взагалі й лише займає
|
||
// місце в списку, тому форма має задавати їх одразу.
|
||
type deviceBody struct {
|
||
Name string `json:"name"`
|
||
Address string `json:"address"`
|
||
Kind string `json:"kind"`
|
||
Vendor string `json:"vendor"`
|
||
AgentID string `json:"agent_id"`
|
||
Enabled *bool `json:"enabled"`
|
||
GroupIDs []string `json:"group_ids"`
|
||
Notes string `json:"notes"`
|
||
|
||
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"`
|
||
CredentialIDs []string `json:"credential_ids"`
|
||
TemplateIDs []string `json:"template_ids"`
|
||
}
|
||
|
||
func (b deviceBody) checkInputs() []store.CheckInput {
|
||
out := make([]store.CheckInput, 0, len(b.Checks))
|
||
for _, c := range b.Checks {
|
||
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,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, deviceBody, bool) {
|
||
var in deviceBody
|
||
if !decodeBody(w, r, &in) {
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
if in.Kind == "" {
|
||
in.Kind = "other"
|
||
}
|
||
if !validDeviceKinds[in.Kind] {
|
||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
return store.DeviceInput{
|
||
Name: strings.TrimSpace(in.Name),
|
||
Address: strings.TrimSpace(in.Address),
|
||
Kind: in.Kind,
|
||
Vendor: in.Vendor,
|
||
AgentID: in.AgentID,
|
||
Enabled: in.Enabled == nil || *in.Enabled,
|
||
GroupIDs: in.GroupIDs,
|
||
Notes: in.Notes,
|
||
}, in, true
|
||
}
|
||
|
||
// isPlanLimit прибрано навмисно, і це варто пояснити, бо функція
|
||
// виглядала робочою півроку.
|
||
//
|
||
// Вона перевіряла strings.Contains(err.Error(), "ліміт") — а тригер
|
||
// 0009 підіймав 'device limit reached for tenant % (limit %)', тобто
|
||
// англійською. Збігу не було ніколи. Тобто в мить, коли стеля вперше
|
||
// спрацювала б, людина отримала б 500 «внутрішня помилка» замість 402 з
|
||
// поясненням — рівно те, чого перевірка в БД мала не допустити.
|
||
//
|
||
// Помітити це неможливо було ніяк: bill.entitlements порожня на всіх
|
||
// інсталяціях, стеля не спрацьовувала жодного разу, і зелений код нічого
|
||
// про це не казав.
|
||
//
|
||
// Заміна (store.AsPlanLimit) впізнає відмову за HINT = 'upgrade_plan' —
|
||
// за полем, яке ставить сама перевірка, а не за текстом, який
|
||
// переписують, коли він погано читається.
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи користувачів
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListUserGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:read") {
|
||
return
|
||
}
|
||
groups, err := s.store.ListUserGroups(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "перелік груп користувачів", err)
|
||
return
|
||
}
|
||
if groups == nil {
|
||
groups = []store.UserGroup{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||
}
|
||
|
||
func (s *Server) handleCreateUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||
return
|
||
}
|
||
id, err := s.store.CreateUserGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Description)
|
||
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})
|
||
}
|
||
|
||
// handlePatchUserGroup міняє склад і права одним запитом.
|
||
//
|
||
// Разом, а не окремими ендпоїнтами: у формі це один екран і одна
|
||
// кнопка «Зберегти», і розбивати її на два запити означало б лишати
|
||
// групу в напівзбереженому стані, якщо другий не пройде.
|
||
func (s *Server) handlePatchUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
MemberIDs *[]string `json:"member_ids"`
|
||
Permissions *map[string]string `json:"permissions"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
groupID := r.PathValue("id")
|
||
if in.MemberIDs != nil {
|
||
if err := s.store.SetUserGroupMembers(r.Context(), p.TenantID, groupID, *in.MemberIDs); err != nil {
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "склад групи", err)
|
||
return
|
||
}
|
||
}
|
||
if in.Permissions != nil {
|
||
if err := s.store.SetUserGroupPermissions(r.Context(), p.TenantID, groupID, *in.Permissions); err != nil {
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "права групи", err)
|
||
return
|
||
}
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteUserGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.writeStoreError(w, "видалення групи", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|