Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
368 lines
16 KiB
Go
368 lines
16 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Відкат конфігурації: намір, погодження, перегляд результату.
|
||
//
|
||
// ПРАВА, і чому саме такі.
|
||
//
|
||
// Усе, крім політики, — під ncm:rollback. Це право заведене ще в 0010 і
|
||
// свідомо не входить у роль «Інженер»: інженер має ncm:write, тобто
|
||
// може налаштувати збір, і давати йому разом із цим можливість писати
|
||
// конфіг на живе залізо означало б роздати найнебезпечнішу дію системи
|
||
// всім, хто вже працює. Той самий висновок, що й для ncm:exec у 0036.
|
||
//
|
||
// Читання теж під ncm:rollback, а не під ncm:read. Причина та, що й у
|
||
// масовому виконанні команд: перелік команд і стенограма сесії
|
||
// показують стан заліза детальніше за будь-яку іншу сторінку, і
|
||
// віддавати їх усім, хто має право дивитись конфіги, означало б зробити
|
||
// обхідний шлях до того, заради чого й заведено окреме право.
|
||
//
|
||
// А от ПОЛІТИКА погодження читається з ncm:rollback, але змінюється
|
||
// лише з settings:write — і це найважливіший рядок у файлі. Вимикач
|
||
// «погодження не потрібне» не має належати тому, хто робить відкати:
|
||
// інакше двоетапне погодження перетворюється на одну галочку, яку
|
||
// знімає той самий, кого воно мало стримати.
|
||
|
||
const rollbackPerm = "ncm:rollback"
|
||
|
||
// handleRollbackPlan — що саме зробить відкат, без жодних змін.
|
||
//
|
||
// Окремий крок перед створенням наміру. Не заради зручності: план і є
|
||
// те, що людина погоджує. Без нього підтвердження звучало б як
|
||
// «відкотити на версію від 12 березня» — тобто як обіцянка без змісту,
|
||
// під якою нема чого підписувати.
|
||
func (s *Server) handleRollbackPlan(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
DeviceID string `json:"device_id"`
|
||
TargetConfigID string `json:"target_config_id"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if in.DeviceID == "" || in.TargetConfigID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id")
|
||
return
|
||
}
|
||
// Право змінювати саме цей хост, а не просто «право на відкати».
|
||
// Обмеження видимості — окремий від прав вимір: інженер над однією
|
||
// філією й інженер над усією мережею мають однакову роль.
|
||
if !p.Scope().CanWrite(in.DeviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом")
|
||
return
|
||
}
|
||
if !s.hasKeyring(w) {
|
||
return
|
||
}
|
||
|
||
prev, err := s.store.PrepareRollback(r.Context(), p.TenantID, in.DeviceID, in.TargetConfigID, s.keyring)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "план відкату", err)
|
||
return
|
||
}
|
||
if prev.Commands == nil {
|
||
prev.Commands = []string{}
|
||
}
|
||
if prev.Manual == nil {
|
||
prev.Manual = []string{}
|
||
}
|
||
writeJSON(w, http.StatusOK, prev)
|
||
}
|
||
|
||
func (s *Server) handleCreateRollback(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
|
||
var in store.RollbackInput
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if in.DeviceID == "" || in.TargetConfigID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id")
|
||
return
|
||
}
|
||
if !p.Scope().CanWrite(in.DeviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом")
|
||
return
|
||
}
|
||
if !s.hasKeyring(w) {
|
||
return
|
||
}
|
||
|
||
rb, err := s.store.CreateRollback(r.Context(), p.TenantID, p.UserID, in, s.keyring)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "створення відкату", err)
|
||
return
|
||
}
|
||
|
||
// Аудит пишеться після успіху й не блокує відповідь: намір уже
|
||
// існує, і приховати його від людини було б гірше, ніж лишити слід
|
||
// лише в журналі сервера. У meta лягає ЗАМАСКОВАНИЙ план — той
|
||
// самий, що бачила людина: журнал аудиту читають ширше, ніж
|
||
// сторінку відкатів, і секретам там місця тим більше немає.
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionRollbackCreate,
|
||
ObjectType: store.AuditObjectRollback,
|
||
ObjectID: rb.ID,
|
||
Meta: map[string]any{
|
||
"device": rb.DeviceName,
|
||
"device_id": rb.DeviceID,
|
||
"target_config_id": rb.TargetConfigID,
|
||
"base_config_id": rb.BaseConfigID,
|
||
"commands": rb.Commands,
|
||
"manual": rb.Manual,
|
||
"reason": rb.Reason,
|
||
"requires_approval": rb.RequiresApproval,
|
||
},
|
||
}); err != nil {
|
||
s.log.Error("аудит створення відкату", "rollback", rb.ID, "err", err)
|
||
}
|
||
|
||
s.log.Info("створено намір відкату",
|
||
"rollback", rb.ID, "device", rb.DeviceName,
|
||
"команд", len(rb.Commands), "вручну", len(rb.Manual),
|
||
"погодження", rb.RequiresApproval, "користувач", p.Username)
|
||
|
||
writeJSON(w, http.StatusOK, rb)
|
||
}
|
||
|
||
// handleApproveRollback — та сама друга людина.
|
||
//
|
||
// Машинному токену тут відмовляємо окремо й навмисно. Погодження — це
|
||
// підпис людини під незворотною дією; токен інтеграції підписати
|
||
// нічого не може, а можливість погодити ним звела б двоетапність до
|
||
// одного curl із того самого скрипта, який намір і створив.
|
||
func (s *Server) handleApproveRollback(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
if !p.IsUser() {
|
||
writeError(w, http.StatusForbidden, "human_required",
|
||
"відкат погоджує людина — машинний токен для цього не годиться")
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Note string `json:"note"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||
|
||
id := r.PathValue("id")
|
||
rb, err := s.store.ApproveRollback(r.Context(), p.TenantID, id, p.UserID, in.Note)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "погодження відкату", err)
|
||
return
|
||
}
|
||
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionRollbackApprove,
|
||
ObjectType: store.AuditObjectRollback,
|
||
ObjectID: id,
|
||
Meta: map[string]any{
|
||
"device": rb.DeviceName,
|
||
"requested_by": rb.RequestedBy,
|
||
"note": in.Note,
|
||
},
|
||
}); err != nil {
|
||
s.log.Error("аудит погодження відкату", "rollback", id, "err", err)
|
||
}
|
||
|
||
s.log.Info("відкат погоджено",
|
||
"rollback", id, "device", rb.DeviceName,
|
||
"автор", rb.RequestedBy, "погодив", p.Username)
|
||
|
||
writeJSON(w, http.StatusOK, rb)
|
||
}
|
||
|
||
func (s *Server) handleRejectRollback(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Note string `json:"note"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||
|
||
id := r.PathValue("id")
|
||
rb, err := s.store.RejectRollback(r.Context(), p.TenantID, id, p.UserID, in.Note)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "відмова у відкаті", err)
|
||
return
|
||
}
|
||
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionRollbackReject,
|
||
ObjectType: store.AuditObjectRollback,
|
||
ObjectID: id,
|
||
Meta: map[string]any{"device": rb.DeviceName, "note": in.Note},
|
||
}); err != nil {
|
||
s.log.Error("аудит відмови у відкаті", "rollback", id, "err", err)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, rb)
|
||
}
|
||
|
||
func (s *Server) handleListRollbacks(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||
list, err := s.store.ListRollbacks(r.Context(), p.TenantID,
|
||
r.URL.Query().Get("device_id"), limit)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "перелік відкатів", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"rollbacks": list})
|
||
}
|
||
|
||
// handleGetRollback — один намір; стенограма за окремим проханням.
|
||
//
|
||
// Стенограма не віддається завжди з тієї самої причини, що й у прогоні
|
||
// команд: сторінка перечитує намір раз на кілька секунд, поки він
|
||
// живий, а стенограма сесії заливки важить сотні кілобайт.
|
||
func (s *Server) handleGetRollback(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
withLog := r.URL.Query().Get("transcript") == "1"
|
||
rb, err := s.store.GetRollback(r.Context(), p.TenantID, r.PathValue("id"), withLog)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "відкат", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, rb)
|
||
}
|
||
|
||
func (s *Server) handleGetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, rollbackPerm) {
|
||
return
|
||
}
|
||
pol, err := s.store.GetRollbackPolicy(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "політика відкату", err)
|
||
return
|
||
}
|
||
// Клієнту важливо не лише значення, а й те, чи має він право його
|
||
// змінити: кнопка, яка дає 403 після натискання, гірша за відсутню.
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"policy": pol,
|
||
"editable": p.Can("settings:write"),
|
||
})
|
||
}
|
||
|
||
// handleSetRollbackPolicy — окреме право, і не ncm:rollback.
|
||
//
|
||
// Див. коментар на початку файлу: вимикач вимоги «другої людини» не має
|
||
// належати тому, кого ця вимога стримує.
|
||
func (s *Server) handleSetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "settings:write") {
|
||
return
|
||
}
|
||
|
||
var in store.RollbackPolicySettings
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
pol, err := s.store.SetRollbackPolicy(r.Context(), p.TenantID, p.UserID, in)
|
||
if err != nil {
|
||
s.writeRollbackError(w, "збереження політики відкату", err)
|
||
return
|
||
}
|
||
|
||
// Аудит обов'язковий саме тут. Вимкнення погодження нічого не
|
||
// ламає в момент збереження — воно змінює правила для всього, що
|
||
// станеться далі, і питання «хто дозволив відкочувати наодинці»
|
||
// має мати відповідь із іменем і часом.
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionRollbackPolicy,
|
||
ObjectType: store.AuditObjectRollbackPolicy,
|
||
Meta: map[string]any{
|
||
"require_approval": pol.RequireApproval,
|
||
"allow_self_approve": pol.AllowSelfApprove,
|
||
},
|
||
}); err != nil {
|
||
s.log.Error("аудит політики відкату", "err", err)
|
||
}
|
||
|
||
s.log.Info("політику відкату змінено",
|
||
"погодження", pol.RequireApproval, "самопогодження", pol.AllowSelfApprove,
|
||
"користувач", p.Username)
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{"policy": pol, "editable": true})
|
||
}
|
||
|
||
// writeRollbackError розкладає помилки відкату на коди відповіді.
|
||
//
|
||
// Окремо від writeStoreError, бо тут майже кожна помилка — не збій, а
|
||
// стан, у якому дію робити не можна, і клієнт має показати різні тексти
|
||
// різними способами. 409 проти 400 тут не педантизм: «запит
|
||
// неправильний» людина виправляє в полях форми, а «конфіг на пристрої
|
||
// змінився» — перезавантаженням сторінки й новим поглядом на різницю.
|
||
func (s *Server) writeRollbackError(w http.ResponseWriter, what string, err error) {
|
||
switch {
|
||
case errors.Is(err, store.ErrRollbackStale):
|
||
writeError(w, http.StatusConflict, "plan_stale", err.Error())
|
||
case errors.Is(err, store.ErrRollbackSelfApprove):
|
||
writeError(w, http.StatusConflict, "self_approve", err.Error())
|
||
case errors.Is(err, store.ErrRollbackState):
|
||
writeError(w, http.StatusConflict, "bad_state", err.Error())
|
||
case errors.Is(err, store.ErrRollbackNotSupported):
|
||
// 422, а не 400: запит правильний, просто ця родина заліза не
|
||
// вміє того, про що просять. Клієнт показує пояснення профілю,
|
||
// а не «перевірте поля».
|
||
writeError(w, http.StatusUnprocessableEntity, "not_supported", err.Error())
|
||
case errors.Is(err, store.ErrRollbackNothing):
|
||
writeError(w, http.StatusUnprocessableEntity, "nothing_to_do", err.Error())
|
||
case errors.Is(err, store.ErrRollbackTooBig):
|
||
writeError(w, http.StatusUnprocessableEntity, "too_big", err.Error())
|
||
case errors.Is(err, store.ErrNoProfile):
|
||
writeError(w, http.StatusUnprocessableEntity, "no_profile", err.Error())
|
||
case errors.Is(err, store.ErrConfigNotFound):
|
||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||
case isBadUUID(err):
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
default:
|
||
s.writeStoreError(w, what, err)
|
||
}
|
||
}
|
||
|
||
// hasKeyring — тіла конфігів зашифровані, і без ключа план не побудувати.
|
||
//
|
||
// Окремою перевіркою, а не «спробуємо й побачимо»: без ключа помилка
|
||
// прийшла б із глибини розшифровки й читалась би як поломка даних, а не
|
||
// як «сервер запущено без NETPULSE_MASTER_KEY».
|
||
func (s *Server) hasKeyring(w http.ResponseWriter) bool {
|
||
if s.keyring == nil {
|
||
writeError(w, http.StatusServiceUnavailable, "no_keyring",
|
||
"сервер запущено без ключа шифрування")
|
||
return false
|
||
}
|
||
return true
|
||
}
|