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