diff --git a/pkg/tsdb/mssql/azure/connection.go b/pkg/tsdb/mssql/azure/connection.go new file mode 100644 index 00000000000..40978229b39 --- /dev/null +++ b/pkg/tsdb/mssql/azure/connection.go @@ -0,0 +1,40 @@ +package azure + +import ( + "fmt" + + "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" +) + +func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) { + connStr := "" + switch c := azureCredentials.(type) { + case *azcredentials.AzureManagedIdentityCredentials: + if azureManagedIdentityClientId != "" { + connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId) + } + connStr += fmt.Sprintf("fedauth=%s;", + "ActiveDirectoryManagedIdentity") + case *azcredentials.AzureClientSecretCredentials: + connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;", + c.ClientId, + c.TenantId, + c.ClientSecret, + "ActiveDirectoryApplication", + ) + case *azcredentials.AzureEntraPasswordCredentials: + if azureEntraPasswordCredentialsEnabled { + connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;", + c.UserId, + c.Password, + c.ClientId, + "ActiveDirectoryPassword", + ) + } else { + return "", fmt.Errorf("azure entra password authentication is not enabled") + } + default: + return "", fmt.Errorf("unsupported azure authentication type") + } + return connStr, nil +} diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 3c617b5828a..ca8595edd2e 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -2,32 +2,18 @@ package mssql import ( "context" - "database/sql" "encoding/json" "fmt" - "net/url" - "reflect" - "regexp" - "strconv" - "strings" - "time" - "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" - mssql "github.com/microsoft/go-mssqldb" - "github.com/microsoft/go-mssqldb/azuread" _ "github.com/microsoft/go-mssqldb/integratedauth/krb5" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng" - "github.com/grafana/grafana/pkg/tsdb/mssql/utils" - "github.com/grafana/grafana/pkg/util" ) type Service struct { @@ -35,16 +21,6 @@ type Service struct { logger log.Logger } -const ( - azureAuthentication = "Azure AD Authentication" - windowsAuthentication = "Windows Authentication" - sqlServerAuthentication = "SQL Server Authentication" - kerberosRaw = "Windows AD: Username + password" - kerberosKeytab = "Windows AD: Keytab" - kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101 - kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101 -) - func ProvideService(cfg *setting.Cfg) *Service { logger := backend.NewLoggerWith("logger", "tsdb.mssql") return &Service{ @@ -70,72 +46,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return dsHandler.QueryData(ctx, req) } -func newMSSQL(ctx context.Context, driverName string, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, error) { - var connector *mssql.Connector - var err error - if driverName == "azuresql" { - connector, err = azuread.NewConnector(cnnstr) - } else { - connector, err = mssql.NewConnector(cnnstr) - } - - if err != nil { - logger.Error("mssql connector creation failed", "error", err) - return nil, nil, fmt.Errorf("mssql connector creation failed") - } - - proxyClient, err := settings.ProxyClient(ctx) - if err != nil { - logger.Error("mssql proxy creation failed", "error", err) - return nil, nil, fmt.Errorf("mssql proxy creation failed") - } - - if proxyClient.SecureSocksProxyEnabled() { - dialer, err := proxyClient.NewSecureSocksProxyContextDialer() - if err != nil { - logger.Error("mssql proxy creation failed", "error", err) - return nil, nil, fmt.Errorf("mssql proxy creation failed") - } - URL, err := ParseURL(dsInfo.URL, logger) - if err != nil { - return nil, nil, err - } - - mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer) - if err != nil { - return nil, nil, err - } - // update the mssql dialer with the proxy dialer - connector.Dialer = (mssqlDialer) - } - - config := sqleng.DataPluginConfiguration{ - DSInfo: dsInfo, - MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, - RowLimit: rowLimit, - } - - queryResultTransformer := mssqlQueryResultTransformer{ - userError: userFacingDefaultError, - } - - db := sql.OpenDB(connector) - - db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) - db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) - db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - - handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(), - logger) - if err != nil { - logger.Error("Failed connecting to Postgres", "err", err) - return nil, nil, err - } - - logger.Debug("Successfully connected to Postgres") - return db, handler, nil -} - func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { grafCfg := backend.GrafanaConfigFromContext(ctx) @@ -151,14 +61,11 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc ConnectionTimeout: 0, SecureDSProxy: false, } - azureCredentials, err := utils.GetAzureCredentials(settings) - if err != nil { - return nil, fmt.Errorf("error reading azure credentials") - } - kerberosAuth, err := kerberos.GetKerberosSettings(settings) + azureSettings, err := azsettings.ReadSettings(ctx) if err != nil { - return nil, fmt.Errorf("error getting kerberos settings: %w", err) + logger.Error("failed to read Azure settings from Grafana", "error", err.Error()) + return nil, err } err = json.Unmarshal(settings.JSONData, &jsonData) @@ -181,23 +88,18 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc UID: settings.UID, DecryptedSecureJSONData: settings.DecryptedSecureJSONData, } - cnnstr, err := generateConnectionString(dsInfo, cfg.Azure.ManagedIdentityClientId, cfg.Azure.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, logger) - if err != nil { - return nil, err - } - - driverName := "mssql" - if jsonData.AuthenticationType == azureAuthentication { - driverName = "azuresql" - } userFacingDefaultError, err := grafCfg.UserFacingDefaultError() if err != nil { return nil, err } - _, handler, err := newMSSQL(ctx, driverName, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings) - + config := sqleng.DataPluginConfiguration{ + DSInfo: dsInfo, + MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, + RowLimit: sqlCfg.RowLimit, + } + handler, err := sqleng.NewQueryDataHandler(ctx, settings, userFacingDefaultError, config, logger, azureSettings) if err != nil { logger.Error("Failed connecting to MSSQL", "err", err) return nil, err @@ -208,159 +110,6 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc } } -// ParseURL is called also from pkg/api/datasource/validation.go, -// which uses a different logging interface, -// so we have a special minimal interface that is fulfilled by -// both places. -type DebugOnlyLogger interface { - Debug(msg string, args ...interface{}) -} - -// ParseURL tries to parse an MSSQL URL string into a URL object. -func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) { - logger.Debug("Parsing MSSQL URL", "url", u) - - // Recognize ODBC connection strings like host\instance:1234 - reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`) - var host string - switch { - case reODBC.MatchString(u): - logger.Debug("Recognized as ODBC URL format", "url", u) - host = u - default: - logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u) - return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u) - } - return &url.URL{ - Scheme: "sqlserver", - Host: host, - }, nil -} - -func generateConnectionString(dsInfo sqleng.DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) { - const dfltPort = "0" - var addr util.NetworkAddress - if dsInfo.URL != "" { - u, err := ParseURL(dsInfo.URL, logger) - if err != nil { - return "", err - } - addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort) - if err != nil { - return "", err - } - } else { - addr = util.NetworkAddress{ - Host: "localhost", - Port: dfltPort, - } - } - - args := []any{ - "url", dsInfo.URL, "host", addr.Host, - } - if addr.Port != "0" { - args = append(args, "port", addr.Port) - } - logger.Debug("Generating connection string", args...) - - encrypt := dsInfo.JsonData.Encrypt - tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify - hostNameInCertificate := dsInfo.JsonData.Servername - certificate := dsInfo.JsonData.RootCertFile - connStr := fmt.Sprintf("server=%s;database=%s;", - addr.Host, - dsInfo.Database, - ) - - switch dsInfo.JsonData.AuthenticationType { - case azureAuthentication: - azureCredentialDSNFragment, err := getAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled) - if err != nil { - return "", err - } - connStr += azureCredentialDSNFragment - case windowsAuthentication: - // No user id or password. We're using windows single sign on. - case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache: - connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth) - default: - connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"]) - } - - // Port number 0 means to determine the port automatically, so we can let the driver choose - if addr.Port != "0" { - connStr += fmt.Sprintf("port=%s;", addr.Port) - } - switch encrypt { - case "true": - connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify) - if hostNameInCertificate != "" { - connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate) - } - - if certificate != "" { - connStr += fmt.Sprintf("certificate=%s;", certificate) - } - case "disable": - connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt) - } - - if dsInfo.JsonData.ConnectionTimeout != 0 { - connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout) - } - - return connStr, nil -} - -func getAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) { - connStr := "" - switch c := azureCredentials.(type) { - case *azcredentials.AzureManagedIdentityCredentials: - if azureManagedIdentityClientId != "" { - connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId) - } - connStr += fmt.Sprintf("fedauth=%s;", - "ActiveDirectoryManagedIdentity") - case *azcredentials.AzureClientSecretCredentials: - connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;", - c.ClientId, - c.TenantId, - c.ClientSecret, - "ActiveDirectoryApplication", - ) - case *azcredentials.AzureEntraPasswordCredentials: - if azureEntraPasswordCredentialsEnabled { - connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;", - c.UserId, - c.Password, - c.ClientId, - "ActiveDirectoryPassword", - ) - } else { - return "", fmt.Errorf("azure entra password authentication is not enabled") - } - default: - return "", fmt.Errorf("unsupported azure authentication type") - } - return connStr, nil -} - -type mssqlQueryResultTransformer struct { - userError string -} - -func (t *mssqlQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error { - // go-mssql overrides source error, so we currently match on string - // ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904 - if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") { - logger.Error("Query error", "error", err) - return fmt.Errorf("failed to connect to server - %s", t.userError) - } - - return err -} - // CheckHealth pings the connected SQL database func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext) @@ -370,100 +119,3 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque return dsHandler.CheckHealth(ctx, req) } - -func (t *mssqlQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { - return []sqlutil.StringConverter{ - { - Name: "handle MONEY", - InputScanKind: reflect.Slice, - InputTypeName: "MONEY", - ConversionFunc: func(in *string) (*string, error) { return in, nil }, - Replacer: &sqlutil.StringFieldReplacer{ - OutputFieldType: data.FieldTypeNullableFloat64, - ReplaceFunc: func(in *string) (any, error) { - if in == nil { - return nil, nil - } - v, err := strconv.ParseFloat(*in, 64) - if err != nil { - return nil, err - } - return &v, nil - }, - }, - }, - { - Name: "handle SMALLMONEY", - InputScanKind: reflect.Slice, - InputTypeName: "SMALLMONEY", - ConversionFunc: func(in *string) (*string, error) { return in, nil }, - Replacer: &sqlutil.StringFieldReplacer{ - OutputFieldType: data.FieldTypeNullableFloat64, - ReplaceFunc: func(in *string) (any, error) { - if in == nil { - return nil, nil - } - v, err := strconv.ParseFloat(*in, 64) - if err != nil { - return nil, err - } - return &v, nil - }, - }, - }, - { - Name: "handle DECIMAL", - InputScanKind: reflect.Slice, - InputTypeName: "DECIMAL", - ConversionFunc: func(in *string) (*string, error) { return in, nil }, - Replacer: &sqlutil.StringFieldReplacer{ - OutputFieldType: data.FieldTypeNullableFloat64, - ReplaceFunc: func(in *string) (any, error) { - if in == nil { - return nil, nil - } - v, err := strconv.ParseFloat(*in, 64) - if err != nil { - return nil, err - } - return &v, nil - }, - }, - }, - { - Name: "handle UNIQUEIDENTIFIER", - InputScanKind: reflect.Slice, - InputTypeName: "UNIQUEIDENTIFIER", - ConversionFunc: func(in *string) (*string, error) { return in, nil }, - Replacer: &sqlutil.StringFieldReplacer{ - OutputFieldType: data.FieldTypeNullableString, - ReplaceFunc: func(in *string) (any, error) { - if in == nil { - return nil, nil - } - uuid := &mssql.UniqueIdentifier{} - if err := uuid.Scan([]byte(*in)); err != nil { - return nil, err - } - v := uuid.String() - return &v, nil - }, - }, - }, - { - Name: "handle SQL_VARIANT", - InputScanKind: reflect.Pointer, - InputTypeName: "SQL_VARIANT", - ConversionFunc: func(in *string) (*string, error) { return in, nil }, - Replacer: &sqlutil.StringFieldReplacer{ - OutputFieldType: data.FieldTypeNullableString, - ReplaceFunc: func(in *string) (any, error) { - if in == nil { - return nil, nil - } - return in, nil - }, - }, - }, - } -} diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index c81b48d274d..7368fd5d93b 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -3,21 +3,21 @@ package mssql import ( "context" "database/sql" - "encoding/json" "fmt" "math/rand" "os" "testing" "time" + "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng" + "github.com/grafana/grafana/pkg/tsdb/mssql/utils" ) // To run this test, set runMssqlTests=true @@ -39,7 +39,6 @@ func TestMSSQL(t *testing.T) { t.Skip() } - queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ DSInfo: dsInfo, @@ -50,8 +49,10 @@ func TestMSSQL(t *testing.T) { logger := backend.NewLoggerWith("logger", "mssql.test") db := initMSSQLTestDB(t, config.DSInfo.JsonData) + ctx := context.Background() + settings := backend.DataSourceInstanceSettings{} - endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{}) require.NoError(t, err) fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) @@ -799,14 +800,16 @@ func TestMSSQL(t *testing.T) { require.NoError(t, err) t.Run("When doing a metric query using stored procedure should return correct result", func(t *testing.T) { - queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: 1000000, } - endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + ctx := context.Background() + settings := backend.DataSourceInstanceSettings{} + + endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{}) require.NoError(t, err) query := &backend.QueryDataRequest{ Queries: []backend.DataQuery{ @@ -1202,7 +1205,6 @@ func TestMSSQL(t *testing.T) { }) t.Run("When row limit set to 1", func(t *testing.T) { - queryResultTransformer := mssqlQueryResultTransformer{} dsInfo := sqleng.DataSourceInfo{} config := sqleng.DataPluginConfiguration{ DSInfo: dsInfo, @@ -1210,7 +1212,10 @@ func TestMSSQL(t *testing.T) { RowLimit: 1, } - handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + ctx := context.Background() + settings := backend.DataSourceInstanceSettings{} + + handler, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{}) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { @@ -1313,7 +1318,7 @@ func TestMSSQL(t *testing.T) { } func TestTransformQueryError(t *testing.T) { - transformer := &mssqlQueryResultTransformer{} + transformer := &utils.MSSQLQueryResultTransformer{} logger := backend.NewLoggerWith("logger", "mssql.test") @@ -1334,252 +1339,6 @@ func TestTransformQueryError(t *testing.T) { }) } -func TestGenerateConnectionString(t *testing.T) { - kerberosLookup := []kerberos.KerberosLookup{ - { - Address: "example.host", - DBName: "testDB", - User: "testUser", - CredentialCacheFilename: "/tmp/cache", - }, - } - tmpFile := genTempCacheFile(t, kerberosLookup) - defer func() { - err := os.Remove(tmpFile) - if err != nil { - t.Log(err) - } - }() - - testCases := []struct { - desc string - kerberosCfg kerberos.KerberosAuth - dataSource sqleng.DataSourceInfo - expConnStr string - }{ - { - desc: "Use Kerberos Credential Cache", - kerberosCfg: kerberos.KerberosAuth{ - CredentialCache: "/tmp/krb5cc_1000", - ConfigFilePath: "/etc/krb5.conf", - UDPConnectionLimit: 1, - }, - dataSource: sqleng.DataSourceInfo{ - URL: "localhost", - Database: "database", - JsonData: sqleng.JsonData{ - AuthenticationType: "Windows AD: Credential cache", - }, - }, - expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;", - }, - { - desc: "Use Kerberos Credential Cache File path", - kerberosCfg: kerberos.KerberosAuth{ - CredentialCacheLookupFile: tmpFile, - ConfigFilePath: "/etc/krb5.conf", - UDPConnectionLimit: 1, - }, - dataSource: sqleng.DataSourceInfo{ - URL: "example.host", - Database: "testDB", - User: "testUser", - JsonData: sqleng.JsonData{ - AuthenticationType: "Windows AD: Credential cache file", - }, - }, - expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;", - }, - { - desc: "Use Kerberos Keytab", - kerberosCfg: kerberos.KerberosAuth{ - KeytabFilePath: "/foo/bar.keytab", - ConfigFilePath: "/etc/krb5.conf", - UDPConnectionLimit: 1, - }, - dataSource: sqleng.DataSourceInfo{ - URL: "localhost", - Database: "database", - User: "foo@test.lab", - JsonData: sqleng.JsonData{ - AuthenticationType: "Windows AD: Keytab", - }, - }, - expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;", - }, - { - desc: "Use Kerberos Username and Password", - kerberosCfg: kerberos.KerberosAuth{ - ConfigFilePath: "/etc/krb5.conf", - UDPConnectionLimit: 1, - }, - dataSource: sqleng.DataSourceInfo{ - URL: "localhost", - Database: "database", - User: "foo@test.lab", - DecryptedSecureJSONData: map[string]string{ - "password": "foo", - }, - JsonData: sqleng.JsonData{ - AuthenticationType: "Windows AD: Username + password", - }, - }, - expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;", - }, - { - desc: "Use non-default UDP connection limit", - kerberosCfg: kerberos.KerberosAuth{ - ConfigFilePath: "/etc/krb5.conf", - UDPConnectionLimit: 0, - }, - dataSource: sqleng.DataSourceInfo{ - URL: "localhost", - Database: "database", - User: "foo@test.lab", - DecryptedSecureJSONData: map[string]string{ - "password": "foo", - }, - JsonData: sqleng.JsonData{ - AuthenticationType: "Windows AD: Username + password", - }, - }, - expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;", - }, - - { - desc: "From URL w/ port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost:1001", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;", - }, - // When no port is specified, the driver should be allowed to choose - { - desc: "From URL w/o port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost;database=database;user id=user;password=;", - }, - // Port 0 should be equivalent to not specifying a port, i.e. let the driver choose - { - desc: "From URL w port 0", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost:0", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost;database=database;user id=user;password=;", - }, - { - desc: "With instance name", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;", - }, - { - desc: "With instance name and port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance:333", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;", - }, - { - desc: "With instance name and ApplicationIntent", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance;ApplicationIntent=ReadOnly", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;", - }, - { - desc: "With ApplicationIntent instance name and port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance:333;ApplicationIntent=ReadOnly", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;", - }, - { - desc: "With instance name", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;", - }, - { - desc: "With instance name and port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance:333", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;", - }, - { - desc: "With instance name and ApplicationIntent", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance;ApplicationIntent=ReadOnly", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;", - }, - { - desc: "With ApplicationIntent instance name and port", - dataSource: sqleng.DataSourceInfo{ - URL: "localhost\\instance:333;ApplicationIntent=ReadOnly", - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;", - }, - { - desc: "Defaults", - dataSource: sqleng.DataSourceInfo{ - Database: "database", - User: "user", - JsonData: sqleng.JsonData{}, - }, - expConnStr: "server=localhost;database=database;user id=user;password=;", - }, - } - - logger := backend.NewLoggerWith("logger", "mssql.test") - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger) - require.NoError(t, err) - assert.Equal(t, tc.expConnStr, connStr) - }) - } -} - func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB { t.Helper() @@ -1614,21 +1373,3 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim return timeRange } - -func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string { - content, err := json.Marshal(lookups) - if err != nil { - t.Fatalf("Unable to marshall json for temp lookup: %v", err) - } - - tmpFile, err := os.CreateTemp("", "lookup*.json") - if err != nil { - t.Fatalf("Unable to create temporary file for temp lookup: %v", err) - } - - if _, err := tmpFile.Write(content); err != nil { - t.Fatalf("Unable to write to temporary file for temp lookup: %v", err) - } - - return tmpFile.Name() -} diff --git a/pkg/tsdb/mssql/sqleng/connection.go b/pkg/tsdb/mssql/sqleng/connection.go new file mode 100644 index 00000000000..58a6d014219 --- /dev/null +++ b/pkg/tsdb/mssql/sqleng/connection.go @@ -0,0 +1,158 @@ +package sqleng + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana/pkg/tsdb/mssql/azure" + "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" + "github.com/grafana/grafana/pkg/tsdb/mssql/utils" + "github.com/grafana/grafana/pkg/util" + mssql "github.com/microsoft/go-mssqldb" + "github.com/microsoft/go-mssqldb/azuread" +) + +func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) { + var connector *mssql.Connector + var err error + if driverName == "azuresql" { + connector, err = azuread.NewConnector(cnnstr) + } else { + connector, err = mssql.NewConnector(cnnstr) + } + + if err != nil { + logger.Error("mssql connector creation failed", "error", err) + return nil, fmt.Errorf("mssql connector creation failed") + } + + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + logger.Error("mssql proxy creation failed", "error", err) + return nil, fmt.Errorf("mssql proxy creation failed") + } + + if proxyClient.SecureSocksProxyEnabled() { + dialer, err := proxyClient.NewSecureSocksProxyContextDialer() + if err != nil { + logger.Error("mssql proxy creation failed", "error", err) + return nil, fmt.Errorf("mssql proxy creation failed") + } + URL, err := utils.ParseURL(dsInfo.URL, logger) + if err != nil { + return nil, err + } + + mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer) + if err != nil { + return nil, err + } + // update the mssql dialer with the proxy dialer + connector.Dialer = (mssqlDialer) + } + + config := DataPluginConfiguration{ + DSInfo: dsInfo, + MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, + RowLimit: rowLimit, + } + + db := sql.OpenDB(connector) + + db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns) + db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) + + return db, nil +} + +const ( + azureAuthentication = "Azure AD Authentication" + windowsAuthentication = "Windows Authentication" + sqlServerAuthentication = "SQL Server Authentication" + kerberosRaw = "Windows AD: Username + password" + kerberosKeytab = "Windows AD: Keytab" + kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101 + kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101 +) + +func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) { + const dfltPort = "0" + var addr util.NetworkAddress + if dsInfo.URL != "" { + u, err := utils.ParseURL(dsInfo.URL, logger) + if err != nil { + return "", err + } + addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort) + if err != nil { + return "", err + } + } else { + addr = util.NetworkAddress{ + Host: "localhost", + Port: dfltPort, + } + } + + args := []any{ + "url", dsInfo.URL, "host", addr.Host, + } + if addr.Port != "0" { + args = append(args, "port", addr.Port) + } + logger.Debug("Generating connection string", args...) + + encrypt := dsInfo.JsonData.Encrypt + tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify + hostNameInCertificate := dsInfo.JsonData.Servername + certificate := dsInfo.JsonData.RootCertFile + connStr := fmt.Sprintf("server=%s;database=%s;", + addr.Host, + dsInfo.Database, + ) + + switch dsInfo.JsonData.AuthenticationType { + case azureAuthentication: + azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled) + if err != nil { + return "", err + } + connStr += azureCredentialDSNFragment + case windowsAuthentication: + // No user id or password. We're using windows single sign on. + case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache: + connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth) + default: + connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"]) + } + + // Port number 0 means to determine the port automatically, so we can let the driver choose + if addr.Port != "0" { + connStr += fmt.Sprintf("port=%s;", addr.Port) + } + switch encrypt { + case "true": + connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify) + if hostNameInCertificate != "" { + connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate) + } + + if certificate != "" { + connStr += fmt.Sprintf("certificate=%s;", certificate) + } + case "disable": + connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt) + } + + if dsInfo.JsonData.ConnectionTimeout != 0 { + connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout) + } + + return connStr, nil +} diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/sqleng/macros.go similarity index 92% rename from pkg/tsdb/mssql/macros.go rename to pkg/tsdb/mssql/sqleng/macros.go index de01cc9a9f2..c0ab99cbd77 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/sqleng/macros.go @@ -1,4 +1,4 @@ -package mssql +package sqleng import ( "fmt" @@ -8,18 +8,17 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng" ) const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type msSQLMacroEngine struct { - *sqleng.SQLMacroEngineBase + *SQLMacroEngineBase } -func newMssqlMacroEngine() sqleng.SQLMacroEngine { - return &msSQLMacroEngine{SQLMacroEngineBase: sqleng.NewSQLMacroEngineBase()} +func newMssqlMacroEngine() SQLMacroEngine { + return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()} } func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange, @@ -79,7 +78,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := sqleng.SetupFillmode(query, interval, args[2]) + err := SetupFillmode(query, interval, args[2]) if err != nil { return "", err } @@ -114,7 +113,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - err := sqleng.SetupFillmode(query, interval, args[2]) + err := SetupFillmode(query, interval, args[2]) if err != nil { return "", err } diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/sqleng/macros_test.go similarity index 99% rename from pkg/tsdb/mssql/macros_test.go rename to pkg/tsdb/mssql/sqleng/macros_test.go index b7d1ce9a87d..74c17a3f10d 100644 --- a/pkg/tsdb/mssql/macros_test.go +++ b/pkg/tsdb/mssql/sqleng/macros_test.go @@ -1,4 +1,4 @@ -package mssql +package sqleng import ( "fmt" diff --git a/pkg/tsdb/mssql/proxy.go b/pkg/tsdb/mssql/sqleng/proxy.go similarity index 98% rename from pkg/tsdb/mssql/proxy.go rename to pkg/tsdb/mssql/sqleng/proxy.go index bf91b2f780d..1f25ced6bb0 100644 --- a/pkg/tsdb/mssql/proxy.go +++ b/pkg/tsdb/mssql/sqleng/proxy.go @@ -1,4 +1,4 @@ -package mssql +package sqleng import ( "context" diff --git a/pkg/tsdb/mssql/proxy_test.go b/pkg/tsdb/mssql/sqleng/proxy_test.go similarity index 99% rename from pkg/tsdb/mssql/proxy_test.go rename to pkg/tsdb/mssql/sqleng/proxy_test.go index 9f7ee106747..eb8fbbfb601 100644 --- a/pkg/tsdb/mssql/proxy_test.go +++ b/pkg/tsdb/mssql/sqleng/proxy_test.go @@ -1,4 +1,4 @@ -package mssql +package sqleng import ( "context" diff --git a/pkg/tsdb/mssql/sqleng/sql_engine.go b/pkg/tsdb/mssql/sqleng/sql_engine.go index 350c038b3c0..80e3d29927c 100644 --- a/pkg/tsdb/mssql/sqleng/sql_engine.go +++ b/pkg/tsdb/mssql/sqleng/sql_engine.go @@ -14,11 +14,16 @@ import ( "sync" "time" + "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" + "github.com/grafana/grafana/pkg/tsdb/mssql/utils" ) // MetaKeyExecutedQueryString is the key where the executed query should get stored @@ -88,6 +93,10 @@ type DataSourceHandler struct { dsInfo DataSourceInfo rowLimit int64 userError string + azureSettings *azsettings.AzureSettings + azureCredentials azcredentials.AzureCredentials + kerberosAuth kerberos.KerberosAuth + driverName string } type QueryJson struct { @@ -113,16 +122,38 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er return e.queryResultTransformer.TransformQueryError(logger, err) } -func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, - macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { +func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanceSettings, userFacingDefaultError string, config DataPluginConfiguration, + log log.Logger, azureSettings *azsettings.AzureSettings) (*DataSourceHandler, error) { + queryResultTransformer := utils.MSSQLQueryResultTransformer{ + UserError: userFacingDefaultError, + } + driverName := "mssql" + if config.DSInfo.JsonData.AuthenticationType == azureAuthentication { + driverName = "azuresql" + } + + azureCredentials, err := utils.GetAzureCredentials(settings) + if err != nil { + return nil, fmt.Errorf("error reading azure credentials") + } + + kerberosAuth, err := kerberos.GetKerberosSettings(settings) + if err != nil { + return nil, fmt.Errorf("error getting kerberos settings: %w", err) + } + queryDataHandler := DataSourceHandler{ - queryResultTransformer: queryResultTransformer, - macroEngine: macroEngine, + queryResultTransformer: &queryResultTransformer, + macroEngine: newMssqlMacroEngine(), timeColumnNames: []string{"time"}, log: log, dsInfo: config.DSInfo, rowLimit: config.RowLimit, userError: userFacingDefaultError, + azureSettings: azureSettings, + azureCredentials: azureCredentials, + kerberosAuth: kerberosAuth, + driverName: driverName, } if len(config.TimeColumnNames) > 0 { @@ -133,7 +164,19 @@ func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataP queryDataHandler.metricColumnTypes = config.MetricColumnTypes } + cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log) + if err != nil { + return nil, err + } + + db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings) + if err != nil { + logger.Error("Failed connecting to MSSQL", "err", err) + return nil, err + } + queryDataHandler.db = db + return &queryDataHandler, nil } diff --git a/pkg/tsdb/mssql/sqleng/sql_engine_test.go b/pkg/tsdb/mssql/sqleng/sql_engine_test.go index 2e3f8da78ee..c4b21b2b849 100644 --- a/pkg/tsdb/mssql/sqleng/sql_engine_test.go +++ b/pkg/tsdb/mssql/sqleng/sql_engine_test.go @@ -1,8 +1,10 @@ package sqleng import ( + "encoding/json" "fmt" "net" + "os" "testing" "time" @@ -13,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng/util" ) @@ -436,3 +439,267 @@ func (t *testQueryResultTransformer) TransformQueryError(_ log.Logger, err error func (t *testQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { return nil } + +func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string { + content, err := json.Marshal(lookups) + if err != nil { + t.Fatalf("Unable to marshall json for temp lookup: %v", err) + } + + tmpFile, err := os.CreateTemp("", "lookup*.json") + if err != nil { + t.Fatalf("Unable to create temporary file for temp lookup: %v", err) + } + + if _, err := tmpFile.Write(content); err != nil { + t.Fatalf("Unable to write to temporary file for temp lookup: %v", err) + } + + return tmpFile.Name() +} + +func TestGenerateConnectionString(t *testing.T) { + kerberosLookup := []kerberos.KerberosLookup{ + { + Address: "example.host", + DBName: "testDB", + User: "testUser", + CredentialCacheFilename: "/tmp/cache", + }, + } + tmpFile := genTempCacheFile(t, kerberosLookup) + defer func() { + err := os.Remove(tmpFile) + if err != nil { + t.Log(err) + } + }() + + testCases := []struct { + desc string + kerberosCfg kerberos.KerberosAuth + dataSource DataSourceInfo + expConnStr string + }{ + { + desc: "Use Kerberos Credential Cache", + kerberosCfg: kerberos.KerberosAuth{ + CredentialCache: "/tmp/krb5cc_1000", + ConfigFilePath: "/etc/krb5.conf", + UDPConnectionLimit: 1, + }, + dataSource: DataSourceInfo{ + URL: "localhost", + Database: "database", + JsonData: JsonData{ + AuthenticationType: "Windows AD: Credential cache", + }, + }, + expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;", + }, + { + desc: "Use Kerberos Credential Cache File path", + kerberosCfg: kerberos.KerberosAuth{ + CredentialCacheLookupFile: tmpFile, + ConfigFilePath: "/etc/krb5.conf", + UDPConnectionLimit: 1, + }, + dataSource: DataSourceInfo{ + URL: "example.host", + Database: "testDB", + User: "testUser", + JsonData: JsonData{ + AuthenticationType: "Windows AD: Credential cache file", + }, + }, + expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;", + }, + { + desc: "Use Kerberos Keytab", + kerberosCfg: kerberos.KerberosAuth{ + KeytabFilePath: "/foo/bar.keytab", + ConfigFilePath: "/etc/krb5.conf", + UDPConnectionLimit: 1, + }, + dataSource: DataSourceInfo{ + URL: "localhost", + Database: "database", + User: "foo@test.lab", + JsonData: JsonData{ + AuthenticationType: "Windows AD: Keytab", + }, + }, + expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;", + }, + { + desc: "Use Kerberos Username and Password", + kerberosCfg: kerberos.KerberosAuth{ + ConfigFilePath: "/etc/krb5.conf", + UDPConnectionLimit: 1, + }, + dataSource: DataSourceInfo{ + URL: "localhost", + Database: "database", + User: "foo@test.lab", + DecryptedSecureJSONData: map[string]string{ + "password": "foo", + }, + JsonData: JsonData{ + AuthenticationType: "Windows AD: Username + password", + }, + }, + expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;", + }, + { + desc: "Use non-default UDP connection limit", + kerberosCfg: kerberos.KerberosAuth{ + ConfigFilePath: "/etc/krb5.conf", + UDPConnectionLimit: 0, + }, + dataSource: DataSourceInfo{ + URL: "localhost", + Database: "database", + User: "foo@test.lab", + DecryptedSecureJSONData: map[string]string{ + "password": "foo", + }, + JsonData: JsonData{ + AuthenticationType: "Windows AD: Username + password", + }, + }, + expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;", + }, + + { + desc: "From URL w/ port", + dataSource: DataSourceInfo{ + URL: "localhost:1001", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;", + }, + // When no port is specified, the driver should be allowed to choose + { + desc: "From URL w/o port", + dataSource: DataSourceInfo{ + URL: "localhost", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost;database=database;user id=user;password=;", + }, + // Port 0 should be equivalent to not specifying a port, i.e. let the driver choose + { + desc: "From URL w port 0", + dataSource: DataSourceInfo{ + URL: "localhost:0", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost;database=database;user id=user;password=;", + }, + { + desc: "With instance name", + dataSource: DataSourceInfo{ + URL: "localhost\\instance", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;", + }, + { + desc: "With instance name and port", + dataSource: DataSourceInfo{ + URL: "localhost\\instance:333", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;", + }, + { + desc: "With instance name and ApplicationIntent", + dataSource: DataSourceInfo{ + URL: "localhost\\instance;ApplicationIntent=ReadOnly", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;", + }, + { + desc: "With ApplicationIntent instance name and port", + dataSource: DataSourceInfo{ + URL: "localhost\\instance:333;ApplicationIntent=ReadOnly", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;", + }, + { + desc: "With instance name", + dataSource: DataSourceInfo{ + URL: "localhost\\instance", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;", + }, + { + desc: "With instance name and port", + dataSource: DataSourceInfo{ + URL: "localhost\\instance:333", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;", + }, + { + desc: "With instance name and ApplicationIntent", + dataSource: DataSourceInfo{ + URL: "localhost\\instance;ApplicationIntent=ReadOnly", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;", + }, + { + desc: "With ApplicationIntent instance name and port", + dataSource: DataSourceInfo{ + URL: "localhost\\instance:333;ApplicationIntent=ReadOnly", + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;", + }, + { + desc: "Defaults", + dataSource: DataSourceInfo{ + Database: "database", + User: "user", + JsonData: JsonData{}, + }, + expConnStr: "server=localhost;database=database;user id=user;password=;", + }, + } + + logger := backend.NewLoggerWith("logger", "mssql.test") + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger) + require.NoError(t, err) + assert.Equal(t, tc.expConnStr, connStr) + }) + } +} diff --git a/pkg/tsdb/mssql/utils/utils.go b/pkg/tsdb/mssql/utils/utils.go index 1e8a2fcb28f..6be125ae6c2 100644 --- a/pkg/tsdb/mssql/utils/utils.go +++ b/pkg/tsdb/mssql/utils/utils.go @@ -3,9 +3,18 @@ package utils import ( "encoding/json" "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "strings" "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + mssql "github.com/microsoft/go-mssqldb" ) // GetJsonData just gets the json in easier to work with type. It's used on multiple places which isn't super effective @@ -26,3 +35,140 @@ func GetAzureCredentials(settings backend.DataSourceInstanceSettings) (azcredent } return azcredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData) } + +type DebugOnlyLogger interface { + Debug(msg string, args ...interface{}) +} + +// ParseURL tries to parse an MSSQL URL string into a URL object. +func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) { + logger.Debug("Parsing MSSQL URL", "url", u) + + // Recognize ODBC connection strings like host\instance:1234 + reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`) + var host string + switch { + case reODBC.MatchString(u): + logger.Debug("Recognized as ODBC URL format", "url", u) + host = u + default: + logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u) + return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u) + } + return &url.URL{ + Scheme: "sqlserver", + Host: host, + }, nil +} + +type MSSQLQueryResultTransformer struct { + UserError string +} + +func (t *MSSQLQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error { + // go-mssql overrides source error, so we currently match on string + // ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904 + if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") { + logger.Error("Query error", "error", err) + return fmt.Errorf("failed to connect to server - %s", t.UserError) + } + + return err +} + +func (t *MSSQLQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { + return []sqlutil.StringConverter{ + { + Name: "handle MONEY", + InputScanKind: reflect.Slice, + InputTypeName: "MONEY", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableFloat64, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + v, err := strconv.ParseFloat(*in, 64) + if err != nil { + return nil, err + } + return &v, nil + }, + }, + }, + { + Name: "handle SMALLMONEY", + InputScanKind: reflect.Slice, + InputTypeName: "SMALLMONEY", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableFloat64, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + v, err := strconv.ParseFloat(*in, 64) + if err != nil { + return nil, err + } + return &v, nil + }, + }, + }, + { + Name: "handle DECIMAL", + InputScanKind: reflect.Slice, + InputTypeName: "DECIMAL", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableFloat64, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + v, err := strconv.ParseFloat(*in, 64) + if err != nil { + return nil, err + } + return &v, nil + }, + }, + }, + { + Name: "handle UNIQUEIDENTIFIER", + InputScanKind: reflect.Slice, + InputTypeName: "UNIQUEIDENTIFIER", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableString, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + uuid := &mssql.UniqueIdentifier{} + if err := uuid.Scan([]byte(*in)); err != nil { + return nil, err + } + v := uuid.String() + return &v, nil + }, + }, + }, + { + Name: "handle SQL_VARIANT", + InputScanKind: reflect.Pointer, + InputTypeName: "SQL_VARIANT", + ConversionFunc: func(in *string) (*string, error) { return in, nil }, + Replacer: &sqlutil.StringFieldReplacer{ + OutputFieldType: data.FieldTypeNullableString, + ReplaceFunc: func(in *string) (any, error) { + if in == nil { + return nil, nil + } + return in, nil + }, + }, + }, + } +}