<feed xmlns='http://www.w3.org/2005/Atom'>
<title>mitmux/cmd, branch main</title>
<subtitle>Terminal-based intercepting HTTP proxy.
</subtitle>
<id>https://srdusr.com/git/mitmux/atom?h=main</id>
<link rel='self' href='https://srdusr.com/git/mitmux/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/'/>
<updated>2026-08-04T07:35:00+00:00</updated>
<entry>
<title>Plugin protocol foundation: tag_entry, tag search/sort, TUI tag view</title>
<updated>2026-08-04T07:35:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-04T07:35:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=dde73a349a68f942a5bd89b27ac980d7973148e0'/>
<id>urn:sha1:dde73a349a68f942a5bd89b27ac980d7973148e0</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>History sorting, status color-coding, and JSON syntax highlighting</title>
<updated>2026-07-31T14:07:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-07-31T14:07:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=9b630b2cbf9206855534668ebaaaee255cabedd4'/>
<id>urn:sha1:9b630b2cbf9206855534668ebaaaee255cabedd4</id>
<content type='text'>
Filtering already existed (FTS5 search plus status:/source:/flagged:
and column filters). Sorting and highlighting didn't, at all.

Sorting: o/O cycle and reverse the sort column (status, size, time
taken, method, host, path) applied client-side on top of whatever
order List/Search already returned. refreshTable() reorders m.entries
itself, not just what's rendered - every "act on the selected row" key
handler indexes m.table.Cursor() straight into m.entries with no
indirection, so keeping the two in identical order sidesteps an entire
class of "highlighted row and actual target silently disagree" bugs
rather than updating every one of those call sites.

JSON syntax highlighting (cmd/mitmux/jsoncolor.go): walks the token
stream via json.Decoder.Token() with an explicit stack, not recursive
calls, so depth is bounded by memory rather than Go's call stack for
adversarial nesting. Every string re-escaped via json.Marshal before
writing, which is also why the colorized output is deliberately never
run through sanitizeControl afterward (unlike every other raw-text
view here): JSON's own encoding rules already forbid a literal control
character in a string, so re-marshaling neutralizes one as a side
effect of producing valid JSON - running sanitizeControl on top would
instead corrupt the ANSI codes this adds.

Status-code color-coding (2xx green through 5xx red) does not live in
the history/Intruder tables, despite an initial attempt to put it
there. Confirmed live: bubbles/table v1.0.0 (the newest available)
fits cell text to its column width via go-runewidth's Truncate, which
has no ANSI awareness - it counts every character of a color escape
sequence as real display width. Coloring the Status cell silently
deleted the status text from the row; the width-fitting truncation cut
into the escape sequence itself. styledStatus is used once instead, in
detailView's title, a plain string rendered whole with no width
constraint.

Verified live in tmux against a running daemon: ascending/descending
sort by status across six real entries: pretty-printed JSON confirmed
correctly colored and indented via raw ANSI capture, not just
eyeballed; the detail title's status color confirmed red for a 500
entry the same way; the request tab (never JSON) confirmed unaffected.
</content>
</entry>
<entry>
<title>WebSocket interception</title>
<updated>2026-06-30T12:52:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-30T12:52:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52'/>
<id>urn:sha1:384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Browser-launcher helper: throwaway proxied profile, one flag</title>
<updated>2026-06-29T07:58:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-29T07:58:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=2ade8c807584bff0b60d6b6f278dbde29b13a5ff'/>
<id>urn:sha1:2ade8c807584bff0b60d6b6f278dbde29b13a5ff</id>
<content type='text'>
Adds -launch-browser=chrome|firefox|auto to the mitmux TUI binary.
Resolves an installed browser (PATH first, then common per-OS install
locations), spins up a brand new throwaway profile, configures it to
proxy through the daemon, and opens straight to http://mitmux.cert/ so
installing the CA in that profile is one click.

This is the answer to "build an in-house browser": a bundled GUI
browser is a different, much larger project and works against this
tool's terminal-native positioning - the actually useful part of that
idea is zero-friction setup (proxy + CA-install page, no profile
pollution), which this delivers by launching the user's own browser
in a disposable profile instead of embedding one.

