П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
243 lines
11 KiB
Go
243 lines
11 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Тариф, стелі й ліцензія.
|
||
//
|
||
// ПРО ПРАВА
|
||
//
|
||
// Дивитись — billing:read. Це право інженера, а не бухгалтера, і межа
|
||
// проведена не там, де здається природним. Питання «чому не заводиться
|
||
// шістнадцятий хост» ставить той, хто заводить хости, і відповідь на
|
||
// нього мусить бути в нього перед очима ДО того, як він упреться. Тому
|
||
// сторінка показує стелю разом із використаним, а не рахунок.
|
||
//
|
||
// Міняти — billing:manage. Воно є лише у власника: 0010 навмисно не
|
||
// дала його навіть адміну. Це стосується й ліцензійного ключа —
|
||
// застосування ключа міняє стелі всієї інсталяції, тобто це та сама дія
|
||
// за наслідками, що й зміна тарифу.
|
||
//
|
||
// Обидва права були заведені 0010 і обидва досі значились у
|
||
// store/roles.go серед dormantPerms: ключ у базі є, коду, який його
|
||
// питає, немає. Цей файл — той код.
|
||
//
|
||
// ЧОГО ТУТ НЕМАЄ
|
||
//
|
||
// Вебхуків платіжки, інвойсів і черги usage-звітів. Не «поки що»: між
|
||
// ними й цим файлом навмисно нема зв'язку. Усе, що читає перевірка
|
||
// лімітів, — bill.entitlements; хто її заповнив, видно в колонці source
|
||
// й нікого більше не обходить. Тому платіжку можна буде дописати
|
||
// окремим файлом, не торкаючись ані цих обробників, ані тригерів у БД.
|
||
|
||
// handleBillingOverview — стан тарифу цілком.
|
||
//
|
||
// Одна відповідь на всю сторінку, а не чотири ручки, з тієї ж причини,
|
||
// що й у черг: стелі, використане й стан ліцензії — це одне питання
|
||
// («на чому я зараз і скільки лишилось»), і зібрана з трьох запитів у
|
||
// різні секунди відповідь суперечила б сама собі — «14 з 15» поруч із
|
||
// «15 з 15» на сусідній картці.
|
||
func (s *Server) handleBillingOverview(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "billing:read") {
|
||
return
|
||
}
|
||
|
||
ent, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "стелі тарифу", err)
|
||
return
|
||
}
|
||
usage, err := s.store.TenantUsage(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "використання тарифу", err)
|
||
return
|
||
}
|
||
plans, err := s.store.ListPlans(r.Context())
|
||
if err != nil {
|
||
s.writeStoreError(w, "перелік тарифів", err)
|
||
return
|
||
}
|
||
features, err := s.store.ListFeatures(r.Context())
|
||
if err != nil {
|
||
s.writeStoreError(w, "каталог можливостей", err)
|
||
return
|
||
}
|
||
|
||
// Стан ліцензії їде разом зі стелями, хоч він і рівня інсталяції.
|
||
// Розділити їх означало б показати «дозволено 15 хостів» без
|
||
// причини, чому саме 15, — а причина тут майже завжди в ліцензії.
|
||
lic, err := s.store.LicenseStatus(r.Context(), s.license)
|
||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||
s.writeStoreError(w, "стан ліцензії", err)
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"entitlement": ent,
|
||
"usage": usage,
|
||
"plans": plans,
|
||
"features": features,
|
||
"license": lic,
|
||
// Чи вільно цій людині міняти тариф. Клієнт вивів би це й сам зі
|
||
// свого переліку прав, але тоді правило жило б у двох місцях і
|
||
// розійшлося б на першій же зміні. Те саме рішення, що на
|
||
// сторінці сховища.
|
||
"can_manage": p.Can("billing:manage"),
|
||
})
|
||
}
|
||
|
||
// handleSetBillingPlan переводить кабінет на тариф.
|
||
func (s *Server) handleSetBillingPlan(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "billing:manage") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Plan string `json:"plan"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
// Знімок ДО зміни: у журнал має поїхати «з free на pro», а не
|
||
// «змінили тариф». Читаємо до, бо після цієї відповіді вже немає.
|
||
before, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "стелі тарифу", err)
|
||
return
|
||
}
|
||
|
||
if err := s.store.SetTenantPlan(r.Context(), p.TenantID, in.Plan, p.UserID); err != nil {
|
||
s.writeStoreError(w, "зміна тарифу", err)
|
||
return
|
||
}
|
||
|
||
s.auditBilling(r, p, store.AuditActionBillingPlan, store.AuditObjectBillingPlan,
|
||
map[string]any{"from": before.PlanKey, "to": in.Plan})
|
||
|
||
s.handleBillingOverview(w, r, p)
|
||
}
|
||
|
||
// handleGetLicense — стан ліцензії й ідентифікатор інсталяції.
|
||
//
|
||
// Окрема ручка попри те, що те саме їде в загальній відповіді:
|
||
// install_id потрібен у мить замовлення ключа, а туди приходять із
|
||
// порожньої інсталяції, де перелік тарифів ще нічого не означає.
|
||
func (s *Server) handleGetLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "billing:read") {
|
||
return
|
||
}
|
||
st, err := s.store.LicenseStatus(r.Context(), s.license)
|
||
if err != nil {
|
||
s.writeStoreError(w, "стан ліцензії", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, st)
|
||
}
|
||
|
||
// handleApplyLicense застосовує введений ключ.
|
||
//
|
||
// Відмова тут — 400 із ПРИЧИНОЮ, а не загальне «ключ недійсний».
|
||
// Причин рівно три, вони не взаємозамінні, і кожна веде до різних дій
|
||
// людини: формат (переплутали поле, обрізали при копіюванні), підпис
|
||
// (ключ не наш або збірка не має відкритого ключа), інсталяція (ключ
|
||
// правильний, але від сусіднього стенду). Звести їх до одного рядка
|
||
// означало б перетворити кожне звернення в підтримку на вгадування.
|
||
func (s *Server) handleApplyLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "billing:manage") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Key string `json:"key"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
st, err := s.store.ApplyLicense(r.Context(), s.license, in.Key, p.UserID)
|
||
switch {
|
||
case errors.Is(err, store.ErrNoVerifier):
|
||
// 501, а не 400: справа не в тому, що прислали, а в тому, що ця
|
||
// збірка ліцензії перевіряти не вміє. Просити людину виправити
|
||
// ключ у такій ситуації означало б послати її по колу.
|
||
writeError(w, http.StatusNotImplemented, "no_verifier",
|
||
"ця збірка не має відкритого ключа для перевірки ліцензій — "+
|
||
"задайте NETPULSE_LICENSE_PUBKEY")
|
||
return
|
||
case errors.Is(err, store.ErrLicenseFormat):
|
||
writeError(w, http.StatusBadRequest, "license_format",
|
||
"ключ не розібрався: "+err.Error())
|
||
return
|
||
case errors.Is(err, store.ErrLicenseSignature):
|
||
writeError(w, http.StatusBadRequest, "license_signature",
|
||
"підпис ключа не сходиться: "+err.Error())
|
||
return
|
||
case errors.Is(err, store.ErrLicenseInstall):
|
||
writeError(w, http.StatusBadRequest, "license_install", err.Error())
|
||
return
|
||
case err != nil:
|
||
s.writeStoreError(w, "застосування ліцензії", err)
|
||
return
|
||
}
|
||
|
||
// У журнал іде ідентифікатор ліцензії й кому вона видана, але НЕ сам
|
||
// ключ. Ключ — це секрет: той, хто його бачить, може підняти з ним
|
||
// іншу інсталяцію. Журнал аудиту читає ширше коло людей, ніж те, що
|
||
// має право керувати тарифом, і секретам там не місце — те саме
|
||
// правило, що для паролів дзеркала Git.
|
||
s.auditBilling(r, p, store.AuditActionLicenseApply, store.AuditObjectLicense,
|
||
map[string]any{
|
||
"license_id": st.LicenseID,
|
||
"issued_to": st.IssuedTo,
|
||
"plan": st.Plan,
|
||
"state": st.State,
|
||
"expires_at": st.ExpiresAt,
|
||
})
|
||
|
||
writeJSON(w, http.StatusOK, st)
|
||
}
|
||
|
||
// handleClearLicense знімає ключ із інсталяції.
|
||
func (s *Server) handleClearLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "billing:manage") {
|
||
return
|
||
}
|
||
before, _ := s.store.LicenseStatus(r.Context(), s.license)
|
||
|
||
if err := s.store.ClearLicense(r.Context(), p.UserID); err != nil {
|
||
s.writeStoreError(w, "зняття ліцензії", err)
|
||
return
|
||
}
|
||
s.auditBilling(r, p, store.AuditActionLicenseClear, store.AuditObjectLicense,
|
||
map[string]any{"license_id": before.LicenseID, "issued_to": before.IssuedTo})
|
||
|
||
st, err := s.store.LicenseStatus(r.Context(), s.license)
|
||
if err != nil {
|
||
s.writeStoreError(w, "стан ліцензії", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, st)
|
||
}
|
||
|
||
// auditBilling — спільний хвіст трьох обробників.
|
||
//
|
||
// Аудит пишеться ПІСЛЯ успішної дії й не скасовує її при збої: тариф
|
||
// уже змінено, і мовчазне «не вдалось» тут гірше за рядок, якого немає
|
||
// в журналі. Той самий порядок, що й у строків зберігання.
|
||
func (s *Server) auditBilling(r *http.Request, p *Principal,
|
||
action, object string, meta map[string]any) {
|
||
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: action,
|
||
ObjectType: object,
|
||
Meta: meta,
|
||
}); err != nil {
|
||
s.log.Error("аудит білінгу", "дія", action, "err", err)
|
||
}
|
||
}
|