diff --git a/Makefile b/Makefile index 89a25b1a..549a9005 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,9 @@ GO := go GOPATH ?= $(firstword $(subst :, ,$(shell $(GO) env GOPATH))) PROMU := $(GOPATH)/bin/promu PROMU_VERSION := v0.17.0 +YQ := $(GOPATH)/bin/yq +YQ_VERSION := v4.52.4 + pkgs = $(shell $(GO) list ./... | grep -v /vendor/) PREFIX ?= $(shell pwd) @@ -53,6 +56,13 @@ build: promu @echo ">> building binaries" @$(PROMU) build --prefix $(PREFIX) +build-nomysql: yq promu + @echo ">> building binaries with -tag nomysql" + @echo ">> updating build tags to include 'nomysql'" + @$(YQ) eval '.build |= (.flags += ",nomysql")' .promu.yml > .promu_nomysql.yml + @$(PROMU) build --prefix $(PREFIX) --config .promu_nomysql.yml + @rm .promu_nomysql.yml + drivers-%: @echo ">> generating drivers.go with selected drivers" @$(GO) run drivers_gen.go -- $* @@ -87,11 +97,19 @@ promu: @set GOOS=windows @set GOARCH=$(subst AMD64,amd64,$(patsubst i%86,386,$(shell echo %PROCESSOR_ARCHITECTURE%))) @$(GO) install github.com/prometheus/promu@$(PROMU_VERSION) +yq: + @set GOOS=windows + @set GOARCH=$(subst AMD64,amd64,$(patsubst i%86,386,$(shell echo %PROCESSOR_ARCHITECTURE%))) + $(GO) install github.com/mikefarah/yq/v4@$(YQ_VERSION) else promu: @GOOS=$(shell uname -s | tr A-Z a-z) \ GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ $(GO) install github.com/prometheus/promu@$(PROMU_VERSION) +yq: + @GOOS=$(shell uname -s | tr A-Z a-z) \ + GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ + $(GO) install github.com/mikefarah/yq/v4@$(YQ_VERSION) endif .PHONY: all style format build test vet tarball docker promu diff --git a/README.md b/README.md index 61e1aa56..4e910964 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,35 @@ The format of the file is described in the [exporter-toolkit](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md) repository. +
+MySQL Custom TLS Certificate Support + +Since v0.20.0 SQL Exporter supports custom TLS certificates for MySQL connections. This is useful when you have a +self-signed certificate, a certificate from a private CA, or want to use mTLS for MySQL connections. + +To use custom TLS certificates for MySQL connections, you need to add the following parameters to the DSN: + +1. `tls=custom` to indicate that you want to use a custom TLS configuration (required to enable custom TLS support); +2. `tls-ca=` to specify the path to the CA certificate file (if your certificate is self-signed or + from a private CA); +3. `tls-cert=` and `tls-key=` to specify the paths to the client certificate + and key files if you want to use mTLS (if your MySQL server requires client authentication). + +The DSN would look like this: +``` +mysql://user:password@hostname:port/dbname?tls=custom&tls-ca=/path/to/ca.pem +mysql://user:password@hostname:port/dbname?tls=custom&tls-cert=/path/to/client-cert.pem&tls-key=/path/to/client-key.pem +``` + +This configuration is only applied to MySQL as there is no way to provide the configuration natively. For other +databases, you may want to consult their documentation on how to set up TLS connections and apply the necessary +parameters to the DSN if it's supported by the driver. + +TLS Configuration is bound to the hostname+port combinations, so if there are connections using the same hostname+port +combination, they will share and re-use the same TLS configuration. +
+ +## Support If you have an issue using sql_exporter, please check [Discussions](https://github.com/burningalchemist/sql_exporter/discussions) or closed [Issues](https://github.com/burningalchemist/sql_exporter/issues?q=is%3Aissue+is%3Aclosed) first. Chances are diff --git a/sql.go b/sql.go index 77aac03b..a5d461a0 100644 --- a/sql.go +++ b/sql.go @@ -3,6 +3,7 @@ package sql_exporter import ( "context" "database/sql" + "encoding/base64" "errors" "fmt" "log/slog" @@ -33,6 +34,38 @@ func OpenConnection(ctx context.Context, logContext, dsn string, maxConns, maxId driver = url.GoDriver } + // Register custom TLS config for MySQL if needed + if driver == "mysql" && url.Query().Get("tls") == "custom" { + + // Encode the hostname and port to create a unique name for the TLS configuration. This ensures that different + // DSNs with the same TLS parameters will reuse the same TLS configuration. + configName := "custom-" + base64.URLEncoding.WithPadding(base64.NoPadding). + EncodeToString([]byte(url.Hostname()+url.Port())) + + if err := handleMySQLTLSConfig(configName, url.Query()); err != nil { + return nil, + fmt.Errorf("failed to register MySQL TLS config: %w", err) + } + + // Strip TLS parameters from the URL as they are interpreted as system variables by the MySQL driver which + // causes connection failure. The TLS configuration is already registered globally. + q := url.Query() + for _, param := range mysqlTLSParams { + q.Del(param) + } + + // Set the "tls" parameter to the unique name of the registered TLS configuration. + q.Set("tls", configName) + url.RawQuery = q.Encode() + + // Regenerate the DSN without TLS parameters for logging and connection purposes + tlsStripped, _, err := dburl.GenMysql(url) + if err != nil { + return nil, fmt.Errorf("failed to generate MySQL DSN: %w", err) + } + url.DSN = tlsStripped + } + // Open the DB handle in a separate goroutine so we can terminate early if the context closes. go func() { conn, err = sql.Open(driver, url.DSN) diff --git a/tls_mysql.go b/tls_mysql.go new file mode 100644 index 00000000..a926f3cf --- /dev/null +++ b/tls_mysql.go @@ -0,0 +1,89 @@ +//go:build !nomysql + +package sql_exporter + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "sync" + + "github.com/go-sql-driver/mysql" +) + +const ( + mysqlTLSParamCACert = "tls-ca" + mysqlTLSParamClientCert = "tls-cert" + mysqlTLSParamClientKey = "tls-key" +) + +// mysqlTLSParams is a list of TLS parameters that can be used in MySQL DSNs. It is used to identify and strip TLS +// parameters from the DSN after registering the TLS configuration, as these parameters are not recognized by the MySQL +// driver and would cause connection failure if left in the DSN. +var ( + mysqlTLSParams = []string{mysqlTLSParamCACert, mysqlTLSParamClientCert, mysqlTLSParamClientKey} + + onceMap sync.Map +) + +// handleMySQLTLSConfig wraps the registration of a MySQL TLS configuration in a thread-safe manner. It uses a +// sync.Once to ensure that the TLS configuration for a given config name is registered only once, even if multiple +// goroutines attempt to register it concurrently. +func handleMySQLTLSConfig(configName string, params url.Values) error { + onceConn, _ := onceMap.LoadOrStore(configName, &sync.Once{}) + once := onceConn.(*sync.Once) + var err error + once.Do(func() { + err = registerMySQLTLSConfig(configName, params) + if err != nil { + slog.Error("Failed to register MySQL TLS config", "error", err) + } + }) + return err +} + +// registerMySQLTLSConfig registers a custom TLS configuration for MySQL with the given config name and parameters. +func registerMySQLTLSConfig(configName string, params url.Values) error { + caCert := params.Get(mysqlTLSParamCACert) + clientCert := params.Get(mysqlTLSParamClientCert) + clientKey := params.Get(mysqlTLSParamClientKey) + + slog.Debug("MySQL TLS config", "configName", configName, mysqlTLSParamCACert, caCert, + mysqlTLSParamClientCert, clientCert, mysqlTLSParamClientKey, clientKey) + + var rootCertPool *x509.CertPool + if caCert != "" { + rootCertPool = x509.NewCertPool() + pem, err := os.ReadFile(caCert) + if err != nil { + return fmt.Errorf("failed to read CA certificate: %w", err) + } + if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { + return errors.New("failed to append PEM") + } + } + + var certs []tls.Certificate + if clientCert != "" || clientKey != "" { + if clientCert == "" || clientKey == "" { + return errors.New("both tls-cert and tls-key must be provided for client authentication") + } + cert, err := tls.LoadX509KeyPair(clientCert, clientKey) + if err != nil { + return fmt.Errorf("failed to load client certificate and key: %w", err) + } + certs = append(certs, cert) + } + + tlsConfig := &tls.Config{ + RootCAs: rootCertPool, + Certificates: certs, + MinVersion: tls.VersionTLS12, + } + + return mysql.RegisterTLSConfig(configName, tlsConfig) +} diff --git a/tls_nomysql.go b/tls_nomysql.go new file mode 100644 index 00000000..05cdacdc --- /dev/null +++ b/tls_nomysql.go @@ -0,0 +1,16 @@ +//go:build nomysql + +package sql_exporter + +import ( + "errors" + "net/url" +) + +// There are no TLS parameters to strip when MySQL support is disabled, but we need to define the variable to avoid compilation errors in sql.go. +var mysqlTLSParams = []string{} + +// registerMySQLTLSConfig is a stub function that returns an error indicating that MySQL TLS support is disabled when the "nomysql" build tag is used. +func handleMySQLTLSConfig(_ url.Values) error { + return errors.New("MySQL TLS support disabled (built with -tags nomysql)") +}