diff --git a/pkg/api/datasource/validation.go b/pkg/api/datasource/validation.go index a924f49f502..469069344f0 100644 --- a/pkg/api/datasource/validation.go +++ b/pkg/api/datasource/validation.go @@ -4,46 +4,59 @@ import ( "fmt" "net/url" "regexp" + "strings" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tsdb/mssql" ) var logger = log.New("datasource") // URLValidationError represents an error from validating a data source URL. type URLValidationError struct { - error + Err error - url string + URL string } // Error returns the error message. func (e URLValidationError) Error() string { - return fmt.Sprintf("Validation of data source URL %q failed: %s", e.url, e.error.Error()) + return fmt.Sprintf("Validation of data source URL %q failed: %s", e.URL, e.Err.Error()) } // Unwrap returns the wrapped error. func (e URLValidationError) Unwrap() error { - return e.error + return e.Err } // reURL is a regexp to detect if a URL specifies the protocol. We match also strings where the actual protocol is // missing (i.e., "://"), in order to catch these as invalid when parsing. var reURL = regexp.MustCompile("^[^:]*://") -// ValidateURL validates a data source URL. +// ValidateURL validates a data source's URL. // -// If successful, the valid URL object is returned, otherwise an error is returned. -func ValidateURL(urlStr string) (*url.URL, error) { - // Make sure the URL starts with a protocol specifier, so parsing is unambiguous - if !reURL.MatchString(urlStr) { - logger.Debug( - "Data source URL doesn't specify protocol, so prepending it with http:// in order to make it unambiguous") - urlStr = fmt.Sprintf("http://%s", urlStr) +// The data source's type and URL must be provided. If successful, the valid URL object is returned, otherwise an +// error is returned. +func ValidateURL(typeName, urlStr string) (*url.URL, error) { + var u *url.URL + var err error + switch strings.ToLower(typeName) { + case "mssql": + u, err = mssql.ParseURL(urlStr) + default: + logger.Debug("Applying default URL parsing for this data source type", "type", typeName, "url", urlStr) + + // Make sure the URL starts with a protocol specifier, so parsing is unambiguous + if !reURL.MatchString(urlStr) { + logger.Debug( + "Data source URL doesn't specify protocol, so prepending it with http:// in order to make it unambiguous", + "type", typeName, "url", urlStr) + urlStr = fmt.Sprintf("http://%s", urlStr) + } + u, err = url.Parse(urlStr) } - u, err := url.Parse(urlStr) if err != nil { - return nil, URLValidationError{error: err, url: urlStr} + return nil, URLValidationError{Err: err, URL: urlStr} } return u, nil diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index bfffa904a6c..967487fb8d6 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -9,12 +9,15 @@ import ( "github.com/grafana/grafana/pkg/api/datasource" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/datasource/wrapper" "github.com/grafana/grafana/pkg/util" ) +var datasourcesLogger = log.New("datasources") + func GetDataSources(c *models.ReqContext) Response { query := models.GetDataSourcesQuery{OrgId: c.OrgId} @@ -127,9 +130,11 @@ func DeleteDataSourceByName(c *models.ReqContext) Response { return Success("Data source deleted") } -func validateURL(u string) Response { +func validateURL(tp string, u string) Response { if u != "" { - if _, err := datasource.ValidateURL(u); err != nil { + if _, err := datasource.ValidateURL(tp, u); err != nil { + datasourcesLogger.Error("Received invalid data source URL as part of data source command", + "url", u) return Error(400, fmt.Sprintf("Validation error, invalid URL: %q", u), err) } } @@ -138,8 +143,9 @@ func validateURL(u string) Response { } func AddDataSource(c *models.ReqContext, cmd models.AddDataSourceCommand) Response { + datasourcesLogger.Debug("Received command to add data source", "url", cmd.Url) cmd.OrgId = c.OrgId - if resp := validateURL(cmd.Url); resp != nil { + if resp := validateURL(cmd.Type, cmd.Url); resp != nil { return resp } @@ -161,9 +167,10 @@ func AddDataSource(c *models.ReqContext, cmd models.AddDataSourceCommand) Respon } func UpdateDataSource(c *models.ReqContext, cmd models.UpdateDataSourceCommand) Response { + datasourcesLogger.Debug("Received command to update data source", "url", cmd.Url) cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":id") - if resp := validateURL(cmd.Url); resp != nil { + if resp := validateURL(cmd.Type, cmd.Url); resp != nil { return resp } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 975f7b3b1f7..000fa50b7ba 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -73,7 +73,7 @@ func (lw *logWrapper) Write(p []byte) (n int, err error) { // NewDataSourceProxy creates a new Datasource proxy func NewDataSourceProxy(ds *models.DataSource, plugin *plugins.DataSourcePlugin, ctx *models.ReqContext, proxyPath string, cfg *setting.Cfg) (*DataSourceProxy, error) { - targetURL, err := datasource.ValidateURL(ds.Url) + targetURL, err := datasource.ValidateURL(ds.Type, ds.Url) if err != nil { return nil, err } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 7b91feca7a7..da415b5752e 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/api/datasource" "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/assert" @@ -583,11 +584,62 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { } cfg := setting.Cfg{} plugin := plugins.DataSourcePlugin{} + _, err := NewDataSourceProxy(&ds, &plugin, &ctx, "api/method", &cfg) require.NoError(t, err) } +// Test wth MSSQL type data sources. +func TestNewDataSourceProxy_MSSQL(t *testing.T) { + ctx := models.ReqContext{ + Context: &macaron.Context{ + Req: macaron.Request{}, + }, + SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR}, + } + tcs := []struct { + description string + url string + err error + }{ + { + description: "Valid ODBC URL", + url: `localhost\instance:1433`, + }, + { + description: "Invalid ODBC URL", + url: `localhost\instance::1433`, + err: datasource.URLValidationError{ + Err: fmt.Errorf(`unrecognized MSSQL URL format: "localhost\\instance::1433"`), + URL: `localhost\instance::1433`, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.description, func(t *testing.T) { + cfg := setting.Cfg{} + plugin := plugins.DataSourcePlugin{} + ds := models.DataSource{ + Type: "mssql", + Url: tc.url, + } + + p, err := NewDataSourceProxy(&ds, &plugin, &ctx, "api/method", &cfg) + if tc.err == nil { + require.NoError(t, err) + assert.Equal(t, &url.URL{ + Scheme: "sqlserver", + Host: ds.Url, + }, p.targetUrl) + } else { + require.Error(t, err) + assert.Equal(t, tc.err, err) + } + }) + } +} + type CloseNotifierResponseRecorder struct { *httptest.ResponseRecorder closeChan chan bool diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index ca4715c0fa2..c6c91265cfd 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -3,17 +3,18 @@ package mssql import ( "database/sql" "fmt" + "net/url" + "regexp" "strconv" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" _ "github.com/denisenkom/go-mssqldb" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/tsdb/sqleng" - "github.com/grafana/grafana/pkg/util" - "github.com/grafana/grafana/pkg/util/errutil" "xorm.io/core" ) @@ -21,9 +22,9 @@ func init() { tsdb.RegisterTsdbQueryEndpoint("mssql", newMssqlQueryEndpoint) } -func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - logger := log.New("tsdb.mssql") +var logger = log.New("tsdb.mssql") +func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { cnnstr, err := generateConnectionString(datasource) if err != nil { return nil, err @@ -46,12 +47,46 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin return sqleng.NewSqlQueryEndpoint(&config, &queryResultTransformer, newMssqlMacroEngine(), logger) } +// ParseURL tries to parse an MSSQL URL string into a URL object. +func ParseURL(u string) (*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(datasource *models.DataSource) (string, error) { - addr, err := util.SplitHostPortDefault(datasource.Url, "localhost", "1433") - if err != nil { - return "", errutil.Wrapf(err, "Invalid data source URL '%s'", datasource.Url) + var addr util.NetworkAddress + if datasource.Url != "" { + u, err := ParseURL(datasource.Url) + if err != nil { + return "", err + } + addr, err = util.SplitHostPortDefault(u.Host, "localhost", "1433") + if err != nil { + return "", err + } + } else { + addr = util.NetworkAddress{ + Host: "localhost", + Port: "1433", + } } + logger.Debug("Generating connection string", "url", datasource.Url, "host", addr.Host, "port", addr.Port) encrypt := datasource.JsonData.Get("encrypt").MustString("false") connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", addr.Host,