srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-08-29 22:03:00 +0200
committersrdusr <[email protected]>2024-08-29 22:03:00 +0200
commit70f675e59ac4bb0e469ca0073f8cc0506b17677e (patch)
treee3a525b26dfcf16ce4f9bde97b1408a373d87944 /cmd
parent8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6 (diff)
downloadmitmux-70f675e59ac4bb0e469ca0073f8cc0506b17677e.tar.gz
mitmux-70f675e59ac4bb0e469ca0073f8cc0506b17677e.zip
Repeater: raw-byte send/resend
Implements build-order step 4, the feature the plan calls out as used daily. internal/proxy/repeat.go adds Server.Repeat(scheme, host, raw): dials fresh (HTTP/1.1-only - raw edited text has no equivalent in HTTP/2's binary framing), writes raw exactly as given with no framing correction or header injection, and captures the exact response bytes. This is deliberately separate from forward()'s parsed-*http.Request path since Repeater's entire point is letting a malformed/edited request reach the wire unmodified. Repeater sends are recorded to the same history table as proxy traffic (added a "source" column: "proxy" vs "repeater") so they show up in the unified history view and the live subscribe stream, not a separate silo. internal/ipc gains a "repeat" request/response pair; cmd/mitmuxd wires proxy.Server into ipc.NewServer via a small Repeater interface so the daemon keeps owning all network I/O and the TUI stays a thin client. cmd/mitmux gains a repeater view (bubbles/textarea for the editable raw request, a read-only viewport for the response), reachable with 'r' from either the list or detail view, ctrl+r to send. One real bug found via testing: bubbles/textarea only understands LF, but HTTP/1.1 requires CRLF, so loading raw bytes straight into it split each line in two on render. Fixed by normalizing CRLF<->LF at the editor boundary only (load: strip \r; send: restore it) - documented as a narrow, known trade-off for bodies with their own embedded LF line breaks, which is the cost of being able to edit raw HTTP as text at all. Verified live: edited and sent a plain-HTTP repeater request (confirmed in SQLite that the edit - including an intentional extra blank line from imprecise cursor navigation during testing - went out completely unmodified, which is the correct behavior: mitmux must never "fix" what the user typed), and sent an HTTPS repeater request against a freshly captured entry, both getting real 200 responses with exact response bytes back.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go171
-rw-r--r--cmd/mitmuxd/main.go8
2 files changed, 166 insertions, 13 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 9f07f91..6d7fe8c 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -12,6 +12,7 @@ import (
"time"
"github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -68,6 +69,7 @@ type viewMode int
const (
viewList viewMode = iota
viewDetail
+ viewRepeater
)
type detailTab int
@@ -77,6 +79,13 @@ const (
tabResponse
)
+type repeaterFocus int
+
+const (
+ focusRequest repeaterFocus = iota
+ focusResponse
+)
+
type model struct {
client *ipc.Client
subCh <-chan store.Summary
@@ -89,6 +98,14 @@ type model struct {
detail *ipc.EntryDetail
activeTab detailTab
+ reqArea textarea.Model
+ respView viewport.Model
+ repeaterFocus repeaterFocus
+ repeaterScheme string
+ repeaterHost string
+ repeaterResult *ipc.EntryDetail
+ sending bool
+
statusMsg string
width int
height int
@@ -114,11 +131,16 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model {
st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true)
t.SetStyles(st)
+ ta := textarea.New()
+ ta.Placeholder = "raw request bytes"
+ ta.ShowLineNumbers = false
+
return &model{
- client: client,
- subCh: subCh,
- mode: viewList,
- table: t,
+ client: client,
+ subCh: subCh,
+ mode: viewList,
+ table: t,
+ reqArea: ta,
}
}
@@ -133,6 +155,12 @@ type newEntryMsg struct {
}
type detailLoadedMsg struct {
+ detail *ipc.EntryDetail
+ err error
+ openRepeater bool
+}
+
+type repeatSentMsg struct {
detail *ipc.EntryDetail
err error
}
@@ -147,13 +175,41 @@ func (m *model) waitForEntry() tea.Msg {
return newEntryMsg{entry: e, ok: ok}
}
-func (m *model) loadDetail(id int64) tea.Cmd {
+func (m *model) loadDetail(id int64, openRepeater bool) tea.Cmd {
return func() tea.Msg {
d, err := m.client.Get(id)
- return detailLoadedMsg{detail: d, err: err}
+ return detailLoadedMsg{detail: d, err: err, openRepeater: openRepeater}
}
}
+func (m *model) sendRepeat() tea.Cmd {
+ scheme, host := m.repeaterScheme, m.repeaterHost
+ // 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"))
+ return func() tea.Msg {
+ d, err := m.client.Repeat(scheme, host, raw)
+ return repeatSentMsg{detail: d, err: err}
+ }
+}
+
+// enterRepeater seeds the repeater view from an already-loaded entry.
+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
+ m.mode = viewRepeater
+ m.statusMsg = ""
+}
+
func (m *model) Init() tea.Cmd {
return tea.Batch(m.loadList, m.waitForEntry)
}
@@ -166,6 +222,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.SetWidth(msg.Width)
m.table.SetHeight(msg.Height - 4)
m.viewport = viewport.New(msg.Width, msg.Height-5)
+
+ reqHeight := (msg.Height - 6) / 2
+ m.reqArea.SetWidth(msg.Width)
+ m.reqArea.SetHeight(reqHeight)
+ m.respView = viewport.New(msg.Width, msg.Height-6-reqHeight)
return m, nil
case listLoadedMsg:
@@ -191,12 +252,28 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "get error: " + msg.err.Error()
return m, nil
}
+ if msg.openRepeater {
+ m.enterRepeater(msg.detail)
+ return m, nil
+ }
m.detail = msg.detail
m.activeTab = tabRequest
m.viewport.SetContent(detailBody(m.detail, m.activeTab))
m.viewport.GotoTop()
return m, nil
+ case repeatSentMsg:
+ m.sending = false
+ if msg.err != nil {
+ 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()
+ return m, nil
+
case tea.KeyMsg:
switch m.mode {
case viewList:
@@ -207,7 +284,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
m.mode = viewDetail
m.statusMsg = ""
- return m, m.loadDetail(m.entries[row].ID)
+ return m, m.loadDetail(m.entries[row].ID, false)
+ }
+ case "r":
+ if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
+ m.statusMsg = ""
+ return m, m.loadDetail(m.entries[row].ID, true)
}
}
var cmd tea.Cmd
@@ -221,6 +303,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "ctrl+c":
return m, tea.Quit
+ case "r":
+ if m.detail != nil {
+ m.enterRepeater(m.detail)
+ }
+ return m, nil
case "tab":
if m.activeTab == tabRequest {
m.activeTab = tabResponse
@@ -236,6 +323,39 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
+
+ case viewRepeater:
+ switch msg.String() {
+ case "esc":
+ m.mode = viewList
+ m.reqArea.Blur()
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "ctrl+r":
+ if !m.sending {
+ m.sending = true
+ m.statusMsg = "sending..."
+ return m, m.sendRepeat()
+ }
+ return m, nil
+ case "tab":
+ if m.repeaterFocus == focusRequest {
+ m.repeaterFocus = focusResponse
+ m.reqArea.Blur()
+ } else {
+ m.repeaterFocus = focusRequest
+ m.reqArea.Focus()
+ }
+ return m, nil
+ }
+ var cmd tea.Cmd
+ if m.repeaterFocus == focusRequest {
+ m.reqArea, cmd = m.reqArea.Update(msg)
+ } else {
+ m.respView, cmd = m.respView.Update(msg)
+ }
+ return m, cmd
}
}
return m, nil
@@ -248,6 +368,8 @@ func (m *model) View() string {
switch m.mode {
case viewDetail:
return m.detailView()
+ case viewRepeater:
+ return m.repeaterView()
default:
return m.listView()
}
@@ -271,7 +393,7 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- b.WriteString(helpStyle.Render("↑/↓ navigate · enter view · q quit"))
+ b.WriteString(helpStyle.Render("↑/↓ navigate · enter view · r repeater · q quit"))
return b.String()
}
@@ -297,7 +419,38 @@ func (m *model) detailView() string {
b.WriteString("\n")
b.WriteString(m.viewport.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · esc back · q quit"))
+ b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · r repeater · esc back · q quit"))
+ return b.String()
+}
+
+func (m *model) repeaterView() string {
+ var b strings.Builder
+ title := fmt.Sprintf(" repeater - %s://%s ", m.repeaterScheme, m.repeaterHost)
+ 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 m.repeaterFocus == focusRequest {
+ b.WriteString(tabActive.Render(reqLabel))
+ b.WriteString(tabInactive.Render(respLabel))
+ } else {
+ b.WriteString(tabInactive.Render(reqLabel))
+ b.WriteString(tabActive.Render(respLabel))
+ }
+ b.WriteString("\n")
+ b.WriteString(m.reqArea.View())
+ b.WriteString("\n")
+ b.WriteString(m.respView.View())
+ b.WriteString("\n")
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("ctrl+r send · tab switch pane · esc back · ctrl+c quit"))
return b.String()
}
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go
index 0d7f214..812e146 100644
--- a/cmd/mitmuxd/main.go
+++ b/cmd/mitmuxd/main.go
@@ -70,17 +70,17 @@ func main() {
defer os.Remove(sockFile)
log.Printf("control socket: %s", sockFile)
+ srv := proxy.New(*listen, root, db)
+
hub := ipc.NewHub()
- ipcSrv := ipc.NewServer(db, hub)
+ srv.OnEntry = ipc.LogAndBroadcast(hub)
+ ipcSrv := ipc.NewServer(db, hub, srv)
go func() {
if err := ipcSrv.Serve(sockLn); err != nil {
log.Printf("control socket: %v", err)
}
}()
- srv := proxy.New(*listen, root, db)
- srv.OnEntry = ipc.LogAndBroadcast(hub)
-
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()