burterm/NOTES.md
2026-09-14 10:55:07 +03:00

236 lines
22 KiB
Markdown
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.

# Заметки по разработке burterm
Чистое описание архитектуры — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
Этот файл — то, что не годится в ARCHITECTURE, потому что описывает не
«как устроено», а «почему так вышло» и «на что обратить внимание». Весь
код писался в среде без сети и без установленного Go — ни разу не
проходил через компилятор.
## Реальные баги, найденные и исправленные по ходу разработки
Не гипотетические — конкретные, с объяснением, как нашлись.
- **UTF-8 в `§маркерах§`.** Маркер `§` (U+00A7) — два байта в UTF-8
(`0xC2 0xA7`). Самая ранняя версия `ParseMarkers` сравнивала байты
напрямую и детектила маркер только по второму байту, оставляя первый
(`0xC2`) мусором в очищенной строке. Баг жил с первого черновика,
найден при разработке `DetectParamName` (умные заготовки payload'ов).
Исправлено: сканирование через `range` по рунам.
- **O(N²) в Results.** `resultsModel` на каждый входящий результат атаки
заново парсил текст всех полей фильтра и пересобирал весь список
отфильтрованных строк через `strings.Join`. На словаре в миллион строк
это останавливало TUI намертво. Исправлено тремя механизмами:
кэшированные предикаты (парсятся один раз при изменении текста поля, а
не на каждый результат), инкрементальный `strings.Builder` вместо
пересборки списка, дросселированный рендер во `viewport` по таймеру
раз в 300мс — и только для той атаки, что видна на экране.
- **`MaxIdleConnsPerHost`баг с самого начала, не только для больших
словарей.** Голый `http.Transport` даёт дефолт 2 соединения на хост.
При `Concurrency > 2` (а дефолт формы — 10) большинство воркеров
закрывало и переоткрывало TCP/TLS-соединение на каждый запрос вместо
переиспользования из пула. Исправлено в `engine.New` и в
`spider.New` (там клиент был вообще голый — Transport==nil, то есть
неявно общий пакетный `http.DefaultTransport`).
- **Прокси не читал тело ответа.** Изначальная версия сохраняла только
заголовки/статус — без тела полнотекстовый поиск по Project был бы
пустым обещанием. Исправлено: `io.ReadAll` полного тела, капированная
копия (8КБ) для хранения, `resp.Body` восстанавливается через
`io.NopCloser` перед `resp.Write()` (тело можно прочитать только один
раз).
- **Утечка горутин при параллельных атаках.** Изначально `Model` хранила
один `resultsCh`/`cancel` на всё приложение — вторая атака без
дожидания первой тихо переключала отслеживание, а горутины первой
оставались писать в канал, который больше никто не читает
(небуферизованный канал — блокировка навечно). Исправлено переходом
на `[]*attackRun` (независимые атаки) и на `select` с `ctx.Done()` при
записи в `out` внутри `engine.send()`.
- **`proxyModel.SetSize` вообще не резервировал место под собственные
заголовок/футер.** `View()` всегда рисует "Перехвачено: N" + пустую
строку сверху и пустую строку + строку подсказки снизу — 4 строки
сверх `viewport`, а `SetSize` отдавал `viewport.Height` всю
переданную высоту без остатка. Из-за этого вкладка Proxy стабильно
вылезала за экран независимо от того, сколько высоты ей давал
`model.go` — сам бюджет внутри файла никогда не учитывал собственный
хром. Найдено построчным разбором `View()`, а не подбором чисел
снаружи. Исправлено вычитанием фиксированных 4 строк внутри
`SetSize`.
- **`comparerModel`: `SetSize` считал раскладку A/B side-by-side (делит
ширину пополам через `halfWidth`), а `View()` рисовал их друг под
другом.** Из-за этого реальная высота была примерно вдвое больше
того, что закладывал бюджет `diff.Height` — вкладка Comparer
вылезала за экран независимо от общего бюджета высоты. Исправлено не
подгонкой чисел, а починкой самого рассинхрона: `View()` теперь
реально кладёт A и B рядом через `lipgloss.JoinHorizontal`, как и
предполагала раскладка ширины с самого начала.
- **Футер Dashboard врал про `pgup`/`pgdn`.** Подсказка гласила
"pgup/pgdn — прокрутка", хотя после того как эти клавиши стали
перехватываться глобально под переключение вкладок (см. раздел
решений ниже), до `viewport` они больше не доходят и ничего не
прокручивают. Текст поправлен на "↑/↓ — прокрутка".
- **Бюджет высоты был неверен на 10 из 12 вкладок одновременно — и по
двум независимым причинам.** Первая: строки-подсказки в футере
доходили до 200+ символов (`targetview.go` — 425 символов исходно с
учётом экранирования, `repeater.go` — 219) — терминал переносил их
сам, а высота этого переноса нигде не учитывалась. Исправлено
разбиением каждого длинного футера на гарантированные 2 (иногда 3)
строки: специфичные подсказки вкладки отдельно, общая навигация
`f1-f12, pgup — вкладки` отдельно —
так поместится в одну физическую строку почти на любом терминале, а
не будет угадывания, перенесётся или нет. Вторая причина — независимая
от первой: у нескольких вкладок константа вычитания была просто
арифметически неверна **с самого начала**, до всей этой истории с
футерами (Fuzzer: `-12` вместо нужных `-20`, Repeater: `-2` вместо
`-8`, Decoder/Sequencer: `-4` вместо `-8`/`-6`, Spider/Project:
`-6`/`-8` вместо `-11`). Обе причины нашлись только построчным
подсчётом каждого `View()` — при этом подсчёте я сам дважды ошибся
на лету (Decoder сначала посчитал 5 вместо верных 8) и пересчитывал
заново. Итоговые числа для всех 12 файлов сведены в одну таблицу для
сверки:
```
comparerview.go: diff.Height = height - inputHeight - 6
dashboardview.go: vh = height - 3
decoderview.go: output.Height = height - inHeight - 8
fuzzerview.go: vh = height - 20 (минимум для "чистого" состояния)
projectview.go: vh = height - 11
proxyview.go: vh = height - 6 (плюс ещё -5 снаружи, в model.go, под "Прокси:...CA:...")
repeater.go: response.Height = height - reqHeight - 8
results.go: vh = height - 11 (плюс ещё -6 снаружи, в model.go, под заголовок атаки)
scannerview.go: vh = height - taHeight - 15 - len(scanner.AllCheckNames())
sequencerview.go: output.Height = height - inHeight - 6
spiderview.go: vh = height - 11
targetview.go: vh = height - 13
```
Request Builder сюда не входит — он не считает высоту `viewport` по
фиксированному бюджету (сайзинг пропорциональный, `height/3`), так
что этот класс бага его не касается, хотя длинный футер разбил и там
для единообразия.
- **Comparer вылезал за экран ещё дважды после починки JoinHorizontal**
(сначала `-5`, потом `-6` — оба раза построчный подсчёт по коду
казался верным, но на практике не сходилось). Рабочая гипотеза:
`textarea.View()` (компонент bubbles), возможно, рендерит **не ровно**
столько строк, сколько задано через `SetHeight` — то есть у неё есть
какой-то собственный оверхед (рамка/индикатор/что-то ещё), которого
нет в исходниках, доступных мне без реального терминала. Это касается
не только Comparer — `Decoder`, `Repeater`, `Scanner`, `Request
Builder` тоже используют `textarea` и МОГЛИ пострадать той же
проблемой, просто не в такой явной форме (или ещё не протестированы
так тщательно). Для Comparer вместо третьей попытки точного подсчёта
поставлен солидный запас (`-12` вместо расчётных `6`, `inputHeight`
урезан до `height/4`) — если и остальные текстовые вкладки продолжат
вылезать за экран после всех починок из записи выше, это будет
подтверждением гипотезы, и той же логике (запас вместо точного числа)
нужно последовать и там.
## Непроверенные места (порядок — от вероятных проблем к маловероятным)
1. **`go.mod`: `modernc.org/sqlite v1.34.4`** — версия по памяти,
`go mod tidy` может подтянуть другую, `go.sum` пересоберётся с нуля.
2. **bubbles/bubbletea API** — `Focus()` у `textinput`/`textarea` в
некоторых версиях возвращает `tea.Cmd`, в некоторых — нет; сигнатуры
`spinner.Tick` могли измениться между версиями.
3. **`exitErr.Sys().(syscall.WaitStatus)`** в `internal/fuzzer/fuzzer.go`
— platform-specific type assertion, работает только на Unix.
4. **Гонки данных, не проверенные race detector'ом.** Весь проект
писался с оглядкой на конкурентный доступ (`sync.RWMutex` в
`proxy.Server`, независимые снапшоты `sitemap.Scope`, `atomic` в
счётчиках фаззера), но ни разу не прогонялся под `go test -race`.
5. **`pgup`** (вкладка Fuzzer, единственная за пределами F1-F12 после
выделения Steganography/аудио в отдельный проект stegterm) —
четвёртая попытка навигации на вкладку, не влезающую в F1-F12
(история первых трёх — в разделе решений ниже). PageUp кодируется
терминалом так же атомарно, как F-клавиши (в отличие от Alt+буква,
которая на практике расщепляется на ESC+символ) — по конструкции
должен быть надёжнее, но конкретно в связке с Zellij ещё не
подтверждён.
## Сознательно отложенные и оставленные как есть решения
- **Навигация на Fuzzer — четыре попытки подряд, есть разобранная
причина третьего провала.** У физической клавиатуры нет F13/F14, а
13 вкладок в F1-F12 не помещаются (изначально этой проблеме были
подвержены сразу две вкладки — Fuzzer и Steganography, — но
Steganography с тех пор выделена в отдельный проект stegterm, и
сейчас в лишней клавише нуждается только Fuzzer). История:
1. `f13`/`f14` — не существуют физически, отпало сразу.
2. `shift+f1` — по историческому терминальному соглашению это и есть
F13 (`kf13` в terminfo), но подтверждённо не сработало в связке с
Zellij.
3. `alt+f` — подтверждённо не сработало, и в этот раз причина
найдена: терминал расщепляет `Alt+буква` на два отдельных байта
(`ESC`, затем сама буква) вместо единой комбинации. Если между
ними случается задержка (обычное дело через мультиплексор/SSH),
bubbletea не успевает склеить их обратно и видит одинокую букву —
а раз в фокусе текстовое поле (что почти всегда так по
умолчанию), эта буква просто печатается как обычный ввод, а не
распознаётся как комбинация. F-клавиши от этой болезни не страдают:
кодируются терминалом атомарно, не могут расщепиться в печатный
символ.
4. `pgup` — та же атомарная категория кодирования, что и у F-клавиш,
поэтому по конструкции не должен страдать той же болезнью, что
Alt-комбинации. Компромисс: `viewport` (Results, Proxy, Scanner и
т.д.) по умолчанию тоже вешает постраничную прокрутку на PageUp —
раз эта клавиша перехватывается глобально на уровне корневой
модели раньше, чем дойдёт до конкретной вкладки, постраничная
прокрутка пропадает везде, остаются только стрелки (построчно).
Осознанный выбор: надёжный доступ к вкладке важнее удобства
постраничной прокрутки. Не проверено на практике конкретно в
связке с Zellij.
- **Единая рамка вокруг тела каждой вкладки — откачена.** Была попытка
обернуть body любой вкладки (кроме Dashboard) в `panel()` для
визуальной консистентности со всем приложением. Дважды подряд не
смог вслепую подобрать для неё правильный бюджет высоты: сначала
меню обрезалось снизу на нескольких вкладках, потом (после первой
попытки исправить) сама рамка вылезала за край экрана на вкладках с
длинным содержимым (Comparer, Scanner). Без реального терминала под
рукой это угадывание, а не расчёт — после второго провала откатил
саму обёртку, а не в третий раз стал подбирать числа. Остались
только подтверждённо рабочие вещи: Dashboard со своими собственными
панелями и стилизованный таб-бар. Если понадобится вернуться к идее
единой рамки на каждой вкладке — делать это нужно с реальным
терминалом под рукой для итеративной проверки, не вслепую.
- **Плагины.** Обсуждалась возможность подключения внешних модулей.
Решение: не строить сейчас, направление на будущее — подпроцессы поверх
JSON Lines по stdin/stdout (не Go `plugin`-пакет — хрупкий, требует
точного совпадения версии Go; не встроенный Lua — новая непроверяемая
зависимость с тем же риском, что и sqlite). Подпроцесс живёт весь
прогон (атаку/скан), не спавнится на каждый вызов. Три типа плагинов
на будущее: `payload-generator`, `scanner-check`, `decoder`. В коде не
реализовано ничего.
- **Побочный эффект рекурсии Intruder.** У `Model` изначально было
заложено поведение «после старта атаки переключиться на Results» —
рекурсия тоже шлёт `attackStartedMsg` на каждую находку, значит при
активной рекурсии с несколькими совпадениями пользователя может
несколько раз подряд дёрнуть на вкладку Results. Решение неочевидно
(нужно различать «юзер нажал ctrl+r» от «рекурсия сама что-то
нашла»), оставлено как есть.
- **`drainGenerator` для Pitchfork/ClusterBomb** вычитывает генератор
целиком в память перед стартом. Для словарей на миллионы строк — разовая
пауза и всплеск памяти перед первым запросом. Не исправлено: риск
сломать корректность одометра выше выигрыша.
- **Только WAV, не MP3/FLAC.** Сознательный выбор, не недоделка: сжатие с
потерями убивает мелкую структуру спектра, в которую в CTF прячут
сообщения — задачи почти всегда именно в PCM WAV.
- **Поля "второе изображение"/"длина LSB" видны и в аудио-режиме
вкладки Steganography**, хотя там ничего не делают. Условный layout
ради их сокрытия усложнил бы `View()` заметнее, чем стоит того.
- **ASCII-логотип не в блочном стиле** (как у референсных TUI-инструментов
вроде Burp) — сознательно: точная посимвольная геометрия блочных букв
без реального терминала для проверки выравнивания легко разъезжается на
один символ. Вместо этого — разрядка текста акцентным цветом.
- **Заголовки панелей — не врезаны в саму рамку** (`┌─ TITLE ─┐`), а
первая строка жирным текстом внутри неё — по той же причине: точная
ширина без права на ошибку.
## Порядок отладки при первой сборке
1. `go mod tidy` — разберётся с зависимостями и `go.sum`.
2. `go build ./...` — почти наверняка всплывут точечные несовпадения
сигнатур bubbles/bubbletea. Правятся по месту, по тексту ошибки.
3. `go vet ./...` — статический анализ.
4. `go run -race ./cmd/burterm` — особенно при активном Proxy +
одновременной смене scope-правил.
5. Дальше — по вкладкам, от простых к сложным: Decoder/Comparer/Sequencer
(чистая логика, без сети) → Repeater (один запрос) → Intruder на
маленьком словаре → Proxy → Scanner/Fuzzer последними — там
наибольшая оставшаяся неопределённость.