Netpulse_SasS/server/internal/store/ncm_rollback_plan.go
byrsapty c83324ae3e
Some checks failed
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m13s
CI / server (push) Successful in 1m34s
CI / dbtest (push) Successful in 1m52s
CI / agent (push) Has been cancelled
Відкат на живому залізі: пароль адміністратора лежав відкритим
Механізм відкату існував з 0035 і жодного разу не виконувався на
справжньому обладнанні. Прогін до межі заліза (план -> заявка ->
погодження, без заливки) дав три правильні відмови й одну знахідку.

D-Link питає пароль інтерактивно, і `show config` віддає відповіді
окремими рядками без ключових слів. Порядкове маскування за зразками
такий рядок не бачить, тож пароль адміністратора живого комутатора
лежав у плані відкату, у ncm.rollbacks і в git-дзеркалі — маскування
перед записом у git немає взагалі.

redactLines отримав стан: після рядка заведення облікового запису до
двох односкладових рядків маскуються. Ім'я запису лишається видимим.

Дорогою: перший рядок збереженого конфігу — відлуння команди
(`Command: show config`), і планувальникклав його в команди до заливки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:42:43 +03:00

542 lines
22 KiB
Go
Raw Permalink 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 store
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/netpulse/netpulse/server/internal/difftext"
)
// Побудова плану відкату: з двох текстів конфігу — перелік команд.
//
// Найважливіше рішення всього відкату ухвалюється тут, тому воно
// винесене в окремий файл без жодного звертання до бази: усе, що варте
// перевірки, — це чиста функція від двох текстів і опису родини.
//
// ЧОМУ РІЗНИЦЯ, А НЕ ВЕСЬ КОНФІГ.
//
// CLI мережевого заліза не має режиму «замінити конфіг на оцей». Рядки,
// віддані в режимі конфігурації, ДОДАЮТЬСЯ до наявного. Тобто «залити
// вчорашній конфіг цілком» на пристрої, де сьогодні помилково створили
// VLAN, дасть учорашній конфіг ПЛЮС той VLAN — рівно те, від чого
// відкочувались. Відкат, який не прибирає зайве, відкатом не є.
//
// Тому на пристрій їде різниця: чого бракує — як є, що зайве — з
// префіксом заперечення родини. А там, де родина заперечення не має
// (D-Link), зайве не вигадується, а показується людині окремо.
//
// ЧОМУ КОНТЕКСТ ВІДНОВЛЮЄТЬСЯ ЗА ВІДСТУПОМ.
//
// Рядок ` ip address 10.0.0.1 255.255.255.0` сам собою не означає
// нічого: він належить інтерфейсу, назва якого стоїть вище й без
// відступу. Віддати його без батьківського рядка означає віддати його
// в глобальний режим — і отримати або помилку, або, що гірше, зміну не
// того об'єкта. Тому кожна зміна їде разом зі своїм ланцюжком батьків,
// а після блока — вихід із контексту.
//
// Відступ як ознака вкладеності — властивість самих конфігів (Cisco,
// ZTE, Huawei друкують ієрархію відступами), а не наш здогад. Родини,
// у яких ієрархії немає (D-Link), просто не мають відступів, і той
// самий код дає для них плаский перелік.
// MaxRollbackCommands — стеля на розмір плану.
//
// Не налаштовується навмисно: це не параметр роботи, а межа, за якою
// відкат перестає бути відкатом. Чотириста команд означають, що конфіги
// розійшлися не «кимось виправленим рядком», а цілком — зміною
// прошивки, заміною заліза, першим збором після переналаштування. У
// такому разі правильна дія не «залити різницю», а віднести конфіг
// руками, дивлячись на кожен блок.
const MaxRollbackCommands = 400
var (
// ErrRollbackNotSupported — профіль не описує заливку.
ErrRollbackNotSupported = errors.New("для профілю цього хоста відкат не налаштований")
// ErrRollbackNothing — конфіги вже однакові.
ErrRollbackNothing = errors.New("обрана версія не відрізняється від того, що зараз на пристрої")
// ErrRollbackTooBig — різниця завелика для автоматичного відкату.
ErrRollbackTooBig = errors.New("різниця завелика для автоматичного відкату")
)
// ApplyProfile — усе, що профіль каже про заливку.
type ApplyProfile struct {
Key string
Name string
Supported bool
Enter []string
Exit []string
Commit string
// Negate порожній означає: родина не вміє прибрати рядок командою.
Negate string
BlockExit string
Note string
// Рядки, що змінюються самі собою (uptime, час збору, лічильники).
// Тут вони потрібні не менше, ніж у порівнянні версій: заливати
// «ntp clock-period 17179869» безглуздо, а після відкату він
// однаково стане іншим — і контрольний збір вважав би це розбіжністю.
ScrubPatterns []string
// Маскування секретів для показу плану людині.
RedactPatterns []string
PromptRegex string
EnableRequired bool
Transport string
}
// RollbackPlan — що саме зробить відкат.
type RollbackPlan struct {
// Commands — рядки, які поїдуть на пристрій у цьому порядку.
Commands []string `json:"commands"`
// Manual — те, що система прибрати не може, з підказкою контексту.
Manual []string `json:"manual"`
// CommitCommand — чим зберегти після успіху.
CommitCommand string `json:"commit_command,omitempty"`
Added int `json:"added"`
Removed int `json:"removed"`
}
// Empty — плану немає: конфіги збігаються в усьому, що можна залити.
func (p RollbackPlan) Empty() bool { return len(p.Commands) == 0 && len(p.Manual) == 0 }
// cfgLine — рядок конфігу з обчисленим рівнем вкладеності.
type cfgLine struct {
text string
indent int
// num — номер у вихідному тексті (1-based), яким його бачить diff.
num int
}
// BuildRollbackPlan будує перелік команд, що перетворять current на target.
//
// current — що зараз на пристрої, target — куди відкочуємось. Обидва
// тексти беруться з архіву версій, тобто це рівно те, що людина бачила
// в порівнянні перед підтвердженням.
func BuildRollbackPlan(current, target string, p ApplyProfile) (RollbackPlan, error) {
var plan RollbackPlan
if !p.Supported {
return plan, ErrRollbackNotSupported
}
scrub, err := compileAll(p.ScrubPatterns)
if err != nil {
return plan, fmt.Errorf("вирази очистки профілю: %w", err)
}
cur := executableLines(current, scrub)
tgt := executableLines(target, scrub)
curText := textsOf(cur)
tgtText := textsOf(tgt)
res := difftext.CompareLines(curText, tgtText)
if res.Truncated {
// Порівняння здалось і показало грубу заміну блока. Будувати з
// цього план означало б віддати на пристрій «видалити все й
// написати все» — найгірше, що можна зробити з живим вузлом.
return plan, fmt.Errorf("%w: версії розійшлися майже повністю", ErrRollbackTooBig)
}
// Зміни в тому порядку, у якому вони йдуть у конфізі. Контекст
// (незмінені рядки) із hunk-ів не потрібен: батьків ми відновлюємо
// самі за відступом, і саме з правильного боку — для доданого
// рядка батько береться з цільового конфігу, для зайвого з
// поточного.
type change struct {
remove bool
line cfgLine
path []string
}
var changes []change
for _, h := range res.Hunks {
for _, ln := range h.Lines {
switch ln.Op {
case difftext.OpAdd:
if ln.NewNum >= 1 && ln.NewNum <= len(tgt) {
l := tgt[ln.NewNum-1]
changes = append(changes, change{line: l, path: parentPath(tgt, ln.NewNum-1)})
}
case difftext.OpRemove:
if ln.OldNum >= 1 && ln.OldNum <= len(cur) {
l := cur[ln.OldNum-1]
changes = append(changes, change{remove: true, line: l,
path: parentPath(cur, ln.OldNum-1)})
}
}
}
}
if len(changes) == 0 {
return plan, ErrRollbackNothing
}
var (
cmds []string
manual []string
curPath []string
inBlock bool
blockOut = strings.TrimSpace(p.BlockExit)
)
// closeBlock виходить із поточного контексту рівно на стільки
// рівнів, на скільки ми в нього зайшли. Без цього наступний блок
// починався б усередині попереднього: `interface Gi0/2`, набране
// всередині `interface Gi0/1`, на частині платформ мовчки нічого не
// робить, а на частині створює підінтерфейс.
closeBlock := func() {
if !inBlock {
return
}
if blockOut != "" {
for range curPath {
cmds = append(cmds, blockOut)
}
}
curPath = nil
inBlock = false
}
for i := 0; i < len(changes); {
// Група — послідовні зміни в одному контексті. Групуємо, щоб
// не входити в `interface Gi0/1` двічі поспіль заради двох його
// рядків.
j := i
for j < len(changes) && samePath(changes[j].path, changes[i].path) {
j++
}
group := changes[i:j]
if !samePath(curPath, group[0].path) {
closeBlock()
for _, ctx := range group[0].path {
cmds = append(cmds, ctx)
}
curPath = group[0].path
inBlock = len(curPath) > 0
}
// Спершу прибирання, потім додавання — і в цьому порядку є
// сенс, а не звичка. Зміна значення виглядає в різниці як пара
// «зайвий рядок / потрібний рядок»; якщо спершу додати новий,
// частина платформ відкине його як конфлікт зі старим, а
// наступне заперечення прибере вже обидва.
for _, c := range group {
if !c.remove {
continue
}
body := strings.TrimSpace(c.line.text)
if p.Negate == "" {
// Родина не вміє прибрати рядок командою. Вигадувати
// команду тут — найдорожча з можливих помилок: вона
// виглядатиме як успішний відкат.
manual = append(manual, manualEntry(c.path, body))
continue
}
cmds = append(cmds, p.Negate+body)
}
for _, c := range group {
if c.remove {
continue
}
cmds = append(cmds, strings.TrimSpace(c.line.text))
}
i = j
}
closeBlock()
plan.Removed = countIf(changes, func(c change) bool { return c.remove })
plan.Added = len(changes) - plan.Removed
if len(cmds) > 0 {
full := make([]string, 0, len(p.Enter)+len(cmds)+len(p.Exit))
full = append(full, trimAll(p.Enter)...)
full = append(full, cmds...)
full = append(full, trimAll(p.Exit)...)
cmds = full
}
for _, c := range cmds {
if strings.ContainsAny(c, "\r\n") {
return plan, fmt.Errorf("%w: рядок конфігу містить перенесення", ErrInvalid)
}
if len(c) > MaxCommandLength {
return plan, fmt.Errorf("%w: рядок конфігу довший за %d символів — %.60s…",
ErrInvalid, MaxCommandLength, c)
}
}
if len(cmds) > MaxRollbackCommands {
return plan, fmt.Errorf("%w: %d команд проти дозволених %d — перенесіть конфіг вручну",
ErrRollbackTooBig, len(cmds), MaxRollbackCommands)
}
plan.Commands = cmds
plan.Manual = manual
if len(cmds) > 0 {
plan.CommitCommand = strings.TrimSpace(p.Commit)
}
return plan, nil
}
// manualEntry — рядок для переліку «прибрати вручну».
//
// З контекстом, бо без нього `speed 100` не каже нічого: людині треба
// знати, на якому інтерфейсі.
func manualEntry(path []string, body string) string {
if len(path) == 0 {
return body
}
return strings.Join(path, " / ") + " / " + body
}
// executableLines лишає рядки, які мають сенс як команда.
//
// Викидаються три речі, і кожна з власної причини.
//
// Порожні рядки — бо Enter у CLI повторює попередню команду на частині
// платформ, а на решті просто дає нове запрошення.
//
// Коментарі (`!`, `#`, `;`) — бо вони не команди. D-Link малює ними
// рамку в шапці конфігу, Cisco розділяє секції, MikroTik пише дату
// експорту. Заливати їх безглуздо, а в різниці вони створюють шум:
// рядок «# конфіг знято 12.03» відрізняється щоразу.
//
// Рядки під scrub_patterns профілю — бо це та сама шапка й ті самі
// лічильники, тільки описані самим профілем. Вони не лише марні для
// заливки: без їх викидання контрольний збір після відкату завжди
// показував би розбіжність, і стан 'mismatch' перестав би щось означати.
func executableLines(text string, scrub []*regexp.Regexp) []cfgLine {
raw := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
out := make([]cfgLine, 0, len(raw))
for _, ln := range raw {
body := strings.TrimRight(ln, " \t")
trimmed := strings.TrimSpace(body)
if trimmed == "" {
continue
}
switch trimmed[0] {
case '!', '#', ';':
continue
}
if matchesAny(scrub, body) {
continue
}
out = append(out, cfgLine{
text: body,
indent: indentOf(body),
num: len(out) + 1,
})
}
return out
}
// indentOf — глибина відступу. Табуляція рахується за один рівень:
// точне число пробілів нам не потрібне, потрібен лише порядок «глибше
// за попередній».
func indentOf(s string) int {
n := 0
for _, r := range s {
if r != ' ' && r != '\t' {
break
}
n++
}
return n
}
// parentPath — ланцюжок батьківських рядків для рядка з індексом i.
//
// Батько — найближчий вище рядок із меншим відступом. Далі те саме
// рекурсивно, доки не дійдемо до нульового рівня. Повертається зверху
// вниз: саме в такому порядку команди й треба віддавати.
func parentPath(lines []cfgLine, i int) []string {
if i < 0 || i >= len(lines) {
return nil
}
var path []string
want := lines[i].indent
for j := i - 1; j >= 0 && want > 0; j-- {
if lines[j].indent < want {
path = append(path, strings.TrimSpace(lines[j].text))
want = lines[j].indent
}
}
// Зібрали знизу вгору — перевертаємо.
for l, r := 0, len(path)-1; l < r; l, r = l+1, r-1 {
path[l], path[r] = path[r], path[l]
}
return path
}
func samePath(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func textsOf(lines []cfgLine) []string {
out := make([]string, len(lines))
for i, l := range lines {
out[i] = l.text
}
return out
}
func trimAll(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
func compileAll(patterns []string) ([]*regexp.Regexp, error) {
out := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
if strings.TrimSpace(p) == "" {
continue
}
re, err := regexp.Compile(p)
if err != nil {
return nil, fmt.Errorf("вираз %q: %w", p, err)
}
out = append(out, re)
}
return out, nil
}
func matchesAny(res []*regexp.Regexp, s string) bool {
for _, re := range res {
if re.MatchString(s) {
return true
}
}
return false
}
func countIf[T any](in []T, ok func(T) bool) int {
n := 0
for _, v := range in {
if ok(v) {
n++
}
}
return n
}
// redactLines маскує секрети в тексті, який побачить людина.
//
// Потрібне саме тут, а не «десь у показі»: план відкату складається з
// рядків конфігу, а серед них — `snmp-server community`, `username …
// password …` і хеші. Тіла конфігів лежать зашифрованими в
// core.secrets; покласти ті самі рядки відкритим текстом у
// ncm.rollbacks означало б обійти власне шифрування через сусідню
// таблицю.
//
// Маскується сам збіг, а не весь рядок: людині при погодженні треба
// бачити, ЩО саме змінюється («буде переписано пароль на vty»), і
// рядок, від якого лишились самі зірочки, цього не каже.
func redactLines(patterns []string, in []string) []string {
res, err := compileAll(patterns)
if err != nil {
// Некомпільований вираз профілю не має ставати причиною
// показати секрет: лишається запасний перелік, і тільки він.
res = nil
}
res = append(res, defaultRedact...)
out := make([]string, len(in))
// echo — скільки наступних рядків є відлунням запиту пароля.
//
// Порядкове маскування ловить лише те, де секрет стоїть в одному
// рядку з ключовим словом. У D-Link це не так: комутатор питає
// пароль інтерактивно, і `show config` віддає ВІДПОВІДІ окремими
// рядками, без жодного слова поруч:
//
// create account admin btadmin
// <пароль>
// <пароль ще раз>
//
// Такий рядок — просто слово. Жоден зразок його не впізнає, і саме
// так пароль адміністратора живого комутатора опинився відкритим у
// плані відкату. Тому стан: після рядка, що заводить обліковий
// запис, наступні односкладові рядки — це секрет, поки не почнеться
// щось, схоже на команду.
echo := 0
for i, s := range in {
if echo > 0 && isBareToken(s) {
out[i] = "***"
echo--
continue
}
echo = 0
for _, re := range res {
s = re.ReplaceAllString(s, "***")
}
if accountOpener.MatchString(s) {
// Двох вистачає: пароль і його підтвердження. Більше —
// означало б з'їдати справжні рядки конфігу.
echo = 2
}
out[i] = s
}
return out
}
// accountOpener — рядок, після якого пристрій питає пароль окремо.
//
// Свідомо ширший за D-Link: та сама звичка є в кількох клонів, а ціна
// зайвого маскування — «***» замість слова в плані, тоді як ціна
// пропуску — пароль адміністратора в базі, у git-дзеркалі й на екрані.
var accountOpener = regexp.MustCompile(`(?i)^[ ]*(create|config)[ ]+account($|[^a-zA-Z0-9_-])`)
// isBareToken — чи схожий рядок на відлуння введеного пароля.
//
// Одне слово без пробілів і без початкового `#`: команди конфігурації
// односкладовими не бувають, а коментар починається з решітки.
func isBareToken(s string) bool {
t := strings.TrimSpace(s)
if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "!") {
return false
}
return !strings.ContainsAny(t, " \t")
}
// redactText — те саме для суцільного тексту (стенограма сесії).
func redactText(patterns []string, in string) string {
if in == "" {
return ""
}
return strings.Join(redactLines(patterns, strings.Split(in, "\n")), "\n")
}
// defaultRedact — запасний перелік на випадок профілю без redact_patterns.
//
// Профілі з каталогу свої зразки мають, але власний профіль кабінету
// цілком може бути заведений без них — і тоді відкат став би єдиним
// місцем, де рядок із паролем лягає в базу відкритим. Запасний перелік
// свідомо грубий: краще замаскувати зайве, ніж пропустити секрет.
// Необов'язкова цифра після ключового слова — це тип шифрування, який
// Cisco й половина клонів пишуть між словом і самим секретом:
// `username admin password 7 0822455D0A16`. Без неї маскування з'їдало б
// саму сімку й лишало хеш на видноті — тобто виглядало б як зроблена
// робота, не будучи нею.
var defaultRedact = []*regexp.Regexp{
regexp.MustCompile(`(?i)(password|passwd|secret|community|pre-shared-key|psk|key-string)\s+(\d+\s+)?\S+`),
regexp.MustCompile(`(?i)(md5|sha|encrypted)\s+(\d+\s+)?\S{8,}`),
}