Netpulse_SasS/agent/internal/ncmx/collect.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

290 lines
11 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 ncmx
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"regexp"
"strings"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// Result — те, що агент відправляє на сервер.
type Result struct {
Body []byte
SHA256 []byte
LineCount int
Duration time.Duration
Transcript string
}
// Collect виконує завдання збору конфігу.
//
// Послідовність команд трактується так, як домовлено в контракті:
// вивід ОСТАННЬОЇ команди — це конфіг, попередні готують консоль.
// Домовленість, а не здогад: інакше довелося б або позначати
// «головну» команду прапорцем, або вгадувати її за текстом.
func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) {
start := time.Now()
if len(job.GetCommands()) == 0 {
return Result{}, fmt.Errorf("завдання без жодної команди")
}
promptRe, err := compilePrompt(job.GetPromptRegex())
if err != nil {
return Result{}, err
}
timeout := job.GetTimeout().AsDuration()
if timeout <= 0 {
timeout = 5 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cred := job.GetCredential()
// Буфер створюємо ДО підключення: розмова входу — частина сеансу, і
// саме на ній найчастіше все зупиняється.
var transcript *bytes.Buffer
if job.GetCaptureTranscript() {
transcript = &bytes.Buffer{}
}
conn, err := Dial(ctx, transportName(job.GetTransport()),
job.GetDevice().GetAddress(), int(job.GetPort()),
cred.GetUsername(), password(cred), privateKey(cred),
connectTimeout(timeout), transcript)
if err != nil {
return withTranscript(Result{}, transcript), err
}
defer conn.Close()
cli := NewCLI(conn, Options{
PromptRe: promptRe,
MaxBytes: int(job.GetMaxBytes()),
Transcript: transcript,
})
// Банер до першої команди.
if err := cli.WaitPrompt(ctx); err != nil {
return withTranscript(Result{}, transcript), fmt.Errorf("привітання пристрою: %w", err)
}
if job.GetEnableRequired() {
if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil {
return withTranscript(Result{}, transcript), err
}
}
cmds := job.GetCommands()
// Підготовчі команди: їхній вивід нікого не цікавить, а помилка не
// фатальна. «terminal length 0» на платформі, яка такої команди не
// знає, дасть «% Invalid input» — і це не привід не збирати конфіг.
for _, c := range cmds[:len(cmds)-1] {
if _, err := cli.Run(ctx, c); err != nil {
return withTranscript(Result{}, transcript),
fmt.Errorf("підготовча команда %q: %w", c, err)
}
}
last := cmds[len(cmds)-1]
body, err := cli.Run(ctx, last)
if err != nil {
return withTranscript(Result{}, transcript), fmt.Errorf("команда %q: %w", last, err)
}
if err := looksLikeConfig(last, body); err != nil {
return withTranscript(Result{}, transcript), err
}
b := []byte(body)
sum := sha256.Sum256(b)
res := Result{
Body: b,
SHA256: sum[:],
LineCount: strings.Count(body, "\n") + 1,
Duration: time.Since(start),
}
return withTranscript(res, transcript), nil
}
// enable піднімає привілеї.
//
// Запрошення пароля впізнаємо за текстом, а не за окремим регексом із
// завдання: «Password:» пишуть однаково всі, а тримати ще один
// налаштовуваний шаблон означало б ще одне поле, яке нікому не хочеться
// заповнювати правильно.
var enablePasswordRe = regexp.MustCompile(`(?i)password\s*:?\s*$`)
func enable(ctx context.Context, cli *CLI, secret string) error {
if _, err := cli.conn.Write([]byte("enable\n")); err != nil {
return fmt.Errorf("enable: %w", err)
}
// Після «enable» пристрій відповідає одним із двох: або одразу
// привілейованим запрошенням (пароль не потрібен), або запитом
// пароля. Розрізняємо за тим, що прийшло.
saved := cli.opt.PromptRe
cli.opt.PromptRe = regexp.MustCompile(saved.String() + `|(?i)password\s*:?\s*$`)
out, err := cli.readUntilPrompt(ctx)
cli.opt.PromptRe = saved
if err != nil {
return fmt.Errorf("enable: %w", err)
}
if !enablePasswordRe.MatchString(strings.TrimSpace(out)) &&
!strings.Contains(strings.ToLower(out), "password") {
return nil // пароль не запитували
}
if secret == "" {
return fmt.Errorf("пристрій просить пароль enable, а його не задано")
}
if _, err := cli.conn.Write([]byte(secret + "\n")); err != nil {
return fmt.Errorf("пароль enable: %w", err)
}
if _, err := cli.readUntilPrompt(ctx); err != nil {
return fmt.Errorf("після пароля enable: %w", err)
}
return nil
}
// compilePrompt готує регекс запрошення.
//
// Порожній шаблон — не привід падати: типовий «>» або «#» у кінці рядка
// покриває більшість платформ, і краще спробувати з ним, ніж відмовити
// профілю, у якого поле просто не заповнили.
func compilePrompt(pattern string) (*regexp.Regexp, error) {
if strings.TrimSpace(pattern) == "" {
pattern = `[>#]\s*$`
}
// Багаторядковий режим обов'язковий: без нього «$» означає кінець
// усього тексту, а запрошення стоїть у кінці ОСТАННЬОГО рядка.
re, err := regexp.Compile(`(?m)` + pattern)
if err != nil {
return nil, fmt.Errorf("некоректний prompt_regex %q: %w", pattern, err)
}
return re, nil
}
// password дістає секрет із oneof.
//
// Для CLI підходить лише пароль: ключ вимагав би ще й розбору формату
// та passphrase, а community й token до SSH не мають стосунку. Тому
// решта варіантів свідомо дає порожній рядок, і причина відмови буде
// видна одразу при вході, а не десь усередині рукостискання.
func password(c *npv1.Credential) string {
if c == nil {
return ""
}
if p, ok := c.GetSecret().(*npv1.Credential_Password); ok {
return p.Password
}
return ""
}
// privateKey — SSH-ключ, якщо доступ заведений із ключем, а не паролем.
func privateKey(c *npv1.Credential) []byte {
if c == nil {
return nil
}
if k, ok := c.GetSecret().(*npv1.Credential_PrivateKey); ok {
return k.PrivateKey
}
return nil
}
func transportName(t npv1.Transport) string {
switch t {
case npv1.Transport_TRANSPORT_TELNET:
return "telnet"
default:
return "ssh"
}
}
// connectTimeout — частка загального бюджету на саме підключення.
//
// Збір конфігу може тривати хвилини, але якщо пристрій не відповідає на
// TCP за пів хвилини, чекати решту дедлайну немає сенсу.
func connectTimeout(total time.Duration) time.Duration {
t := total / 5
if t > 30*time.Second {
t = 30 * time.Second
}
if t < 5*time.Second {
t = 5 * time.Second
}
return t
}
func withTranscript(r Result, b *bytes.Buffer) Result {
if b != nil {
r.Transcript = b.String()
}
return r
}
// Скільки змістовних рядків мусить мати те, що ми називаємо конфігом.
//
// Порогу вистачає одного, і він грубий навмисне: конфігурація будь-якої
// коробки — це десятки рядків щонайменше, а все, що коротше, — це
// пристрій відповідає щось інше. Тонше налаштування тут було б
// самообманом: точного числа не існує, а видимість точності заважала б
// помітити, що поріг узагалі спрацював.
const minConfigLines = 5
// cliRejects — як залізо каже «я не зрозумів команду».
//
// Кожен виробник каже по-своєму, і жоден не робить це кодом помилки:
// для CLI відмова — це просто ще один рядок виводу. Зразки шукаються
// лише в короткому виводі (див. looksLikeConfig): у справжньому конфізі
// на тридцять тисяч рядків слова «invalid» чи «unknown» трапляються в
// іменах ACL і в банерах, і глушити збір через них не можна.
var cliRejects = regexp.MustCompile(`(?i)next possible completions|` +
`invalid input|unknown command|invalid command|incomplete command|` +
`available commands|command not found|syntax error|% *bad|permission denied`)
// looksLikeConfig відсіює відповіді, які конфігом не є.
//
// Навіщо окрема перевірка, якщо порожній вивід ми й так ловили. Бо
// пристрій рідко мовчить: на невідому команду D-Link відповідає
// «Next possible completions», Cisco — «% Invalid input», і кожна з цих
// відповідей непорожня. Далі вона лягала в сховище як версія конфігу,
// завдання позначалось успішним, і людина дізнавалась про це через
// півроку — коли конфіг знадобився.
//
// Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву.
func looksLikeConfig(cmd, body string) error {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return fmt.Errorf("команда %q повернула порожній вивід", cmd)
}
var lines int
for _, ln := range strings.Split(trimmed, "\n") {
if strings.TrimSpace(ln) != "" {
lines++
}
}
if lines >= minConfigLines && !cliRejects.MatchString(trimmed) {
return nil
}
// Зразок відмови в довгому виводі нічого не означає — там це просто
// слово всередині конфігу.
if lines >= minConfigLines {
return nil
}
// Показуємо, що саме сказав пристрій: без цього людині лишається
// здогадуватись, чим команда з профілю не підійшла її залізу.
said := trimmed
if len(said) > 300 {
said = said[:300] + "…"
}
return fmt.Errorf("команда %q не дала конфігу — пристрій відповів: %s", cmd, said)
}