Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
290 lines
11 KiB
Go
290 lines
11 KiB
Go
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)
|
||
}
|