diff options
| author | srdusr <[email protected]> | 2026-06-16 22:57:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-16 22:57:00 +0200 |
| commit | 23c8ab359c2108654d57176e233d2c099b398f31 (patch) | |
| tree | 3e2d1c66d987b4b771da69e67bd8a0c9ad2fc3db /cmd | |
| parent | 6114567258bcad0517a0d881168711aaacdba5d5 (diff) | |
| download | mitmux-23c8ab359c2108654d57176e233d2c099b398f31.tar.gz mitmux-23c8ab359c2108654d57176e233d2c099b398f31.zip | |
Client (mutual-TLS) certificates
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.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 383 | ||||
| -rw-r--r-- | cmd/mitmux/mouse.go | 29 |
2 files changed, 386 insertions, 26 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 304d3fc..f529681 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/lipgloss" "mitmux/internal/ca" + "mitmux/internal/clientcert" "mitmux/internal/ipc" "mitmux/internal/proxy" "mitmux/internal/rules" @@ -97,6 +98,7 @@ const ( viewCompare viewDecoder viewScope + viewClientCerts viewHelp ) @@ -139,6 +141,16 @@ const ( fieldRegex ) +type clientCertField int + +const ( + ccFieldName clientCertField = iota + ccFieldPattern + ccFieldCertPath + ccFieldKeyPath + ccFieldRegex +) + type intruderFocus int const ( @@ -193,6 +205,20 @@ type model struct { scopePattern textinput.Model scopeIsRegex bool + // Client (mutual-TLS) certificates: which cert/key pair mitmux + // presents to an upstream server that requires one, matched by host + // pattern the same way scope rules are. Add-only like scope, for the + // same reason - delete and re-add covers changing anything about it. + clientCertTable table.Model + clientCertRows []clientcert.Cert + clientCertForm bool + clientCertField clientCertField + clientCertName textinput.Model + clientCertPattern textinput.Model + clientCertCertPath textinput.Model + clientCertKeyPath textinput.Model + clientCertIsRegex bool + intruderScheme string intruderHost string intruderTemplate viTextarea @@ -328,6 +354,24 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) scopePatternIn := textinput.New() scopePatternIn.Placeholder = "host substring, or a regex - e.g. example.com" + clientCertCols := []table.Column{ + {Title: "On", Width: 3}, + {Title: "Name", Width: 16}, + {Title: "Pattern", Width: 30}, + {Title: "Regex", Width: 5}, + } + clientCertTbl := table.New(table.WithColumns(clientCertCols), table.WithFocused(true)) + clientCertTbl.SetStyles(st) + + ccNameIn := textinput.New() + ccNameIn.Placeholder = "certificate name" + ccPatternIn := textinput.New() + ccPatternIn.Placeholder = "host substring, or a regex - e.g. internal.example.com" + ccCertPathIn := textinput.New() + ccCertPathIn.Placeholder = "path to PEM certificate file" + ccKeyPathIn := textinput.New() + ccKeyPathIn.Placeholder = "path to PEM private key file" + itmpl := newViTextarea() itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" itmpl.ta.ShowLineNumbers = false @@ -365,29 +409,34 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) impIn.Placeholder = "HAR file path to read" return &model{ - client: client, - subCh: subCh, - socketPath: socketPath, - mode: viewList, - table: t, - repeaterIndex: -1, - searchInput: si, - rulesTable: rt, - scopeTable: scopeTbl, - scopePattern: scopePatternIn, - ruleName: nameIn, - ruleMatch: matchIn, - ruleReplace: replaceIn, - ruleScope: "request", - rulePart: "header", - intruderTemplate: itmpl, - intruderPayloads: ipayloads, - intruderResults: iresults, - grepMatchInput: gmIn, - grepExtractInput: geIn, - decoderInput: din, - exportInput: expIn, - importInput: impIn, + client: client, + subCh: subCh, + socketPath: socketPath, + mode: viewList, + table: t, + repeaterIndex: -1, + searchInput: si, + rulesTable: rt, + scopeTable: scopeTbl, + scopePattern: scopePatternIn, + clientCertTable: clientCertTbl, + clientCertName: ccNameIn, + clientCertPattern: ccPatternIn, + clientCertCertPath: ccCertPathIn, + clientCertKeyPath: ccKeyPathIn, + ruleName: nameIn, + ruleMatch: matchIn, + ruleReplace: replaceIn, + ruleScope: "request", + rulePart: "header", + intruderTemplate: itmpl, + intruderPayloads: ipayloads, + intruderResults: iresults, + grepMatchInput: gmIn, + grepExtractInput: geIn, + decoderInput: din, + exportInput: expIn, + importInput: impIn, } } @@ -959,6 +1008,114 @@ func (m *model) scopeRuleFromForm() scope.Rule { return scope.Rule{Enabled: true, Pattern: m.scopePattern.Value(), IsRegex: m.scopeIsRegex} } +type clientCertsLoadedMsg struct { + certs []clientcert.Cert + err error +} + +type clientCertWriteDoneMsg struct { + action string // "added", "deleted", "toggled" - for the status line + err error +} + +func (m *model) loadClientCerts() tea.Msg { + certs, err := m.client.ListClientCerts() + return clientCertsLoadedMsg{certs: certs, err: err} +} + +// addClientCertFromForm reads the cert/key PEM files given in the form +// (client-side - the TUI and daemon conventionally run on the same host +// in this tool, same assumption the CA install flow already makes) and +// sends their contents, not the paths, to the daemon: a client cert has +// to keep working even if the file it was loaded from later moves or is +// deleted, same as a match-and-replace rule doesn't keep re-reading +// anything after it's saved. +func (m *model) addClientCertFromForm() tea.Cmd { + name := m.clientCertName.Value() + pattern := m.clientCertPattern.Value() + isRegex := m.clientCertIsRegex + certPath := m.clientCertCertPath.Value() + keyPath := m.clientCertKeyPath.Value() + client := m.client + return func() tea.Msg { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return clientCertWriteDoneMsg{action: "added", err: fmt.Errorf("read cert file: %w", err)} + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return clientCertWriteDoneMsg{action: "added", err: fmt.Errorf("read key file: %w", err)} + } + _, err = client.AddClientCert(clientcert.Cert{ + Enabled: true, + Name: name, + Pattern: pattern, + IsRegex: isRegex, + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + return clientCertWriteDoneMsg{action: "added", err: err} + } +} + +func (m *model) deleteSelectedClientCert() tea.Cmd { + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return nil + } + id := m.clientCertRows[row].ID + return func() tea.Msg { + err := m.client.DeleteClientCert(id) + return clientCertWriteDoneMsg{action: "deleted", err: err} + } +} + +func (m *model) toggleSelectedClientCert() tea.Cmd { + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return nil + } + c := m.clientCertRows[row] + return func() tea.Msg { + err := m.client.SetClientCertEnabled(c.ID, !c.Enabled) + return clientCertWriteDoneMsg{action: "toggled", err: err} + } +} + +// enterClientCertForm opens the (add-only) client certificate form - +// add-only for the same reason scope rules are: delete and re-add covers +// changing anything about it, and it's a rarely-touched, low-cardinality +// list. +func (m *model) enterClientCertForm() { + m.clientCertForm = true + m.clientCertField = ccFieldName + m.clientCertName.SetValue("") + m.clientCertPattern.SetValue("") + m.clientCertCertPath.SetValue("") + m.clientCertKeyPath.SetValue("") + m.clientCertIsRegex = false + m.focusClientCertField() +} + +// focusClientCertField moves input focus to m.clientCertField, blurring +// the others - same convention as focusRuleField. +func (m *model) focusClientCertField() { + m.clientCertName.Blur() + m.clientCertPattern.Blur() + m.clientCertCertPath.Blur() + m.clientCertKeyPath.Blur() + switch m.clientCertField { + case ccFieldName: + m.clientCertName.Focus() + case ccFieldPattern: + m.clientCertPattern.Focus() + case ccFieldCertPath: + m.clientCertCertPath.Focus() + case ccFieldKeyPath: + m.clientCertKeyPath.Focus() + } +} + func (m *model) Init() tea.Cmd { return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus) } @@ -1001,6 +1158,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scopeTable.SetHeight(h - 5) m.scopePattern.Width = formWidth + m.clientCertTable.SetWidth(msg.Width) + m.clientCertTable.SetHeight(h - 5) + m.clientCertName.Width = formWidth + m.clientCertPattern.Width = formWidth + m.clientCertCertPath.Width = formWidth + m.clientCertKeyPath.Width = formWidth + // h-9 rather than h-8: one extra line reserved for the payload // rules / grep-match-extract status line in intruderView. itmplHeight := (h - 9) / 3 @@ -1189,6 +1353,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "scope rule " + msg.action return m, m.loadScopeRules + case clientCertsLoadedMsg: + if msg.err != nil { + m.statusMsg = "client cert error: " + msg.err.Error() + return m, nil + } + m.clientCertRows = msg.certs + setTableRows(&m.clientCertTable, clientCertRowsFor(m.clientCertRows)) + return m, nil + + case clientCertWriteDoneMsg: + if msg.err != nil { + m.statusMsg = "client cert " + msg.action + " error: " + msg.err.Error() + return m, nil + } + m.clientCertForm = false + m.statusMsg = "client cert " + msg.action + return m, m.loadClientCerts + case intrudeStartedMsg: if msg.err != nil { m.intruderRunning = false @@ -1405,6 +1587,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = viewScope m.statusMsg = "" return m, m.loadScopeRules + case "t": + m.mode = viewClientCerts + m.statusMsg = "" + return m, m.loadClientCerts case "esc": if m.query != "" { m.query = "" @@ -1711,6 +1897,70 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scopeTable, cmd = m.scopeTable.Update(msg) return m, cmd + case viewClientCerts: + if m.clientCertForm { + switch msg.String() { + case "esc": + m.clientCertForm = false + return m, nil + case "ctrl+c": + return m, tea.Quit + case "ctrl+s": + return m, m.addClientCertFromForm() + case "tab": + m.clientCertField = (m.clientCertField + 1) % 5 + m.focusClientCertField() + return m, nil + case "shift+tab": + m.clientCertField = (m.clientCertField + 4) % 5 + m.focusClientCertField() + return m, nil + } + if m.clientCertField == ccFieldRegex { + switch msg.String() { + case "left", "right", "enter", " ": + m.clientCertIsRegex = !m.clientCertIsRegex + return m, nil + } + } + var cmd tea.Cmd + switch m.clientCertField { + case ccFieldName: + m.clientCertName, cmd = m.clientCertName.Update(msg) + case ccFieldPattern: + m.clientCertPattern, cmd = m.clientCertPattern.Update(msg) + case ccFieldCertPath: + m.clientCertCertPath, cmd = m.clientCertCertPath.Update(msg) + case ccFieldKeyPath: + m.clientCertKeyPath, cmd = m.clientCertKeyPath.Update(msg) + } + return m, cmd + } + + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewClientCerts + m.mode = viewHelp + return m, nil + case "a": + m.enterClientCertForm() + return m, nil + case "d": + m.statusMsg = "" + return m, m.deleteSelectedClientCert() + case " ": + m.statusMsg = "" + return m, m.toggleSelectedClientCert() + } + var cmd tea.Cmd + m.clientCertTable, cmd = m.clientCertTable.Update(msg) + return m, cmd + case viewIntruder: // Editing a grep pattern is a modal overlay on top of the // normal template/payloads/results panes, same pattern as @@ -1962,6 +2212,12 @@ func (m *model) View() string { } else { body = m.scopeView() } + case viewClientCerts: + if m.clientCertForm { + body = m.clientCertFormView() + } else { + body = m.clientCertView() + } case viewIntruder: body = m.intruderView() case viewCompare: @@ -1991,7 +2247,7 @@ func (m *model) statusBar() string { view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", - viewScope: "scope", + viewScope: "scope", viewClientCerts: "client certs", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -2040,6 +2296,7 @@ func (m *model) helpView() string { "esc clear active search filter", "m match-and-replace rules", "s target scope (what gets recorded)", + "t client (mutual-TLS) certificates", "q quit", ) section("Detail view", @@ -2092,6 +2349,14 @@ func (m *model) helpView() string { "tab toggle regex (in the add form)", "ctrl+s save form esc cancel/back", ) + section("Client certificates", + "Which cert/key mitmux presents to an upstream server that", + "requires mutual TLS, matched by host pattern like scope rules.", + "a add (name, pattern, cert/key file paths, regex toggle)", + "d delete selected space toggle enabled", + "tab/shift+tab move between form fields ctrl+s save form", + "◀▶ / enter toggle regex (when that field is focused)", + ) b.WriteString(helpStyle.Render("press any key to go back")) return b.String() @@ -2147,9 +2412,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · t client certs · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · t client certs · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -2385,6 +2650,72 @@ func scopeRowsFor(rs []scope.Rule) []table.Row { return rows } +func (m *model) clientCertView() string { + var b strings.Builder + title := fmt.Sprintf(" client certificates (%d) - presented on mutual-TLS handshakes to matching hosts ", len(m.clientCertRows)) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.clientCertTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("a add · d delete · space toggle · esc back · q quit")) + return b.String() +} + +func (m *model) clientCertFormView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" add client certificate ")) + b.WriteString("\n\n") + + label := func(field clientCertField, text string) string { + if m.clientCertField == field { + return tabActive.Render(text) + } + return tabInactive.Render(text) + } + + b.WriteString(label(ccFieldName, "Name") + "\n") + b.WriteString(m.clientCertName.View() + "\n\n") + b.WriteString(label(ccFieldPattern, "Pattern (substring match, or a regex against the host)") + "\n") + b.WriteString(m.clientCertPattern.View() + "\n\n") + b.WriteString(label(ccFieldCertPath, "Cert file (PEM)") + "\n") + b.WriteString(m.clientCertCertPath.View() + "\n\n") + b.WriteString(label(ccFieldKeyPath, "Key file (PEM)") + "\n") + b.WriteString(m.clientCertKeyPath.View() + "\n\n") + + regexText := "Regex: off (◀▶ to change)" + if m.clientCertIsRegex { + regexText = "Regex: on (◀▶ to change)" + } + b.WriteString(label(ccFieldRegex, regexText) + "\n\n") + + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab/shift+tab move · ctrl+s save · esc cancel · ctrl+c quit")) + return b.String() +} + +func clientCertRowsFor(cs []clientcert.Cert) []table.Row { + rows := make([]table.Row, len(cs)) + for i, c := range cs { + on := " " + if c.Enabled { + on = "✓" + } + regex := "" + if c.IsRegex { + regex = "yes" + } + rows[i] = table.Row{on, c.Name, c.Pattern, regex} + } + return rows +} + // nextAttackMode cycles Sniper -> BatteringRam -> Pitchfork -> ClusterBomb // -> Sniper. func nextAttackMode(mode proxy.AttackMode) proxy.AttackMode { diff --git a/cmd/mitmux/mouse.go b/cmd/mitmux/mouse.go index 102dc4f..e2d4936 100644 --- a/cmd/mitmux/mouse.go +++ b/cmd/mitmux/mouse.go @@ -79,6 +79,8 @@ func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m.handleRulesMouse(msg) case viewScope: return m.handleScopeMouse(msg) + case viewClientCerts: + return m.handleClientCertMouse(msg) } return m, nil } @@ -231,3 +233,30 @@ func (m *model) handleScopeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } + +func (m *model) handleClientCertMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.clientCertForm { + return m, nil + } + switch { + case msg.Button == tea.MouseButtonWheelUp: + m.clientCertTable.MoveUp(3) + case msg.Button == tea.MouseButtonWheelDown: + m.clientCertTable.MoveDown(3) + case msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress: + row := m.clientCertTable.Cursor() + if row < 0 || row >= len(m.clientCertRows) { + return m, nil + } + sel := m.clientCertRows[row] + enableLabel := "enable" + if sel.Enabled { + enableLabel = "disable" + } + m.openContextMenu([]contextMenuItem{ + {label: enableLabel, action: func() tea.Cmd { return m.toggleSelectedClientCert() }}, + {label: "delete", action: func() tea.Cmd { return m.deleteSelectedClientCert() }}, + }) + } + return m, nil +} |