diff options
| -rw-r--r-- | PLAN.md | 39 | ||||
| -rw-r--r-- | README.md | 14 | ||||
| -rw-r--r-- | cmd/mitmux/har.go | 149 | ||||
| -rw-r--r-- | cmd/mitmux/har_test.go | 148 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 89 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 50 | ||||
| -rw-r--r-- | internal/ipc/server.go | 21 |
7 files changed, 504 insertions, 6 deletions
@@ -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, @@ -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 { |