srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
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 /internal
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 'internal')
-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
7 files changed, 401 insertions, 16 deletions
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()