Механізм відкату існував з 0035 і жодного разу не виконувався на справжньому обладнанні. Прогін до межі заліза (план -> заявка -> погодження, без заливки) дав три правильні відмови й одну знахідку. D-Link питає пароль інтерактивно, і `show config` віддає відповіді окремими рядками без ключових слів. Порядкове маскування за зразками такий рядок не бачить, тож пароль адміністратора живого комутатора лежав у плані відкату, у ncm.rollbacks і в git-дзеркалі — маскування перед записом у git немає взагалі. redactLines отримав стан: після рядка заведення облікового запису до двох односкладових рядків маскуються. Ім'я запису лишається видимим. Дорогою: перший рядок збереженого конфігу — відлуння команди (`Command: show config`), і планувальникклав його в команди до заливки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
22 KiB
Go
542 lines
22 KiB
Go
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,}`),
|
||
}
|