diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 229 |
1 files changed, 172 insertions, 57 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 607f21c..31491e9 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -92,6 +92,20 @@ const ( focusResponse ) +// repeaterTab is one open Repeater buffer - its own request editor, +// response view and send state, independent of every other open tab. +// Burp/Caido both let you keep several requests open in Repeater at +// once for side-by-side iteration; a single shared buffer that every +// "send to repeater" overwrote was the gap this closes. +type repeaterTab struct { + scheme, host string + reqArea viTextarea + respView viewport.Model + focus repeaterFocus + result *ipc.EntryDetail + sending bool +} + type ruleField int const ( @@ -129,13 +143,8 @@ type model struct { activeTab detailTab prettyMode bool // display-only JSON reformatting of the response body - reqArea viTextarea - respView viewport.Model - repeaterFocus repeaterFocus - repeaterScheme string - repeaterHost string - repeaterResult *ipc.EntryDetail - sending bool + repeaterTabs []*repeaterTab + repeaterIndex int rulesTable table.Model ruleRows []rules.Rule @@ -201,10 +210,6 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true) t.SetStyles(st) - ta := newViTextarea() - ta.ta.Placeholder = "raw request bytes" - ta.ta.ShowLineNumbers = false - si := textinput.New() si.Prompt = "/" si.Placeholder = "search - plain text, host:x, status:404/4xx/>=400, source:repeater, flagged:true" @@ -256,7 +261,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) socketPath: socketPath, mode: viewList, table: t, - reqArea: ta, + repeaterIndex: -1, searchInput: si, rulesTable: rt, ruleName: nameIn, @@ -290,8 +295,9 @@ type detailLoadedMsg struct { } type repeatSentMsg struct { - detail *ipc.EntryDetail - err error + detail *ipc.EntryDetail + err error + tabIndex int // which repeater tab this send belongs to } type statusLoadedMsg struct { @@ -382,30 +388,77 @@ func (m *model) loadCompare(idA, idB int64) tea.Cmd { } } +// activeRepeaterTab returns the currently selected tab, or nil if none +// are open yet. +func (m *model) activeRepeaterTab() *repeaterTab { + if m.repeaterIndex < 0 || m.repeaterIndex >= len(m.repeaterTabs) { + return nil + } + return m.repeaterTabs[m.repeaterIndex] +} + +// sizeRepeaterTab applies the current terminal dimensions to one tab's +// editor and response viewport. Called for every open tab on resize, +// and for a single new tab right after creation (which otherwise +// wouldn't get sized until the next WindowSizeMsg). +func (m *model) sizeRepeaterTab(t *repeaterTab) { + h := m.height - 1 + reqHeight := (h - 6) / 2 + t.reqArea.SetWidth(m.width) + t.reqArea.SetHeight(reqHeight) + t.respView = viewport.New(m.width, h-6-reqHeight) +} + +// closeRepeaterTab removes the tab at idx and moves the active index +// onto a neighbor so closing the last tab never leaves a stale +// out-of-range selection. +func (m *model) closeRepeaterTab(idx int) { + if idx < 0 || idx >= len(m.repeaterTabs) { + return + } + m.repeaterTabs = append(m.repeaterTabs[:idx], m.repeaterTabs[idx+1:]...) + if m.repeaterIndex >= len(m.repeaterTabs) { + m.repeaterIndex = len(m.repeaterTabs) - 1 + } +} + func (m *model) sendRepeat() tea.Cmd { - scheme, host := m.repeaterScheme, m.repeaterHost + t := m.activeRepeaterTab() + if t == nil { + return nil + } + scheme, host := t.scheme, t.host // The textarea only understands LF; HTTP/1.1 requires CRLF. Restoring // it here means a body containing its own bare LF line breaks (a // multi-line JSON/XML payload, say) gets those normalized to CRLF too // - a known, narrow trade-off for being able to edit the request as // text at all. Headers and single-line bodies, the common case, are // unaffected. - raw := []byte(strings.ReplaceAll(m.reqArea.Value(), "\n", "\r\n")) + raw := []byte(strings.ReplaceAll(t.reqArea.Value(), "\n", "\r\n")) + tabIdx := m.repeaterIndex return func() tea.Msg { d, err := m.client.Repeat(scheme, host, raw) - return repeatSentMsg{detail: d, err: err} + return repeatSentMsg{detail: d, err: err, tabIndex: tabIdx} } } -// enterRepeater seeds the repeater view from an already-loaded entry. +// enterRepeater opens a NEW repeater tab seeded from an already-loaded +// entry and switches to it - existing tabs stay open, matching Burp's +// "send to repeater" behavior of accumulating tabs rather than +// overwriting whatever was already there. func (m *model) enterRepeater(d *ipc.EntryDetail) { - m.repeaterScheme = d.Scheme - m.repeaterHost = d.Host - m.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) - m.reqArea.Focus() - m.respView.SetContent("") - m.repeaterResult = nil - m.repeaterFocus = focusRequest + t := &repeaterTab{ + scheme: d.Scheme, + host: d.Host, + reqArea: newViTextarea(), + } + t.reqArea.ta.ShowLineNumbers = false + t.reqArea.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) + t.reqArea.Focus() + t.focus = focusRequest + m.sizeRepeaterTab(t) + m.repeaterTabs = append(m.repeaterTabs, t) + m.repeaterIndex = len(m.repeaterTabs) - 1 m.mode = viewRepeater m.statusMsg = "" } @@ -585,10 +638,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.decoderInput.SetHeight(decInHeight) m.decoderOutput = viewport.New(msg.Width, h-8-decInHeight) - reqHeight := (h - 6) / 2 - m.reqArea.SetWidth(msg.Width) - m.reqArea.SetHeight(reqHeight) - m.respView = viewport.New(msg.Width, h-6-reqHeight) + for _, t := range m.repeaterTabs { + m.sizeRepeaterTab(t) + } m.rulesTable.SetWidth(msg.Width) m.rulesTable.SetHeight(h - 5) @@ -682,15 +734,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case repeatSentMsg: - m.sending = false + if msg.tabIndex < 0 || msg.tabIndex >= len(m.repeaterTabs) { + // Tab was closed while the send was in flight - drop the result. + return m, nil + } + t := m.repeaterTabs[msg.tabIndex] + t.sending = false + active := msg.tabIndex == m.repeaterIndex if msg.err != nil { - m.statusMsg = "send error: " + msg.err.Error() + if active { + m.statusMsg = "send error: " + msg.err.Error() + } return m, nil } - m.repeaterResult = msg.detail - m.statusMsg = fmt.Sprintf("-> %d (%s)", msg.detail.StatusCode, msg.detail.Duration.Round(time.Millisecond)) - m.respView.SetContent(detailBody(msg.detail, tabResponse)) - m.respView.GotoTop() + t.result = msg.detail + if active { + m.statusMsg = fmt.Sprintf("-> %d (%s)", msg.detail.StatusCode, msg.detail.Duration.Round(time.Millisecond)) + } + t.respView.SetContent(detailBody(msg.detail, tabResponse)) + t.respView.GotoTop() return m, nil case rulesLoadedMsg: @@ -865,48 +927,79 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case viewRepeater: + t := m.activeRepeaterTab() + if t == nil { + m.mode = viewList + return m, nil + } + // Tab-management keys (]/[ switch tabs, ctrl+w closes one) only + // fire outside insert mode - otherwise they'd be untypeable + // characters (or, for ctrl+w, steal the textarea's own + // delete-word-backward binding) while composing a request. + inInsert := t.focus == focusRequest && t.reqArea.Mode() == viInsert switch msg.String() { case "esc": // In insert mode, esc belongs to the textarea - it drops // to normal mode without leaving the view, matching vi. // Only back out when already in normal mode (or focus is // elsewhere entirely). - if m.repeaterFocus == focusRequest && m.reqArea.Mode() == viInsert { + if inInsert { break } m.mode = viewList - m.reqArea.Blur() + t.reqArea.Blur() return m, nil case "ctrl+c": return m, tea.Quit case "?": - if m.repeaterFocus != focusRequest || m.reqArea.Mode() != viInsert { + if !inInsert { m.prevMode = viewRepeater m.mode = viewHelp return m, nil } case "ctrl+r": - if !m.sending { - m.sending = true + if !t.sending { + t.sending = true m.statusMsg = "sending..." return m, m.sendRepeat() } return m, nil case "tab": - if m.repeaterFocus == focusRequest { - m.repeaterFocus = focusResponse - m.reqArea.Blur() + if t.focus == focusRequest { + t.focus = focusResponse + t.reqArea.Blur() } else { - m.repeaterFocus = focusRequest - m.reqArea.Focus() + t.focus = focusRequest + t.reqArea.Focus() + } + return m, nil + case "]": + if !inInsert && len(m.repeaterTabs) > 1 { + m.repeaterIndex = (m.repeaterIndex + 1) % len(m.repeaterTabs) + m.statusMsg = "" } return m, nil + case "[": + if !inInsert && len(m.repeaterTabs) > 1 { + m.repeaterIndex = (m.repeaterIndex - 1 + len(m.repeaterTabs)) % len(m.repeaterTabs) + m.statusMsg = "" + } + return m, nil + case "ctrl+w": + if !inInsert { + m.closeRepeaterTab(m.repeaterIndex) + if len(m.repeaterTabs) == 0 { + m.mode = viewList + } + m.statusMsg = "" + return m, nil + } } var cmd tea.Cmd - if m.repeaterFocus == focusRequest { - cmd = m.reqArea.Update(msg) + if t.focus == focusRequest { + cmd = t.reqArea.Update(msg) } else { - m.respView, cmd = m.respView.Update(msg) + t.respView, cmd = t.respView.Update(msg) } return m, cmd @@ -1249,6 +1342,8 @@ func (m *model) helpView() string { "gg / G top/bottom of buffer esc back to normal mode", "ctrl+r send / start attack tab switch pane", "ctrl+g (Intruder template only) insert a § marker at cursor", + "]/[ (Repeater only) next/previous tab", + "ctrl+w (Repeater only) close current tab", ) section("Rules", "a add rule enter / e edit selected", @@ -1337,16 +1432,36 @@ func (m *model) detailView() string { func (m *model) repeaterView() string { var b strings.Builder - title := fmt.Sprintf(" repeater - %s://%s ", m.repeaterScheme, m.repeaterHost) + t := m.activeRepeaterTab() + if t == nil { + b.WriteString(titleStyle.Render(" repeater ")) + b.WriteString("\n\nno tabs open - press r on a history entry\n") + b.WriteString(helpStyle.Render("esc back · ? help · ctrl+c quit")) + return b.String() + } + + if len(m.repeaterTabs) > 1 { + for i, rt := range m.repeaterTabs { + label := fmt.Sprintf(" %d:%s ", i+1, rt.host) + if i == m.repeaterIndex { + b.WriteString(tabActive.Render(label)) + } else { + b.WriteString(tabInactive.Render(label)) + } + } + b.WriteString("\n") + } + + title := fmt.Sprintf(" repeater - %s://%s ", t.scheme, t.host) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") reqLabel := "Request (editable)" respLabel := "Response" - if m.repeaterResult != nil { - respLabel = fmt.Sprintf("Response (%d bytes%s)", len(m.repeaterResult.ResponseRaw), exactSuffix(m.repeaterResult.ResponseExact)) + if t.result != nil { + respLabel = fmt.Sprintf("Response (%d bytes%s)", len(t.result.ResponseRaw), exactSuffix(t.result.ResponseExact)) } - if m.repeaterFocus == focusRequest { + if t.focus == focusRequest { b.WriteString(tabActive.Render(reqLabel)) b.WriteString(tabInactive.Render(respLabel)) } else { @@ -1354,19 +1469,19 @@ func (m *model) repeaterView() string { b.WriteString(tabActive.Render(respLabel)) } b.WriteString("\n") - b.WriteString(m.reqArea.View()) + b.WriteString(t.reqArea.View()) b.WriteString("\n") - b.WriteString(m.respView.View()) + b.WriteString(t.respView.View()) b.WriteString("\n") - if m.repeaterFocus == focusRequest { - b.WriteString(viModeLabel(&m.reqArea)) + if t.focus == focusRequest { + b.WriteString(viModeLabel(&t.reqArea)) b.WriteString("\n") } if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · esc back · ? help · ctrl+c quit")) + b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · ]/[ next/prev tab · ctrl+w close tab · esc back · ? help · ctrl+c quit")) return b.String() } |