Chrome takes --proxy-server as a flag; Firefox has none, so its
profile gets a generated user.js instead - the only non-interactive
way to configure it. Verified against this machine's real installed
Chrome and Firefox: binary discovery resolves both, auto prefers
chrome-family when both are present, and the generated Firefox prefs
are well-formed. Deliberately did not spawn a live browser window as
part of verification - that's a visible GUI action on whoever runs
it, left for a user to trigger by hand via the flag.
</content>
</entry>
<entry>
<title>SOCKS5 upstream proxy chaining</title>
<updated>2026-06-24T12:40:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-24T12:40:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=e01afcbf0de00af52fe90959ba77288679e3303d'/>
<id>urn:sha1:e01afcbf0de00af52fe90959ba77288679e3303d</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Client (mutual-TLS) certificates</title>
<updated>2026-06-16T20:57:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-16T20:57:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=23c8ab359c2108654d57176e233d2c099b398f31'/>
<id>urn:sha1:23c8ab359c2108654d57176e233d2c099b398f31</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Intruder: Battering ram, Pitchfork, and Cluster bomb attack modes</title>
<updated>2026-06-10T22:37:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-10T22:37:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=6114567258bcad0517a0d881168711aaacdba5d5'/>
<id>urn:sha1:6114567258bcad0517a0d881168711aaacdba5d5</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Body match-and-replace rules</title>
<updated>2026-06-09T13:46:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-09T13:46:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=a2362fc08c31b23fb971279f8b160555123ad2f6'/>
<id>urn:sha1:a2362fc08c31b23fb971279f8b160555123ad2f6</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Mouse support: wheel scroll everywhere, right-click context menus</title>
<updated>2026-06-09T12:35:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-09T12:35:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=9028f8175bda63d6a85e5a2dee2b4039020317a4'/>
<id>urn:sha1:9028f8175bda63d6a85e5a2dee2b4039020317a4</id>
<content type='text'>
Explicitly requested - this is a real terminal app meant to work in any
terminal (the name is a naming convention, not a tmux runtime
dependency), and should be genuinely mouse-driven, not keyboard-only.
Enabled via tea.WithMouseCellMotion() (SGR mouse mode, the same
protocol nvim and most modern TUI apps use); coexists with tmux's own
mouse mode the same way it would for any other terminal app.

Scope was decided by a real, verified library constraint, not
convenience: bubbles/table exposes no way to learn its own scroll
offset (confirmed by reading its source - no YOffset accessor, the
rendered window comes from unexported fields via a second internal
layer of viewport scrolling on top of that). Mapping a click's screen
coordinates to a specific table row can't be done without reaching into
that library's private internals, which this deliberately doesn't do -
silently selecting the wrong row on a misjudged click is worse than not
supporting precise click-to-row at all.

What's shipped, the reliable subset:

- Wheel scroll everywhere there's something to scroll: tables via the
  already-exported MoveUp/MoveDown (no scroll-state assumptions
  needed), viewports via their own native wheel handling (bubbles/
  viewport already has this - nothing in the codebase was routing
  tea.MouseMsg to it yet), and vi-modal text editors via new
  viTextarea.ScrollUp/ScrollDown (bubbles/textarea has zero native
  mouse handling at all, confirmed the same way - feeds wheel events as
  repeated up/down keypresses through the same tested movement path
  h/j/k/l already use).
- Right-click opens a context menu (cmd/mitmux/contextmenu.go) - a
  horizontal strip taking over the status/help line, the same "replace
  the bottom of the screen" pattern confirmPrompt and the export/import
  prompts already use, rather than a floating popup positioned at the
  click (lipgloss/bubbletea have no compositor for splicing an overlay
  into an arbitrary screen position - not worth building just for
  this). Wired into every list-based view: history
  (view/repeater/intruder/flag-unflag/delete), rules
  (edit/enable-disable/delete), scope (enable-disable/delete). Menu
  items ARE reliably clickable, unlike table rows - the menu renders
  its own strip, so every item's width is fully known rather than
  hidden behind a library's unexported scroll state. Navigable by mouse
  click or j/k/arrows+enter; esc or right-clicking again dismisses.

Deferred, not silently dropped: click-to-select-a-different-row (same
scroll-offset limitation), and click-to-switch-pane-focus in Repeater/
Intruder (tractable via the same Y-coordinate math WindowSizeMsg
already computes, just not done yet - keyboard tab already covers it,
so lower priority than what shipped).

Verified live in tmux by injecting real SGR mouse escape sequences
directly into the pane (tmux send-keys -l with hand-built ESC [ &lt;
Cb;Cx;Cy M/m sequences, since tmux has no built-in "synthesize a click"
primitive) against a running daemon with real captured entries:
wheel-down/up on the history table correctly moved the selected-row
highlight (confirmed via ANSI-aware capture, not just "no crash");
right-click opened the menu with the right actions; clicking directly
on a computed menu-item position correctly triggered that exact action
(clicked "delete", saw the correct entry's ID in the resulting confirm
prompt); keyboard navigation inside the menu moved the highlight
correctly; esc and a second right-click both dismissed cleanly with no
side effects; wheel events in Repeater's text pane and Detail's
viewport caused no crash and left vi-mode state intact; an empty rules
table's right-click correctly no-opped; adding a real rule then
right-clicking and clicking "disable" correctly toggled it off
(confirmed via the rendered checkmark disappearing).

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Version flag, Makefile, and honest cross-platform documentation</title>
<updated>2026-05-25T21:13:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-25T21:13:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=dc059db6db67abad249fa153593a89a45fa37486'/>
<id>urn:sha1:dc059db6db67abad249fa153593a89a45fa37486</id>
<content type='text'>
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.
</content>
</entry>
</feed>
