srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-16 22:57:00 +0200
committersrdusr <[email protected]>2026-06-16 22:57:00 +0200
commit23c8ab359c2108654d57176e233d2c099b398f31 (patch)
tree3e2d1c66d987b4b771da69e67bd8a0c9ad2fc3db /cmd
parent6114567258bcad0517a0d881168711aaacdba5d5 (diff)
downloadmitmux-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.go383
-rw-r--r--cmd/mitmux/mouse.go29
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
+}