Netpulse_SasS/server/internal/httpapi/maps_write.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

248 lines
9.3 KiB
Go
Raw Permalink 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"
"errors"
"net/http"
"github.com/netpulse/netpulse/server/internal/store"
)
// Обмеження тіла запиту. Полотно на 1000 вузлів у патчі — це ~1 МБ;
// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять.
const maxPatchBody = 8 << 20
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var in struct {
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
}
if !decodeBody(w, r, &in) {
return
}
m, err := s.store.CreateMap(r.Context(), p.TenantID, in.Name, in.Slug, in.Kind)
if err != nil {
s.writeStoreError(w, "створення мапи", err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
if err := s.store.DeleteMap(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення мапи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handlePatchMap — редактор полотна.
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var patch store.MapPatch
if !decodeBody(w, r, &patch) {
return
}
mapID := r.PathValue("id")
// Право «редагувати мапи» і право «редагувати ЦЮ мапу» — різні речі:
// друге дають групи доступу.
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
level != "write" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цієї мапи")
return
}
res, err := s.store.ApplyMapPatch(r.Context(), p.TenantID, mapID, "", &patch)
if err != nil {
s.writeStoreError(w, "збереження мапи", err)
return
}
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
// мапою часто працюють кілька людей, і мовчазне розходження станів
// закінчується взаємним затиранням.
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// handleBuildMap добудовує мапу з виявленої топології.
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
st, err := s.store.BuildFromTopology(r.Context(), p.TenantID, mapID, "")
if err != nil {
s.writeStoreError(w, "автопобудова мапи", err)
return
}
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
s.hub.BroadcastMapUpdate(p.TenantID, mapID, st.Revision)
}
writeJSON(w, http.StatusOK, st)
}
// handleMapInterfaces — порти хостів мапи для ручного зіставлення.
//
// Право читання мапи, а не хостів: перелік імен портів пристроїв, які
// вже намальовані на цій мапі, нічого не додає до того, що людина
// й так бачить.
func (s *Server) handleMapInterfaces(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
mapID := r.PathValue("id")
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
level == "deny" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
return
}
ifs, err := s.store.MapInterfaces(r.Context(), p.TenantID, mapID)
if err != nil {
s.writeStoreError(w, "порти мапи", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"interfaces": ifs})
}
// handleMapDiscover просить зібрати топологію з хостів цієї мапи.
//
// devices:write, а не maps:write: дія заводить хостам перевірку й
// змушує зонд їх опитати. Це зміна опитування, і той, кому дозволили
// лише малювати схеми, робити її не має.
func (s *Server) handleMapDiscover(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("devices:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права devices:write")
return
}
mapID := r.PathValue("id")
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
level == "deny" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
return
}
res, err := s.store.RequestMapDiscovery(r.Context(), p.TenantID, mapID, p.Scope())
if err != nil {
s.writeStoreError(w, "збір топології", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// handleUndoMap відкочує полотно до попереднього знімка.
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
res, err := s.store.UndoMap(r.Context(), p.TenantID, mapID, "")
if errors.Is(err, store.ErrNothingToUndo) {
writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає")
return
}
if err != nil {
s.writeStoreError(w, "відкат мапи", err)
return
}
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPatchBody))
// Невідоме поле — майже завжди друкарська помилка в клієнті.
// Мовчки її проковтнути означає, що правка «збереглась», але
// не застосувалась.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "bad_body", "не вдалося розібрати тіло: "+err.Error())
return false
}
return true
}
// isPlanLimitPg — коротка форма для switch вище.
func isPlanLimitPg(err error) bool {
_, ok := store.AsPlanLimit(err)
return ok
}
func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "не знайдено")
case errors.Is(err, store.ErrConflict):
// 409 із поточною ревізією дозволяє клієнту перечитати мапу
// й повторити правку, а не втратити її.
writeError(w, http.StatusConflict, "revision_conflict", err.Error())
case errors.Is(err, store.ErrPlanLimit):
// 402 — саме те, що сталося: потрібен інший тариф.
writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error())
case isPlanLimitPg(err):
// Та сама відмова, але сирою помилкою бази — так вона приходить
// з усіх шляхів, крім правки мапи (єдиного, який її перекладав).
//
// Рядок стоїть саме тут, у СПІЛЬНОМУ обробнику помилок, а не в
// кожному місці окремо, і це головне про нього. Стеля тепер
// стоїть не лише на створенні хоста: вона спрацьовує на
// поверненні з архіву, на масовому ввімкненні, на реєстрації
// зонда, на додаванні людини в кабінет. Кожен із цих шляхів
// віддав би 500 «внутрішня помилка» — і кожен довелося б
// згадати окремо. Забути один із них нічого не коштує й нічим
// не проявляється, доки хтось не впреться саме в нього.
lim, _ := store.AsPlanLimit(err)
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,
},
})
case errors.Is(err, store.ErrInvalid):
writeError(w, http.StatusBadRequest, "invalid", err.Error())
default:
s.log.Error(what, "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
}
}