diff --git a/config/config.go b/config/config.go index 58b4d6df..5ca9c463 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ package config import ( + "context" "crypto/tls" "crypto/x509" "fmt" @@ -24,6 +25,8 @@ import ( "strings" "sync" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + rdsAuth "github.com/aws/aws-sdk-go-v2/feature/rds/auth" "github.com/go-sql-driver/mysql" "github.com/prometheus/client_golang/prometheus" @@ -73,6 +76,8 @@ type MySqlConfig struct { TlsInsecureSkipVerify bool `ini:"ssl-skip-verfication"` //nolint:misspell Tls string `ini:"tls"` EnableCleartextPlugin bool `ini:"enable-cleartext-plugin"` + AwsIamAuth bool `ini:"aws-iam-auth"` + AwsRegion string `ini:"aws-region"` } type MySqlConfigHandler struct { @@ -209,6 +214,22 @@ func (m MySqlConfig) FormDSN(target string) (string, error) { config.TLSConfig = "custom" } } + if m.AwsIamAuth { + if m.AwsRegion == "" { + return "", fmt.Errorf("aws region must be specified for IAM authentication") + } + awsCfg, err := awsConfig.LoadDefaultConfig(context.TODO()) + if err != nil { + return "", fmt.Errorf("failed to load AWS config for IAM authentication: %w", err) + } + authToken, err := rdsAuth.BuildAuthToken( + context.TODO(), config.Addr, m.AwsRegion, m.User, awsCfg.Credentials) + if err != nil { + return "", fmt.Errorf("failed to build auth token for IAM authentication: %w", err) + } + config.Passwd = authToken + } + if m.EnableCleartextPlugin { config.AllowCleartextPasswords = true } diff --git a/config/config_aws_iam_test.go b/config/config_aws_iam_test.go new file mode 100644 index 00000000..196c7965 --- /dev/null +++ b/config/config_aws_iam_test.go @@ -0,0 +1,480 @@ +// Copyright 2026 Percona LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" + "github.com/prometheus/common/promslog" +) + +// Example credentials from the AWS SigV4 documentation. They are syntactically +// valid, which is all BuildAuthToken needs -- nothing here talks to AWS. +const ( + testAccessKeyID = "AKIAIOSFODNN7EXAMPLE" + testSecretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +) + +// awsEnvKeys is every variable the SDK's default config chain reads that could +// make these tests depend on the developer's shell or on the CI runner's role. +var awsEnvKeys = []string{ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_MAX_ATTEMPTS", + "AWS_RETRY_MODE", + "AWS_USE_DUALSTACK_ENDPOINT", + "AWS_USE_FIPS_ENDPOINT", + "AWS_REQUEST_CHECKSUM_CALCULATION", + "AWS_RESPONSE_CHECKSUM_VALIDATION", + "AWS_ACCOUNT_ID_ENDPOINT_MODE", +} + +// isolateAWSEnv makes LoadDefaultConfig deterministic and offline: the shared +// config and credentials files point at a path that does not exist, IMDS is +// switched off so the credential chain cannot reach the network, and every +// other AWS_* variable is dropped. +func isolateAWSEnv(t *testing.T) { + t.Helper() + + missing := filepath.Join(t.TempDir(), "no-such-aws-file") + t.Setenv("AWS_CONFIG_FILE", missing) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", missing) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + for _, key := range awsEnvKeys { + unsetEnv(t, key) + } +} + +// unsetEnv removes key for the duration of the test. t.Setenv captures the +// original value -- and whether it was set at all -- before we delete it, so +// the cleanup it registers still restores the environment exactly. +func unsetEnv(t *testing.T, key string) { + t.Helper() + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatalf("failed to unset %s: %s", key, err) + } +} + +// staticAWSCreds gives the default credential chain a resolvable set of +// credentials, so BuildAuthToken can sign without any provider I/O. +func staticAWSCreds(t *testing.T) { + t.Helper() + t.Setenv("AWS_ACCESS_KEY_ID", testAccessKeyID) + t.Setenv("AWS_SECRET_ACCESS_KEY", testSecretAccessKey) +} + +// rdsAuthToken splits an RDS IAM auth token into the endpoint it was signed +// for and its query parameters, failing the test if it is not shaped like one. +// A token looks like "host:port?Action=connect&...&X-Amz-Signature=...". +func rdsAuthToken(t *testing.T, token string) (string, url.Values) { + t.Helper() + + endpoint, rawQuery, found := strings.Cut(token, "?") + if !found { + t.Fatalf("auth token %q has no query string", token) + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("failed to parse auth token query %q: %s", rawQuery, err) + } + return endpoint, values +} + +// TestFormDSNAwsIamAuth covers the happy path: the signed token replaces +// whatever password the section carried, and it is signed for the address the +// DSN actually connects to, for the configured user and region. +func TestFormDSNAwsIamAuth(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + // A different region in the environment must not win over the section's. + t.Setenv("AWS_REGION", "us-west-2") + + m := MySqlConfig{ + User: "iamuser", + Password: "should-be-replaced", + Host: "rds.example.com", + Port: 3306, + AwsIamAuth: true, + AwsRegion: "eu-central-1", + } + + dsn, err := m.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + + // The token contains "?", "&" and ":", so the DSN must still be parseable + // by the driver that will receive it. + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("driver cannot parse dsn %q: %s", dsn, err) + } + if cfg.User != "iamuser" { + t.Errorf("user: got %q, want %q", cfg.User, "iamuser") + } + if cfg.Net != "tcp" { + t.Errorf("net: got %q, want %q", cfg.Net, "tcp") + } + if cfg.Addr != "rds.example.com:3306" { + t.Errorf("addr: got %q, want %q", cfg.Addr, "rds.example.com:3306") + } + if cfg.Passwd == "should-be-replaced" { + t.Error("password was not replaced by an IAM auth token") + } + + endpoint, values := rdsAuthToken(t, cfg.Passwd) + if endpoint != "rds.example.com:3306" { + t.Errorf("token endpoint: got %q, want %q", endpoint, "rds.example.com:3306") + } + if got := values.Get("Action"); got != "connect" { + t.Errorf("token Action: got %q, want %q", got, "connect") + } + if got := values.Get("DBUser"); got != "iamuser" { + t.Errorf("token DBUser: got %q, want %q", got, "iamuser") + } + if got := values.Get("X-Amz-Expires"); got != "900" { + t.Errorf("token X-Amz-Expires: got %q, want %q", got, "900") + } + if got := values.Get("X-Amz-Algorithm"); got != "AWS4-HMAC-SHA256" { + t.Errorf("token X-Amz-Algorithm: got %q, want %q", got, "AWS4-HMAC-SHA256") + } + if values.Get("X-Amz-Signature") == "" { + t.Error("token is not signed: X-Amz-Signature is empty") + } + // Credential scope is "///rds-db/aws4_request", so it + // proves the section's region -- not AWS_REGION -- was signed for. + credential := values.Get("X-Amz-Credential") + if !strings.HasPrefix(credential, testAccessKeyID+"/") { + t.Errorf("token X-Amz-Credential %q does not start with the configured access key", credential) + } + if !strings.HasSuffix(credential, "/eu-central-1/rds-db/aws4_request") { + t.Errorf("token X-Amz-Credential %q was not scoped to eu-central-1/rds-db", credential) + } +} + +// TestFormDSNAwsIamAuthSignsTarget pins that the token is signed for the +// resolved target address rather than the section's own host and port. +func TestFormDSNAwsIamAuthSignsTarget(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + + m := MySqlConfig{ + User: "iamuser", + Host: "rds.example.com", + Port: 3306, + AwsIamAuth: true, + AwsRegion: "eu-central-1", + } + + dsn, err := m.FormDSN("replica.example.com:5000") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("driver cannot parse dsn %q: %s", dsn, err) + } + if cfg.Addr != "replica.example.com:5000" { + t.Errorf("addr: got %q, want %q", cfg.Addr, "replica.example.com:5000") + } + + endpoint, _ := rdsAuthToken(t, cfg.Passwd) + if endpoint != "replica.example.com:5000" { + t.Errorf("token endpoint: got %q, want %q", endpoint, "replica.example.com:5000") + } +} + +// TestFormDSNAwsIamAuthMissingRegion covers the guard that rejects IAM +// authentication without a region, before any AWS call is attempted. +func TestFormDSNAwsIamAuthMissingRegion(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + // Even a usable region in the environment must not satisfy the guard. + t.Setenv("AWS_REGION", "eu-central-1") + + m := MySqlConfig{ + User: "iamuser", + Host: "rds.example.com", + Port: 3306, + AwsIamAuth: true, + } + + dsn, err := m.FormDSN("") + if err == nil { + t.Fatalf("expected an error, got dsn %q", dsn) + } + if want := "aws region must be specified for IAM authentication"; err.Error() != want { + t.Errorf("error: got %q, want %q", err.Error(), want) + } + if dsn != "" { + t.Errorf("dsn: got %q, want an empty string", dsn) + } +} + +// TestFormDSNAwsIamAuthLoadConfigError covers the failure to load the AWS +// config, provoked by a value the SDK cannot parse. +func TestFormDSNAwsIamAuthLoadConfigError(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + t.Setenv("AWS_MAX_ATTEMPTS", "not-a-number") + + m := MySqlConfig{ + User: "iamuser", + Host: "rds.example.com", + Port: 3306, + AwsIamAuth: true, + AwsRegion: "eu-central-1", + } + + dsn, err := m.FormDSN("") + if err == nil { + t.Fatalf("expected an error, got dsn %q", dsn) + } + if want := "failed to load AWS config for IAM authentication: "; !strings.HasPrefix(err.Error(), want) { + t.Errorf("error %q does not start with %q", err.Error(), want) + } + if !strings.Contains(err.Error(), "AWS_MAX_ATTEMPTS") { + t.Errorf("error %q does not mention the offending variable", err.Error()) + } + if dsn != "" { + t.Errorf("dsn: got %q, want an empty string", dsn) + } +} + +// TestFormDSNAwsIamAuthBuildTokenError covers the failure to build the token, +// which is where an unusable endpoint or an unresolvable credential lands. +func TestFormDSNAwsIamAuthBuildTokenError(t *testing.T) { + for _, tc := range []struct { + name string + creds bool + config MySqlConfig + target string + wantDetails string + }{ + { + // A socket address has no port, which BuildAuthToken requires, + // so IAM authentication cannot work over a UNIX socket. + name: "unix socket target has no port", + creds: true, + config: MySqlConfig{ + User: "iamuser", + AwsIamAuth: true, + AwsRegion: "eu-central-1", + }, + target: "unix:///run/mysqld/mysqld.sock", + wantDetails: "the provided endpoint is missing a port", + }, + { + name: "socket from config has no port", + creds: true, + config: MySqlConfig{ + User: "iamuser", + Socket: "/run/mysqld/mysqld.sock", + AwsIamAuth: true, + AwsRegion: "eu-central-1", + }, + wantDetails: "the provided endpoint is missing a port", + }, + { + name: "no credentials to sign with", + creds: false, + config: MySqlConfig{ + User: "iamuser", + Host: "rds.example.com", + Port: 3306, + AwsIamAuth: true, + AwsRegion: "eu-central-1", + }, + wantDetails: "failed to refresh cached credentials", + }, + } { + t.Run(tc.name, func(t *testing.T) { + isolateAWSEnv(t) + if tc.creds { + staticAWSCreds(t) + } + + dsn, err := tc.config.FormDSN(tc.target) + if err == nil { + t.Fatalf("expected an error, got dsn %q", dsn) + } + if want := "failed to build auth token for IAM authentication: "; !strings.HasPrefix(err.Error(), want) { + t.Errorf("error %q does not start with %q", err.Error(), want) + } + if !strings.Contains(err.Error(), tc.wantDetails) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantDetails) + } + if dsn != "" { + t.Errorf("dsn: got %q, want an empty string", dsn) + } + }) + } +} + +// TestFormDSNAwsIamAuthDisabled pins that a section keeps its static password +// when IAM authentication is off, even with a region configured, and that no +// AWS credentials are needed to form the DSN. +func TestFormDSNAwsIamAuthDisabled(t *testing.T) { + isolateAWSEnv(t) + + m := MySqlConfig{ + User: "iamuser", + Password: "staticpassword", + Host: "rds.example.com", + Port: 3306, + AwsRegion: "eu-central-1", + } + + dsn, err := m.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + if want := "iamuser:staticpassword@tcp(rds.example.com:3306)/"; dsn != want { + t.Errorf("dsn: got %q, want %q", dsn, want) + } +} + +// TestReloadConfigAwsIam covers parsing of the aws-iam-auth and aws-region +// keys out of a my.cnf section. +func TestReloadConfigAwsIam(t *testing.T) { + c := MySqlConfigHandler{Config: &Config{}} + if err := c.ReloadConfig("testdata/client_aws_iam.cnf", "localhost:3306", "", false, promslog.NewNopLogger()); err != nil { + t.Fatalf("error reloading config: %s", err) + } + cfg := c.GetConfig() + + for _, tc := range []struct { + section string + wantIamAuth bool + wantRegion string + wantPassword string + wantCleartext bool + wantTls string + }{ + {section: "client_aws_iam", wantIamAuth: true, wantRegion: "eu-central-1"}, + {section: "client_aws_iam_no_region", wantIamAuth: true}, + {section: "client_aws_iam_disabled", wantRegion: "eu-central-1", wantPassword: "staticpassword"}, + {section: "client_aws_iam_rds", wantIamAuth: true, wantRegion: "eu-central-1", wantCleartext: true, wantTls: "true"}, + } { + t.Run(tc.section, func(t *testing.T) { + section, ok := cfg.Sections[tc.section] + if !ok { + t.Fatalf("section %q is missing from the parsed config", tc.section) + } + if section.AwsIamAuth != tc.wantIamAuth { + t.Errorf("AwsIamAuth: got %v, want %v", section.AwsIamAuth, tc.wantIamAuth) + } + if section.AwsRegion != tc.wantRegion { + t.Errorf("AwsRegion: got %q, want %q", section.AwsRegion, tc.wantRegion) + } + if section.Password != tc.wantPassword { + t.Errorf("Password: got %q, want %q", section.Password, tc.wantPassword) + } + if section.EnableCleartextPlugin != tc.wantCleartext { + t.Errorf("EnableCleartextPlugin: got %v, want %v", section.EnableCleartextPlugin, tc.wantCleartext) + } + if section.Tls != tc.wantTls { + t.Errorf("Tls: got %q, want %q", section.Tls, tc.wantTls) + } + }) + } +} + +// TestFormDSNAwsIamAuthFromConfigFile is the end-to-end shape RDS actually +// needs: an IAM token as the password, TLS on, and the cleartext plugin +// allowed so the long token reaches the server. +func TestFormDSNAwsIamAuthFromConfigFile(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + + c := MySqlConfigHandler{Config: &Config{}} + if err := c.ReloadConfig("testdata/client_aws_iam.cnf", "localhost:3306", "", false, promslog.NewNopLogger()); err != nil { + t.Fatalf("error reloading config: %s", err) + } + section := c.GetConfig().Sections["client_aws_iam_rds"] + + dsn, err := section.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("driver cannot parse dsn %q: %s", dsn, err) + } + if !cfg.AllowCleartextPasswords { + t.Error("allowCleartextPasswords is not set") + } + if cfg.TLSConfig != "true" { + t.Errorf("tls: got %q, want %q", cfg.TLSConfig, "true") + } + + endpoint, values := rdsAuthToken(t, cfg.Passwd) + if endpoint != "rds.example.com:3306" { + t.Errorf("token endpoint: got %q, want %q", endpoint, "rds.example.com:3306") + } + if got := values.Get("DBUser"); got != "iamuser" { + t.Errorf("token DBUser: got %q, want %q", got, "iamuser") + } +} + +// TestFormDSNAwsIamAuthWithSkipVerify pins that IAM authentication composes +// with TlsInsecureSkipVerify, which takes a different branch of the TLS setup +// that runs just before the token is built. +func TestFormDSNAwsIamAuthWithSkipVerify(t *testing.T) { + isolateAWSEnv(t) + staticAWSCreds(t) + + m := MySqlConfig{ + User: "iamuser", + Host: "rds.example.com", + Port: 3306, + TlsInsecureSkipVerify: true, + AwsIamAuth: true, + AwsRegion: "eu-central-1", + } + + dsn, err := m.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("driver cannot parse dsn %q: %s", dsn, err) + } + if cfg.TLSConfig != "skip-verify" { + t.Errorf("tls: got %q, want %q", cfg.TLSConfig, "skip-verify") + } + if _, values := rdsAuthToken(t, cfg.Passwd); values.Get("X-Amz-Signature") == "" { + t.Error("token is not signed: X-Amz-Signature is empty") + } +} diff --git a/config/config_test.go b/config/config_test.go index 33d1f003..a8c4328b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -284,3 +284,72 @@ func TestFormDSNWithCustomTls(t *testing.T) { }) } + +func TestFormDSNAddressResolution(t *testing.T) { + convey.Convey("Address resolution without a target", t, func() { + convey.Convey("Defaults to 127.0.0.1:3306", func() { + section := MySqlConfig{User: "usr", Password: "pwd"} + dsn, err := section.FormDSN("") + convey.So(err, convey.ShouldBeNil) + convey.So(dsn, convey.ShouldEqual, "usr:pwd@tcp(127.0.0.1:3306)/") + }) + + convey.Convey("Host from config with the default port", func() { + section := MySqlConfig{User: "usr", Password: "pwd", Host: "server1"} + dsn, err := section.FormDSN("") + convey.So(err, convey.ShouldBeNil) + convey.So(dsn, convey.ShouldEqual, "usr:pwd@tcp(server1:3306)/") + }) + + convey.Convey("Port from config with the default host", func() { + section := MySqlConfig{User: "usr", Password: "pwd", Port: 5000} + dsn, err := section.FormDSN("") + convey.So(err, convey.ShouldBeNil) + convey.So(dsn, convey.ShouldEqual, "usr:pwd@tcp(127.0.0.1:5000)/") + }) + + convey.Convey("Socket from config wins over host and port", func() { + section := MySqlConfig{ + User: "usr", + Password: "pwd", + Host: "server1", + Port: 5000, + Socket: "/run/mysqld/mysqld.sock", + } + dsn, err := section.FormDSN("") + convey.So(err, convey.ShouldBeNil) + convey.So(dsn, convey.ShouldEqual, "usr:pwd@unix(/run/mysqld/mysqld.sock)/") + }) + }) + + convey.Convey("Target without a port", t, func() { + section := MySqlConfig{User: "usr", Password: "pwd"} + dsn, err := section.FormDSN("server1") + convey.So(err, convey.ShouldBeError, "failed to parse target: address server1: missing port in address") + convey.So(dsn, convey.ShouldBeEmpty) + }) +} + +func TestReloadConfigErrors(t *testing.T) { + convey.Convey("Malformed config file", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + err := c.ReloadConfig("testdata/malformed.cnf", "localhost:3306", "root", true, promslog.NewNopLogger()) + convey.So(err, convey.ShouldBeError) + convey.So(err.Error(), convey.ShouldStartWith, "failed to load config from testdata/malformed.cnf: ") + }) + + convey.Convey("Sections that fail to parse are skipped", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + err := c.ReloadConfig("testdata/invalid_value.cnf", "localhost:3306", "root", true, promslog.NewNopLogger()) + convey.So(err, convey.ShouldBeNil) + + cfg := c.GetConfig() + convey.So(cfg.Sections, convey.ShouldContainKey, "client") + convey.So(cfg.Sections, convey.ShouldNotContainKey, "client.bad_port") + convey.So(cfg.Sections, convey.ShouldNotContainKey, "client.bad_aws_iam_auth") + }) +} diff --git a/config/config_tls_test.go b/config/config_tls_test.go new file mode 100644 index 00000000..e6adbe4a --- /dev/null +++ b/config/config_tls_test.go @@ -0,0 +1,283 @@ +// Copyright 2026 Percona LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// testTLSFiles holds paths to a throwaway CA certificate and a client key pair +// signed by it. CustomizeTLS only parses these, so they are never used for a +// real handshake. +type testTLSFiles struct { + ca string + cert string + key string +} + +// writeTestTLSFiles generates a self-signed CA and a client key pair signed by +// it, writing all three PEM files into the test's temporary directory. +func writeTestTLSFiles(t *testing.T) testTLSFiles { + t.Helper() + + dir := t.TempDir() + notBefore := time.Now().Add(-time.Hour) + notAfter := notBefore.Add(24 * time.Hour) + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate CA key: %s", err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mysqld_exporter test CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatalf("failed to create CA certificate: %s", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("failed to parse CA certificate: %s", err) + } + + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate client key: %s", err) + } + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "mysqld_exporter test client"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatalf("failed to create client certificate: %s", err) + } + clientKeyDER, err := x509.MarshalECPrivateKey(clientKey) + if err != nil { + t.Fatalf("failed to marshal client key: %s", err) + } + + files := testTLSFiles{ + ca: filepath.Join(dir, "ca.pem"), + cert: filepath.Join(dir, "client-cert.pem"), + key: filepath.Join(dir, "client-key.pem"), + } + writePEM(t, files.ca, "CERTIFICATE", caDER) + writePEM(t, files.cert, "CERTIFICATE", clientDER) + writePEM(t, files.key, "EC PRIVATE KEY", clientKeyDER) + + return files +} + +func writePEM(t *testing.T, path, blockType string, der []byte) { + t.Helper() + + encoded := pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}) + if encoded == nil { + t.Fatalf("failed to pem-encode %s", path) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatalf("failed to write %s: %s", path, err) + } +} + +// writeGarbage writes a file that is readable but is not valid PEM. +func writeGarbage(t *testing.T, name string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte("this is not a certificate\n"), 0o600); err != nil { + t.Fatalf("failed to write %s: %s", path, err) + } + return path +} + +// TestCustomizeTLS covers each way CustomizeTLS can succeed or fail. +func TestCustomizeTLS(t *testing.T) { + files := writeTestTLSFiles(t) + missing := filepath.Join(t.TempDir(), "no-such-ca.pem") + + for _, tc := range []struct { + name string + config MySqlConfig + wantErr string + }{ + { + name: "ca only", + config: MySqlConfig{SslCa: files.ca}, + }, + { + name: "ca with client key pair", + config: MySqlConfig{SslCa: files.ca, SslCert: files.cert, SslKey: files.key}, + }, + { + // Only a complete pair is loaded, so a cert without a key is + // silently ignored rather than rejected. + name: "cert without key is ignored", + config: MySqlConfig{SslCa: files.ca, SslCert: files.cert}, + }, + { + name: "key without cert is ignored", + config: MySqlConfig{SslCa: files.ca, SslKey: files.key}, + }, + { + name: "unreadable ca", + config: MySqlConfig{SslCa: missing}, + wantErr: "no such file or directory", + }, + { + name: "ca is not pem", + config: MySqlConfig{SslCa: writeGarbage(t, "garbage-ca.pem")}, + wantErr: "failed to parse pem-encoded CA certificates from", + }, + { + name: "client cert is not pem", + config: MySqlConfig{SslCa: files.ca, SslCert: writeGarbage(t, "garbage-cert.pem"), SslKey: files.key}, + wantErr: "failed to parse pem-encoded SSL cert", + }, + { + name: "client key is not pem", + config: MySqlConfig{SslCa: files.ca, SslCert: files.cert, SslKey: writeGarbage(t, "garbage-key.pem")}, + wantErr: "failed to parse pem-encoded SSL cert", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.CustomizeTLS() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + return + } + if err == nil { + t.Fatalf("expected an error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +// TestFormDSNWithSslCa covers the ssl-ca branch of FormDSN. The registered +// configuration is read back through the driver, which resolves tls=custom to +// the tls.Config CustomizeTLS registered. +func TestFormDSNWithSslCa(t *testing.T) { + files := writeTestTLSFiles(t) + + m := MySqlConfig{ + User: "usr", + Password: "pwd", + Host: "server1", + Port: 3306, + SslCa: files.ca, + SslCert: files.cert, + SslKey: files.key, + // Ignored while forming the DSN, but CustomizeTLS copies it into the + // registered tls.Config. + Tls: "true", + } + + dsn, err := m.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + if want := "usr:pwd@tcp(server1:3306)/?tls=custom"; dsn != want { + t.Fatalf("dsn: got %q, want %q", dsn, want) + } + + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("driver cannot parse dsn %q: %s", dsn, err) + } + if cfg.TLS == nil { + t.Fatal("tls=custom did not resolve to a registered tls.Config") + } + if cfg.TLS.RootCAs == nil { + t.Error("registered tls.Config has no root CAs") + } + if got := len(cfg.TLS.Certificates); got != 1 { + t.Errorf("registered client certificates: got %d, want 1", got) + } + if cfg.TLS.InsecureSkipVerify { + t.Error("registered tls.Config skips verification") + } +} + +// TestFormDSNSslCaError covers the failure to register the custom TLS +// configuration. +func TestFormDSNSslCaError(t *testing.T) { + m := MySqlConfig{ + User: "usr", + Host: "server1", + Port: 3306, + SslCa: writeGarbage(t, "garbage-ca.pem"), + } + + dsn, err := m.FormDSN("") + if err == nil { + t.Fatalf("expected an error, got dsn %q", dsn) + } + if want := "failed to register a custom TLS configuration for mysql dsn: "; !strings.HasPrefix(err.Error(), want) { + t.Errorf("error %q does not start with %q", err.Error(), want) + } + if dsn != "" { + t.Errorf("dsn: got %q, want an empty string", dsn) + } +} + +// TestFormDSNSslCaIgnoredWhenSkipVerify pins that TlsInsecureSkipVerify wins +// over ssl-ca: no custom configuration is registered at all, so an unusable CA +// file is never even read. +func TestFormDSNSslCaIgnoredWhenSkipVerify(t *testing.T) { + m := MySqlConfig{ + User: "usr", + Password: "pwd", + Host: "server1", + Port: 3306, + SslCa: writeGarbage(t, "garbage-ca.pem"), + TlsInsecureSkipVerify: true, + } + + dsn, err := m.FormDSN("") + if err != nil { + t.Fatalf("error forming dsn: %s", err) + } + if want := "usr:pwd@tcp(server1:3306)/?tls=skip-verify"; dsn != want { + t.Errorf("dsn: got %q, want %q", dsn, want) + } +} diff --git a/config/testdata/client_aws_iam.cnf b/config/testdata/client_aws_iam.cnf new file mode 100644 index 00000000..9bd3172f --- /dev/null +++ b/config/testdata/client_aws_iam.cnf @@ -0,0 +1,26 @@ +[client_aws_iam] +host = rds.example.com +port = 3306 +user = iamuser +aws-iam-auth = true +aws-region = eu-central-1 +[client_aws_iam_no_region] +host = rds.example.com +port = 3306 +user = iamuser +aws-iam-auth = true +[client_aws_iam_disabled] +host = rds.example.com +port = 3306 +user = iamuser +password = staticpassword +aws-iam-auth = false +aws-region = eu-central-1 +[client_aws_iam_rds] +host = rds.example.com +port = 3306 +user = iamuser +aws-iam-auth = true +aws-region = eu-central-1 +enable-cleartext-plugin = true +tls = true diff --git a/config/testdata/invalid_value.cnf b/config/testdata/invalid_value.cnf new file mode 100644 index 00000000..074017ef --- /dev/null +++ b/config/testdata/invalid_value.cnf @@ -0,0 +1,9 @@ +[client] +user = root +password = abc +[client.bad_port] +user = test +port = notanumber +[client.bad_aws_iam_auth] +user = test +aws-iam-auth = notabool diff --git a/config/testdata/malformed.cnf b/config/testdata/malformed.cnf new file mode 100644 index 00000000..1976a736 --- /dev/null +++ b/config/testdata/malformed.cnf @@ -0,0 +1,2 @@ +[client +user = root diff --git a/go.mod b/go.mod index 1e74ccb8..719fbec0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.8 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/aws/aws-sdk-go-v2/config v1.31.13 + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.10 github.com/blang/semver/v4 v4.0.0 github.com/go-sql-driver/mysql v1.10.0 github.com/google/go-cmp v0.7.0 @@ -31,6 +33,18 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect + github.com/aws/aws-sdk-go-v2 v1.39.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 11b75a89..396785e9 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,34 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= +github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.10 h1:xfgjONWMae6+y//dlhVukwt9N+I++FPuiwcQt7DI7Qg= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.10/go.mod h1:FO6aarJTHA2N3S8F2A4wKfnX9Jr6MPerJFaqoLgTctU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=