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" }