srdusr
aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--cmd/mitmux/main.go171
-rw-r--r--cmd/mitmuxd/main.go8
-rw-r--r--go.mod1
-rw-r--r--go.sum4
-rw-r--r--internal/ipc/ipc.go28
-rw-r--r--internal/ipc/server.go54
-rw-r--r--internal/proxy/proxy.go1
-rw-r--r--internal/proxy/repeat.go137
-rw-r--r--internal/store/store.go26
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()
diff --git a/go.mod b/go.mod
index cdfdcd7..f80d9ad 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 7049d1f..e37c484 100644
--- a/go.sum
+++ b/go.sum
@@ -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)