Netpulse_SasS/server/internal/httpapi/ncm_commands.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +03:00

336 lines
14 KiB
Go
Raw 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"
"strconv"
"github.com/netpulse/netpulse/server/internal/store"
)
// Масове виконання команд по фільтру.
//
// Найнебезпечніша дія в системі: одна команда на двісті пристроїв, без
// попереднього перегляду наслідків і без відкату. Тому запобіжники тут
// не «на майбутнє», а частина функції:
//
// - окреме право ncm:exec (не ncm:write і тим паче не devices:read);
// - обмеження видимості: у прогін потрапляє лише те, що людині
// дозволено ЗМІНЮВАТИ, а не лише бачити;
// - перелік хостів повертається до запуску, і запуск іде по перетину
// фільтра з тим переліком, який людина бачила;
// - кожен прогін і кожна зупинка — рядок у журналі аудиту.
//
// Право перевіряється однакове на читання й на запуск. Вивід команди
// показує стан заліза детальніше за будь-яку іншу сторінку, і давати
// його всім, хто має ncm:read, означало б зробити обхідний шлях до
// того, заради чого й заведено окреме право.
const execPerm = "ncm:exec"
// handlePreviewCommandTargets — «кого зачепить цей фільтр».
//
// POST, хоч і нічого не змінює: фільтр — це структура з кількома
// списками, і складати її в рядок запиту означало б винайти власне
// кодування масивів заради того, щоб виглядати як GET.
func (s *Server) handlePreviewCommandTargets(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
var f store.DeviceFilter
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр")
return
}
list, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), f)
if err != nil {
s.writeStoreError(w, "добір хостів за фільтром", err)
return
}
if list == nil {
list = []store.CommandCandidate{}
}
// Ліміт повідомляється разом із переліком: людина має побачити, що
// впирається в стелю, ще у вікні підтвердження, а не отримати
// відмову після натискання.
writeJSON(w, http.StatusOK, map[string]any{
"devices": list,
"total": len(list),
"max": store.MaxRunDevices,
})
}
func (s *Server) handleListCommandRuns(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
runs, err := s.store.ListCommandRuns(r.Context(), p.TenantID, limit)
if err != nil {
s.writeStoreError(w, "перелік прогонів", err)
return
}
if runs == nil {
runs = []store.CommandRun{}
}
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
}
func (s *Server) handleGetCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
// Стенограма — за окремим проханням: сторінка перечитує прогін раз
// на дві секунди, поки він живий, а стенограма сесії до великого
// шасі важить сотні кілобайт на хост.
withTranscript := r.URL.Query().Get("transcript") == "1"
run, err := s.store.GetCommandRun(r.Context(), p.TenantID, r.PathValue("id"), withTranscript)
if err != nil {
s.writeStoreError(w, "прогін команд", err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (s *Server) handleCreateCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
var in store.CommandRunInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати запит")
return
}
// Перелік звіряється з фільтром ще раз, уже на сервері.
//
// Не паранойя, а два різні захисти в одному місці. По-перше, тіло
// запиту складає клієнт, і без звірки сюди можна було б покласти
// будь-який ідентифікатор — зокрема хост із чужої філії, який
// фільтр ніколи б не повернув. По-друге, між переглядом і
// натисканням минає час: хост могли вимкнути, перевести в іншу
// групу або завести новий, що теж підпадає під фільтр. Перетин
// означає рівно те, що обіцяє підтвердження, — «виконати на тому,
// що я щойно бачив, і ні на чому більше».
matched, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), in.Filter)
if err != nil {
s.writeStoreError(w, "добір хостів за фільтром", err)
return
}
allowed := make(map[string]store.CommandCandidate, len(matched))
for _, c := range matched {
allowed[c.DeviceID] = c
}
var (
final []string
names []string
dropped []string
)
for _, id := range in.DeviceIDs {
c, ok := allowed[id]
if !ok {
dropped = append(dropped, id)
continue
}
final = append(final, id)
names = append(names, c.Name)
}
if len(final) == 0 {
writeError(w, http.StatusBadRequest, "no_targets",
"жоден з обраних хостів більше не підпадає під фільтр — перегляньте перелік ще раз")
return
}
in.DeviceIDs = final
run, err := s.store.CreateCommandRun(r.Context(), p.TenantID, p.UserID, in)
if err != nil {
s.writeStoreError(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.AuditActionCommandRunCreate,
ObjectType: store.AuditObjectCommandRun,
ObjectID: run.ID,
Meta: map[string]any{
"commands": run.Commands,
"devices": len(final),
"device_ids": final,
"names": names,
"filter": in.Filter,
"concurrency": run.Concurrency,
"timeout_sec": run.TimeoutSec,
},
}); err != nil {
s.log.Error("аудит прогону команд", "run", run.ID, "err", err)
}
s.log.Info("масове виконання команд запущено",
"run", run.ID, "хостів", len(final), "команд", len(run.Commands),
"користувач", p.Username, "user_id", p.UserID)
writeJSON(w, http.StatusOK, map[string]any{
"run": run,
// Хости, що відпали між переглядом і запуском. Мовчати про них
// не можна: людина підтверджувала перелік, і різницю має
// побачити вона, а не наступний, хто відкриє журнал.
"dropped": dropped,
})
}
func (s *Server) handleCancelCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
runID := r.PathValue("id")
stopped, err := s.store.CancelCommandRun(r.Context(), p.TenantID, runID, p.UserID)
if err != nil {
s.writeStoreError(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.AuditActionCommandRunCancel,
ObjectType: store.AuditObjectCommandRun,
ObjectID: runID,
Meta: map[string]any{"stopped": stopped},
}); err != nil {
s.log.Error("аудит зупинки прогону", "run", runID, "err", err)
}
s.log.Info("прогін команд зупинено",
"run", runID, е_почато", stopped, "користувач", p.Username)
writeJSON(w, http.StatusOK, map[string]any{"stopped": stopped})
}
// handleDeleteCommandRun прибирає прогін разом із виводом.
//
// Право — те саме ncm:exec, і поруч тепер є спокуслива альтернатива:
// міграція 0037 завела ncm:delete для видалення версій конфігів. Брати
// його сюди було б помилкою, і різниця тут не в масштабі, а в тому, чи
// можна знищене відтворити.
//
// Версія конфігу — єдиний слід того, як виглядало залізо в конкретний
// день; стерти її означає стерти доказ, і другого такого дня не буде.
// Вивід прогону відтворюється повторним запуском тієї самої команди —
// причому тим самим, хто натискає «видалити»: побачити прогін узагалі
// може лише власник ncm:exec. Вимагати для видалення право, без якого
// цих даних не видно й не створити, — це не захист, а зайвий прапорець,
// який хтось однаково видасть разом із першим.
//
// Захищає тут не кнопка, а журнал. Команди, перелік хостів, автор і час
// лежать у core.audit_log із моменту запуску, звідси не прибираються, і
// саме видалення теж лягає туди — разом зі зведенням, що саме зникло.
func (s *Server) handleDeleteCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
runID := r.PathValue("id")
run, err := s.store.DeleteCommandRun(r.Context(), p.TenantID, runID)
if errors.Is(err, store.ErrRunActive) {
// 409, а не 400: із запитом усе гаразд, це стан системи, який
// сам мине. Текст пояснює, чого чекати, — див. store.
writeError(w, http.StatusConflict, "run_active", err.Error())
return
}
if err != nil {
s.writeStoreError(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.AuditActionCommandRunDelete,
ObjectType: store.AuditObjectCommandRun,
ObjectID: runID,
Meta: map[string]any{
"commands": run.Commands,
"devices": run.Total,
"counts": run.Counts,
"status": run.Status,
"created_by": run.CreatedBy,
"created_at": run.CreatedAt,
},
}); err != nil {
s.log.Error("аудит видалення прогону", "run", runID, "err", err)
}
s.log.Info("прогін команд видалено",
"run", runID, "хостів", run.Total, "користувач", p.Username)
w.WriteHeader(http.StatusNoContent)
}
// handleCommandRunReportLink видає посилання на файл звіту.
//
// Окремий крок, а не одразу файл, бо файл тягне браузер звичайним
// посиланням, без заголовка Authorization. Чому саме так — у
// downloads.go; тут важливо, що право перевіряється саме на цьому кроці,
// під нормальною автентифікацією, і квиток живе хвилини.
func (s *Server) handleCommandRunReportLink(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, execPerm) {
return
}
runID := r.PathValue("id")
var in struct {
Format string `json:"format"`
}
// Порожнє тіло — не помилка: типовий формат читає людина.
_ = json.NewDecoder(r.Body).Decode(&in)
if in.Format == "" {
in.Format = store.ReportFormatTxt
}
if in.Format != store.ReportFormatTxt && in.Format != store.ReportFormatCSV {
writeError(w, http.StatusBadRequest, "bad_format", "формат звіту — txt або csv")
return
}
// Прогін звіряється тут, а не при завантаженні: посилання, яке
// віддає 404, людина побачить уже у вкладці завантажень, де пояснити
// їй нічого не можна.
run, err := s.store.CommandRunHead(r.Context(), p.TenantID, runID)
if err != nil {
s.writeStoreError(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.AuditActionCommandRunReport,
ObjectType: store.AuditObjectCommandRun,
ObjectID: runID,
Meta: map[string]any{"format": in.Format, "devices": run.Total},
}); err != nil {
s.log.Error("аудит звіту про прогін", "run", runID, "err", err)
}
s.issueDownloadTicket(w, r, p, store.DownloadKindCommandReport, runID, in.Format)
}