From fe00969864a42cba7341f608d17d4601a0891370 Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 16:58:01 +0700 Subject: [PATCH 1/8] test(cli): cover mysql client certificate config --- sql-migrate/config_test.go | 247 +++++++++++++++++++++++++++++++++++++ sql-migrate/main_test.go | 9 ++ 2 files changed, 256 insertions(+) create mode 100644 sql-migrate/config_test.go diff --git a/sql-migrate/config_test.go b/sql-migrate/config_test.go new file mode 100644 index 00000000..7839c7b0 --- /dev/null +++ b/sql-migrate/config_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "time" + + "github.com/go-sql-driver/mysql" + //revive:disable-next-line:dot-imports + . "gopkg.in/check.v1" + "gopkg.in/yaml.v2" +) + +type ConfigSuite struct{} + +var _ = Suite(&ConfigSuite{}) + +func (*ConfigSuite) TestEnvironmentParsesMySQLTLSFields(c *C) { + config := map[string]*Environment{} + err := yaml.Unmarshal([]byte(` +development: + dialect: mysql + datasource: user:password@tcp(localhost:3306)/dbname?parseTime=true + dir: migrations + mysql-client-cert: /certs/client-cert.pem + mysql-client-key: /certs/client-key.pem + mysql-ca-cert: /certs/ca.pem + mysql-server-name: mysql.example.com + mysql-tls-config: sql-migrate-test +`), &config) + c.Assert(err, IsNil) + + env := config["development"] + c.Assert(env.MySQLClientCert, Equals, "/certs/client-cert.pem") + c.Assert(env.MySQLClientKey, Equals, "/certs/client-key.pem") + c.Assert(env.MySQLCACert, Equals, "/certs/ca.pem") + c.Assert(env.MySQLServerName, Equals, "mysql.example.com") + c.Assert(env.MySQLTLSConfig, Equals, "sql-migrate-test") +} + +func (*ConfigSuite) TestPrepareMySQLTLSRegistersConfigAndPreservesDatasource(c *C) { + dir := c.MkDir() + certFile, keyFile, caFile := writeTestCertificates(c, dir) + configName := "sql-migrate-test-valid" + defer mysql.DeregisterTLSConfig(configName) + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true&timeout=5s", + MySQLClientCert: certFile, + MySQLClientKey: keyFile, + MySQLCACert: caFile, + MySQLServerName: "localhost", + MySQLTLSConfig: configName, + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + + cfg, err := mysql.ParseDSN(env.DataSource) + c.Assert(err, IsNil) + c.Assert(cfg.TLSConfig, Equals, configName) + c.Assert(cfg.ParseTime, Equals, true) + c.Assert(cfg.Timeout, Equals, 5*time.Second) +} + +func (*ConfigSuite) TestPrepareMySQLTLSDefaultsConfigName(c *C) { + dir := c.MkDir() + certFile, keyFile, _ := writeTestCertificates(c, dir) + defer mysql.DeregisterTLSConfig("sql-migrate") + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLClientCert: certFile, + MySQLClientKey: keyFile, + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + + cfg, err := mysql.ParseDSN(env.DataSource) + c.Assert(err, IsNil) + c.Assert(cfg.TLSConfig, Equals, "sql-migrate") + c.Assert(cfg.ParseTime, Equals, true) +} + +func (*ConfigSuite) TestPrepareMySQLTLSNoFieldsLeavesDatasource(c *C) { + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + c.Assert(env.DataSource, Equals, "user:password@tcp(localhost:3306)/dbname?parseTime=true") +} + +func (*ConfigSuite) TestPrepareMySQLTLSNonMySQLIgnoresFields(c *C) { + env := &Environment{ + Dialect: "postgres", + DataSource: "dbname=test sslmode=disable", + MySQLClientCert: "/missing/client-cert.pem", + MySQLClientKey: "/missing/client-key.pem", + MySQLCACert: "/missing/ca.pem", + MySQLServerName: "localhost", + MySQLTLSConfig: "sql-migrate-test-postgres", + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + c.Assert(env.DataSource, Equals, "dbname=test sslmode=disable") +} + +func (*ConfigSuite) TestPrepareMySQLTLSRequiresClientKey(c *C) { + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLClientCert: "/certs/client-cert.pem", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*mysql-client-key.*required.*") +} + +func (*ConfigSuite) TestPrepareMySQLTLSRequiresClientCert(c *C) { + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLClientKey: "/certs/client-key.pem", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*mysql-client-cert.*required.*") +} + +func (*ConfigSuite) TestPrepareMySQLTLSReportsMissingCertFile(c *C) { + dir := c.MkDir() + _, keyFile, _ := writeTestCertificates(c, dir) + missingCert := filepath.Join(dir, "missing-client-cert.pem") + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLClientCert: missingCert, + MySQLClientKey: keyFile, + MySQLTLSConfig: "sql-migrate-test-missing-cert", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*"+missingCert+".*") +} + +func (*ConfigSuite) TestPrepareMySQLTLSReportsInvalidCertPair(c *C) { + dir := c.MkDir() + certFile := filepath.Join(dir, "client-cert.pem") + keyFile := filepath.Join(dir, "client-key.pem") + c.Assert(os.WriteFile(certFile, []byte("not a cert"), 0644), IsNil) + c.Assert(os.WriteFile(keyFile, []byte("not a key"), 0644), IsNil) + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLClientCert: certFile, + MySQLClientKey: keyFile, + MySQLTLSConfig: "sql-migrate-test-invalid-pair", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*client certificate.*") +} + +func (*ConfigSuite) TestPrepareMySQLTLSRejectsDatasourceTLSConflict(c *C) { + dir := c.MkDir() + certFile, keyFile, _ := writeTestCertificates(c, dir) + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true&tls=skip-verify", + MySQLClientCert: certFile, + MySQLClientKey: keyFile, + MySQLTLSConfig: "sql-migrate-test-conflict", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*datasource.*tls.*conflict.*") +} + +func writeTestCertificates(c *C, dir string) (string, string, string) { + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + c.Assert(err, IsNil) + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) + c.Assert(err, IsNil) + + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "sql-migrate-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + c.Assert(err, IsNil) + + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "sql-migrate-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey) + c.Assert(err, IsNil) + + certFile := filepath.Join(dir, "client-cert.pem") + keyFile := filepath.Join(dir, "client-key.pem") + caFile := filepath.Join(dir, "ca.pem") + + writePEM(c, certFile, "CERTIFICATE", clientDER) + writePEM(c, caFile, "CERTIFICATE", caDER) + writePrivateKey(c, keyFile, clientKey) + + return certFile, keyFile, caFile +} + +func writePEM(c *C, filename, typ string, der []byte) { + file, err := os.Create(filename) + c.Assert(err, IsNil) + defer func() { _ = file.Close() }() + + err = pem.Encode(file, &pem.Block{Type: typ, Bytes: der}) + c.Assert(err, IsNil) +} + +func writePrivateKey(c *C, filename string, key *rsa.PrivateKey) { + der := x509.MarshalPKCS1PrivateKey(key) + writePEM(c, filename, "RSA PRIVATE KEY", der) +} diff --git a/sql-migrate/main_test.go b/sql-migrate/main_test.go index 06ab7d0f..7e7907cc 100644 --- a/sql-migrate/main_test.go +++ b/sql-migrate/main_test.go @@ -1 +1,10 @@ package main + +import ( + "testing" + + //revive:disable-next-line:dot-imports + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } From 45fc2773997c3495da558948e4ec6b822953b3be Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 16:59:39 +0700 Subject: [PATCH 2/8] feat(cli): support mysql client certificate tls --- sql-migrate/config.go | 93 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/sql-migrate/config.go b/sql-migrate/config.go index 6947a534..41b9ccad 100644 --- a/sql-migrate/config.go +++ b/sql-migrate/config.go @@ -1,19 +1,23 @@ package main import ( + "crypto/tls" + "crypto/x509" "database/sql" "errors" "flag" "fmt" + "net/url" "os" "runtime/debug" + "strings" "github.com/go-gorp/gorp/v3" + "github.com/go-sql-driver/mysql" "gopkg.in/yaml.v2" migrate "github.com/rubenv/sql-migrate" - _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" ) @@ -41,6 +45,12 @@ type Environment struct { TableName string `yaml:"table"` SchemaName string `yaml:"schema"` IgnoreUnknown bool `yaml:"ignoreunknown"` + + MySQLClientCert string `yaml:"mysql-client-cert"` + MySQLClientKey string `yaml:"mysql-client-key"` + MySQLCACert string `yaml:"mysql-ca-cert"` + MySQLServerName string `yaml:"mysql-server-name"` + MySQLTLSConfig string `yaml:"mysql-tls-config"` } func ReadConfig() (map[string]*Environment, error) { @@ -96,6 +106,10 @@ func GetEnvironment() (*Environment, error) { } func GetConnection(env *Environment) (*sql.DB, string, error) { + if err := prepareMySQLTLS(env); err != nil { + return nil, "", fmt.Errorf("Cannot configure MySQL TLS: %w", err) + } + db, err := sql.Open(env.Dialect, env.DataSource) if err != nil { return nil, "", fmt.Errorf("Cannot connect to database: %w", err) @@ -110,6 +124,83 @@ func GetConnection(env *Environment) (*sql.DB, string, error) { return db, env.Dialect, nil } +func prepareMySQLTLS(env *Environment) error { + if env.Dialect != "mysql" || !env.hasMySQLTLSConfig() { + return nil + } + + if env.MySQLClientCert == "" { + return errors.New("mysql-client-cert is required when configuring MySQL TLS") + } + if env.MySQLClientKey == "" { + return errors.New("mysql-client-key is required when configuring MySQL TLS") + } + if dataSourceHasTLSParam(env.DataSource) { + return errors.New("datasource tls parameter conflicts with MySQL client certificate config") + } + + cfg, err := mysql.ParseDSN(env.DataSource) + if err != nil { + return fmt.Errorf("parse MySQL datasource: %w", err) + } + + cert, err := tls.LoadX509KeyPair(env.MySQLClientCert, env.MySQLClientKey) + if err != nil { + return fmt.Errorf("load MySQL client certificate %q and key %q: %w", env.MySQLClientCert, env.MySQLClientKey, err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: env.MySQLServerName, + } + + if env.MySQLCACert != "" { + rootCAs := x509.NewCertPool() + caCert, err := os.ReadFile(env.MySQLCACert) + if err != nil { + return fmt.Errorf("read MySQL CA certificate %q: %w", env.MySQLCACert, err) + } + if ok := rootCAs.AppendCertsFromPEM(caCert); !ok { + return fmt.Errorf("read MySQL CA certificate %q: no certificates found", env.MySQLCACert) + } + tlsConfig.RootCAs = rootCAs + } + + configName := env.MySQLTLSConfig + if configName == "" { + configName = "sql-migrate" + } + if err := mysql.RegisterTLSConfig(configName, tlsConfig); err != nil { + return fmt.Errorf("register MySQL TLS config %q: %w", configName, err) + } + + cfg.TLSConfig = configName + env.DataSource = cfg.FormatDSN() + return nil +} + +func (env *Environment) hasMySQLTLSConfig() bool { + return env.MySQLClientCert != "" || + env.MySQLClientKey != "" || + env.MySQLCACert != "" || + env.MySQLServerName != "" || + env.MySQLTLSConfig != "" +} + +func dataSourceHasTLSParam(dataSource string) bool { + questionMark := strings.Index(dataSource, "?") + if questionMark == -1 { + return false + } + + values, err := url.ParseQuery(dataSource[questionMark+1:]) + if err != nil { + return strings.Contains(dataSource[questionMark+1:], "tls=") + } + _, ok := values["tls"] + return ok +} + // GetVersion returns the version. func GetVersion() string { if buildInfo, ok := debug.ReadBuildInfo(); ok && buildInfo.Main.Version != "(devel)" { From e2445decc0379d8babe5415a0eceffd90060c842 Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 17:00:20 +0700 Subject: [PATCH 3/8] chore(docs): document mysql client certificate auth --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 65fd7f70..c61a1fb3 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,25 @@ production: See [here](https://github.com/go-sql-driver/mysql#parsetime) for more information. +### MySQL Client Certificate Auth + +For MySQL servers that require client certificate authentication, configure the certificate files in the environment. `sql-migrate` registers a MySQL TLS config and adds the matching `tls` option to the datasource automatically. + +```yml +production: + dialect: mysql + datasource: user:password@tcp(mysql.example.com:3306)/dbname?parseTime=true + dir: migrations/mysql + table: migrations + mysql-client-cert: /path/client-cert.pem + mysql-client-key: /path/client-key.pem + mysql-ca-cert: /path/ca-cert.pem + mysql-server-name: mysql.example.com + mysql-tls-config: sql-migrate +``` + +`mysql-client-cert` and `mysql-client-key` are required when enabling this mode. `mysql-ca-cert`, `mysql-server-name`, and `mysql-tls-config` are optional. Do not set `tls=` in the datasource when using these fields, because `sql-migrate` manages that option. + ### Oracle (oci8) Oracle Driver is [oci8](https://github.com/mattn/go-oci8), it is not pure Go code and relies on Oracle Office Client ([Instant Client](https://www.oracle.com/database/technologies/instant-client/downloads.html)), more detailed information is in the [oci8 repo](https://github.com/mattn/go-oci8). From 19144be4645433a3857733406853928c76995ed2 Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 17:31:15 +0700 Subject: [PATCH 4/8] test(cli): cover mysql ca-only tls config --- sql-migrate/config_test.go | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/sql-migrate/config_test.go b/sql-migrate/config_test.go index 7839c7b0..5eda49c4 100644 --- a/sql-migrate/config_test.go +++ b/sql-migrate/config_test.go @@ -70,6 +70,30 @@ func (*ConfigSuite) TestPrepareMySQLTLSRegistersConfigAndPreservesDatasource(c * c.Assert(cfg.Timeout, Equals, 5*time.Second) } +func (*ConfigSuite) TestPrepareMySQLTLSAllowsCAOnlyConfig(c *C) { + dir := c.MkDir() + _, _, caFile := writeTestCertificates(c, dir) + configName := "sql-migrate-test-ca-only" + defer mysql.DeregisterTLSConfig(configName) + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true&timeout=5s", + MySQLCACert: caFile, + MySQLServerName: "localhost", + MySQLTLSConfig: configName, + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + + cfg, err := mysql.ParseDSN(env.DataSource) + c.Assert(err, IsNil) + c.Assert(cfg.TLSConfig, Equals, configName) + c.Assert(cfg.ParseTime, Equals, true) + c.Assert(cfg.Timeout, Equals, 5*time.Second) +} + func (*ConfigSuite) TestPrepareMySQLTLSDefaultsConfigName(c *C) { dir := c.MkDir() certFile, keyFile, _ := writeTestCertificates(c, dir) @@ -91,6 +115,26 @@ func (*ConfigSuite) TestPrepareMySQLTLSDefaultsConfigName(c *C) { c.Assert(cfg.ParseTime, Equals, true) } +func (*ConfigSuite) TestPrepareMySQLTLSDefaultsConfigNameForCAOnly(c *C) { + dir := c.MkDir() + _, _, caFile := writeTestCertificates(c, dir) + defer mysql.DeregisterTLSConfig("sql-migrate") + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLCACert: caFile, + } + + err := prepareMySQLTLS(env) + c.Assert(err, IsNil) + + cfg, err := mysql.ParseDSN(env.DataSource) + c.Assert(err, IsNil) + c.Assert(cfg.TLSConfig, Equals, "sql-migrate") + c.Assert(cfg.ParseTime, Equals, true) +} + func (*ConfigSuite) TestPrepareMySQLTLSNoFieldsLeavesDatasource(c *C) { env := &Environment{ Dialect: "mysql", @@ -157,6 +201,20 @@ func (*ConfigSuite) TestPrepareMySQLTLSReportsMissingCertFile(c *C) { c.Assert(err, ErrorMatches, ".*"+missingCert+".*") } +func (*ConfigSuite) TestPrepareMySQLTLSReportsMissingCAFile(c *C) { + missingCA := filepath.Join(c.MkDir(), "missing-ca.pem") + + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLCACert: missingCA, + MySQLTLSConfig: "sql-migrate-test-missing-ca", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*"+missingCA+".*") +} + func (*ConfigSuite) TestPrepareMySQLTLSReportsInvalidCertPair(c *C) { dir := c.MkDir() certFile := filepath.Join(dir, "client-cert.pem") @@ -192,6 +250,28 @@ func (*ConfigSuite) TestPrepareMySQLTLSRejectsDatasourceTLSConflict(c *C) { c.Assert(err, ErrorMatches, ".*datasource.*tls.*conflict.*") } +func (*ConfigSuite) TestPrepareMySQLTLSRejectsConfigNameWithoutTLSMaterial(c *C) { + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLTLSConfig: "sql-migrate-test-no-material", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*mysql-ca-cert.*mysql-client-cert.*required.*") +} + +func (*ConfigSuite) TestPrepareMySQLTLSRejectsServerNameWithoutTLSMaterial(c *C) { + env := &Environment{ + Dialect: "mysql", + DataSource: "user:password@tcp(localhost:3306)/dbname?parseTime=true", + MySQLServerName: "localhost", + } + + err := prepareMySQLTLS(env) + c.Assert(err, ErrorMatches, ".*mysql-ca-cert.*mysql-client-cert.*required.*") +} + func writeTestCertificates(c *C, dir string) (string, string, string) { caKey, err := rsa.GenerateKey(rand.Reader, 2048) c.Assert(err, IsNil) From a76d5a83fb7ebf314bad597346d8c2f3b68cfb05 Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 17:32:56 +0700 Subject: [PATCH 5/8] feat(cli): support mysql ca-only tls --- sql-migrate/config.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/sql-migrate/config.go b/sql-migrate/config.go index 41b9ccad..89fe2fbe 100644 --- a/sql-migrate/config.go +++ b/sql-migrate/config.go @@ -129,11 +129,14 @@ func prepareMySQLTLS(env *Environment) error { return nil } - if env.MySQLClientCert == "" { - return errors.New("mysql-client-cert is required when configuring MySQL TLS") + if env.MySQLClientCert == "" && env.MySQLClientKey != "" { + return errors.New("mysql-client-cert is required when mysql-client-key is set") } - if env.MySQLClientKey == "" { - return errors.New("mysql-client-key is required when configuring MySQL TLS") + if env.MySQLClientCert != "" && env.MySQLClientKey == "" { + return errors.New("mysql-client-key is required when mysql-client-cert is set") + } + if env.MySQLClientCert == "" && env.MySQLCACert == "" { + return errors.New("mysql-ca-cert or mysql-client-cert/mysql-client-key is required when configuring MySQL TLS") } if dataSourceHasTLSParam(env.DataSource) { return errors.New("datasource tls parameter conflicts with MySQL client certificate config") @@ -144,14 +147,8 @@ func prepareMySQLTLS(env *Environment) error { return fmt.Errorf("parse MySQL datasource: %w", err) } - cert, err := tls.LoadX509KeyPair(env.MySQLClientCert, env.MySQLClientKey) - if err != nil { - return fmt.Errorf("load MySQL client certificate %q and key %q: %w", env.MySQLClientCert, env.MySQLClientKey, err) - } - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - ServerName: env.MySQLServerName, + ServerName: env.MySQLServerName, } if env.MySQLCACert != "" { @@ -166,6 +163,14 @@ func prepareMySQLTLS(env *Environment) error { tlsConfig.RootCAs = rootCAs } + if env.MySQLClientCert != "" { + cert, err := tls.LoadX509KeyPair(env.MySQLClientCert, env.MySQLClientKey) + if err != nil { + return fmt.Errorf("load MySQL client certificate %q and key %q: %w", env.MySQLClientCert, env.MySQLClientKey, err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + configName := env.MySQLTLSConfig if configName == "" { configName = "sql-migrate" From 4d46c349c20812f3d226c34a7c3728a1f196cbbc Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 17:33:45 +0700 Subject: [PATCH 6/8] chore(docs): document mysql ca-only tls --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c61a1fb3..2c35efc7 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,22 @@ production: See [here](https://github.com/go-sql-driver/mysql#parsetime) for more information. -### MySQL Client Certificate Auth +### MySQL TLS Certificates -For MySQL servers that require client certificate authentication, configure the certificate files in the environment. `sql-migrate` registers a MySQL TLS config and adds the matching `tls` option to the datasource automatically. +For MySQL servers that use certificates signed by a private CA, configure `mysql-ca-cert`. `sql-migrate` registers a MySQL TLS config and adds the matching `tls` option to the datasource automatically. + +```yml +production: + dialect: mysql + datasource: user:password@tcp(mysql.example.com:3306)/dbname?parseTime=true + dir: migrations/mysql + table: migrations + mysql-ca-cert: /path/ca-cert.pem + mysql-server-name: mysql.example.com + mysql-tls-config: sql-migrate +``` + +For MySQL servers that require client certificate authentication, also configure the client certificate and key: ```yml production: @@ -154,7 +167,7 @@ production: mysql-tls-config: sql-migrate ``` -`mysql-client-cert` and `mysql-client-key` are required when enabling this mode. `mysql-ca-cert`, `mysql-server-name`, and `mysql-tls-config` are optional. Do not set `tls=` in the datasource when using these fields, because `sql-migrate` manages that option. +`mysql-client-cert` and `mysql-client-key` must be provided together. `mysql-ca-cert` can be used without a client certificate for server certificate verification. Do not set `tls=` in the datasource when using these fields, because `sql-migrate` manages that option. ### Oracle (oci8) From 1b7de635f35305044970673d0a9400fccfb11549 Mon Sep 17 00:00:00 2001 From: Nghia Date: Mon, 29 Jun 2026 18:36:15 +0700 Subject: [PATCH 7/8] test(integration): add mysql tls script --- test-integration/mysql-tls.sh | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100755 test-integration/mysql-tls.sh diff --git a/test-integration/mysql-tls.sh b/test-integration/mysql-tls.sh new file mode 100755 index 00000000..7ba58706 --- /dev/null +++ b/test-integration/mysql-tls.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# Tweak PATH for Travis +export PATH=$PATH:$HOME/gopath/bin + +set -e + +# Defaults match the local Docker TLS setup used for manual testing. +# Set MYSQL_CLIENT_CERT and MYSQL_CLIENT_KEY to test a REQUIRE X509 user. +export SQL_MIGRATE=${SQL_MIGRATE:-sql-migrate} +export MYSQL_USER=${MYSQL_USER:-caonly} +export DATABASE_NAME=${DATABASE_NAME:-test_caonly} +export MYSQL_PASSWORD=${MYSQL_PASSWORD:-caonlypass} +export MYSQL_HOST=${MYSQL_HOST:-127.0.0.1} +export MYSQL_PORT=${MYSQL_PORT:-13306} +export MYSQL_CA_CERT=${MYSQL_CA_CERT:-/tmp/sql-migrate-mtls-test/certs/ca.pem} +export MYSQL_CLIENT_CERT=${MYSQL_CLIENT_CERT:-} +export MYSQL_CLIENT_KEY=${MYSQL_CLIENT_KEY:-} +export MYSQL_SERVER_NAME=${MYSQL_SERVER_NAME:-localhost} +export MYSQL_TLS_CONFIG=${MYSQL_TLS_CONFIG:-sql-migrate-tls} +export MYSQL_TLS_CONFIG_FILE=${MYSQL_TLS_CONFIG_FILE:-/tmp/sql-migrate-mysql-tls-dbconfig.yml} + +if [ ! -f "$MYSQL_CA_CERT" ]; then + echo "MYSQL_CA_CERT does not exist: $MYSQL_CA_CERT" >&2 + exit 1 +fi + +if [ -n "$MYSQL_CLIENT_CERT" ] && [ -z "$MYSQL_CLIENT_KEY" ]; then + echo "MYSQL_CLIENT_KEY is required when MYSQL_CLIENT_CERT is set" >&2 + exit 1 +fi + +if [ -n "$MYSQL_CLIENT_KEY" ] && [ -z "$MYSQL_CLIENT_CERT" ]; then + echo "MYSQL_CLIENT_CERT is required when MYSQL_CLIENT_KEY is set" >&2 + exit 1 +fi + +cat >"$MYSQL_TLS_CONFIG_FILE" <>"$MYSQL_TLS_CONFIG_FILE" < Date: Mon, 29 Jun 2026 19:49:52 +0700 Subject: [PATCH 8/8] test(integration): add mysql tls setup script --- test-integration/mysql-tls-setup.sh | 180 ++++++++++++++++++++++++++++ test-integration/mysql-tls.sh | 9 +- 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100755 test-integration/mysql-tls-setup.sh diff --git a/test-integration/mysql-tls-setup.sh b/test-integration/mysql-tls-setup.sh new file mode 100755 index 00000000..5b637e6e --- /dev/null +++ b/test-integration/mysql-tls-setup.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# Tweak PATH for Travis +export PATH=$PATH:$HOME/gopath/bin + +set -e + +export MYSQL_TLS_WORKDIR=${MYSQL_TLS_WORKDIR:-/tmp/sql-migrate-mtls-test} +export CERT_DIR=${CERT_DIR:-$MYSQL_TLS_WORKDIR/certs} +export MYSQL_CONTAINER=${MYSQL_CONTAINER:-sql-migrate-mtls-mysql} +export MYSQL_IMAGE=${MYSQL_IMAGE:-mysql:8.0} +export MYSQL_PORT=${MYSQL_PORT:-13306} +export MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-rootpass} + +export MYSQL_CA_USER=${MYSQL_CA_USER:-caonly} +export MYSQL_CA_PASSWORD=${MYSQL_CA_PASSWORD:-caonlypass} +export MYSQL_CA_DATABASE=${MYSQL_CA_DATABASE:-test_caonly} + +export MYSQL_X509_USER=${MYSQL_X509_USER:-migrate} +export MYSQL_X509_PASSWORD=${MYSQL_X509_PASSWORD:-migratepass} +export MYSQL_X509_DATABASE=${MYSQL_X509_DATABASE:-test_mtls} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +log() { + printf '\n==> %s\n' "$*" >&2 +} + +generate_certs() { + if [ -f "$CERT_DIR/ca.pem" ] && + [ -f "$CERT_DIR/server-cert.pem" ] && + [ -f "$CERT_DIR/server-key.pem" ] && + [ -f "$CERT_DIR/client-cert.pem" ] && + [ -f "$CERT_DIR/client-key.pem" ]; then + log "Using existing certificates in $CERT_DIR" + return + fi + + log "Generating test certificates in $CERT_DIR" + mkdir -p "$CERT_DIR" + rm -f "$CERT_DIR"/*.pem "$CERT_DIR"/*.cnf "$CERT_DIR"/*.srl + + ( + cd "$CERT_DIR" + + openssl genrsa 2048 >ca-key.pem + openssl req -new -x509 -nodes -days 3650 \ + -key ca-key.pem \ + -subj "/CN=sql-migrate-test-ca" \ + -out ca.pem + + cat >server-ext.cnf <client-ext.cnf </dev/null 2>&1 +} + +start_mysql() { + log "Starting MySQL container $MYSQL_CONTAINER on port $MYSQL_PORT" + docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true + + docker run --name "$MYSQL_CONTAINER" --rm -d \ + -e MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \ + -p "$MYSQL_PORT:3306" \ + -v "$CERT_DIR:/etc/mysql/certs:ro" \ + "$MYSQL_IMAGE" \ + --ssl-ca=/etc/mysql/certs/ca.pem \ + --ssl-cert=/etc/mysql/certs/server-cert.pem \ + --ssl-key=/etc/mysql/certs/server-key.pem \ + --require_secure_transport=ON >/dev/null +} + +wait_mysql() { + log "Waiting for MySQL to become ready" + + for _ in {1..60}; do + if root_auth_succeeds; then + return + fi + + sleep 2 + done + + docker logs "$MYSQL_CONTAINER" >&2 || true + echo "MySQL did not become ready in time." >&2 + exit 1 +} + +configure_users() { + log "Resetting TLS test users and databases" + + docker exec "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e " +DROP DATABASE IF EXISTS ${MYSQL_CA_DATABASE}; +CREATE DATABASE ${MYSQL_CA_DATABASE}; +DROP USER IF EXISTS '${MYSQL_CA_USER}'@'%'; +CREATE USER '${MYSQL_CA_USER}'@'%' IDENTIFIED BY '${MYSQL_CA_PASSWORD}' REQUIRE SSL; +GRANT ALL PRIVILEGES ON ${MYSQL_CA_DATABASE}.* TO '${MYSQL_CA_USER}'@'%'; + +DROP DATABASE IF EXISTS ${MYSQL_X509_DATABASE}; +CREATE DATABASE ${MYSQL_X509_DATABASE}; +DROP USER IF EXISTS '${MYSQL_X509_USER}'@'%'; +CREATE USER '${MYSQL_X509_USER}'@'%' IDENTIFIED BY '${MYSQL_X509_PASSWORD}' REQUIRE X509; +GRANT ALL PRIVILEGES ON ${MYSQL_X509_DATABASE}.* TO '${MYSQL_X509_USER}'@'%'; + +FLUSH PRIVILEGES; +" +} + +show_setup() { + log "Checking MySQL TLS setup" + + docker exec "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e " +SHOW VARIABLES LIKE 'have_ssl'; +SHOW VARIABLES LIKE 'require_secure_transport'; +SELECT user, host, ssl_type FROM mysql.user WHERE user IN ('${MYSQL_CA_USER}', '${MYSQL_X509_USER}'); +" + + cat <&2 + echo "Run test-integration/mysql-tls-setup.sh to create the default Docker TLS environment." >&2 exit 1 fi @@ -39,7 +44,7 @@ cat >"$MYSQL_TLS_CONFIG_FILE" <