Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.
РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.
Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.
ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:
1. Перше збереження будь-якого вбудованого правила стирало результати.
Селектор порівнювався в базі, але порівнювались різні представлення
одного значення: міграція кладе {}, Go марширує сім ключів із null.
Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
файл відкриває аудитор. Одне місце екранування на всі три звіти:
дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
рахувались як чинні. Три заслони: фільтр у списку, прибирання при
прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
412 lines
15 KiB
Go
412 lines
15 KiB
Go
package store
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Звіт про прогін файлом.
|
||
//
|
||
// Головне обмеження тут не формат, а розмір. Двісті хостів, на кожному
|
||
// `display current-configuration` — це десятки мегабайтів тексту, і
|
||
// зібрати їх у рядок, щоб потім віддати одним writeJSON, означає
|
||
// покласти всю цю купу в пам'ять сервера рівно тоді, коли на неї
|
||
// найменше запасу: звіти качають після великих прогонів.
|
||
//
|
||
// Тому потік. Рядки читаються курсором і одразу йдуть у відповідь, у
|
||
// пам'яті живе один хост. Ціною одного зайнятого з'єднання до бази на
|
||
// час завантаження — це дешевше за мегабайти, помножені на кількість
|
||
// одночасних операторів.
|
||
|
||
// Формати звіту. Два, і кожен відповідає на своє питання.
|
||
//
|
||
// txt читає людина: вивід кожної команди під її назвою, у тому порядку,
|
||
// у якому воно виконувалось. Це те, що вкладають у тікет і надсилають
|
||
// вендору.
|
||
//
|
||
// csv вантажать у щось інше — таблицю, скрипт, систему обліку. Один
|
||
// рядок на пару «хост + команда»: саме така форма потрібна, щоб
|
||
// відповісти на «покажи всі хости, де ця команда впала».
|
||
//
|
||
// JSON тут не третій формат, а те саме, що вже віддає GET
|
||
// /command-runs/{id}: заводити йому ще один шлях означало б мати два
|
||
// описи однієї структури.
|
||
const (
|
||
ReportFormatTxt = "txt"
|
||
ReportFormatCSV = "csv"
|
||
)
|
||
|
||
// Підписи станів. Ті самі, що бачить людина на сторінці: звіт і екран
|
||
// мають називати те саме тими самими словами, інакше їх не звести.
|
||
var reportTargetLabel = map[string]string{
|
||
"pending": "у черзі",
|
||
"queued": "відправляється",
|
||
"running": "виконується",
|
||
"success": "готово",
|
||
"failed": "помилка",
|
||
"canceled": "скасовано",
|
||
}
|
||
|
||
var reportRunLabel = map[string]string{
|
||
"running": "у роботі",
|
||
"done": "завершено",
|
||
"canceled": "зупинено",
|
||
}
|
||
|
||
// CommandRunHead читає шапку прогону без хостів.
|
||
//
|
||
// Потрібна двом: перевірці «а чи існує цей прогін» перед видачею
|
||
// посилання й самому звіту, який мусить знати, що писати в заголовок, ще
|
||
// до того, як піде перший хост. GetCommandRun сюди не годиться — він
|
||
// тягне всі результати, тобто рівно те, чого потоковий звіт і уникає.
|
||
func (s *Store) CommandRunHead(ctx context.Context, tenantID, runID string) (CommandRun, error) {
|
||
var out CommandRun
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return s.readRunHead(ctx, tx, tenantID, runID, &out)
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) readRunHead(ctx context.Context, tx pgx.Tx, tenantID, runID string,
|
||
out *CommandRun) error {
|
||
|
||
var cmdRaw, filterRaw string
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT r.id::text, r.status::text, r.commands::text, r.filter::text,
|
||
r.concurrency, r.timeout_sec, r.total,
|
||
COALESCE(u.username, ''), r.created_at, r.finished_at
|
||
FROM ncm.command_runs r
|
||
LEFT JOIN core.users u ON u.id = r.created_by
|
||
WHERE r.id = $1 AND r.tenant_id = $2
|
||
`, runID, tenantID).Scan(&out.ID, &out.Status, &cmdRaw, &filterRaw,
|
||
&out.Concurrency, &out.TimeoutSec, &out.Total,
|
||
&out.CreatedBy, &out.CreatedAt, &out.FinishedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_ = jsonUnmarshalStrings(cmdRaw, &out.Commands)
|
||
var f DeviceFilter
|
||
if json.Unmarshal([]byte(filterRaw), &f) == nil {
|
||
out.Filter = f
|
||
}
|
||
|
||
out.Counts = map[string]int{}
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT status::text, count(*) FROM ncm.command_targets
|
||
WHERE run_id = $1 AND tenant_id = $2 GROUP BY status
|
||
`, runID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var st string
|
||
var n int
|
||
if err := rows.Scan(&st, &n); err != nil {
|
||
return err
|
||
}
|
||
out.Counts[st] = n
|
||
}
|
||
return rows.Err()
|
||
}
|
||
|
||
// ReportFileName — ім'я файлу, під яким звіт ляже на диск.
|
||
//
|
||
// Дата й початок ідентифікатора прогону: у теці «Завантаження» лежить
|
||
// десяток таких файлів, і «report.txt» серед них не значить нічого.
|
||
func ReportFileName(run CommandRun, format string) string {
|
||
short := run.ID
|
||
if len(short) > 8 {
|
||
short = short[:8]
|
||
}
|
||
return fmt.Sprintf("netpulse-commands-%s-%s.%s",
|
||
run.CreatedAt.UTC().Format("2006-01-02"), short, format)
|
||
}
|
||
|
||
// StreamCommandRunReport пише звіт у потік.
|
||
//
|
||
// open викликається РІВНО ОДИН раз і рівно тоді, коли шапка прочитана, а
|
||
// перший хост ще ні. Це не химерність, а єдиний спосіб чесно віддати 404
|
||
// на неіснуючий прогін: коли перший байт тіла вже пішов, статус відповіді
|
||
// змінити не можна, і помилка перетворилась би на битий файл із кодом
|
||
// 200. Тому обробник виставляє заголовки саме в open — за фактом того,
|
||
// що прогін знайдено.
|
||
//
|
||
// Помилка ПІСЛЯ open уже нікуди, крім самого файлу, не подінеться: у
|
||
// нього дописується рядок про обрив. Мовчазно обрізаний звіт виглядає як
|
||
// повний, і людина зробить із нього висновок про хости, яких у ньому
|
||
// просто немає.
|
||
func (s *Store) StreamCommandRunReport(ctx context.Context, tenantID, runID, format string,
|
||
open func(CommandRun) (io.Writer, error)) error {
|
||
|
||
if format != ReportFormatTxt && format != ReportFormatCSV {
|
||
return fmt.Errorf("%w: невідомий формат звіту %q", ErrInvalid, format)
|
||
}
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var run CommandRun
|
||
if err := s.readRunHead(ctx, tx, tenantID, runID, &run); err != nil {
|
||
return err
|
||
}
|
||
|
||
dst, err := open(run)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
bw := bufio.NewWriterSize(dst, 64<<10)
|
||
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT d.name, COALESCE(host(d.address),''),
|
||
t.status::text, t.outcomes::text, COALESCE(t.error,''),
|
||
t.started_at, t.finished_at, COALESCE(t.duration_ms, 0)
|
||
FROM ncm.command_targets t
|
||
JOIN inv.devices d ON d.id = t.device_id
|
||
WHERE t.run_id = $1 AND t.tenant_id = $2
|
||
ORDER BY d.name
|
||
`, runID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
if format == ReportFormatCSV {
|
||
err = writeCSVReport(bw, run, rows)
|
||
} else {
|
||
err = writeTextReport(bw, run, rows)
|
||
}
|
||
if err != nil {
|
||
// Слід обриву — у самому файлі. Помилка все одно повертається
|
||
// нагору для логів, але людина побачить її там, де відкриє
|
||
// звіт.
|
||
fmt.Fprintf(bw, "\n\n*** ЗВІТ ОБІРВАНО: %v ***\n", err)
|
||
_ = bw.Flush()
|
||
return err
|
||
}
|
||
return bw.Flush()
|
||
})
|
||
}
|
||
|
||
// reportTarget — один хост, зчитаний із курсора.
|
||
type reportTarget struct {
|
||
name, address, status, errMsg string
|
||
outcomes []CommandOutcome
|
||
started, finished *time.Time
|
||
durationMs int
|
||
}
|
||
|
||
func scanReportTarget(rows pgx.Rows) (reportTarget, error) {
|
||
var t reportTarget
|
||
var outcomesRaw string
|
||
if err := rows.Scan(&t.name, &t.address, &t.status, &outcomesRaw, &t.errMsg,
|
||
&t.started, &t.finished, &t.durationMs); err != nil {
|
||
return t, err
|
||
}
|
||
_ = json.Unmarshal([]byte(outcomesRaw), &t.outcomes)
|
||
return t, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Текстовий звіт
|
||
// ---------------------------------------------------------------------
|
||
|
||
func writeTextReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
|
||
const rule = "======================================================================"
|
||
const thin = "----------------------------------------------------------------------"
|
||
|
||
fmt.Fprintf(w, "NetPulse — звіт про масове виконання команд\n%s\n", rule)
|
||
fmt.Fprintf(w, "Прогін : %s\n", run.ID)
|
||
fmt.Fprintf(w, "Стан : %s\n", orRaw(reportRunLabel[run.Status], run.Status))
|
||
if run.CreatedBy != "" {
|
||
fmt.Fprintf(w, "Запустив : %s\n", run.CreatedBy)
|
||
}
|
||
fmt.Fprintf(w, "Створено : %s\n", reportTime(&run.CreatedAt))
|
||
if run.FinishedAt != nil {
|
||
fmt.Fprintf(w, "Завершено : %s\n", reportTime(run.FinishedAt))
|
||
}
|
||
fmt.Fprintf(w, "Хостів : %d (%s)\n", run.Total, countsSummary(run.Counts))
|
||
fmt.Fprintf(w, "Паралельно : %d\n", run.Concurrency)
|
||
fmt.Fprintf(w, "Таймаут : %d с\n", run.TimeoutSec)
|
||
|
||
fmt.Fprintf(w, "\nКоманди:\n")
|
||
for _, c := range run.Commands {
|
||
fmt.Fprintf(w, " %s\n", c)
|
||
}
|
||
|
||
n := 0
|
||
for rows.Next() {
|
||
t, err := scanReportTarget(rows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
n++
|
||
|
||
fmt.Fprintf(w, "\n%s\n[%d/%d] %s", rule, n, run.Total, t.name)
|
||
if t.address != "" {
|
||
fmt.Fprintf(w, " %s", t.address)
|
||
}
|
||
fmt.Fprintf(w, "\n%s", orRaw(reportTargetLabel[t.status], t.status))
|
||
if t.durationMs > 0 {
|
||
fmt.Fprintf(w, " · %.1f с", float64(t.durationMs)/1000)
|
||
}
|
||
if t.started != nil {
|
||
fmt.Fprintf(w, " · %s", reportTime(t.started))
|
||
}
|
||
fmt.Fprintf(w, "\n")
|
||
if t.errMsg != "" {
|
||
fmt.Fprintf(w, "ПОМИЛКА: %s\n", t.errMsg)
|
||
}
|
||
|
||
if len(t.outcomes) == 0 {
|
||
fmt.Fprintf(w, "%s\n(виводу немає)\n", thin)
|
||
continue
|
||
}
|
||
for _, o := range t.outcomes {
|
||
fmt.Fprintf(w, "%s\n", thin)
|
||
// Підготовчі команди профілю позначені окремо: їх не набирала
|
||
// людина, і без позначки вона шукатиме свою команду серед
|
||
// чужих — так само, як на екрані.
|
||
if o.Prep {
|
||
fmt.Fprintf(w, "· %s (підготовча команда профілю)\n", o.Command)
|
||
} else {
|
||
fmt.Fprintf(w, "$ %s\n", o.Command)
|
||
}
|
||
if o.Output != "" {
|
||
fmt.Fprintf(w, "%s\n", strings.TrimRight(o.Output, "\r\n"))
|
||
}
|
||
if o.Error != "" {
|
||
fmt.Fprintf(w, "!! %s\n", o.Error)
|
||
}
|
||
}
|
||
// Скидання після кожного хоста: у пам'яті буфера живе один хост,
|
||
// а людина бачить, що файл іде, а не висить.
|
||
if err := w.Flush(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintf(w, "\n%s\nКінець звіту. Хостів у звіті: %d\n", rule, n)
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// CSV
|
||
// ---------------------------------------------------------------------
|
||
|
||
// writeCSVReport — один рядок на пару «хост + команда».
|
||
//
|
||
// Хост без жодного виводу (не під'єднались, скасували) теж отримує свій
|
||
// рядок із порожньою командою. Інакше з таблиці зникли б рівно ті хости,
|
||
// заради яких її найчастіше й відкривають.
|
||
func writeCSVReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
|
||
// BOM. Excel без нього читає UTF-8 як однобайтове кодування, і звіт
|
||
// із кирилицею відкривається кракозябрами — а відкриють його саме в
|
||
// Excel.
|
||
if _, err := w.WriteString("\ufeff"); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Через safeCSV: у клітинку `output` їде СИРИЙ вивід команди з
|
||
// пристрою, а в `command` — те, що набрала людина. Клітинка, яка
|
||
// починається з `=`, для Excel не текст, а формула, і виконає її та
|
||
// сама людина, яка відкрила звіт. Одне місце екранування на всі три
|
||
// наші csv — у csv_safe.go.
|
||
cw := newSafeCSV(csv.NewWriter(w))
|
||
if err := cw.Write([]string{
|
||
"run_id", "host", "address", "status", "duration_ms",
|
||
"started_at", "finished_at", "command", "prep", "output",
|
||
"command_error", "host_error",
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
for rows.Next() {
|
||
t, err := scanReportTarget(rows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
base := []string{
|
||
run.ID, t.name, t.address,
|
||
orRaw(reportTargetLabel[t.status], t.status),
|
||
strconv.Itoa(t.durationMs),
|
||
reportTime(t.started), reportTime(t.finished),
|
||
}
|
||
if len(t.outcomes) == 0 {
|
||
if err := cw.Write(append(append([]string{}, base...),
|
||
"", "", "", "", t.errMsg)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, o := range t.outcomes {
|
||
prep := ""
|
||
if o.Prep {
|
||
prep = "1"
|
||
}
|
||
if err := cw.Write(append(append([]string{}, base...),
|
||
o.Command, prep, o.Output, o.Error, t.errMsg)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
cw.Flush()
|
||
if err := cw.Error(); err != nil {
|
||
return err
|
||
}
|
||
if err := w.Flush(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
cw.Flush()
|
||
return cw.Error()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
// reportTime — час у RFC3339.
|
||
//
|
||
// Не «красиво», а однозначно: звіт читають у тікеті через тиждень і в
|
||
// іншому часовому поясі, і «12:03» там не означає нічого.
|
||
func reportTime(t *time.Time) string {
|
||
if t == nil {
|
||
return ""
|
||
}
|
||
return t.UTC().Format(time.RFC3339)
|
||
}
|
||
|
||
func orRaw(label, raw string) string {
|
||
if label == "" {
|
||
return raw
|
||
}
|
||
return label
|
||
}
|
||
|
||
// countsSummary — «готово 5, помилка 1» у порядку важливості.
|
||
func countsSummary(counts map[string]int) string {
|
||
order := []string{"success", "failed", "canceled", "running", "queued", "pending"}
|
||
parts := make([]string, 0, len(order))
|
||
for _, k := range order {
|
||
if n := counts[k]; n > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s %d", orRaw(reportTargetLabel[k], k), n))
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
return "порожньо"
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|