diff options
| author | srdusr <[email protected]> | 2024-08-29 22:03:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-08-29 22:03:00 +0200 |
| commit | 70f675e59ac4bb0e469ca0073f8cc0506b17677e (patch) | |
| tree | e3a525b26dfcf16ce4f9bde97b1408a373d87944 | |
| parent | 8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6 (diff) | |
| download | mitmux-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.
| -rw-r--r-- | cmd/mitmux/main.go | 171 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 8 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 28 | ||||
| -rw-r--r-- | internal/ipc/server.go | 54 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 1 | ||||
| -rw-r--r-- | internal/proxy/repeat.go | 137 | ||||
| -rw-r--r-- | internal/store/store.go | 26 |
9 files changed, 391 insertions, 39 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() @@ -11,6 +11,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -1,3 +1,7 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index c9d92b4..b1a997a 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -15,17 +15,22 @@ import ( // Request is sent by a client to the daemon. type Request struct { - Type string `json:"type"` // "list", "get", or "subscribe" + Type string `json:"type"` // "list", "get", "subscribe", or "repeat" Limit int `json:"limit,omitempty"` BeforeID int64 `json:"before_id,omitempty"` ID int64 `json:"id,omitempty"` + + // For "repeat": send Raw to scheme://host exactly as given. + Scheme string `json:"scheme,omitempty"` + Host string `json:"host,omitempty"` + Raw []byte `json:"raw,omitempty"` } // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", or "error" + Type string `json:"type"` // "list", "get", "new", "repeat", or "error" Entries []store.Summary `json:"entries,omitempty"` // for "list" - Detail *EntryDetail `json:"detail,omitempty"` // for "get" + Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) Error string `json:"error,omitempty"` } @@ -92,6 +97,23 @@ func (c *Client) Get(id int64) (*EntryDetail, error) { return resp.Detail, nil } +// Repeat sends raw to scheme://host exactly as given (no re-serialization, +// no header injection) and returns the resulting entry, including the raw +// response bytes. The exchange is also recorded to history. +func (c *Client) Repeat(scheme, host string, raw []byte) (*EntryDetail, error) { + if err := c.enc.Encode(Request{Type: "repeat", Scheme: scheme, Host: host, Raw: raw}); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.Detail, nil +} + // Subscribe opens a dedicated connection that streams newly captured // history entries as they happen. The returned channel is closed when // the connection ends; call the returned close func to stop early. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 11ba033..e3b2a29 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -1,6 +1,7 @@ package ipc import ( + "context" "encoding/json" "log" "net" @@ -9,6 +10,12 @@ import ( "mitmux/internal/store" ) +// Repeater sends raw bytes to scheme://host exactly as given and +// records the exchange to history - implemented by *proxy.Server. +type Repeater interface { + Repeat(ctx context.Context, scheme, host string, raw []byte) (*store.Entry, error) +} + // Hub fans out newly captured history entries to subscribed clients. type Hub struct { mu sync.Mutex @@ -50,14 +57,15 @@ func (h *Hub) unsubscribe(ch chan store.Summary) { // Server serves the daemon side of the mitmux control protocol. type Server struct { - db *store.Store - hub *Hub + db *store.Store + hub *Hub + repeater Repeater } // NewServer creates a control-protocol Server backed by db, broadcasting -// through hub. -func NewServer(db *store.Store, hub *Hub) *Server { - return &Server{db: db, hub: hub} +// through hub and sending Repeater requests through rep. +func NewServer(db *store.Store, hub *Hub, rep Repeater) *Server { + return &Server{db: db, hub: hub, repeater: rep} } // Serve accepts connections on ln until it returns an error (e.g. the @@ -98,16 +106,19 @@ func (s *Server) handleConn(conn net.Conn) { enc.Encode(Response{Type: "error", Error: err.Error()}) continue } - enc.Encode(Response{Type: "get", Detail: &EntryDetail{ - Summary: store.Summary{ - ID: e.ID, StartedAt: e.StartedAt, Duration: e.Duration, - Method: e.Method, Scheme: e.Scheme, Host: e.Host, Path: e.Path, - StatusCode: e.StatusCode, ReqSize: len(e.RequestRaw), RespSize: len(e.ResponseRaw), - Error: e.Error, - }, - RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw, - RequestExact: e.RequestExact, ResponseExact: e.ResponseExact, - }}) + enc.Encode(Response{Type: "get", Detail: detailFromEntry(e)}) + + case "repeat": + if s.repeater == nil { + enc.Encode(Response{Type: "error", Error: "repeater not available"}) + continue + } + e, err := s.repeater.Repeat(context.Background(), req.Scheme, req.Host, req.Raw) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "repeat", Detail: detailFromEntry(e)}) case "subscribe": sub := s.hub.subscribe() @@ -126,6 +137,19 @@ func (s *Server) handleConn(conn net.Conn) { } } +func detailFromEntry(e *store.Entry) *EntryDetail { + return &EntryDetail{ + Summary: store.Summary{ + ID: e.ID, StartedAt: e.StartedAt, Duration: e.Duration, + Method: e.Method, Scheme: e.Scheme, Host: e.Host, Path: e.Path, + StatusCode: e.StatusCode, ReqSize: len(e.RequestRaw), RespSize: len(e.ResponseRaw), + Error: e.Error, Source: e.Source, + }, + RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw, + RequestExact: e.RequestExact, ResponseExact: e.ResponseExact, + } +} + // LogAndBroadcast is a convenience OnEntry callback: logs the entry and // broadcasts it through hub. func LogAndBroadcast(hub *Hub) func(store.Summary) { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 0a20382..fcf8c14 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -345,6 +345,7 @@ func (s *Server) record(started time.Time, duration time.Duration, scheme, host ReqSize: len(reqRaw), RespSize: len(respRaw), Error: errMsg, + Source: "proxy", }) } } diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go new file mode 100644 index 0000000..6373ee1 --- /dev/null +++ b/internal/proxy/repeat.go @@ -0,0 +1,137 @@ +package proxy + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "time" + + "mitmux/internal/store" +) + +// Repeat sends raw exactly as given - no framing correction, no header +// injection - to scheme://host, and records the exchange to history +// with Source "repeater". This is the raw-byte send/resend primitive: +// unlike forward(), which round-trips a parsed *http.Request, Repeat +// exists specifically so an edited, possibly malformed request (the +// whole point of a Repeater tool) reaches the wire unmodified. +// +// Repeater only speaks HTTP/1.1: raw edited text has no equivalent in +// HTTP/2's binary framing, so the connection is negotiated HTTP/1.1-only +// rather than letting the server pick. +func (s *Server) Repeat(ctx context.Context, scheme, host string, raw []byte) (*store.Entry, error) { + started := time.Now() + method, path := parseRequestLine(raw) + + conn, err := dialForRepeat(ctx, scheme, host) + if err != nil { + return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + } + defer conn.Close() + + if _, err := conn.Write(raw); err != nil { + return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + } + + tee := newTeeConn(conn) + resp, err := http.ReadResponse(bufio.NewReader(tee), &http.Request{Method: method}) + duration := time.Since(started) + if err != nil { + return s.recordRepeat(started, duration, scheme, host, method, path, raw, nil, 0, err.Error()) + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + return s.recordRepeat(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "") +} + +func (s *Server) recordRepeat(started time.Time, duration time.Duration, scheme, host, method, path string, + reqRaw, respRaw []byte, status int, errMsg string) (*store.Entry, error) { + e := &store.Entry{ + StartedAt: started, + Duration: duration, + Method: method, + Scheme: scheme, + Host: host, + Path: path, + StatusCode: status, + RequestRaw: reqRaw, + ResponseRaw: respRaw, + RequestExact: true, + ResponseExact: respRaw != nil, + Error: errMsg, + Source: "repeater", + } + if s.store != nil { + id, err := s.store.Insert(e) + if err != nil { + return nil, fmt.Errorf("store repeater entry: %w", err) + } + e.ID = id + if s.OnEntry != nil { + s.OnEntry(store.Summary{ + ID: id, StartedAt: e.StartedAt, Duration: e.Duration, + Method: e.Method, Scheme: e.Scheme, Host: e.Host, Path: e.Path, + StatusCode: e.StatusCode, ReqSize: len(reqRaw), RespSize: len(respRaw), + Error: errMsg, Source: e.Source, + }) + } + } + return e, nil +} + +// dialForRepeat connects to host for scheme, forcing HTTP/1.1 over ALPN +// when TLS is involved (see Repeat's doc comment for why). +func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { + nd := &net.Dialer{Timeout: 10 * time.Second} + if scheme != "https" { + hostPort := host + if _, _, err := net.SplitHostPort(host); err != nil { + hostPort = net.JoinHostPort(host, "80") + } + return nd.DialContext(ctx, "tcp", hostPort) + } + + hostname, hostPort := host, host + if h, _, err := net.SplitHostPort(host); err == nil { + hostname = h + } else { + hostPort = net.JoinHostPort(host, "443") + } + raw, err := nd.DialContext(ctx, "tcp", hostPort) + if err != nil { + return nil, err + } + conn := tls.Client(raw, &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}}) + if err := conn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, err + } + return conn, nil +} + +// parseRequestLine extracts the method and request-target from the +// first line of a raw HTTP/1.1 request, without validating or parsing +// anything else - used only to label the stored entry and to tell +// http.ReadResponse whether this was a HEAD request (which changes +// response body framing rules). +func parseRequestLine(raw []byte) (method, path string) { + nl := bytes.IndexByte(raw, '\n') + if nl < 0 { + nl = len(raw) + } + line := bytes.TrimRight(raw[:nl], "\r\n") + fields := bytes.Fields(line) + if len(fields) > 0 { + method = string(fields[0]) + } + if len(fields) > 1 { + path = string(fields[1]) + } + return method, path +} diff --git a/internal/store/store.go b/internal/store/store.go index c0219e4..88c9991 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS history ( response_raw BLOB, request_exact INTEGER NOT NULL, response_exact INTEGER NOT NULL, - error TEXT NOT NULL DEFAULT '' + error TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'proxy' ); ` @@ -59,6 +60,9 @@ func Open(path string) (*Store, error) { db.Close() return nil, fmt.Errorf("create schema: %w", err) } + // Added after the initial schema; ignore the "duplicate column" error + // on databases that already have it. + db.Exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'proxy'") return &Store{db: db}, nil } @@ -82,6 +86,7 @@ type Entry struct { RequestExact bool // true if RequestRaw is wire-exact, false if reconstructed (e.g. HTTP/2) ResponseExact bool Error string // network/transport error, if the request never got a response + Source string // "proxy" or "repeater" } // Summary is the lightweight metadata used for the history list view - @@ -98,6 +103,7 @@ type Summary struct { ReqSize int RespSize int Error string + Source string } // Insert stores e and returns its assigned ID. @@ -106,13 +112,17 @@ func (s *Store) Insert(e *Entry) (int64, error) { if e.StatusCode != 0 { statusCode = e.StatusCode } + source := e.Source + if source == "" { + source = "proxy" + } res, err := s.db.Exec( `INSERT INTO history (started_at, duration_ms, method, scheme, host, path, status_code, - request_raw, response_raw, request_exact, response_exact, error) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request_raw, response_raw, request_exact, response_exact, error, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.StartedAt.UnixMilli(), e.Duration.Milliseconds(), e.Method, e.Scheme, e.Host, e.Path, - statusCode, e.RequestRaw, e.ResponseRaw, boolToInt(e.RequestExact), boolToInt(e.ResponseExact), e.Error, + statusCode, e.RequestRaw, e.ResponseRaw, boolToInt(e.RequestExact), boolToInt(e.ResponseExact), e.Error, source, ) if err != nil { return 0, fmt.Errorf("insert history entry: %w", err) @@ -131,7 +141,7 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { } rows, err := s.db.Query( `SELECT id, started_at, duration_ms, method, scheme, host, path, - COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error + COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error, source FROM history WHERE id < ? ORDER BY id DESC LIMIT ?`, beforeID, limit, ) @@ -145,7 +155,7 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { var sum Summary var startedAt, durationMs int64 if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, - &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error); err != nil { + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source); err != nil { return nil, fmt.Errorf("scan history row: %w", err) } sum.StartedAt = time.UnixMilli(startedAt) @@ -160,7 +170,7 @@ func (s *Store) Get(id int64) (*Entry, error) { row := s.db.QueryRow( `SELECT id, started_at, duration_ms, method, scheme, host, path, COALESCE(status_code, 0), request_raw, response_raw, - request_exact, response_exact, error + request_exact, response_exact, error, source FROM history WHERE id = ?`, id, ) @@ -168,7 +178,7 @@ func (s *Store) Get(id int64) (*Entry, error) { var startedAt, durationMs int64 var reqExact, respExact int if err := row.Scan(&e.ID, &startedAt, &durationMs, &e.Method, &e.Scheme, &e.Host, &e.Path, - &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error); err != nil { + &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error, &e.Source); err != nil { return nil, fmt.Errorf("get history entry %d: %w", id, err) } e.StartedAt = time.UnixMilli(startedAt) |