srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/PLAN.md
AgeCommit message (Collapse)AuthorFilesLines
2026-04-08Multiple proxy listeners and upstream proxy chainingsrdusr1-2/+38
Closes the last two items from the original "worth considering" list. Multiple listeners: -listen takes a comma-separated address list (-listen "127.0.0.1:8080,127.0.0.1:8081"). Server.Addr became Server.Addrs; ListenAndServe binds every address up front - before any of them start serving - so a bad address fails startup immediately rather than leaving the daemon partially listening, and rolls back already-opened listeners if a later one fails to bind. All addresses share the same handler/history/CA/rules: one logical proxy reachable on more than one address, not several independent proxies in one process. Upstream proxy chaining: -upstream-proxy host:port (optional http:// prefix, stripped for convenience) routes every outbound connection through another HTTP CONNECT proxy instead of dialing origins directly. dialViaProxy does the CONNECT handshake to the upstream and hands back a plain net.Conn as if it were a direct connection; dialUpstreamTLS (CONNECT/HTTPS path) and dialUpstreamPlain (plain-HTTP path) both take an upstreamProxy parameter and route through it when set. The two paths need different handling: CONNECT/HTTPS is transparent below the tunnel (once the CONNECT handshake succeeds, TLS and the request on top of it look identical to a direct connection, so roundTripH2 and the H1 read side need no changes at all), but plain HTTP has to send an absolute-form request line to the upstream proxy instead of origin-form - so roundTripH1 gained a proxyForm parameter, and forward() selects it based on scheme=="http" && UpstreamProxy!="". Chaining into another intercepting/MITM proxy (including another mitmuxd) needs that proxy's own CA trusted too, or TLS verification fails - this is inherent to chaining MITM proxies, not a gap here, and confirmed live below rather than left as a guess. internal/proxy/dialer_test.go: dialViaProxy against a real local CONNECT stub (not a mock) - direct dial, successful tunnel-and-echo through a proxy, and a proxy that refuses the CONNECT with a non-200. All three exercise the actual network code path, not just the string-building around it. Verified live: started a daemon with two -listen addresses, sent requests through both, confirmed a single shared history; killed it mid-flight with SIGTERM and confirmed both listeners closed cleanly; started it with one bad address in the list and confirmed startup failed immediately with the already-bound port released, no lingering process. For chaining: sent plain HTTP and HTTPS through a downstream mitmuxd configured with -upstream-proxy pointing at a genuine passthrough CONNECT stub (tunnels raw bytes, doesn't MITM) and got real content back on both; separately chained through a second mitmuxd instance and got the expected "certificate signed by unknown authority" error, cleanly recorded in history rather than hanging. go build/vet/gofmt/test/mod tidy all clean.
2026-04-02Per-OS CA install instructions (mitmuxd -install-ca)srdusr1-2/+23
Trusting the CA was previously "import ca.pem into whatever's making the requests" with no further help. -install-ca generates the CA if needed and prints copy-pasteable, OS-specific steps, then exits without starting the proxy. Deliberately instructions-only, never auto-executing anything: Linux trust-store tooling varies enough across distros (trust vs update-ca-trust vs update-ca-certificates) that guessing wrong and running the wrong command unattended is worse than asking, and installing a root CA is a system-wide trust change affecting every TLS connection on the machine, not just mitmux's own traffic - running the printed command themselves keeps the user in control of that. internal/ca/install.go: InstallInstructions(goos, caPath) dispatches by OS. Linux detects trust (p11-kit - Arch, also on Fedora) / update-ca-trust (RHEL/Fedora/CentOS) / update-ca-certificates (Debian/Ubuntu/Gentoo) via PATH lookup and prints whichever is actually present, plus separate certutil/NSS instructions for Firefox/Chrome's own certificate store (which doesn't always follow the system trust store on Linux). macOS (security add-trusted-cert) and Windows (certutil -addstore / Import-Certificate) are implemented from each platform's standard documented tooling but not verified live - no macOS/Windows machine was available to test against, unlike Linux. commandExists is a package var (not a direct exec.LookPath call) so tests can fake which tools are "present" and exercise every detection branch deterministically, independent of what's actually installed on whatever machine runs `go test`. Verified live: built mitmuxd, ran -install-ca against a throwaway CA dir on this (Arch Linux) machine - correctly detected `trust` and `certutil` on PATH and printed accurate commands, confirmed the CA files were actually generated, confirmed no proxy/daemon process was left running (exits immediately after printing), and confirmed running it a second time reuses the existing CA (identical file hash) rather than regenerating. go build/vet/gofmt/test/mod tidy all clean.
2026-03-27Intruder payload processing and grep-match/grep-extractsrdusr1-4/+24
Payload processing: an optional case rule (upper/lower) and an optional encode rule (URL/Base64/Hex/HTML) applied to every payload line before it's substituted into the request, cycled with 'c'/'e'. Case always runs before encode - folding an already-encoded value would corrupt it (e.g. uppercasing Base64 padding). Applied entirely client-side in startIntrude() (payload_rules.go): a pure string transform with no proxy-side state, so it needs no protocol changes and reuses the Decoder's own urlEncodeAll. Grep-match/grep-extract: two optional Go regexps, edited with 'm'/'v' using the same modal edit-buffer pattern as the history list's '/' search (enter validates-and-commits, esc reverts to the last-confirmed pattern, an unparseable regexp is rejected with an error rather than silently accepted). Evaluated server-side, in internal/ipc/server.go's "intrude" handler, against each result's actual entry.ResponseRaw - that's where the real response bytes already are, and it's how Burp's own grep options work (matched against the real response, not a client-refetched copy). Grep-match flags a result (new Match column); grep-extract captures the first submatch, or the whole match if the pattern has no capturing group (new Extract column). Both patterns are compiled once before the attack starts and apply for that run only, not retroactively if changed mid-attack. All four new keys (c/e/m/v) are gated to normal mode, checked in the view's outer key switch before ever reaching the template/payloads vi-textareas - otherwise they'd be either untypeable letters or steal keystrokes mid-edit. Same discipline as the Repeater tab keys. internal/ipc: Request gained GrepMatch/GrepExtract string fields (for "intrude"), IntrudeResultMsg gained GrepMatch bool/GrepExtract string, and the client Intrude() helper takes the two pattern strings as new trailing parameters. Verified live in tmux against a running daemon and real httpbin.org traffic: built a template with a §marked§ query param, payloads 1/2/3, grep-match `"id": "2"` and grep-extract `"id": "([0-9]+)"`, ran the attack and confirmed the Match column flagged only the payload=2 row and Extract correctly pulled 1/2/3 from each response respectively; cycled case/encode through all states; confirmed an invalid regexp (`[abc`) is rejected with a visible error and esc correctly reverts to the last-confirmed pattern instead of committing the invalid one. (Also confirmed, incidentally: a batch of vi normal-mode two-key commands like "gg"/"dd" sent as one multi-character tmux send-keys argument doesn't reliably reach the app as separate keystrokes - a tmux scripting artifact, not a bug in the vi-mode implementation, which works correctly when each key is sent as its own event, as any real keypress would be.) go build/vet/gofmt/test/mod tidy all clean.
2026-03-18Multiple concurrent Repeater tabssrdusr1-4/+17
Repeater previously had one shared request/response buffer - sending a new entry to Repeater silently overwrote whatever was already open, even mid-edit. Replaced the singular reqArea/respView/repeaterScheme/ etc. model fields with a []*repeaterTab slice plus an active index; 'r' now opens a new tab and switches to it, existing tabs stay put. New keys, all gated to normal mode so they stay inert while typing (]/[ show up in JSON bodies constantly, and ctrl+w is the textarea's own delete-word-backward that must still work mid-edit): ] next tab [ previous tab ctrl+w close the active tab (falls back to a neighbor, or to the history list if it was the last one) Async send results now carry the tab index they belong to, so a slow send whose response lands after the user has switched tabs (or closed one) updates the right tab rather than whichever happens to be active when the result arrives; the status line and response pane only reflect it live if that tab is still the one being viewed. Verified live in tmux against a running daemon: opened two tabs from different history entries, confirmed independent buffers, sent from a background tab while another was active and confirmed the result routed to the correct (non-visible) tab, switched with ]/[, closed with ctrl+w down to zero tabs (falls back to the history list), and confirmed [, ], and ctrl+w are all correctly inert in insert mode (typed "[a]" literally, ctrl+w did textarea's word-delete instead of closing the tab). go build/vet/gofmt/test/mod tidy all clean.
2026-03-17Standalone Decoder: URL/Base64/Hex/HTML encode & decodesrdusr1-6/+15
First of the remaining "worth considering" items. A self-contained tool ('d' from the history list, not seeded from any entry - this is for arbitrary snippets, pasted tokens, encoded parameter values) with a vi-modal input pane and a live output pane that updates on every keystroke and every transform switch (tab/shift+tab cycles through the 8 transforms). decoder.go is pure logic, deliberately kept separate from the TUI wiring so it's directly testable: urlEncodeAll implements strict RFC 3986 percent-encoding (space -> %20) rather than using Go's url.QueryEscape, whose form-encoding behavior (space -> '+') isn't what "URL encode" means to a pentester reaching for this tool. Base64 decode tries standard/URL-safe/padded/unpadded encodings in turn rather than requiring the user to know which one they're looking at - real pasted data is as likely to be one as the other. Decode failures return a visible "(error: ...)" placeholder rather than blanking the output, so a bad guess at the transform is obviously wrong rather than looking like nothing happened. decoder_test.go covers each transform directly, three "this input isn't valid for this transform" error cases, and a round-trip matrix (all 4 encode/decode pairs against 5 inputs chosen to be awkward for at least one encoding - spaces, slashes, HTML-special characters, empty string, embedded newlines) confirming encode-then-decode always recovers the original. Single-transform only, not chained/pipelined like Burp's Decoder - v1 scope, tracked in PLAN.md. Verified live: typed text and watched the output pane update in real time; confirmed URL-encoding, then cycled to Base64 via tab and watched it re-encode the same input live; confirmed the active-transform highlighting via raw ANSI codes in the captured pane; fed invalid input to Base64 decode and confirmed the error placeholder renders instead of silently showing stale output; confirmed esc correctly backs out to the history list.
2026-02-24Comparer: unified diff between two history entriessrdusr1-6/+15
Next item off the "worth considering" list from the Burp/ZAP/Caido gap research. Mark an entry with 'c' (from the history list or detail view - no fetch yet, just remembers the ID), then 'c' on a different entry fetches both and opens a colored unified diff of either side's request or response, tab to switch between them. Unified (git-diff style: +/- prefixed lines) rather than Burp's side-by-side two-pane layout - a two-column view fights terminal width for anything but a wide window, and unified reuses the same scrollable viewport pattern already used everywhere else in this TUI rather than needing new layout machinery. Uses github.com/pmezard/go-difflib (SequenceMatcher-based, a tested port of Python's difflib) rather than hand-rolling LCS/Myers diff, which has real edge cases worth not reinventing. CRLF is normalized to LF before diffing - display-only, same reasoning as the JSON pretty-printer - so an HTTP/1.1 exact capture doesn't show every single line as changed purely from an invisible trailing \r. Verified live against two real, distinctly different captured POST requests (different form bodies, different Content-Length): the request diff correctly isolated exactly the two changed lines with the unchanged headers shown as context, colors confirmed via raw ANSI codes in the captured pane output (red 203 for removed, green 42 for added) rather than assumed from the code, and the response tab showed a correct independent diff of the two responses (Date header, JSON body). Also confirmed the "same entry marked twice" path shows a hint rather than silently doing something confusing.
2026-02-17Flagged marker for history entriessrdusr1-0/+28
Last of the "should build soon" items from the Burp/ZAP/Caido gap research - Burp's row highlighting and Caido's Findings both serve the same real workflow: mark something interesting mid-engagement, revisit later. Scoped to a boolean flag (★) rather than full free-text notes/comments, which would need their own text-input overlay for comparatively modest extra value over a simple marker - tracked as a real follow-up in PLAN.md, not dropped silently. internal/store: history gains a flagged column (migrated in for existing databases the same way source was) plus Store.SetFlagged and Summary/Entry.Flagged. Search's structured-filter layer (added last commit for status:/source:) gains flagged:true/false alongside them - extractStructured already existed for exactly this kind of "pull it out before it reaches FTS5" filter. internal/ipc gains a "set_flagged" request. cmd/mitmux: 'f' toggles the flag on the selected history row (applied optimistically to local state, persisted async - a drift between local and server state on failure is an acceptable trade-off for a marker this low-stakes), shown as a ★ column in the list and in the detail view's title. store_test.go covers the flagged: parsing (true/false spellings, and a "looks like it but isn't" case - flagged:maybe - falling through as literal search text, matching the existing pattern for status:). Verified live: toggling 'f' shows the star immediately, flagged:true correctly filtered to just that entry, and a direct SQLite check confirmed the flag actually persisted to the database (flagged=1), not just reflected in local UI state.
2026-02-03Intruder-equivalent: Sniper attacks with § markerssrdusr1-0/+11
Implements build-order step 7, the last (optional) item. Scoped to Sniper only - one payload set, one §-marked position fuzzed at a time, others held at their base value - since that covers most real Intruder usage; battering ram / pitchfork / cluster bomb aren't implemented. Sequential sending, capped at 1000 generated requests as a fixed safety limit. internal/proxy: repeat.go's Repeat() is refactored into a shared sendRaw(..., source) primitive so Intrude can reuse the exact same raw-byte send/record path with source="intruder" instead of duplicating it. intrude.go adds ParseMarkers/buildRequest (marker parsing and payload substitution, covered by intrude_test.go - this is fiddly byte-splicing logic, worth locking down with real tests rather than trusting it by inspection) and Intrude(), which walks positions × payloads calling sendRaw and streaming each result through a callback. internal/ipc gains a dedicated streaming "intrude" connection (same shape as Subscribe, but blocking sends rather than drop-on-slow- consumer - each result is the attack's actual data, not a notification). cmd/mitmux gains an Intruder view: editable request template (ctrl+p inserts a § marker at the cursor - typing § directly also works, ctrl+p just doesn't require a keyboard layout that can produce it), editable payload list, and a live results table wired to the existing detail view (selecting a row and hitting enter opens the full request/response for that specific attack request). Verified live against real external traffic: a Sniper attack against httpbin.org/status/§200§ with payloads 200/404/500 produced exactly the three corresponding real status codes back (not a canned/local result), confirmed the three requests landed in history tagged source="intruder" with the § markers correctly stripped from what was actually sent, and confirmed opening a result row's full detail from the results table. This closes out the full build order from PLAN.md (steps 1-7).
2024-09-23Match-and-replace: header rewrite rulessrdusr1-0/+12
Implements build-order step 6, scoped to headers only for this pass - see PLAN.md for why bodies are a separate problem (request-body capture currently depends on streaming straight through, which a body-rewriting rule would have to interrupt; deciding what "exact" means for a rule-modified request needs its own pass, not a rushed add-on to this one). internal/rules: Rule type and ApplyHeaders, which serializes a Header map to a raw "Name: value\r\n" block, runs enabled rules' match/replace over that text, and reparses it - operating on text rather than per-value substitution is what lets a rule add or remove a header, not just rewrite one, matching how Burp's header match/replace works. Invalid rule output (bad regex, unparseable result) leaves the header map untouched rather than corrupting the request. internal/store: rules table + CRUD. internal/proxy: forward() fetches enabled rules for each scope and applies them to outReq.Header / resp.Header, positioned so the existing capture/history pipeline is untouched - request_raw keeps showing what the client actually sent and response_raw what the origin actually sent, while the wire itself reflects the rules. Deliberate split: match-and-replace transforms traffic, it doesn't rewrite the audit trail. internal/ipc gains rules_list/rules_save/rules_delete/rules_toggle. cmd/mitmux gains a rules view ('m' from history) with add/edit/delete/toggle and a small form (name, match, replace, scope, regex). Verified live against real external traffic, not just local echoes: a request-scope rule rewriting User-Agent, confirmed via httpbin.org's own header echo that the origin received the rewritten value while curl sent the real one; a response-scope rule rewriting the Server header, confirmed the client actually received the rewritten value; disabling a rule confirmed via a follow-up request that it stops applying; and throughout, history continued showing the pre-rule original on both sides, confirming the capture/transform split holds.
2024-02-14History view: SQLite storage, daemon/TUI split over Unix socketsrdusr1-1/+6
Implements build-order step 3. Adds: - internal/store: SQLite (WAL, single-writer) history table, raw request/response blobs plus metadata for the list view. - internal/proxy: request/response capture wired into forward(). HTTP/1.1 legs are captured byte-exact via a teeConn that records wire bytes as they're read, taken right after the message is fully drained (so no manual re-reading/replaying is needed - RoundTrip's own streaming does the draining). HTTP/2 legs (no meaningful "raw bytes" of their own - multiplexed, HPACK-compressed framing) are reconstructed instead, and marked as such in storage. - internal/ipc: JSON-over-Unix-socket protocol between mitmuxd (owns the proxy and the DB) and any client - list/get for queries, subscribe for a live push stream of newly captured entries. Keeps the proxy engine independent of the UI, per the architecture sketch. - cmd/mitmux: Bubble Tea TUI - a live-updating history table and a request/response detail view with raw bytes. Two real bugs surfaced during testing and got fixed before commit: 1. http.Transport's HTTP/2 auto-dispatch does a literal *tls.Conn type assertion on the dialed connection; wrapping it in a capturing teeConn broke that silently, and HTTP/2 framing got parsed as HTTP/1.1 text. Fixed by dropping http.Transport for the upstream leg entirely in favor of an explicit per-protocol round trip (see PLAN.md stack note). 2. singleConnListener wrapped the client teeConn *inside* a closeSignalConn, so ConnContext's type assertion for it silently failed and HTTP/1.1 client-side capture never activated. Fixed the wrap order; verified via direct SQLite inspection that request_exact flips back to 1 and the stored bytes are genuinely wire-exact (preserved chunked-encoding framing, original header casing/order). Verified live: plain HTTP, HTTPS H1.1, HTTPS H2, and a POST with a body, checked against the raw stored bytes directly in SQLite; IPC list/get/ subscribe against a throwaway client; and the TUI driven end-to-end in a tmux session (list, detail view, tab between request/response, live update on a new request while sitting on the list).
2024-01-16Scaffold mitmux: proxy daemon, CA generation, HTTP/CONNECT passthroughsrdusr1-0/+48
Implements build-order step 1: headless proxy daemon (mitmuxd) with plaintext HTTP passthrough and raw CONNECT tunneling, plus root CA generation/persistence for later TLS interception. Verified live against real HTTP and HTTPS requests through the proxy.