burterm/internal/tui/requestbuilder.go
2026-09-14 10:55:07 +03:00

441 lines
16 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 tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/cli"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/payload"
)
// requestBuilderModel — форма ввода: сырой HTTP-запрос с §маркерами§,
// режим атаки, спецификации payload'ов (по одной на точку вставки,
// в порядке §маркеров§) и параметры движка.
type requestBuilderModel struct {
textarea textarea.Model
mode engine.AttackMode
payloadInputs []textinput.Model
concurrency textinput.Model
rateLimitMs textinput.Model
timeoutSec textinput.Model
insecure bool
scheme string // "https" | "http" — см. комментарий к engine.Config.ForceScheme
// focusIdx: 0 — textarea, 1..len(payloadInputs) — поля payload'ов,
// затем concurrency, rateLimitMs, timeoutSec.
focusIdx int
errMsg string
statusMsg string // информационная строка (не ошибка) — напр. что предложила ctrl+n/ctrl+l
presetCycleIdx int // текущая позиция обзора заготовок по ctrl+l
lastSentRaw string // содержимое textarea на момент последнего ctrl+r — для звёздочки "есть несохранённые правки"
// recursionEnabled — включает авто-рекурсию в найденные директории
// (аналог -recursion у ffuf, см. комментарий у recursionMeta в model.go).
// Работает только когда в запросе ровно один §маркер§ — при нескольких
// точках вставки неясно, по какой из них углубляться, а городить ради
// этого отдельный UI-селектор точки не стали ради простоты.
recursionEnabled bool
}
// defaultRecursionStatuses/MaxDepth — параметры рекурсии не вынесены
// в отдельные поля формы (и так уже 9+ полей на экране) — фиксированные
// разумные дефолты, как первое приближение. Статусы совпадают с тем,
// что ffuf по умолчанию считает "нашли что-то, стоит углубиться".
const (
defaultRecursionStatuses = "200,301,302,403"
defaultRecursionMaxDepth = 2
)
func newRequestBuilder() requestBuilderModel {
ta := textarea.New()
ta.Placeholder = "GET /api/user?id=§1§ HTTP/1.1\nHost: example.com\n\n"
ta.ShowLineNumbers = true
ta.Focus()
payloadIn := textinput.New()
payloadIn.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
payloadIn.CharLimit = 256
concurrency := textinput.New()
concurrency.Placeholder = "10"
concurrency.CharLimit = 6
rateLimit := textinput.New()
rateLimit.Placeholder = "0"
rateLimit.CharLimit = 8
timeout := textinput.New()
timeout.Placeholder = "10"
timeout.CharLimit = 6
return requestBuilderModel{
textarea: ta,
mode: engine.ModeSniper,
scheme: "https",
payloadInputs: []textinput.Model{payloadIn},
concurrency: concurrency,
rateLimitMs: rateLimit,
timeoutSec: timeout,
}
}
// totalFocusable — сколько полей можно обойти табом: textarea +
// поля payload'ов + concurrency/rateLimit/timeout.
func (m *requestBuilderModel) totalFocusable() int {
return 1 + len(m.payloadInputs) + 3
}
// applyFocus снимает фокус со всех полей и ставит его на m.focusIdx.
// Возвращаемые Focus() команды (мигание курсора) осознанно отбрасываются
// ради простоты — не влияет на функциональность, только на анимацию.
func (m *requestBuilderModel) applyFocus() {
m.textarea.Blur()
for i := range m.payloadInputs {
m.payloadInputs[i].Blur()
}
m.concurrency.Blur()
m.rateLimitMs.Blur()
m.timeoutSec.Blur()
switch {
case m.focusIdx == 0:
m.textarea.Focus()
case m.focusIdx <= len(m.payloadInputs):
m.payloadInputs[m.focusIdx-1].Focus()
case m.focusIdx == len(m.payloadInputs)+1:
m.concurrency.Focus()
case m.focusIdx == len(m.payloadInputs)+2:
m.rateLimitMs.Focus()
default:
m.timeoutSec.Focus()
}
}
// IsModified — правки в textarea после последней отправки (ctrl+r) есть?
// Используется корневой моделью для звёздочки в названии вкладки [f1].
// Сравнение с изначально пустым lastSentRaw тоже корректно: если текст
// набран, но ни разу не отправлен, это и есть несохранённые правки.
func (m requestBuilderModel) IsModified() bool {
return m.textarea.Value() != m.lastSentRaw
}
func modeName(mode engine.AttackMode) string {
switch mode {
case engine.ModeSniper:
return "sniper"
case engine.ModeBattering:
return "battering"
case engine.ModePitchfork:
return "pitchfork"
case engine.ModeClusterBomb:
return "clusterbomb"
default:
return "?"
}
}
func nextMode(mode engine.AttackMode) engine.AttackMode {
return (mode + 1) % 4
}
func (m requestBuilderModel) Update(msg tea.Msg) (requestBuilderModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focusIdx = (m.focusIdx + 1) % m.totalFocusable()
m.applyFocus()
return m, nil
case "shift+tab":
m.focusIdx--
if m.focusIdx < 0 {
m.focusIdx = m.totalFocusable() - 1
}
m.applyFocus()
return m, nil
case "ctrl+g":
// смена режима атаки; ctrl+m конфликтует с Enter в некоторых
// терминалах, поэтому используем ctrl+g ("go mode")
m.mode = nextMode(m.mode)
return m, nil
case "ctrl+t":
m.insecure = !m.insecure
return m, nil
case "ctrl+h":
if m.scheme == "https" {
m.scheme = "http"
} else {
m.scheme = "https"
}
return m, nil
case "ctrl+p":
ti := textinput.New()
ti.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
ti.CharLimit = 256
m.payloadInputs = append(m.payloadInputs, ti)
return m, nil
case "ctrl+x":
if len(m.payloadInputs) > 1 {
m.payloadInputs = m.payloadInputs[:len(m.payloadInputs)-1]
if m.focusIdx >= m.totalFocusable() {
m.focusIdx = m.totalFocusable() - 1
}
m.applyFocus()
}
return m, nil
case "ctrl+r":
m.errMsg = ""
m.lastSentRaw = m.textarea.Value()
return m, m.runCmd()
case "ctrl+n":
m.suggestPreset()
return m, nil
case "ctrl+l":
m.cyclePreset()
return m, nil
case "ctrl+u":
m.recursionEnabled = !m.recursionEnabled
return m, nil
}
}
var cmd tea.Cmd
switch {
case m.focusIdx == 0:
m.textarea, cmd = m.textarea.Update(msg)
case m.focusIdx <= len(m.payloadInputs):
m.payloadInputs[m.focusIdx-1], cmd = m.payloadInputs[m.focusIdx-1].Update(msg)
case m.focusIdx == len(m.payloadInputs)+1:
m.concurrency, cmd = m.concurrency.Update(msg)
case m.focusIdx == len(m.payloadInputs)+2:
m.rateLimitMs, cmd = m.rateLimitMs.Update(msg)
default:
m.timeoutSec, cmd = m.timeoutSec.Update(msg)
}
return m, cmd
}
// currentPayloadPoint возвращает индекс точки вставки (0-based),
// соответствующей полю payload'а в фокусе, и true — если фокус сейчас
// действительно на одном из полей payload'ов (а не на textarea или
// concurrency/rateLimit/timeout).
func (m *requestBuilderModel) currentPayloadPoint() (int, bool) {
if m.focusIdx < 1 || m.focusIdx > len(m.payloadInputs) {
return 0, false
}
return m.focusIdx - 1, true
}
// suggestPreset (ctrl+n) определяет имя параметра рядом с §маркером§,
// на который смотрит поле payload'а в фокусе, и подставляет туда лучшую
// по эвристике заготовку из payload.SmartSuggest. Остальные варианты
// показывает в statusMsg — это подсказка, а не единственно верный ответ,
// пользователь всегда может переопределить вручную.
func (m *requestBuilderModel) suggestPreset() {
point, ok := m.currentPayloadPoint()
if !ok {
m.statusMsg = "ctrl+n работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
return
}
paramName := engine.DetectParamName(m.textarea.Value(), point)
suggestions := payload.SmartSuggest(paramName)
if len(suggestions) == 0 {
m.statusMsg = "не удалось предложить заготовку для этой точки"
return
}
m.payloadInputs[point].SetValue("preset:" + suggestions[0])
label := paramName
if label == "" {
label = "(имя параметра не распознано)"
}
if len(suggestions) > 1 {
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s (ещё варианты: %s)", label, suggestions[0], strings.Join(suggestions[1:], ", "))
} else {
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s", label, suggestions[0])
}
}
// cyclePreset (ctrl+l) обходит все встроенные заготовки по кругу,
// независимо от контекста — для случаев, когда хочется вручную
// пролистать варианты (числа, буквы и т.д.), а не полагаться на
// эвристику suggestPreset.
func (m *requestBuilderModel) cyclePreset() {
point, ok := m.currentPayloadPoint()
if !ok {
m.statusMsg = "ctrl+l работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
return
}
names := payload.PresetNames()
if len(names) == 0 {
return
}
name := names[m.presetCycleIdx%len(names)]
m.presetCycleIdx++
m.payloadInputs[point].SetValue("preset:" + name)
m.statusMsg = fmt.Sprintf("%s — %s", name, payload.PresetDescription(name))
}
// runCmd снимает копию всех значений формы (чтобы не держать ссылку на
// изменяемую модель внутри замыкания команды), валидирует их и запускает
// атаку. Возвращает attackStartedMsg с каналом результатов и cancel'ом,
// либо errMsg с человекочитаемой причиной отказа.
func (m requestBuilderModel) runCmd() tea.Cmd {
raw := m.textarea.Value()
mode := m.mode
insecure := m.insecure
scheme := m.scheme
recursionEnabled := m.recursionEnabled
specs := make([]string, len(m.payloadInputs))
for i, ti := range m.payloadInputs {
specs[i] = ti.Value()
}
concurrencyStr := m.concurrency.Value()
rateLimitStr := m.rateLimitMs.Value()
timeoutStr := m.timeoutSec.Value()
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return errMsg{fmt.Errorf("маркеры §...§: %w", err)}
}
if len(points) == 0 {
return errMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего фаззить")}
}
gens := make([]payload.Generator, 0, len(specs))
for _, spec := range specs {
if spec == "" {
continue
}
g, err := cli.ParsePayloadSpec(spec)
if err != nil {
return errMsg{fmt.Errorf("payload %q: %w", spec, err)}
}
gens = append(gens, g)
}
if len(gens) == 0 {
return errMsg{fmt.Errorf("укажи хотя бы один payload (ctrl+p — добавить поле)")}
}
concurrency, _ := strconv.Atoi(concurrencyStr)
if concurrency <= 0 {
concurrency = 10
}
rateLimitVal, _ := strconv.Atoi(rateLimitStr)
timeoutVal, _ := strconv.Atoi(timeoutStr)
if timeoutVal <= 0 {
timeoutVal = 10
}
rateLimit := time.Duration(rateLimitVal) * time.Millisecond
timeout := time.Duration(timeoutVal) * time.Second
eng := engine.New(engine.Config{
Concurrency: concurrency,
RateLimit: rateLimit,
Timeout: timeout,
InsecureSkipVerify: insecure,
ForceScheme: scheme,
})
base := make([]string, len(points))
total := engine.EstimateTotal(mode, points, gens)
ctx, cancel := context.WithCancel(context.Background())
ch, err := eng.Run(ctx, mode, clean, points, gens, base)
if err != nil {
cancel()
return errMsg{err}
}
var recursion *recursionMeta
if recursionEnabled && len(points) == 1 {
statusPred, _ := engine.ParseStatusFilter(defaultRecursionStatuses) // синтаксис уже проверен константой, ошибки не будет
recursion = &recursionMeta{
rawTemplate: raw,
pointIndex: 0,
mode: mode,
specs: specs,
depth: 0,
maxDepth: defaultRecursionMaxDepth,
statusPred: statusPred,
concurrency: concurrency,
rateLimit: rateLimit,
timeout: timeout,
insecure: insecure,
forceScheme: scheme,
}
}
return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: recursion}
}
}
func (m requestBuilderModel) View() string {
var b strings.Builder
b.WriteString(fieldLabel("Запрос (§...§ отмечает точку вставки)", m.focusIdx == 0))
b.WriteString(m.textarea.View())
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Режим: %-11s (ctrl+g) Схема: %-5s (ctrl+h) Insecure TLS: %v (ctrl+t)\n", modeName(m.mode), m.scheme, m.insecure))
b.WriteString(fmt.Sprintf("Рекурсия в директории: %v (ctrl+u; работает только с одним §маркером§, статусы %s, глубина %d)\n\n", m.recursionEnabled, defaultRecursionStatuses, defaultRecursionMaxDepth))
b.WriteString("Payload'ы, по одному на §маркер§ (ctrl+p — добавить, ctrl+x — убрать):\n")
for i, ti := range m.payloadInputs {
b.WriteString(fieldLabel(fmt.Sprintf(" #%d", i+1), m.focusIdx == i+1))
b.WriteString(ti.View())
b.WriteString("\n")
}
b.WriteString("\n")
idxConcurrency := len(m.payloadInputs) + 1
idxRateLimit := len(m.payloadInputs) + 2
idxTimeout := len(m.payloadInputs) + 3
b.WriteString(fieldLabel("Concurrency", m.focusIdx == idxConcurrency))
b.WriteString(m.concurrency.View())
b.WriteString("\n")
b.WriteString(fieldLabel("Rate limit, мс", m.focusIdx == idxRateLimit))
b.WriteString(m.rateLimitMs.View())
b.WriteString("\n")
b.WriteString(fieldLabel("Timeout, с", m.focusIdx == idxTimeout))
b.WriteString(m.timeoutSec.View())
b.WriteString("\n\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n\n")
}
b.WriteString("tab/shift+tab — между полями · ctrl+r — атака · ctrl+n — подсказать заготовку\n")
b.WriteString("ctrl+l — обзор заготовок · ctrl+u — рекурсия · f2 — результаты · ctrl+c — выход\n")
return b.String()
}
// fieldLabel помечает подпись поля маркером «▸», если оно сейчас в фокусе.
func fieldLabel(name string, focused bool) string {
marker := " "
if focused {
marker = "▸ "
}
return marker + name + "\n"
}