srdusr
aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--PLAN.md21
-rw-r--r--README.md30
-rw-r--r--cmd/mitmux/main.go383
-rw-r--r--cmd/mitmux/mouse.go29
-rw-r--r--internal/clientcert/clientcert.go62
-rw-r--r--internal/clientcert/clientcert_test.go111
-rw-r--r--internal/ipc/ipc.go75
-rw-r--r--internal/ipc/server.go47
-rw-r--r--internal/proxy/proxy.go43
-rw-r--r--internal/proxy/repeat.go15
-rw-r--r--internal/store/store.go64
11 files changed, 830 insertions, 50 deletions
diff --git a/PLAN.md b/PLAN.md
index 87c01cf..16a8599 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -384,8 +384,25 @@ This closes every item from the expanded "worth considering" list.
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
-Collaborator/OAST, team collaboration, CI integration, client TLS
-(mutual-TLS) certs, invisible/non-proxy-aware proxying.
+Collaborator/OAST, team collaboration, CI integration,
+invisible/non-proxy-aware proxying. Client (mutual-TLS) certificates
+were later added - see below.
+
+## Client (mutual-TLS) certificates
+
+internal/clientcert stores cert/key pairs matched to hosts by the same
+substring-or-regex pattern model as scope.Rule (internal/scope) - one
+consistent mental model across every "which rule applies to this
+host" decision in the tool. A match is looked up in proxy.go's
+handleConnect (live proxied HTTPS) and repeat.go's dialForRepeat
+(Repeater/Intruder resends), both funneling through
+Server.clientCertFor, and passed into the outbound tls.Config's
+Certificates field when non-nil. Deliberately add-only in the TUI, no
+edit-in-place, same reasoning as scope: delete and re-add covers
+changing anything, and it's a rarely-touched, low-cardinality list.
+The TUI form takes file paths and reads them once at save time - the
+PEM content itself, not the path, is what's stored, so a cert keeps
+working even if the original file later moves.
## Licensing, packaging, and browser/mobile support
diff --git a/README.md b/README.md
index 4d424e6..b1237f6 100644
--- a/README.md
+++ b/README.md
@@ -49,11 +49,14 @@ list of what's deliberately not implemented (and why), see
sent) and grep-match/grep-extract (flag or pull text out of each
result's response with a regexp) are both configurable before starting
an attack - see [Intruder](#intruder) below.
-- **Match-and-replace**: header rewrite rules (add, remove, or modify)
- for requests and/or responses, applied live as traffic passes
- through. History still shows what was actually sent/received on each
- side - match-and-replace transforms the wire, it doesn't rewrite the
- audit trail.
+- **Match-and-replace**: header or body rewrite rules (add, remove, or
+ modify) for requests and/or responses, applied live as traffic
+ passes through. History still shows what was actually sent/received
+ on each side - match-and-replace transforms the wire, it doesn't
+ rewrite the audit trail.
+- **Client certificates**: configure a mutual-TLS cert/key per host
+ pattern, presented automatically on matching handshakes - for
+ proxied traffic and Repeater/Intruder resends alike.
- **Flagging**: mark an entry to revisit later (★), filterable via
`flagged:true`.
- **Comparer**: mark one entry (`c`), then `c` on a different entry to
@@ -442,6 +445,22 @@ Repeater and Intruder always record regardless of scope - a request you
deliberately resend or fuzz is something you clearly want to see the
result of, not noise scope exists to cut.
+### Client (mutual-TLS) certificates
+
+Press `t` from the history view to manage which client certificate
+mitmux presents when an upstream server's TLS handshake requests one
+- a target requiring mutual TLS otherwise fails the handshake before
+any request/response ever happens. `a` adds one: a name, a host
+pattern (same substring-or-regex model as scope and match-and-replace
+rules), and paths to a PEM certificate file and its matching PEM
+private key. The files are read once, at save time, and their content
+- not the paths - is what's stored and later presented, so a cert
+keeps working even if the original file moves or is deleted
+afterward. `space` toggles one on/off, `d` deletes it. Applies to
+proxied HTTPS traffic and to Repeater/Intruder resends against
+`https://` targets alike; a host matching no configured certificate
+just handshakes without one, same as if this feature didn't exist.
+
### Mouse
This is a real terminal application (any terminal, not just tmux - the
@@ -538,7 +557,6 @@ reasoning behind each:
- `mitmuxd -install-ca` prints per-OS trust-store install steps; it
never runs them for you (see Quick start above for why)
- No WebSocket interception
-- No client (mutual-TLS) certificate support
- Upstream proxy chaining (`-upstream-proxy`) is HTTP CONNECT only, no
SOCKS5
- No active or passive vulnerability scanning, no plugin system - this
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
+}
diff --git a/internal/clientcert/clientcert.go b/internal/clientcert/clientcert.go
new file mode 100644
index 0000000..4bba835
--- /dev/null
+++ b/internal/clientcert/clientcert.go
@@ -0,0 +1,62 @@
+// Package clientcert manages client (mutual-TLS) certificates: which
+// certificate mitmux presents to an upstream server that requires one,
+// selected by matching the request's hostname the same way scope rules
+// do (see internal/scope) - substring match by default, or a regex - so
+// the "which rule applies to this host" mental model stays identical
+// throughout the tool.
+package clientcert
+
+import (
+ "crypto/tls"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Cert is one client certificate, scoped to hosts matching Pattern.
+type Cert struct {
+ ID int64
+ Enabled bool
+ Name string
+ Pattern string
+ IsRegex bool
+ CertPEM []byte
+ KeyPEM []byte
+}
+
+func (c Cert) matches(host string) bool {
+ if c.IsRegex {
+ re, err := regexp.Compile(c.Pattern)
+ if err != nil {
+ return false
+ }
+ return re.MatchString(host)
+ }
+ return strings.Contains(strings.ToLower(host), strings.ToLower(c.Pattern))
+}
+
+// FindFor returns the first enabled cert whose pattern matches host, or
+// nil if none applies - mitmux then just doesn't present a client
+// certificate for that connection, same as if mutual TLS weren't
+// configured at all. First-match-wins on ID order, same convention as
+// match-and-replace rules' Position ordering, minus the extra field:
+// client certs are keyed by host, not layered edits, so insertion order
+// is a reasonable enough tiebreaker without adding one.
+func FindFor(certs []Cert, host string) *Cert {
+ for i := range certs {
+ if certs[i].Enabled && certs[i].matches(host) {
+ return &certs[i]
+ }
+ }
+ return nil
+}
+
+// TLSCertificate parses c's PEM-encoded cert/key pair into the form
+// crypto/tls needs to present it during a handshake.
+func (c Cert) TLSCertificate() (tls.Certificate, error) {
+ cert, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", c.Name, err)
+ }
+ return cert, nil
+}
diff --git a/internal/clientcert/clientcert_test.go b/internal/clientcert/clientcert_test.go
new file mode 100644
index 0000000..712562b
--- /dev/null
+++ b/internal/clientcert/clientcert_test.go
@@ -0,0 +1,111 @@
+package clientcert
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "testing"
+ "time"
+)
+
+// selfSignedPEM generates a throwaway self-signed cert/key pair entirely
+// in memory (crypto/tls + crypto/x509 stdlib only) rather than shelling
+// out to openssl or checking in a fixture - keeps the test hermetic and
+// portable.
+func selfSignedPEM(t *testing.T) (certPEM, keyPEM []byte) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "test"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatalf("create certificate: %v", err)
+ }
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ t.Fatalf("marshal key: %v", err)
+ }
+ var certBuf, keyBuf bytes.Buffer
+ pem.Encode(&certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
+ pem.Encode(&keyBuf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+ return certBuf.Bytes(), keyBuf.Bytes()
+}
+
+func TestFindForExactMatch(t *testing.T) {
+ certs := []Cert{{ID: 1, Enabled: true, Pattern: "internal.example.com"}}
+ got := FindFor(certs, "internal.example.com")
+ if got == nil || got.ID != 1 {
+ t.Errorf("got %+v, want match on id 1", got)
+ }
+}
+
+func TestFindForSubstringMatch(t *testing.T) {
+ certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}}
+ if FindFor(certs, "api.example.com") == nil {
+ t.Error("expected subdomain to match by substring, same convention as scope.Rule")
+ }
+}
+
+func TestFindForRegex(t *testing.T) {
+ certs := []Cert{{ID: 1, Enabled: true, Pattern: `^api-\d+\.example\.com$`, IsRegex: true}}
+ if FindFor(certs, "api-42.example.com") == nil {
+ t.Error("expected regex pattern to match")
+ }
+ if FindFor(certs, "api-x.example.com") != nil {
+ t.Error("expected regex pattern not to match a non-numeric suffix")
+ }
+}
+
+func TestFindForSkipsDisabled(t *testing.T) {
+ certs := []Cert{{ID: 1, Enabled: false, Pattern: "example.com"}}
+ if FindFor(certs, "example.com") != nil {
+ t.Error("expected a disabled cert not to match")
+ }
+}
+
+func TestFindForNoMatch(t *testing.T) {
+ certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}}
+ if FindFor(certs, "other.org") != nil {
+ t.Error("expected no match for an unrelated host")
+ }
+}
+
+func TestFindForFirstMatchWins(t *testing.T) {
+ certs := []Cert{
+ {ID: 1, Enabled: true, Pattern: "example.com"},
+ {ID: 2, Enabled: true, Pattern: "example.com"},
+ }
+ got := FindFor(certs, "example.com")
+ if got == nil || got.ID != 1 {
+ t.Errorf("got %+v, want the first matching cert (id 1)", got)
+ }
+}
+
+func TestTLSCertificateValidPair(t *testing.T) {
+ certPEM, keyPEM := selfSignedPEM(t)
+ c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: keyPEM}
+ if _, err := c.TLSCertificate(); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestTLSCertificateMismatchedPairErrors(t *testing.T) {
+ certPEM, _ := selfSignedPEM(t)
+ _, otherKeyPEM := selfSignedPEM(t)
+ c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: otherKeyPEM}
+ if _, err := c.TLSCertificate(); err == nil {
+ t.Error("expected an error pairing a cert with a key that doesn't match it")
+ }
+}
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index d581313..68217c2 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "mitmux/internal/clientcert"
"mitmux/internal/proxy"
"mitmux/internal/rules"
"mitmux/internal/scope"
@@ -69,6 +70,13 @@ type Request struct {
ScopeRule *scope.Rule `json:"scope_rule,omitempty"`
ScopeRuleID int64 `json:"scope_rule_id,omitempty"`
+ // For "clientcert_add": the new certificate (always an add, same
+ // reasoning as scope rules above). For "clientcert_delete"/
+ // "clientcert_toggle": ClientCertID (and RuleEnabled for toggle)
+ // identify the target.
+ ClientCert *clientcert.Cert `json:"client_cert,omitempty"`
+ ClientCertID int64 `json:"client_cert_id,omitempty"`
+
// For "set_flagged" and "delete_entry": ID identifies the history
// entry. "clear_history" needs no fields at all.
Flagged bool `json:"flagged,omitempty"`
@@ -99,13 +107,14 @@ type ImportEntry struct {
// Response is sent by the daemon to a client.
type Response struct {
- Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error"
- Entries []store.Summary `json:"entries,omitempty"` // for "list"
- Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat"
- New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push)
- Rules []rules.Rule `json:"rules,omitempty"` // for "rules"
- ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules"
- Status *StatusMsg `json:"status,omitempty"` // for "status"
+ Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error"
+ Entries []store.Summary `json:"entries,omitempty"` // for "list"
+ Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat"
+ New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push)
+ Rules []rules.Rule `json:"rules,omitempty"` // for "rules"
+ ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules"
+ ClientCerts []clientcert.Cert `json:"client_certs,omitempty"` // for "client_certs"
+ Status *StatusMsg `json:"status,omitempty"` // for "status"
// For "import_done": how many entries were actually inserted (a
// per-entry insert failure is skipped, not fatal to the batch).
@@ -444,6 +453,58 @@ func (c *Client) scopeRoundTrip(req Request) ([]scope.Rule, error) {
return resp.ScopeRules, nil
}
+// ListClientCerts returns every client certificate, enabled or not.
+func (c *Client) ListClientCerts() ([]clientcert.Cert, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.clientCertRoundTrip(Request{Type: "clientcert_list"})
+}
+
+// AddClientCert adds cert and returns its assigned ID.
+func (c *Client) AddClientCert(cert clientcert.Cert) (int64, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ saved, err := c.clientCertRoundTrip(Request{Type: "clientcert_add", ClientCert: &cert})
+ if err != nil {
+ return 0, err
+ }
+ if len(saved) == 0 {
+ return 0, errors.New("clientcert_add: daemon returned no certificate")
+ }
+ return saved[0].ID, nil
+}
+
+// DeleteClientCert removes a client certificate.
+func (c *Client) DeleteClientCert(id int64) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.clientCertRoundTrip(Request{Type: "clientcert_delete", ClientCertID: id})
+ return err
+}
+
+// SetClientCertEnabled toggles a client cert without touching its
+// content.
+func (c *Client) SetClientCertEnabled(id int64, enabled bool) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _, err := c.clientCertRoundTrip(Request{Type: "clientcert_toggle", ClientCertID: id, RuleEnabled: enabled})
+ return err
+}
+
+func (c *Client) clientCertRoundTrip(req Request) ([]clientcert.Cert, error) {
+ if err := c.enc.Encode(req); err != nil {
+ return nil, err
+ }
+ var resp Response
+ if err := c.dec.Decode(&resp); err != nil {
+ return nil, err
+ }
+ if resp.Type == "error" {
+ return nil, errors.New(resp.Error)
+ }
+ return resp.ClientCerts, nil
+}
+
// Subscribe opens a dedicated connection that streams newly captured
// history entries as they happen. The returned channel is closed when
// the connection ends; call the returned close func to stop early.
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index c890a54..4db27b9 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -9,6 +9,7 @@ import (
"regexp"
"sync"
+ "mitmux/internal/clientcert"
"mitmux/internal/proxy"
"mitmux/internal/rules"
"mitmux/internal/scope"
@@ -347,6 +348,52 @@ func (s *Server) handleConn(conn net.Conn) {
}
enc.Encode(Response{Type: "scope_rules"})
+ case "clientcert_list":
+ certs, err := s.db.ListClientCerts()
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "client_certs", ClientCerts: certs})
+
+ case "clientcert_add":
+ if req.ClientCert == nil {
+ enc.Encode(Response{Type: "error", Error: "clientcert_add: missing certificate"})
+ continue
+ }
+ cert := *req.ClientCert
+ if cert.IsRegex {
+ if _, err := regexp.Compile(cert.Pattern); err != nil {
+ enc.Encode(Response{Type: "error", Error: "invalid regex: " + err.Error()})
+ continue
+ }
+ }
+ if _, err := cert.TLSCertificate(); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ id, err := s.db.AddClientCert(cert)
+ if err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ cert.ID = id
+ enc.Encode(Response{Type: "client_certs", ClientCerts: []clientcert.Cert{cert}})
+
+ case "clientcert_delete":
+ if err := s.db.DeleteClientCert(req.ClientCertID); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "client_certs"})
+
+ case "clientcert_toggle":
+ if err := s.db.SetClientCertEnabled(req.ClientCertID, req.RuleEnabled); err != nil {
+ enc.Encode(Response{Type: "error", Error: err.Error()})
+ continue
+ }
+ enc.Encode(Response{Type: "client_certs"})
+
case "subscribe":
sub := s.hub.subscribe()
defer s.hub.unsubscribe(sub)
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index aec724a..bc22bcf 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -39,6 +39,7 @@ import (
"golang.org/x/net/http2"
"mitmux/internal/ca"
+ "mitmux/internal/clientcert"
"mitmux/internal/rules"
"mitmux/internal/scope"
"mitmux/internal/store"
@@ -228,7 +229,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
client.SetDeadline(time.Time{})
dial := func(ctx context.Context) (net.Conn, string, error) {
- return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy)
+ return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy, s.clientCertFor(hostname))
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.forward(dial, "https", hostname, w, r)
@@ -256,16 +257,22 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
// HTTP/2 and HTTP/1.1 over ALPN and letting the server pick. Chaining
// through another proxy is transparent to everything from here on: once
// the CONNECT tunnel is up, TLS and the request/response on top of it
-// look identical to a direct connection.
-func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (net.Conn, string, error) {
+// look identical to a direct connection. cert, if non-nil, is presented
+// during the handshake for servers that require mutual TLS - see
+// clientCertFor.
+func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string, cert *tls.Certificate) (net.Conn, string, error) {
raw, err := dialViaProxy(ctx, hostPort, upstreamProxy)
if err != nil {
return nil, "", err
}
- conn := tls.Client(raw, &tls.Config{
+ cfg := &tls.Config{
ServerName: sni,
NextProtos: []string{http2.NextProtoTLS, "http/1.1"},
- })
+ }
+ if cert != nil {
+ cfg.Certificates = []tls.Certificate{*cert}
+ }
+ conn := tls.Client(raw, cfg)
if err := conn.HandshakeContext(ctx); err != nil {
raw.Close()
return nil, "", err
@@ -273,6 +280,32 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (
return conn, conn.ConnectionState().NegotiatedProtocol, nil
}
+// clientCertFor returns the client certificate configured for host, if
+// any - see clientcert.FindFor. Errors (a bad DB read, an unparseable
+// PEM pair) are logged and treated as "no certificate configured" rather
+// than failing the connection outright: a broken client-cert config
+// shouldn't take down otherwise-working proxying for that host.
+func (s *Server) clientCertFor(host string) *tls.Certificate {
+ if s.store == nil {
+ return nil
+ }
+ certs, err := s.store.ListClientCerts()
+ if err != nil {
+ log.Printf("list client certs: %v", err)
+ return nil
+ }
+ c := clientcert.FindFor(certs, host)
+ if c == nil {
+ return nil
+ }
+ tc, err := c.TLSCertificate()
+ if err != nil {
+ log.Printf("client cert %q: %v", c.Name, err)
+ return nil
+ }
+ return &tc
+}
+
// dialUpstreamPlain connects to a plain (non-TLS) upstream for the
// non-CONNECT proxy path, which is always HTTP/1.1. Unlike the TLS/
// CONNECT path, chaining here means dialing the upstream proxy's own
diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go
index f682b99..8e7836d 100644
--- a/internal/proxy/repeat.go
+++ b/internal/proxy/repeat.go
@@ -36,7 +36,7 @@ func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, s
started := time.Now()
method, path := parseRequestLine(raw)
- conn, err := dialForRepeat(ctx, scheme, host)
+ conn, err := s.dialForRepeat(ctx, scheme, host)
if err != nil {
return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source)
}
@@ -101,8 +101,11 @@ func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, ho
}
// dialForRepeat connects to host for scheme, forcing HTTP/1.1 over ALPN
-// when TLS is involved (see Repeat's doc comment for why).
-func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) {
+// when TLS is involved (see Repeat's doc comment for why), presenting a
+// client certificate if one is configured for hostname (see
+// clientCertFor) - a resent or fuzzed request against a mutual-TLS host
+// needs one just as much as a live proxied request does.
+func (s *Server) dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) {
nd := &net.Dialer{Timeout: 10 * time.Second}
if scheme != "https" {
hostPort := host
@@ -122,7 +125,11 @@ func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) {
if err != nil {
return nil, err
}
- conn := tls.Client(raw, &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}})
+ cfg := &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}}
+ if cert := s.clientCertFor(hostname); cert != nil {
+ cfg.Certificates = []tls.Certificate{*cert}
+ }
+ conn := tls.Client(raw, cfg)
if err := conn.HandshakeContext(ctx); err != nil {
raw.Close()
return nil, err
diff --git a/internal/store/store.go b/internal/store/store.go
index befd5a6..0e57c3c 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -15,6 +15,7 @@ import (
_ "modernc.org/sqlite"
+ "mitmux/internal/clientcert"
"mitmux/internal/rules"
"mitmux/internal/scope"
)
@@ -63,6 +64,16 @@ CREATE TABLE IF NOT EXISTS scope_rules (
pattern TEXT NOT NULL,
is_regex INTEGER NOT NULL DEFAULT 0
);
+
+CREATE TABLE IF NOT EXISTS client_certs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ name TEXT NOT NULL DEFAULT '',
+ pattern TEXT NOT NULL,
+ is_regex INTEGER NOT NULL DEFAULT 0,
+ cert_pem BLOB NOT NULL,
+ key_pem BLOB NOT NULL
+);
`
// Store is a handle to the history database. Safe for concurrent use.
@@ -619,6 +630,59 @@ func (s *Store) DeleteScopeRule(id int64) error {
return nil
}
+// ListClientCerts returns every client certificate, including disabled
+// ones (the management view needs to show and let you re-enable those
+// too).
+func (s *Store) ListClientCerts() ([]clientcert.Cert, error) {
+ rows, err := s.db.Query(`SELECT id, enabled, name, pattern, is_regex, cert_pem, key_pem FROM client_certs ORDER BY id`)
+ if err != nil {
+ return nil, fmt.Errorf("list client certs: %w", err)
+ }
+ defer rows.Close()
+
+ var out []clientcert.Cert
+ for rows.Next() {
+ var c clientcert.Cert
+ var enabled, isRegex int
+ if err := rows.Scan(&c.ID, &enabled, &c.Name, &c.Pattern, &isRegex, &c.CertPEM, &c.KeyPEM); err != nil {
+ return nil, fmt.Errorf("scan client cert row: %w", err)
+ }
+ c.Enabled = enabled != 0
+ c.IsRegex = isRegex != 0
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+// AddClientCert stores c and returns its assigned ID.
+func (s *Store) AddClientCert(c clientcert.Cert) (int64, error) {
+ res, err := s.db.Exec(
+ `INSERT INTO client_certs (enabled, name, pattern, is_regex, cert_pem, key_pem) VALUES (?, ?, ?, ?, ?, ?)`,
+ boolToInt(c.Enabled), c.Name, c.Pattern, boolToInt(c.IsRegex), c.CertPEM, c.KeyPEM,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("add client cert: %w", err)
+ }
+ return res.LastInsertId()
+}
+
+// SetClientCertEnabled toggles a client cert without touching its
+// content.
+func (s *Store) SetClientCertEnabled(id int64, enabled bool) error {
+ if _, err := s.db.Exec(`UPDATE client_certs SET enabled = ? WHERE id = ?`, boolToInt(enabled), id); err != nil {
+ return fmt.Errorf("set client cert %d enabled: %w", id, err)
+ }
+ return nil
+}
+
+// DeleteClientCert removes a client certificate.
+func (s *Store) DeleteClientCert(id int64) error {
+ if _, err := s.db.Exec(`DELETE FROM client_certs WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("delete client cert %d: %w", id, err)
+ }
+ return nil
+}
+
// DeleteEntry removes a single history entry and its search index row.
func (s *Store) DeleteEntry(id int64) error {
tx, err := s.db.Begin()