srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-24 09:50:00 +0200
committersrdusr <[email protected]>2026-05-24 09:50:00 +0200
commit0d1ce88962fe31ce1d204634e6ea10998a02827a (patch)
treed1ccafa01f166dea867c6eca5a02fbaa477f86f6
parentd522b1177a9e1fdd04888121975f2b3509d19564 (diff)
downloadmitmux-0d1ce88962fe31ce1d204634e6ea10998a02827a.tar.gz
mitmux-0d1ce88962fe31ce1d204634e6ea10998a02827a.zip
Import: bring a HAR file's entries into history
Closes the interop loop HAR export opened - traffic can now move both directions between mitmux and any other HAR-producing tool (browser DevTools, Burp, Postman), not just out. cmd/mitmux/har.go: rawRequestFromHAR/rawResponseFromHAR reconstruct HTTP/1.1 wire bytes from HAR's structured fields - the mirror image of harEntryFromDetail on the export side. Deliberately tolerant of a HAR file that didn't come from mitmux at all: lowercase header names, "HTTP/2" in httpVersion, missing optional fields like postData, a redirectURL nobody filled in. Content-Encoding and Transfer-Encoding headers are stripped from the reconstructed response before writing it - HAR's content.text is already decoded per spec, so re-emitting those headers would describe framing the body no longer has and break any client that tried to decode it again - and a Content-Length is computed if the HAR didn't carry a consistent one. importEntriesFromHAR converts a whole document, skipping (not failing on) any entry that fails to convert, same reasoning as export's own skip-and-continue for a malformed capture. Imported entries are always request_exact=false/response_exact=false: reconstructed from structured HAR fields, the same situation an HTTP/2 capture is already in, never claiming to be the literal bytes that were actually on the wire for the original request. internal/ipc: ImportEntry (the slim shape the client sends - the daemon just stores what it's given, all HAR parsing happens client-side) and an "import" request type; Client.Import returns how many entries were actually inserted, a per-entry store failure is skipped rather than aborting the batch. Server-side, imported entries are tagged source="import" so source:import finds them in search, same as source:repeater/source:intruder already work. TUI: 'I' from the history list prompts for a HAR path (same modal pattern as export, in reverse - reading instead of writing), then reloads the list and status once the import completes. Verified live: exported real captured traffic to HAR, cleared history entirely, imported the same file back and got both entries with correct content (byte-different after the round trip - headers get reordered/reformatted - but semantically identical, correctly labeled "reconstructed" rather than falsely "exact"); hand-built a HAR mimicking a real Chrome DevTools export (lowercase headers, HTTP/2, a base64- encoded binary PNG body, several optional fields omitted) and confirmed it imports cleanly with the binary body decoded correctly (PNG magic bytes verified byte-for-byte); confirmed a missing file and invalid JSON both fail with a clear error and no crash, history left untouched. go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--PLAN.md39
-rw-r--r--README.md14
-rw-r--r--cmd/mitmux/har.go149
-rw-r--r--cmd/mitmux/har_test.go148
-rw-r--r--cmd/mitmux/main.go89
-rw-r--r--internal/ipc/ipc.go50
-rw-r--r--internal/ipc/server.go21
7 files changed, 504 insertions, 6 deletions
diff --git a/PLAN.md b/PLAN.md
index a070b20..f113bd8 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -336,8 +336,43 @@ injection fix from the robustness audit, just for a different output
format - captured content controlling the tool that later processes it,
rather than the terminal that renders it.
-Still open from the expanded "worth considering" list: import (no path
-back in yet). Requested explicitly; not started yet.
+Shipped since: import. `I` from the history list reads a HAR file and
+inserts its entries into history, tagged source="import" (so
+`source:import` finds them, same as `source:repeater`/`source:intruder`
+already do). This closes the interop loop HAR export opened: traffic
+can now move both directions between mitmux and any other HAR-producing
+tool (browser DevTools, Burp, Postman), not just out.
+
+The reverse conversion (HAR entry -> raw HTTP/1.1 bytes) is the mirror
+image of HAR export's harEntryFromDetail, deliberately written to
+tolerate a HAR file that didn't come from mitmux at all - lowercase
+header names, HTTP/2 in httpVersion, missing optional fields like
+postData, a redirectURL nobody bothered filling in. Content-Encoding
+and Transfer-Encoding headers are stripped from the reconstructed
+response before writing it (HAR's content.text is already decoded per
+spec - re-emitting those headers would describe framing the body no
+longer has, breaking any client that tried to decode it again), and a
+Content-Length is computed if the HAR didn't carry a consistent one.
+Imported entries are always request_exact=false/response_exact=false -
+reconstructed from structured HAR fields, same situation an HTTP/2
+capture is already in, never claiming to be the literal bytes that
+were on the wire for the original request. An entry that fails to
+convert (an unparseable URL, say) is skipped rather than failing the
+whole import, same reasoning as export's own skip-and-continue.
+
+Verified live: exported real captured traffic to HAR, cleared history
+entirely, imported the same file back and got both entries back with
+correct content (byte-different from the original - headers get
+reordered/reformatted through the round trip - but semantically
+identical, and correctly labeled "reconstructed" rather than falsely
+claiming "exact"); separately hand-built a HAR mimicking a real Chrome
+DevTools export (lowercase headers, HTTP/2, a base64-encoded binary
+PNG body, several optional fields omitted) and confirmed it imports
+cleanly with the binary body correctly decoded (PNG magic bytes
+verified byte-for-byte); confirmed a missing file and invalid JSON
+both fail with a clear error and no crash, history left untouched.
+
+This closes every item from the expanded "worth considering" list.
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
diff --git a/README.md b/README.md
index 7058180..4a9def0 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,7 @@ below is enough to get going.
| `x` | delete the selected entry (asks `y`/`n` to confirm) |
| `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) |
| `E` | export the current view (respects an active search filter) - `.har` or `.csv` |
+| `I` | import a HAR file's entries into history |
| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
@@ -228,7 +229,18 @@ format-selection control:
is neutralized with a leading quote before writing, the standard
CSV-injection mitigation.
-There's no import yet (see `PLAN.md`).
+### Import
+
+`I` from the history list reads a HAR file and inserts its entries into
+history, tagged `source:import` so they're easy to find or filter out
+later (`source:import` in search). Works with a HAR from mitmux itself
+or from anywhere else that produces one - Chrome/Firefox DevTools, Burp,
+Postman. An entry that fails to convert is skipped rather than aborting
+the whole import; the status line reports how many, if any. Imported
+entries are always shown as "reconstructed" (never "exact") - they're
+rebuilt from HAR's structured fields, not the literal bytes that were
+actually on the wire for the original request, same situation an
+HTTP/2 capture is already in.
### Search syntax
diff --git a/cmd/mitmux/har.go b/cmd/mitmux/har.go
index 250ba5e..33da596 100644
--- a/cmd/mitmux/har.go
+++ b/cmd/mitmux/har.go
@@ -9,6 +9,8 @@ import (
"io"
"net/http"
"net/url"
+ "strings"
+ "time"
"unicode/utf8"
"mitmux/internal/ipc"
@@ -231,3 +233,150 @@ func harDocFrom(details []*ipc.EntryDetail) (harDoc, int) {
func harMarshal(doc harDoc) ([]byte, error) {
return json.MarshalIndent(doc, "", " ")
}
+
+// --- Import: the reverse direction, HAR entries back into raw bytes.
+//
+// A HAR file being imported didn't necessarily come from mitmux - it
+// could be a browser DevTools export, or from another tool entirely -
+// so this reconstructs wire bytes from HAR's structured fields rather
+// than assuming anything mitmux-specific. Imported entries are always
+// marked not-exact: this is a reconstruction from structured data, the
+// same as an HTTP/2 capture already is, never the literal bytes that
+// were actually on the wire when the original request happened.
+
+// rawRequestFromHAR reconstructs an HTTP/1.1 request line + headers +
+// body from a HAR request object.
+func rawRequestFromHAR(req harRequest) (raw []byte, scheme, host, path string, err error) {
+ u, err := url.Parse(req.URL)
+ if err != nil {
+ return nil, "", "", "", fmt.Errorf("parse url %q: %w", req.URL, err)
+ }
+ if u.Host == "" {
+ return nil, "", "", "", fmt.Errorf("url %q has no host", req.URL)
+ }
+ scheme, host, path = u.Scheme, u.Host, u.RequestURI()
+
+ proto := req.HTTPVersion
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+ method := req.Method
+ if method == "" {
+ method = "GET"
+ }
+
+ var body []byte
+ if req.PostData != nil {
+ if req.PostData.Encoding == "base64" {
+ if body, err = base64.StdEncoding.DecodeString(req.PostData.Text); err != nil {
+ return nil, "", "", "", fmt.Errorf("decode base64 request body: %w", err)
+ }
+ } else {
+ body = []byte(req.PostData.Text)
+ }
+ }
+
+ var b bytes.Buffer
+ fmt.Fprintf(&b, "%s %s %s\r\n", method, path, proto)
+ hasHost, hasCL := false, false
+ for _, h := range req.Headers {
+ hasHost = hasHost || strings.EqualFold(h.Name, "Host")
+ hasCL = hasCL || strings.EqualFold(h.Name, "Content-Length")
+ fmt.Fprintf(&b, "%s: %s\r\n", h.Name, h.Value)
+ }
+ if !hasHost {
+ fmt.Fprintf(&b, "Host: %s\r\n", host)
+ }
+ if len(body) > 0 && !hasCL {
+ fmt.Fprintf(&b, "Content-Length: %d\r\n", len(body))
+ }
+ b.WriteString("\r\n")
+ b.Write(body)
+ return b.Bytes(), scheme, host, path, nil
+}
+
+// rawResponseFromHAR reconstructs an HTTP/1.1 status line + headers +
+// body. Returns nil, nil for a HAR entry with no response (status 0) -
+// not every captured exchange got one.
+func rawResponseFromHAR(resp harResponse) ([]byte, error) {
+ if resp.Status == 0 {
+ return nil, nil
+ }
+ proto := resp.HTTPVersion
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+ statusText := resp.StatusText
+ if statusText == "" {
+ statusText = http.StatusText(resp.Status)
+ }
+
+ var body []byte
+ var err error
+ if resp.Content.Encoding == "base64" {
+ if body, err = base64.StdEncoding.DecodeString(resp.Content.Text); err != nil {
+ return nil, fmt.Errorf("decode base64 response body: %w", err)
+ }
+ } else {
+ body = []byte(resp.Content.Text)
+ }
+
+ var b bytes.Buffer
+ fmt.Fprintf(&b, "%s %d %s\r\n", proto, resp.Status, statusText)
+ hasCL := false
+ for _, h := range resp.Headers {
+ // Content.Text is already decoded/decompressed per the HAR
+ // spec - re-emitting Content-Encoding or Transfer-Encoding
+ // would describe framing the body no longer has, breaking any
+ // client that tries to decode it again.
+ if strings.EqualFold(h.Name, "Content-Encoding") || strings.EqualFold(h.Name, "Transfer-Encoding") {
+ continue
+ }
+ hasCL = hasCL || strings.EqualFold(h.Name, "Content-Length")
+ fmt.Fprintf(&b, "%s: %s\r\n", h.Name, h.Value)
+ }
+ if !hasCL {
+ fmt.Fprintf(&b, "Content-Length: %d\r\n", len(body))
+ }
+ b.WriteString("\r\n")
+ b.Write(body)
+ return b.Bytes(), nil
+}
+
+// importEntriesFromHAR parses a HAR document and converts every entry
+// it can into an ipc.ImportEntry ready to send to the daemon. An entry
+// that fails to convert (an unparseable URL, say) is skipped rather
+// than failing the whole import - one bad entry in a large HAR
+// shouldn't cost every other one, same reasoning as harDocFrom on the
+// export side.
+func importEntriesFromHAR(data []byte) (entries []ipc.ImportEntry, skipped int, err error) {
+ var doc harDoc
+ if err := json.Unmarshal(data, &doc); err != nil {
+ return nil, 0, fmt.Errorf("parse HAR: %w", err)
+ }
+ for _, e := range doc.Log.Entries {
+ reqRaw, scheme, host, path, err := rawRequestFromHAR(e.Request)
+ if err != nil {
+ skipped++
+ continue
+ }
+ respRaw, err := rawResponseFromHAR(e.Response)
+ if err != nil {
+ skipped++
+ continue
+ }
+ started, _ := time.Parse(time.RFC3339, e.StartedDateTime) // zero time if unparseable - not fatal
+ entries = append(entries, ipc.ImportEntry{
+ Method: e.Request.Method,
+ Scheme: scheme,
+ Host: host,
+ Path: path,
+ StatusCode: e.Response.Status,
+ RequestRaw: reqRaw,
+ ResponseRaw: respRaw,
+ StartedAt: started,
+ Duration: time.Duration(e.Time) * time.Millisecond,
+ })
+ }
+ return entries, skipped, nil
+}
diff --git a/cmd/mitmux/har_test.go b/cmd/mitmux/har_test.go
index 58bfb99..159b706 100644
--- a/cmd/mitmux/har_test.go
+++ b/cmd/mitmux/har_test.go
@@ -1,8 +1,10 @@
package main
import (
+ "bytes"
"encoding/base64"
"encoding/json"
+ "strings"
"testing"
"time"
@@ -141,3 +143,149 @@ func TestHarMarshalProducesValidJSON(t *testing.T) {
t.Errorf("expected top-level \"log\" key, got %v", out)
}
}
+
+func TestRawRequestFromHAR(t *testing.T) {
+ req := harRequest{
+ Method: "POST",
+ URL: "https://example.com/a?x=1",
+ HTTPVersion: "HTTP/1.1",
+ Headers: []harHeader{{Name: "X-Foo", Value: "bar"}},
+ PostData: &harPostData{MimeType: "text/plain", Text: "hello"},
+ }
+ raw, scheme, host, path, err := rawRequestFromHAR(req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if scheme != "https" || host != "example.com" || path != "/a?x=1" {
+ t.Errorf("scheme/host/path = %q/%q/%q, want https/example.com//a?x=1", scheme, host, path)
+ }
+ s := string(raw)
+ for _, want := range []string{"POST /a?x=1 HTTP/1.1\r\n", "X-Foo: bar\r\n", "Host: example.com\r\n", "Content-Length: 5\r\n", "\r\n\r\nhello"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("raw request missing %q, got:\n%s", want, s)
+ }
+ }
+}
+
+func TestRawRequestFromHARBase64Body(t *testing.T) {
+ req := harRequest{
+ Method: "POST", URL: "http://x/",
+ PostData: &harPostData{Text: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00}), Encoding: "base64"},
+ }
+ raw, _, _, _, err := rawRequestFromHAR(req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !bytes.Contains(raw, []byte{0xff, 0x00}) {
+ t.Errorf("expected decoded binary body in raw request, got: %q", raw)
+ }
+}
+
+func TestRawRequestFromHARInvalidURL(t *testing.T) {
+ req := harRequest{Method: "GET", URL: "://not a url"}
+ if _, _, _, _, err := rawRequestFromHAR(req); err == nil {
+ t.Error("expected an error for an invalid URL, got nil")
+ }
+}
+
+func TestRawRequestFromHARNoHost(t *testing.T) {
+ req := harRequest{Method: "GET", URL: "/just/a/path"}
+ if _, _, _, _, err := rawRequestFromHAR(req); err == nil {
+ t.Error("expected an error for a URL with no host, got nil")
+ }
+}
+
+func TestRawResponseFromHARNoResponse(t *testing.T) {
+ raw, err := rawResponseFromHAR(harResponse{})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if raw != nil {
+ t.Errorf("expected nil for a HAR entry with status 0, got %q", raw)
+ }
+}
+
+func TestRawResponseFromHARStripsEncodingHeaders(t *testing.T) {
+ resp := harResponse{
+ Status: 200, StatusText: "OK", HTTPVersion: "HTTP/1.1",
+ Headers: []harHeader{
+ {Name: "Content-Encoding", Value: "gzip"},
+ {Name: "Content-Type", Value: "text/plain"},
+ },
+ Content: harContent{Text: "hello"},
+ }
+ raw, err := rawResponseFromHAR(resp)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ s := string(raw)
+ if strings.Contains(s, "Content-Encoding") {
+ t.Errorf("expected Content-Encoding to be stripped (body is already decoded), got:\n%s", s)
+ }
+ if !strings.Contains(s, "Content-Type: text/plain") {
+ t.Errorf("expected Content-Type preserved, got:\n%s", s)
+ }
+ if !strings.Contains(s, "Content-Length: 5") {
+ t.Errorf("expected a computed Content-Length, got:\n%s", s)
+ }
+}
+
+func TestImportEntriesFromHARRoundTrip(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", StatusCode: 200},
+ RequestRaw: []byte("GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n"),
+ ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"),
+ }
+ doc, skipped := harDocFrom([]*ipc.EntryDetail{d})
+ if skipped != 0 {
+ t.Fatalf("unexpected skips building the HAR: %d", skipped)
+ }
+ data, err := harMarshal(doc)
+ if err != nil {
+ t.Fatalf("harMarshal: %v", err)
+ }
+
+ entries, skipped, err := importEntriesFromHAR(data)
+ if err != nil {
+ t.Fatalf("importEntriesFromHAR: %v", err)
+ }
+ if skipped != 0 {
+ t.Fatalf("unexpected skips importing: %d", skipped)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(entries))
+ }
+ e := entries[0]
+ if e.Method != "GET" || e.Scheme != "https" || e.Host != "example.com" || e.Path != "/a" {
+ t.Errorf("unexpected entry metadata: %+v", e)
+ }
+ if e.StatusCode != 200 {
+ t.Errorf("status = %d, want 200", e.StatusCode)
+ }
+ if !bytes.Contains(e.ResponseRaw, []byte("hello")) {
+ t.Errorf("expected response body preserved, got: %q", e.ResponseRaw)
+ }
+}
+
+func TestImportEntriesFromHARInvalidJSON(t *testing.T) {
+ if _, _, err := importEntriesFromHAR([]byte("not json")); err == nil {
+ t.Error("expected an error for invalid JSON, got nil")
+ }
+}
+
+func TestImportEntriesFromHARSkipsBadEntries(t *testing.T) {
+ data := []byte(`{"log":{"version":"1.2","entries":[
+ {"request":{"method":"GET","url":"://bad"},"response":{}},
+ {"request":{"method":"GET","url":"http://good/"},"response":{"status":200,"content":{}}}
+ ]}}`)
+ entries, skipped, err := importEntriesFromHAR(data)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if skipped != 1 {
+ t.Errorf("skipped = %d, want 1", skipped)
+ }
+ if len(entries) != 1 {
+ t.Errorf("entries = %d, want 1", len(entries))
+ }
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 35cadaa..7ffe9b9 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -233,6 +233,12 @@ type model struct {
exportBulk bool
exportInput textinput.Model
+ // importEditing is the same modal-path-prompt pattern as export, in
+ // reverse: 'I' from the history list prompts for a HAR file to read
+ // and insert into history rather than write to.
+ importEditing bool
+ importInput textinput.Model
+
statusMsg string
width int
height int
@@ -325,6 +331,9 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
expIn := textinput.New()
expIn.Placeholder = "file path to write"
+ impIn := textinput.New()
+ impIn.Placeholder = "HAR file path to read"
+
return &model{
client: client,
subCh: subCh,
@@ -347,6 +356,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
grepExtractInput: geIn,
decoderInput: din,
exportInput: expIn,
+ importInput: impIn,
}
}
@@ -479,6 +489,35 @@ func (m *model) exportCSV(entries []store.Summary, path string) tea.Cmd {
}
}
+type historyImportedMsg struct {
+ count int
+ skipped int
+ err error
+}
+
+// importHAR reads path, converts every entry it can into an
+// ipc.ImportEntry (see cmd/mitmux/har.go - the reverse of exportHAR's
+// conversion), and sends the batch to the daemon in one request. Runs
+// as a tea.Cmd since it's a file read plus a network round trip.
+func (m *model) importHAR(path string) tea.Cmd {
+ client := m.client
+ return func() tea.Msg {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return historyImportedMsg{err: err}
+ }
+ entries, skipped, err := importEntriesFromHAR(data)
+ if err != nil {
+ return historyImportedMsg{err: err}
+ }
+ imported, err := client.Import(entries)
+ if err != nil {
+ return historyImportedMsg{err: err}
+ }
+ return historyImportedMsg{count: imported, skipped: skipped + (len(entries) - imported)}
+ }
+}
+
func (m *model) loadList() tea.Msg {
var entries []store.Summary
var err error
@@ -897,6 +936,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.grepMatchInput.Width = msg.Width - 2
m.grepExtractInput.Width = msg.Width - 2
m.exportInput.Width = msg.Width - 2
+ m.importInput.Width = msg.Width - 2
return m, nil
case listLoadedMsg:
@@ -969,6 +1009,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
+ case historyImportedMsg:
+ if msg.err != nil {
+ m.statusMsg = "import error: " + msg.err.Error()
+ return m, nil
+ }
+ m.statusMsg = fmt.Sprintf("imported %d entries", msg.count)
+ if msg.skipped > 0 {
+ m.statusMsg += fmt.Sprintf(" (%d skipped - failed to parse or store)", msg.skipped)
+ }
+ return m, tea.Batch(m.loadList, m.loadStatus)
+
case detailLoadedMsg:
if msg.err != nil {
m.statusMsg = "get error: " + msg.err.Error()
@@ -1124,6 +1175,28 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exportInput, cmd = m.exportInput.Update(msg)
return m, cmd
}
+ if m.importEditing {
+ switch msg.String() {
+ case "enter":
+ path := m.importInput.Value()
+ m.importEditing = false
+ m.importInput.Blur()
+ if path == "" {
+ return m, nil
+ }
+ m.statusMsg = "importing..."
+ return m, m.importHAR(path)
+ case "esc":
+ m.importEditing = false
+ m.importInput.Blur()
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ var cmd tea.Cmd
+ m.importInput, cmd = m.importInput.Update(msg)
+ return m, cmd
+ }
if m.searching {
switch msg.String() {
case "enter":
@@ -1208,6 +1281,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exportInput.SetValue("mitmux-export.har")
m.exportInput.CursorEnd()
return m, m.exportInput.Focus()
+ case "I":
+ m.importEditing = true
+ m.importInput.SetValue("")
+ return m, m.importInput.Focus()
case "d":
m.mode = viewDecoder
m.statusMsg = ""
@@ -1831,6 +1908,7 @@ func (m *model) helpView() string {
"x delete the selected entry (asks to confirm)",
"X clear ALL history, not just the current filter (asks to confirm)",
"E export the current view (respects an active filter) - .har or .csv",
+ "I import a HAR file - inserts its entries into history (source:import)",
"d decoder (URL/Base64/Hex/HTML encode/decode)",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
@@ -1931,6 +2009,13 @@ func (m *model) listView() string {
b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .har (full, default) or .csv (summary table)"))
return b.String()
}
+ if m.importEditing {
+ b.WriteString("import HAR file: ")
+ b.WriteString(m.importInput.View())
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render("enter import · esc cancel · ctrl+c quit"))
+ return b.String()
+ }
if m.confirmPrompt != "" {
b.WriteString(statusStyle.Render(m.confirmPrompt))
b.WriteString("\n")
@@ -1938,9 +2023,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (HAR/CSV) · / search · m rules · s scope · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · / search · esc clear filter · s scope · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index 2abce89..89a8343 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -67,11 +67,34 @@ type Request struct {
// For "set_flagged" and "delete_entry": ID identifies the history
// entry. "clear_history" needs no fields at all.
Flagged bool `json:"flagged,omitempty"`
+
+ // For "import": entries to insert directly into history - the
+ // client has already parsed/converted them (e.g. from a HAR file,
+ // see cmd/mitmux/har.go); the daemon just stores them.
+ ImportEntries []ImportEntry `json:"import_entries,omitempty"`
+}
+
+// ImportEntry is one entry to insert directly into history via
+// "import". RequestExact/ResponseExact are always false once stored:
+// an imported entry is reconstructed from whatever structured format it
+// came from (HAR, say), never the literal bytes that were on the wire
+// for the original request - the same situation an HTTP/2 capture is
+// already in.
+type ImportEntry struct {
+ Method string `json:"method"`
+ Scheme string `json:"scheme"`
+ Host string `json:"host"`
+ Path string `json:"path"`
+ StatusCode int `json:"status_code"`
+ RequestRaw []byte `json:"request_raw"`
+ ResponseRaw []byte `json:"response_raw"`
+ StartedAt time.Time `json:"started_at"`
+ Duration time.Duration `json:"duration"`
}
// Response is sent by the daemon to a client.
type Response struct {
- Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "status", "flagged", "deleted", "cleared", or "error"
+ Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error"
Entries []store.Summary `json:"entries,omitempty"` // for "list"
Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat"
New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push)
@@ -79,6 +102,10 @@ type Response struct {
ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules"
Status *StatusMsg `json:"status,omitempty"` // for "status"
+ // For "import_done": how many entries were actually inserted (a
+ // per-entry insert failure is skipped, not fatal to the batch).
+ Imported int `json:"imported,omitempty"`
+
// For "intrude_result": one completed attack request.
IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"`
@@ -203,6 +230,27 @@ func (c *Client) ClearHistory() error {
return nil
}
+// Import inserts entries directly into history - used to bring in
+// traffic from an external source (a HAR file, say) rather than
+// something mitmux itself captured. Returns how many were actually
+// inserted; a per-entry insert failure is skipped rather than aborting
+// the whole batch.
+func (c *Client) Import(entries []ImportEntry) (int, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if err := c.enc.Encode(Request{Type: "import", ImportEntries: entries}); err != nil {
+ return 0, err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return 0, err
+ }
+ if resp.Type == "error" {
+ return 0, errors.New(resp.Error)
+ }
+ return resp.Imported, nil
+}
+
// Status returns basic daemon info for a status bar.
func (c *Client) Status() (*StatusMsg, error) {
c.mu.Lock()
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 303fdef..c83254e 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -230,6 +230,27 @@ func (s *Server) handleConn(conn net.Conn) {
}
enc.Encode(Response{Type: "cleared"})
+ case "import":
+ imported := 0
+ for _, ie := range req.ImportEntries {
+ e := &store.Entry{
+ StartedAt: ie.StartedAt,
+ Duration: ie.Duration,
+ Method: ie.Method,
+ Scheme: ie.Scheme,
+ Host: ie.Host,
+ Path: ie.Path,
+ StatusCode: ie.StatusCode,
+ RequestRaw: ie.RequestRaw,
+ ResponseRaw: ie.ResponseRaw,
+ Source: "import",
+ }
+ if _, err := s.db.Insert(e); err == nil {
+ imported++
+ }
+ }
+ enc.Encode(Response{Type: "import_done", Imported: imported})
+
case "rules_list":
rs, err := s.db.ListRules()
if err != nil {