srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
AgeCommit message (Collapse)AuthorFilesLines
2026-08-25Wire-format JSON tag consistency fix, and the first real pluginsrdusr4-64/+67
Writing PLUGINS.md as an authoritative external spec surfaced a real, pre-existing bug: store.Summary/Entry/EntryTag/WSMessage, rules.Rule, scope.Rule, and clientcert.Cert had no JSON struct tags at all, so Go's default marshaling serialized them PascalCase ("ID", "StartedAt") while the rest of the protocol (EntryDetail's own fields, every Request/Response wrapper field) uses snake_case. Confirmed live against a real daemon before touching anything: a raw socket "list" request came back with "ID"/"StartedAt"/"StatusCode", exactly the mismatch suspected. Nothing outside this repo's own Go code consumes this wire format yet, so this was a free, purely additive fix rather than something to work around - every affected struct now tags snake_case consistently. plugins/authcheck is the first real plugin: an Autorize-style authorization checker. For every proxied request carrying an Authorization or Cookie header, resends it with that header stripped and compares status classes - a resend that still succeeds where the original did too is a likely missing-function-level-access-control bug, tagged authcheck:bypass with structured detail. Deliberately speaks the wire protocol directly (its own local request/response/ summary/entryDetail structs mirroring the real ones field-for-field, not imported from internal/ipc) rather than taking the shortcut a Go plugin could - proof the documented protocol is actually sufficient on its own, since that's all a non-Go plugin author has to work with. Verified live end to end: a real daemon, a real Python origin with one endpoint that looks like it enforces auth but doesn't (vulnerable by design) and one that actually does (the control case) - the broken endpoint was correctly tagged, the secure one correctly left alone, no false positive, confirmed both via the stored tag data directly and visually in the TUI (tmux, real keystrokes): the Tags column badge, T's tag list, and the tag detail view's JSON-colorized data (ANSI-verified, not eyeballed) all showing the plugin's actual findings.
2026-08-04Plugin protocol foundation: tag_entry, tag search/sort, TUI tag viewsrdusr4-6/+177
The prerequisite for the plugin ecosystem: any external process - any language - that can reach the control socket can now tag a history entry with a short string marker and an opaque JSON data blob, stored in a new entry_tags table rather than requiring the plugin stay connected for a later live round-trip. A plugin does its analysis once; the data it attaches is what a human reviewing the entry later actually sees. internal/ipc: new "tag_entry" request (id, tag_plugin, tag, tag_data) and EntryDetail.Tags (the full record for one entry, populated by "get"). internal/store: entry_tags table, EntryTag struct, AddEntryTag/ ListEntryTags, a comma-joined Tags aggregate added to List/Search via a correlated subquery (cheap enough per row that showing a tag badge in the history list needs no N+1 query), and a new tag: search filter alongside the existing status:/source:/flagged:. TUI: a Tags column in the history table (sortable via o/O, the eighth sort column), T from detail view opens a tag list (mirroring the WebSocket-messages view's table-then-detail-viewport pattern), enter on one shows its data - JSON-colorized via the existing jsoncolor.go if it parses as JSON, sanitized plain text otherwise. Also fixed a pre-existing gap while touching this: the WebSocket-messages view never got mouse wheel support when it shipped; wired both it and the new tags view up together. PLUGINS.md documents the wire protocol for non-Go plugin authors - connection model (subscribe vs request/response), the handful of request types a plugin actually needs, and the trust boundary (the socket has no auth beyond OS file permissions, same as the TUI's own access). PLAN.md records the architecture decision (external process over an embedded scripting language - mirrors the daemon/TUI split already in place, no interpreter to sandbox, any language) and groups ~20 researched Burp extensions/Pro features into what Phase 1 already covers (Autorize, Param Miner, Backslash Powered Scanner, Retire.js - all just subscribe+repeat+tag, no new capability needed), what needs a second protocol addition (JWT Editor, SAML Raider - live RPC to a specific connected plugin for interactive actions like re-signing), and what deserves its own separate project rather than a plugin (active vulnerability scanning, Collaborator/OAST, a crawler). Verified live end to end against a real daemon: a throwaway program simulating a real plugin tagged a captured entry with structured JWT data over the actual wire protocol; confirmed the tag badge, tag: search filter, and full tag record all round-tripped correctly through List/Search/Get. Confirmed in the TUI itself (tmux, real keystrokes): the Tags column renders, T opens the tag list, entering it shows the JSON data with real ANSI-verified syntax highlighting (not just eyeballed), and tag: search filtering works from the history list.
2026-06-30WebSocket interceptionsrdusr6-6/+557
The last "known limitation": a ws://wss:// connection stops being one-shot request/response the instant its 101 Switching Protocols lands, and forward()'s normal write-response-then-record flow has no way to represent that. Scoped to HTTP/1.1 client legs (HTTP/2 can't be hijacked for raw post-response access the way HTTP/1.1 can, and browsers open a dedicated HTTP/1.1 connection for WebSocket regardless of the surrounding page's protocol, so this isn't a real-world gap). internal/proxy/websocket.go decodes each RFC 6455 frame's opcode and payload for capture while relaying the exact same raw bytes it read unmodified - this is capture, not tampering, matching the rest of the codebase's raw-bytes-as-source-of-truth stance. One row per frame, not per reassembled message (fragmentation is rare in real-world WebSocket traffic; not worth buffering an unbounded number of pending fragments to handle it). forward() branches on a matching 101 into handleWebSocketUpgrade, which hijacks the client connection, relays the handshake response raw, records the upgrade request/response to history normally, then relays frames bidirectionally into a new ws_messages table - reachable from the TUI's detail view via `w`. Found and fixed two real bugs by actually driving a WebSocket connection through a running daemon, not by reading the code: stripHopByHop was deleting Connection/Upgrade from every outgoing request (correct for an ordinary request per RFC 7230, catastrophic for one asking to upgrade - every WebSocket attempt silently became a 426); and the relay tore the whole connection down the instant either side saw a close frame, before the peer's own close-frame reply could be relayed back, producing an abrupt EOF instead of a clean close. Verified live end to end on both paths a real client uses: ws:// (plain HTTP forward-proxying) against a Python websockets echo server, and wss:// (CONNECT-tunneled, TLS-intercepted) against the same server behind TLS - text, binary, and extended-length frames, plus a full close handshake with both directions' close frames present, confirmed via the actual bytes captured in ws_messages.
2026-06-24SOCKS5 upstream proxy chainingsrdusr2-11/+329
Extends -upstream-proxy to accept a socks5://[user:pass@]host:port prefix, using golang.org/x/net/proxy (already an indirect dependency via http2, so no new module) rather than hand-rolling the client side of RFC 1928/1929. parseSOCKS5 is the single place that decides which kind of upstream a given UpstreamProxy string names; dialViaProxy (CONNECT/TLS path) and dialUpstreamPlain (plain-HTTP path) both check it first and fall through to the existing HTTP CONNECT behavior otherwise. SOCKS5 needs no absolute-form request adjustment on the plain-HTTP path the way HTTP-proxy chaining does, since it tunnels straight to the target rather than expecting a proxy-aware request. Tested against a real, minimal SOCKS5 server built for the test suite (exercises dialSOCKS5's actual wire behavior, not a mock of the client library), plus live against a real standalone SOCKS5 relay process: both a plain HTTP and an HTTPS request through mitmux were confirmed, via the relay's own log, to have actually traversed it.
2026-06-16Client (mutual-TLS) certificatessrdusr7-16/+401
Adds internal/clientcert: a cert/key pair matched to hosts by the same substring-or-regex pattern model as scope.Rule, so mitmux can present a client certificate on an upstream TLS handshake that requires one - the previous behavior was a hard handshake failure with no way to authenticate. Wired into both places mitmux dials an https:// upstream over its own TLS client connection: proxy.go's handleConnect (live proxied traffic) and repeat.go's dialForRepeat (Repeater/Intruder resends), both through a new Server.clientCertFor(host) helper. Stored in a new client_certs table, mirroring the existing scope_rules persistence pattern. The TUI (`t` from history) is add-only like scope, for the same reason: delete and re-add covers changing anything, and it's a rarely-touched, low-cardinality list. The add form takes cert/key file paths and reads them once at save time - PEM content, not the path, is what's stored and later presented, so a cert keeps working even if the original file moves afterward. Verified live against a real mutual-TLS-requiring origin server: without a matching cert the handshake correctly fails; with one configured, the origin receives it and the request succeeds; toggling it off reproduces the failure, confirming the enable/disable path works end to end.
2026-06-11Intruder: Battering ram, Pitchfork, and Cluster bomb attack modessrdusr4-87/+326
Generalizes Intrude beyond Sniper to all four of Burp's attack modes (proxy.AttackMode). Sniper and Battering ram only ever need one shared payload set; Pitchfork and Cluster bomb are inherently per-position, so they take one payload set per §marked§ position instead. Request-set generation (intrudeValues) is pure and side-effect free, so the total request count is validated against the existing 1000 cap before anything is dispatched - Cluster bomb's product is checked incrementally, one payload set at a time, so a pathological product bails out before ever trying to enumerate it. This also makes the combinatorics unit-testable without a live target. IntrudeResultMsg now reports Values (one substitution per marked position, in order) instead of a single Position/Payload pair, since three of the four modes touch multiple positions per request. TUI: `a` cycles the attack mode. Pitchfork/Cluster bomb reuse the existing single Payloads pane rather than a new multi-widget editor - sets are separated by a `---` delimiter line, in position order. Verified live against a real daemon: all four modes produce the expected substitution values and request counts, and pitchfork correctly rejects a payload-set count that doesn't match the template's marked positions.
2026-06-09Body match-and-replace rulessrdusr5-15/+330
Extends match-and-replace rules to request/response bodies, not just headers. A body rule materializes the body into memory (bounded by the same maxCaptureBytes cap as history capture) instead of streaming it straight through - the opposite of the normal path, so it's only paid when a body rule is actually configured. A body over the cap passes through byte-exact and unmodified rather than being partially rewritten. Response Content-Length is recomputed explicitly when a rule changes body length: unlike http.Request.Write, http.ResponseWriter doesn't derive it from resp.ContentLength on its own, so a stale header would otherwise corrupt response framing for the client. The history audit trail still shows the original, pre-rule bytes on both legs; only the wire traffic reflects the rewrite. Verified live against a real daemon: origin receives the rewritten request body, client receives the rewritten response body with correct Content-Length, and history keeps the unmodified bytes. Adds a Part selector (header/body) to the Rules add/edit form and table in the TUI.
2026-06-05Serve the CA certificate for browsers/mobile at http://mitmux.cert/srdusr2-0/+63
Browser/mobile setup previously meant "find ca.pem on disk and import it manually" - awkward on a phone or tablet especially, which has no convenient way to get a file onto the device at all short of emailing it to yourself or similar. Any client already configured to proxy through mitmux can now just visit http://mitmux.cert/ and get the cert directly, with Content-Type: application/x-x509-ca-cert triggering iOS/Android's native "install this certificate" prompt. Same idea as mitmproxy's own http://mitm.it/, arrived at independently rather than reusing their domain - mitmux.cert isn't a registered TLD, so it can never collide with a real site someone meant to visit. Deliberately HTTP-only: fetching it over HTTPS would require the client to already trust mitmux's CA to MITM that very connection, the exact chicken-and-egg problem this exists to solve, so it's not attempted on the CONNECT/TLS path at all. internal/proxy/proxy.go: isCertDownloadHost matches the hostname case-insensitively regardless of port; handleHTTP checks it before ever dialing upstream and answers directly via serveCACert, using the CA's own CertPEM bytes already held in memory. Short-circuits before record() is ever reached, so the download itself never pollutes history. Verified live: fetched http://mitmux.cert/ through a real running proxy and confirmed the downloaded bytes are byte-identical to the actual ca.pem on disk (diff, not just "the request succeeded"); confirmed a request with an explicit port and a path still matches; confirmed normal proxying to an unrelated host is completely unaffected; confirmed via direct SQLite query that the cert-download requests never appear in history while a normal request in the same session does. Also documented (no code needed): any standard proxy-switcher extension (FoxyProxy, etc.) or a phone/tablet's own Wi-Fi proxy setting already works with mitmux exactly like it would with Burp/ZAP/Caido, since it's a normal forward proxy speaking the standard protocol. This was true before but never actually spelled out in the README for the phone/ tablet case specifically, which is a real, common daily workflow. go build/vet/gofmt/test/mod tidy all clean.
2026-05-25Version flag, Makefile, and honest cross-platform documentationsrdusr2-0/+62
Neither binary had a -version flag - a basic expectation for any CLI tool, and useful for anyone reporting a bug ("which build is this"). internal/version holds Version/Commit/Date, set via -ldflags "-X mitmux/internal/version.X=..." at build time and defaulting to "dev" for a plain `go build` with no ldflags, so -version is never blank or misleading about whether a given binary is a tagged release or a local build. Both mitmux and mitmuxd gained a -version flag that prints it and exits. Makefile: `make build` (both binaries for the current platform, version info from `git describe`), `make test` (the same build/vet/gofmt/test checks expected before every commit here), `make install` (a thin wrapper over `go install`, respecting GOBIN/GOPATH as usual - not reimplementing Go's own path resolution), `make release` (cross-compiles both binaries for linux/darwin/windows/freebsd, amd64+arm64 where it makes sense, into dist/). Every target is CGO_ENABLED=0: modernc.org/sqlite is pure Go, so no C toolchain is needed anywhere, cross-compiling included - this was already true before this commit, just not verified or made easy to use. Verified live, every target actually run rather than just written: `make build` produces working binaries with version info correctly picked up from git (confirmed against a real -version invocation, both the "dev" default and an ldflags-injected release-style version string); `make test` runs clean; `make release` was run for real and produced 6 platform/arch binaries, each confirmed with `file` to be a genuinely correctly-formatted executable for its target (Mach-O for both macOS architectures, PE32+ for Windows, ELF for both Linux architectures and FreeBSD) - not just "the command exited zero." `make install`'s correctness rests on `go install` itself, Go's own well-tested mechanism; deliberately not run for real here since it writes into the real GOPATH/bin outside this repo, unprompted. README gained an honest Platforms section: Linux is what's actually been run and verified throughout this project's development; macOS, Windows, and FreeBSD cross-compile cleanly and pass go vet, and the code has nothing Linux-specific in it (CA/history storage already used Go's own cross-platform os.UserConfigDir, not a hardcoded XDG path - also fixed the README's install-directory example, which had been Linux-only text), but they haven't run on real hardware, so they're documented as "should work, not yet verified" rather than a claim this session can't actually back up. Also flagged a concrete, real gotcha: macOS's shorter Unix domain socket path limit combined with the deeper ~/Library/Application Support default control-socket location could matter for a long username, with the existing -socket flag as the workaround. go build/vet/gofmt/test/mod tidy all clean.
2026-05-24Import: bring a HAR file's entries into historysrdusr2-1/+70
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.
2026-05-20Target scope: filter what gets recorded, not what gets proxiedsrdusr6-6/+339
The proxy captured and stored literally everything with no way to exclude unrelated traffic - every CDN asset, analytics beacon, and third-party tracker request on a real engagement pollutes history and search right alongside the traffic that actually matters. internal/scope: Rule{Enabled, Pattern, IsRegex} and InScope(rules, host). A non-regex pattern matches by case-insensitive substring against the host - "example.com" matches "example.com", "www.example.com", and "api.example.com" alike, covering "this domain and its subdomains" without inventing a separate wildcard syntax. IsRegex mirrors the same toggle match-and-replace rules already use, for one consistent mental model across both rule types in this tool. An empty or all-disabled rule set means everything is in scope - the behavior before scope existed at all, unchanged, so a fresh install or a user who never opens the scope view keeps recording everything rather than silently nothing. Deliberately a recording filter, not access control: out-of-scope traffic still proxies completely normally, reaching its destination and the client exactly as before. internal/proxy's forward() already writes the response to the client before record() ever runs, so the scope check (new in record()) can only affect whether the exchange gets stored, never whether it happens. Blocking out-of-scope traffic outright would be a materially different, much riskier feature - a wrong scope pattern could silently break the very traffic someone's trying to test, which is a far worse failure mode than a noisier history. Repeat/Intrude (recordRaw, a separate function from record()) deliberately don't go through the scope check at all: a user explicitly resending or fuzzing a specific request wants to see the result regardless of scope, which exists to cut passive-capture noise, not second-guess a deliberate action. internal/store: new scope_rules table (CREATE TABLE IF NOT EXISTS, no migration needed - it's a new table, not a new column on an existing one) plus List/Add/SetEnabled/Delete, mirroring the existing match-and-replace rules CRUD exactly. internal/ipc: scope_list/ scope_add/scope_delete/scope_toggle request types and matching Client methods; scope_add validates a regex pattern compiles before persisting, same reasoning and same fix as the earlier rules_save validation (an invalid regex should be rejected up front, not silently never match at apply time with zero feedback). TUI: 's' from the history list opens scope management, mirroring the Rules view's own list+form pattern but simpler (add-only, no edit-in-place - a pattern and a regex toggle don't need a five-field form, delete-and-re-add covers changing one). Verified live in tmux against a running daemon: added a substring scope rule for one host, sent requests to both a matching and a non-matching host - the non-matching one proxied successfully (client got its 200) but was never recorded, the matching one was recorded normally; confirmed a Repeater resend of the excluded host WAS recorded despite being out of scope; toggled the rule off and confirmed recording resumed for everything; added and confirmed a regex-mode rule saves and displays correctly; deleted a rule and confirmed the list returns to empty ("no rules means everything is recorded"). go build/vet/gofmt/test/mod tidy all clean.
2026-05-18History deletion: delete one entry (x) or clear everything (X)srdusr3-3/+85
Store had full CRUD for match-and-replace rules but no way to delete or prune history at all - it only ever grew, with no way to remove an accidental capture or start a new engagement clean short of manually deleting the DB file outside the tool entirely. internal/store: DeleteEntry(id) removes one history row and its history_fts search index row in a transaction. ClearHistory() empties both tables entirely; rules are untouched. internal/ipc: new "delete_entry" and "clear_history" request types, Client.DeleteEntry/ ClearHistory methods. TUI: 'x' deletes the selected history entry, 'X' clears the whole database. Both gated behind a y/n confirmation - a small reusable confirmPrompt/confirmYes model state, checked first in the history list's key handling, so any key other than y/Y safely cancels rather than falling through to whatever that key normally does elsewhere (this also means ctrl+c during a pending confirmation cancels the prompt rather than quitting - a deliberate fail-safe, not an oversight: quick to dismiss, and a second ctrl+c then quits normally). 'X' is explicitly NOT scoped to an active search filter - it always clears the true total (read from daemon status, not len(m.entries), which would understate the count under a filter and make the confirmation prompt itself misleading about what's about to happen). Verified live in tmux against a running daemon with real captured entries: 'x' shows "delete #N? y/n", 'n' cancels with the entry untouched, 'y' deletes it and the list/count both refresh correctly; 'X' shows "clear all N history entries (not just this view)? y/n" with the true count, 'y' empties the database (confirmed via direct SQLite query: both history and history_fts at 0 rows afterward) and the TUI correctly shows "history (0)" / "0 requests"; 'x'/'X' on an empty list correctly no-op without crashing. go build/vet/gofmt/test/mod tidy all clean.
2026-05-16Bound slow-loris connections and hung TLS handshakessrdusr1-3/+36
Neither the main proxy's http.Server nor the per-CONNECT-tunnel one had any timeouts - a client that opened a connection and either trickled request headers forever or, on the CONNECT/HTTPS path, completed the CONNECT handshake and then never sent a TLS ClientHello at all, held that connection and its goroutine open indefinitely. Confirmed live before the fix: partial headers with no terminator, and a completed CONNECT with no ClientHello, both held the connection open 10s+ with no sign of ever stopping. Low real-world risk at the 127.0.0.1 default, but multi-listener support means mitmuxd can now bind other interfaces, making this a real DoS-by-neglect surface rather than a purely theoretical one. clientHeaderTimeout (30s) is applied as ReadHeaderTimeout on both http.Server instances, and clientIdleTimeout (120s) as IdleTimeout on both - deliberately narrow, bounding only the pre-body header-parsing phase and idle time between keep-alive requests, not overall request duration, so a legitimately slow multi-minute upload/download still works exactly as before (verified: normal HTTP and HTTPS requests both still succeed after this change). The CONNECT-tunnel's TLS handshake specifically had no deadline at all before calling clientTLS.Handshake() - unlike the upstream leg, which already correctly calls conn.SetDeadline before its own round trip (see forward()). Now client.SetDeadline(...) is set with the same clientHeaderTimeout right before the handshake and cleared immediately after a successful one, so the request/response phase that follows doesn't inherit a stale handshake-only deadline. Verified live: a client sending a partial request line with no terminator was cut off at exactly 30.1s (previously indefinite); a client completing CONNECT and then never sending a ClientHello was cut off at exactly 30.1s (previously indefinite); normal HTTP and HTTPS requests through the proxy both still succeed afterward. go build/vet/gofmt/test/mod tidy all clean.
2026-05-15Stop mislabeling truncated captures as "exact"srdusr7-62/+113
internal/proxy/tee.go's teeConn silently drops bytes past maxCaptureBytes (10 MiB) but callers unconditionally marked the result "exact" anyway. Confirmed live: proxying a 15 MiB response worked correctly end-to-end (the client got the full, real 15 MiB - proxying itself is unbounded, only storage is capped), but the stored history entry was exactly 10485760 bytes with response_exact=1 still set. For a tool whose core value proposition is "raw bytes are the source of truth," a silently truncated capture presented as complete could hide the very evidence a smuggling or parser-differential investigation is looking for in the tail of a large body - and give false confidence that it isn't there. teeConn.Take() now returns (data, truncated) instead of just data; truncated is true whenever a Read had to drop bytes because the buffer was already at cap. Every caller (proxy.go's forward() on both the request and response side, repeat.go's sendRaw for Repeater/Intruder) now folds truncated into exact - a truncated capture is never marked exact - and additionally threads a distinct RequestTruncated/ ResponseTruncated bool through store.Entry, ipc.EntryDetail, and the TUI, since "truncated" and "reconstructed" (HTTP/2, which never had wire-exact bytes to begin with) are different situations worth telling apart: a truncated capture is still real wire bytes, just incomplete, not a synthesized reconstruction. The detail/repeater views now show "truncated (hit capture size limit)" specifically rather than lumping it in with "reconstructed", which would have implied more transformation happened than actually did. Schema: history gains request_truncated/response_truncated columns via the same ALTER-TABLE-and-ignore-duplicate-column pattern already used for source/flagged, so existing databases upgrade in place. Verified live: a target server returning a 15 MiB body (over the 10 MiB cap) proxied through cleanly - full body reached the client - while the stored entry shows length=10485760, response_exact=0, response_truncated=1 (previously would have shown response_exact=1); the TUI's Detail view correctly displays "Response (10485760 bytes, truncated (hit capture size limit))" instead of "exact". go build/vet/gofmt/test/mod tidy all clean.
2026-04-17Reject an invalid regex when saving a match-and-replace rulesrdusr1-0/+12
internal/rules/rules.go's apply() treats a regex that fails to compile exactly the same as "no match" - it silently returns the text unchanged with no error surfaced anywhere in the call chain. rules_save had no validation before persisting, so a rule with a typo'd regex would save successfully, show as Enabled in the UI, and simply never fire on any traffic - no indication anything was wrong. internal/ipc/server.go's "rules_save" handler now compiles r.Match with regexp.Compile before persisting when IsRegex is set, rejecting with a clear "invalid regex: ..." error otherwise. No TUI changes needed: the existing ruleWriteDoneMsg error path already surfaces any saveRule error inline via the status line and - since it only clears ruleForm on success - keeps the form open with the user's draft intact so they can fix the pattern without losing their edits. That path already existed for other error classes (DB errors); this just adds a new one flowing through it. Verified live over the real protocol (raw JSON on the control socket): an unclosed-bracket regex is rejected with the expected error and never reaches the rules table; a valid regex rule still saves and returns normally. go build/vet/gofmt/test/mod tidy all clean.
2026-04-16Fix Intruder silently hanging on body-parameter fuzzingsrdusr2-0/+102
internal/proxy/intrude.go's buildRequest substitutes marker text but never recalculated Content-Length. A payload's length routinely differs from the base value it replaces, so any body-parameter fuzzing attack left a stale Content-Length from the original captured request in every substituted request. When the declared length is larger than the actual body sent, the target server blocks waiting for bytes that never arrive, and each such request eats the full 60s upstreamTimeout before failing - silently, with no error or warning anywhere. Since body- parameter fuzzing is one of the most common Intruder use cases and payload lengths vary within essentially every real attack, this made most real body-fuzzing attacks take payloads×60s for no visible reason. Found by a live audit that timed identical attacks: URL-only marker fuzzing (no body length change) completed in single-digit milliseconds per payload; the same attack with the marker in a body parameter took 60s per payload. fixContentLength recalculates an existing Content-Length header to match the actual body length after substitution, called right after buildRequest in the Intrude loop. Deliberately narrow: only touches a request with exactly one Content-Length header and a clean header/body boundary. Zero found means nothing to fix (unchanged). More than one is left alone too - a request smuggling test's own deliberately ambiguous framing, where guessing which one to "fix" would be worse than leaving both as the user built them. This is Intruder-specific, not a change to Repeater: what the user types into Repeater still goes on the wire completely unmodified, no auto-fixed Content-Length there, same as always. A fuzzed value's length is a side effect of automated substitution Intruder performs on the user's behalf, not a deliberate edit the way a Repeater request is. internal/proxy/intrude_test.go: TestFixContentLength covers recalculating a stale length, case-insensitive header matching, no-header and no-boundary no-ops, and the two-headers-left-alone case. Verified live against a real HTTP target and a real daemon (JSON over the control socket, not the TUI, for precise timing): a template with Content-Length declared far larger than any actual substituted body - the exact hang-triggering direction - completed all 4 payloads in 0.01s total, and the recorded history entries carry exactly the correct recalculated Content-Length for each (10/19/11/14, byte-for-byte matching each actual body). go build/vet/gofmt/test/mod tidy all clean.
2026-04-08Multiple proxy listeners and upstream proxy chainingsrdusr2-28/+285
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)srdusr2-0/+196
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-extractsrdusr2-15/+61
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-02-17Flagged marker for history entriessrdusr4-11/+103
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-16Structured search filters: status:, source:srdusr2-15/+194
Closes another top item from the Burp/ZAP/Caido gap research: status- code and MIME/type filtering alongside free text is used constantly in practice (Caido's HTTPQL, Burp's proxy history filter). Scoped to status and source for now - method: already works today via FTS5's own method column (a plain text match on "POST" is effectively exact for a short alphanumeric token), so it didn't need special handling. status_code isn't a text column FTS5 can index, and doesn't benefit from full-text matching anyway (it's a numeric comparison, not a word search), so extractStructured pulls status:/source: tokens out of the query before it reaches FTS5 and turns them into real parameterized SQL predicates against history's typed columns: status:404 (exact), status:>=400 / status:!=200 (comparison operators), status:4xx (also 2xx/3xx/5xx - the shorthand people actually reach for: "show me the errors"), source:repeater/intruder/proxy. Whatever text remains after extraction still goes through the existing FTS5 path, so "admin status:200" correctly ANDs a real full-text match with a real status predicate in one query. When nothing remains (pure "status:4xx"), Search skips the FTS5 join entirely and queries history directly. store_test.go covers the parsing (exact/operator/range/source, combined with free text, and two "looks like it but isn't" cases - status:banana and the malformed 4-digit status:4004 - to confirm they fall through as literal search text instead of being misparsed). Verified live against real varied traffic (status 200/404/500 requests plus a POST with an "admin" body) - status:4xx matched only the 404; status:>=400 matched both 404 and 500; "admin status:200" correctly matched only the POST and excluded the other unrelated 200; source:proxy matched everything captured so far. All against the actual SQL execution path, not just the pure parsing function.
2026-02-05Status bar and help screensrdusr3-7/+50
Baseline TUI UX that should exist regardless of feature parity - flagged directly by the Burp/ZAP/Caido comparison research as missing. A persistent one-line status bar (proxy address, live request count, current view) is now appended to every screen. Fetched once at startup via a new "status" IPC request (internal/store gains Store.Count(); internal/ipc gains StatusMsg plus a daemon-side handler reading proxy.Server.Addr through ipc.NewServer's new proxyAddr parameter), then kept approximately live by incrementing locally on each "new" subscribe push rather than re-querying every time. '?' opens a full keybinding reference from every view, gated so it never shadows literal text entry - it's a no-op while typing in the search box, a rule form field, or (checked via viTextarea.Mode()) insert-mode text in Repeater/Intruder, where a URL query string literally starting with '?' is completely ordinary input. Any key dismisses it and returns to whichever view opened it. Every existing height calculation (table, viewport, textarea panes) had to shrink by one line to make room for the status bar without pushing content off-screen - done once via a shared `h := msg.Height-1` in the WindowSizeMsg handler rather than touching each call site individually. Verified live: status bar shows the real proxy address and updates its count after a live-captured request; '?' renders the full reference from the history list; dismissing returns to the correct prior view; and specifically confirmed '?' still types literally (tested typing "?foo=bar" into a Repeater request body in insert mode) rather than being swallowed by the help shortcut.
2026-02-03Intruder-equivalent: Sniper attacks with § markerssrdusr5-15/+383
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 rulessrdusr5-2/+344
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-09-19Fix hang and data-race bugs found while re-verifying steps 1-5srdusr5-13/+62
Audited every file in the proxy/store/ipc/TUI stack before starting step 6, per request. Found and fixed three real bugs in already-shipped code, all confirmed with live tests (including a race-detector build) rather than just read: 1. No timeout covered the write-request/read-response phase of an upstream exchange, in either the main proxy path (roundTripH1/ roundTripH2) or Repeater - only the dial itself was bounded. A server that accepted the connection and then never finished responding hung the request forever. Fixed with conn.SetDeadline after a successful dial in both forward() and Repeat() (new upstreamTimeout constant, 60s). Verified against a real hung TCP listener: the daemon returned a clean "i/o timeout" error at exactly 60s instead of hanging. 2. ipc.Client shared one connection/encoder/decoder with no locking. Bubble Tea dispatches each request as its own goroutine, and viewList's 'r' key doesn't change mode while its loadDetail call is in flight - pressing it again (or 'enter' on another row) before the first response arrives calls Get/List/Repeat concurrently on the same connection, which can interleave JSON on the wire or hand one call another's response. Fixed with a mutex serializing round trips. Stress-tested with rapid overlapping key input against a -race build of both binaries: no warnings, no corruption. 3. The IPC "subscribe" handler only noticed a disconnected client when the next broadcast's Encode failed - a subscriber that quit while the daemon was otherwise idle leaked its goroutine and channel indefinitely. Fixed by reading the connection in the background too, so disconnection is detected immediately regardless of traffic. Also removed a dead, misleading parameter: captureResponse took a *teeConn it was never actually called with (the exact-capture path is handled directly in forward()), so the branch using it was unreachable. Re-verified all five prior steps end-to-end against a fresh build: plain HTTP, HTTPS H1.1/H2/untrusted-CA-rejection, exact vs reconstructed capture flags cross-checked directly in SQLite, Repeater over both HTTP and HTTPS, and search (plain text, dotted domains, hyphenated terms, column filters) - all correct.
2024-09-14Search/filter: FTS5 index over historysrdusr3-5/+150
Implements build-order step 5. internal/store gains an FTS5 virtual table (history_fts) kept in sync with every Insert in the same transaction, indexing method/host/path plus the full raw request and response text - so search covers headers and bodies, not just metadata. Store.Search ranks by bm25 relevance. internal/ipc's existing "list" request grows an optional query field rather than a new message type. cmd/mitmux gets an inline '/' filter on the history view (bubbles/ textinput), esc to clear; live entries arriving while a filter is active are held back with a "+N new" indicator rather than guessed at, since FTS match can't be evaluated against a bare Summary. Two real bugs found via testing against the actual sqlite3 CLI, not assumed from docs: 1. This SQLite build doesn't support MATCH/bm25() against an aliased FTS5 table ("no such column") - only the literal table name resolves. Fixed by leaving history_fts unaliased in the JOIN. 2. FTS5's query grammar treats a wide range of punctuation as syntax, not literal characters - confirmed '.', '-', '/', '@', '(', ')' all produce parse errors (or worse, silently different results, as hyphens get misparsed as column-filter syntax) in an unquoted bareword. Since that covers the most common things people search proxy history for (domains, paths, hyphenated headers, IPs), this would have made the feature fail by default for its primary use case. Fixed with prepareFTSQuery: quote every plain token as an FTS5 phrase (syntactically valid regardless of content) while still recognizing AND/OR/NOT and column:value filters. Also caught, mid-testing, that a query fix wasn't taking effect - traced to the daemon still running an old `go run` build from before the fix while only the TUI had been restarted; not a code bug, but a reminder to restart both. Verified live end-to-end: plain-text search matching header/body/JSON content, a previously-failing dotted-domain search now returning exactly the right single match, a hyphen/host:-filter case, boolean-free numeric search, filter-clear returning to the unfiltered list, and the pending- count indicator when new traffic arrives mid-filter.
2024-08-29Repeater: raw-byte send/resendsrdusr5-26/+220
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.
2024-02-14History view: SQLite storage, daemon/TUI split over Unix socketsrdusr6-71/+821
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-27TLS interception: per-host leaf certs, terminate-and-resign MITM, native HTTP/2srdusr2-29/+239
Implements build-order step 2. CA gains LeafFor(host), signing and caching per-host leaf certificates on demand. The proxy's CONNECT handler now terminates TLS with the client using a matching leaf cert instead of tunneling raw bytes, and forwards each request upstream over its own independently negotiated TLS connection. Client-side and upstream-side ALPN are negotiated separately rather than one being forced to mirror the other: an http.Transport configured via http2.ConfigureTransport auto-bridges HTTP/1.1 and HTTP/2 on each side independently, so e.g. an HTTP/1.1-only client reaching an HTTP/2-preferring origin still works instead of failing the handshake (caught by testing curl --http1.1 against example.com before this fix). Verified live: plain HTTP passthrough, HTTPS with default (H2) and forced HTTP/1.1 clients, and that requests without the mitmux CA trusted are correctly rejected.
2024-01-16Scaffold mitmux: proxy daemon, CA generation, HTTP/CONNECT passthroughsrdusr2-0/+306
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.