# Заметки по разработке 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 последними — там наибольшая оставшаяся неопределённость.