diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a66b967ba38..604ea00b4ab 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -81,6 +81,7 @@ /pkg/infra/metrics/ @grafana/backend-platform /pkg/infra/network/ @grafana/backend-platform /pkg/infra/process/ @grafana/backend-platform +/pkg/infra/proxy/ @grafana/hosted-grafana-team /pkg/infra/remotecache/ @grafana/backend-platform /pkg/infra/serverlock/ @grafana/backend-platform /pkg/infra/slugify/ @grafana/backend-platform diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index bbfaaaf9711..991247bf7bd 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" + "github.com/grafana/grafana/pkg/infra/proxy" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/validations" @@ -55,8 +56,8 @@ func New(cfg *setting.Cfg, validator validations.PluginRequestValidator, tracer } if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && - cfg.SecureSocksDSProxy.Enabled && secureSocksProxyEnabledOnDS(opts) { - err = newSecureSocksProxy(&cfg.SecureSocksDSProxy, transport) + cfg.SecureSocksDSProxy.Enabled && proxy.SecureSocksProxyEnabledOnDS(opts) { + err = proxy.NewSecureSocksHTTPProxy(&cfg.SecureSocksDSProxy, transport) if err != nil { logger.Error("Failed to enable secure socks proxy", "error", err.Error(), "datasource", datasourceName) } diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go b/pkg/infra/proxy/proxyutil/proxyutil.go similarity index 53% rename from pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go rename to pkg/infra/proxy/proxyutil/proxyutil.go index 857141c5d03..080cdfbeda6 100644 --- a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go +++ b/pkg/infra/proxy/proxyutil/proxyutil.go @@ -1,4 +1,4 @@ -package httpclientprovider +package proxyutil import ( "crypto/rand" @@ -7,30 +7,20 @@ import ( "crypto/x509/pkix" "encoding/pem" "math/big" - "net/http" "os" "path/filepath" "testing" "time" - "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewSecureSocksProxy(t *testing.T) { +func SetupTestSecureSocksProxySettings(t *testing.T) *setting.SecureSocksDSProxySettings { proxyAddress := "localhost:3000" serverName := "localhost" tempDir := t.TempDir() - // create empty file for testing invalid configs - tempEmptyFile := filepath.Join(tempDir, "emptyfile.txt") - // nolint:gosec - // The gosec G304 warning can be ignored because all values come from the test - _, err := os.Create(tempEmptyFile) - require.NoError(t, err) - // generate test rootCA ca := &x509.Certificate{ SerialNumber: big.NewInt(2019), @@ -98,80 +88,11 @@ func TestNewSecureSocksProxy(t *testing.T) { }) require.NoError(t, err) - settings := &setting.SecureSocksDSProxySettings{ + return &setting.SecureSocksDSProxySettings{ ClientCert: clientCert, ClientKey: clientKey, RootCA: rootCACert, ServerName: serverName, ProxyAddress: proxyAddress, } - - t.Run("New socks proxy should be properly configured when all settings are valid", func(t *testing.T) { - require.NoError(t, newSecureSocksProxy(settings, &http.Transport{})) - }) - - t.Run("Client cert must be valid", func(t *testing.T) { - settings.ClientCert = tempEmptyFile - t.Cleanup(func() { - settings.ClientCert = clientCert - }) - require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) - }) - - t.Run("Client key must be valid", func(t *testing.T) { - settings.ClientKey = tempEmptyFile - t.Cleanup(func() { - settings.ClientKey = clientKey - }) - require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) - }) - - t.Run("Root CA must be valid", func(t *testing.T) { - settings.RootCA = tempEmptyFile - t.Cleanup(func() { - settings.RootCA = rootCACert - }) - require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) - }) -} - -func TestSecureSocksProxyEnabledOnDS(t *testing.T) { - t.Run("Secure socks proxy should only be enabled when the json data contains enableSecureSocksProxy=true", func(t *testing.T) { - tests := []struct { - instanceSettings *backend.AppInstanceSettings - enabled bool - }{ - { - instanceSettings: &backend.AppInstanceSettings{ - JSONData: []byte("{}"), - }, - enabled: false, - }, - { - instanceSettings: &backend.AppInstanceSettings{ - JSONData: []byte("{ \"enableSecureSocksProxy\": \"nonbool\" }"), - }, - enabled: false, - }, - { - instanceSettings: &backend.AppInstanceSettings{ - JSONData: []byte("{ \"enableSecureSocksProxy\": false }"), - }, - enabled: false, - }, - { - instanceSettings: &backend.AppInstanceSettings{ - JSONData: []byte("{ \"enableSecureSocksProxy\": true }"), - }, - enabled: true, - }, - } - - for _, tt := range tests { - opts, err := tt.instanceSettings.HTTPClientOptions() - assert.NoError(t, err) - - assert.Equal(t, tt.enabled, secureSocksProxyEnabledOnDS(opts)) - } - }) } diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go b/pkg/infra/proxy/secure_socks_proxy.go similarity index 63% rename from pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go rename to pkg/infra/proxy/secure_socks_proxy.go index 65e4da5bdff..3827c7da97c 100644 --- a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go +++ b/pkg/infra/proxy/secure_socks_proxy.go @@ -1,4 +1,4 @@ -package httpclientprovider +package proxy import ( "crypto/tls" @@ -14,24 +14,40 @@ import ( "golang.org/x/net/proxy" ) -// newSecureSocksProxy takes a http.DefaultTransport and wraps it in a socks5 proxy with TLS -func newSecureSocksProxy(cfg *setting.SecureSocksDSProxySettings, transport *http.Transport) error { +// NewSecureSocksHTTPProxy takes a http.DefaultTransport and wraps it in a socks5 proxy with TLS +func NewSecureSocksHTTPProxy(cfg *setting.SecureSocksDSProxySettings, transport *http.Transport) error { + dialSocksProxy, err := NewSecureSocksProxyContextDialer(cfg) + if err != nil { + return err + } + + contextDialer, ok := dialSocksProxy.(proxy.ContextDialer) + if !ok { + return errors.New("unable to cast socks proxy dialer to context proxy dialer") + } + + transport.DialContext = contextDialer.DialContext + return nil +} + +// NewSecureSocksProxyContextDialer returns a proxy context dialer that will wrap connections in a secure socks proxy +func NewSecureSocksProxyContextDialer(cfg *setting.SecureSocksDSProxySettings) (proxy.Dialer, error) { certPool := x509.NewCertPool() for _, rootCAFile := range strings.Split(cfg.RootCA, " ") { // nolint:gosec // The gosec G304 warning can be ignored because `rootCAFile` comes from config ini. pem, err := os.ReadFile(rootCAFile) if err != nil { - return err + return nil, err } if !certPool.AppendCertsFromPEM(pem) { - return errors.New("failed to append CA certificate " + rootCAFile) + return nil, errors.New("failed to append CA certificate " + rootCAFile) } } cert, err := tls.LoadX509KeyPair(cfg.ClientCert, cfg.ClientKey) if err != nil { - return err + return nil, err } tlsDialer := &tls.Dialer{ @@ -43,21 +59,14 @@ func newSecureSocksProxy(cfg *setting.SecureSocksDSProxySettings, transport *htt } dialSocksProxy, err := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, tlsDialer) if err != nil { - return err + return nil, err } - contextDialer, ok := dialSocksProxy.(proxy.ContextDialer) - if !ok { - return errors.New("unable to cast socks proxy dialer to context proxy dialer") - } - - transport.DialContext = contextDialer.DialContext - - return nil + return dialSocksProxy, nil } -// secureSocksProxyEnabledOnDS checks the datasource json data to see if the secure socks proxy is enabled on it -func secureSocksProxyEnabledOnDS(opts sdkhttpclient.Options) bool { +// SecureSocksProxyEnabledOnDS checks the datasource json data to see if the secure socks proxy is enabled on it +func SecureSocksProxyEnabledOnDS(opts sdkhttpclient.Options) bool { jsonData := backend.JSONDataFromHTTPClientOptions(opts) res, enabled := jsonData["enableSecureSocksProxy"] if !enabled { diff --git a/pkg/infra/proxy/secure_socks_proxy_test.go b/pkg/infra/proxy/secure_socks_proxy_test.go new file mode 100644 index 00000000000..80058780b65 --- /dev/null +++ b/pkg/infra/proxy/secure_socks_proxy_test.go @@ -0,0 +1,97 @@ +package proxy + +import ( + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/infra/proxy/proxyutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecureSocksProxy(t *testing.T) { + settings := proxyutil.SetupTestSecureSocksProxySettings(t) + + // create empty file for testing invalid configs + tempDir := t.TempDir() + tempEmptyFile := filepath.Join(tempDir, "emptyfile.txt") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + _, err := os.Create(tempEmptyFile) + require.NoError(t, err) + + t.Run("New socks proxy should be properly configured when all settings are valid", func(t *testing.T) { + require.NoError(t, NewSecureSocksHTTPProxy(settings, &http.Transport{})) + }) + + t.Run("Client cert must be valid", func(t *testing.T) { + clientCertBefore := settings.ClientCert + settings.ClientCert = tempEmptyFile + t.Cleanup(func() { + settings.ClientCert = clientCertBefore + }) + require.Error(t, NewSecureSocksHTTPProxy(settings, &http.Transport{})) + }) + + t.Run("Client key must be valid", func(t *testing.T) { + clientKeyBefore := settings.ClientKey + settings.ClientKey = tempEmptyFile + t.Cleanup(func() { + settings.ClientKey = clientKeyBefore + }) + require.Error(t, NewSecureSocksHTTPProxy(settings, &http.Transport{})) + }) + + t.Run("Root CA must be valid", func(t *testing.T) { + rootCABefore := settings.RootCA + settings.RootCA = tempEmptyFile + t.Cleanup(func() { + settings.RootCA = rootCABefore + }) + require.Error(t, NewSecureSocksHTTPProxy(settings, &http.Transport{})) + }) +} + +func TestSecureSocksProxyEnabledOnDS(t *testing.T) { + t.Run("Secure socks proxy should only be enabled when the json data contains enableSecureSocksProxy=true", func(t *testing.T) { + tests := []struct { + instanceSettings *backend.AppInstanceSettings + enabled bool + }{ + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{}"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": \"nonbool\" }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": false }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": true }"), + }, + enabled: true, + }, + } + + for _, tt := range tests { + opts, err := tt.instanceSettings.HTTPClientOptions() + assert.NoError(t, err) + + assert.Equal(t, tt.enabled, SecureSocksProxyEnabledOnDS(opts)) + } + }) +} diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 47d2bae6a53..5838380049f 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" "github.com/grafana/grafana/pkg/util" @@ -60,6 +61,7 @@ func newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFactoryFunc { ConnMaxLifetime: 14400, Encrypt: "false", ConnectionTimeout: 0, + SecureDSProxy: false, } err := json.Unmarshal(settings.JSONData, &jsonData) @@ -90,8 +92,18 @@ func newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFactoryFunc { if cfg.Env == setting.Dev { logger.Debug("GetEngine", "connection", cnnstr) } + + driverName := "mssql" + // register a new proxy driver if the secure socks proxy is enabled + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && cfg.SecureSocksDSProxy.Enabled && jsonData.SecureDSProxy { + driverName, err = createMSSQLProxyDriver(&cfg.SecureSocksDSProxy, cnnstr) + if err != nil { + return nil, err + } + } + config := sqleng.DataPluginConfiguration{ - DriverName: "mssql", + DriverName: driverName, ConnectionString: cnnstr, DSInfo: dsInfo, MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, diff --git a/pkg/tsdb/mssql/proxy.go b/pkg/tsdb/mssql/proxy.go new file mode 100644 index 00000000000..8b7e8856899 --- /dev/null +++ b/pkg/tsdb/mssql/proxy.go @@ -0,0 +1,91 @@ +package mssql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + + mssql "github.com/denisenkom/go-mssqldb" + iproxy "github.com/grafana/grafana/pkg/infra/proxy" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/sqleng" + "github.com/grafana/grafana/pkg/util" + "golang.org/x/net/proxy" + "xorm.io/core" +) + +// createMSSQLProxyDriver creates and registers a new sql driver that uses a mssql connector and updates the dialer to +// route connections through the secure socks proxy +func createMSSQLProxyDriver(settings *setting.SecureSocksDSProxySettings, cnnstr string) (string, error) { + sqleng.XormDriverMu.Lock() + defer sqleng.XormDriverMu.Unlock() + + // create a unique driver per connection string + hash, err := util.Md5SumString(cnnstr) + if err != nil { + return "", err + } + driverName := "mssql-proxy-" + hash + + // only register the driver once + if core.QueryDriver(driverName) == nil { + connector, err := mssql.NewConnector(cnnstr) + if err != nil { + return "", err + } + + driver, err := newMSSQLProxyDriver(settings, connector) + if err != nil { + return "", err + } + sql.Register(driverName, driver) + core.RegisterDriver(driverName, driver) + } + + return driverName, nil +} + +// mssqlProxyDriver is a regular mssql driver with an updated dialer. +// This is needed because there is no way to save a dialer to the mssql driver in xorm +type mssqlProxyDriver struct { + c *mssql.Connector +} + +var _ driver.DriverContext = (*mssqlProxyDriver)(nil) +var _ core.Driver = (*mssqlProxyDriver)(nil) + +// newMSSQLProxyDriver updates the dialer for a mssql connector with a dialer that proxys connections through the secure socks proxy +// and returns a new mssql driver to register +func newMSSQLProxyDriver(cfg *setting.SecureSocksDSProxySettings, connector *mssql.Connector) (*mssqlProxyDriver, error) { + dialer, err := iproxy.NewSecureSocksProxyContextDialer(cfg) + if err != nil { + return nil, err + } + + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, errors.New("unable to cast socks proxy dialer to context proxy dialer") + } + + connector.Dialer = contextDialer + return &mssqlProxyDriver{c: connector}, nil +} + +// Parse uses the xorm mssql dialect for the driver (this has to be implemented to register the driver with xorm) +func (d *mssqlProxyDriver) Parse(a string, b string) (*core.Uri, error) { + sqleng.XormDriverMu.RLock() + defer sqleng.XormDriverMu.RUnlock() + + return core.QueryDriver("mssql").Parse(a, b) +} + +// OpenConnector returns the normal mssql connector that has the updated dialer context +func (d *mssqlProxyDriver) OpenConnector(name string) (driver.Connector, error) { + return d.c, nil +} + +// Open uses the connector with the updated dialer context to open a new connection +func (d *mssqlProxyDriver) Open(dsn string) (driver.Conn, error) { + return d.c.Connect(context.Background()) +} diff --git a/pkg/tsdb/mssql/proxy_test.go b/pkg/tsdb/mssql/proxy_test.go new file mode 100644 index 00000000000..b279a538aec --- /dev/null +++ b/pkg/tsdb/mssql/proxy_test.go @@ -0,0 +1,66 @@ +package mssql + +import ( + "context" + "fmt" + "testing" + + mssql "github.com/denisenkom/go-mssqldb" + "github.com/grafana/grafana/pkg/infra/proxy/proxyutil" + "github.com/stretchr/testify/require" + "xorm.io/core" +) + +func TestMSSQLProxyDriver(t *testing.T) { + settings := proxyutil.SetupTestSecureSocksProxySettings(t) + dialect := "mssql" + cnnstr := "server=127.0.0.1;port=1433;user id=sa;password=yourStrong(!)Password;database=db" + driverName, err := createMSSQLProxyDriver(settings, cnnstr) + require.NoError(t, err) + + t.Run("Driver should not be registered more than once", func(t *testing.T) { + testDriver, err := createMSSQLProxyDriver(settings, cnnstr) + require.NoError(t, err) + require.Equal(t, driverName, testDriver) + }) + + t.Run("A new driver should be created for a new connection string", func(t *testing.T) { + testDriver, err := createMSSQLProxyDriver(settings, "server=localhost;user id=sa;password=yourStrong(!)Password;database=db2") + require.NoError(t, err) + require.NotEqual(t, driverName, testDriver) + }) + + t.Run("Parse should have the same result as xorm mssql parse", func(t *testing.T) { + xormDriver := core.QueryDriver(dialect) + xormResult, err := xormDriver.Parse(dialect, cnnstr) + require.NoError(t, err) + + xormNewDriver := core.QueryDriver(driverName) + xormNewResult, err := xormNewDriver.Parse(dialect, cnnstr) + require.NoError(t, err) + require.Equal(t, xormResult, xormNewResult) + }) + + t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { + connector, err := mssql.NewConnector(cnnstr) + require.NoError(t, err) + driver, err := newMSSQLProxyDriver(settings, connector) + require.NoError(t, err) + + conn, err := driver.OpenConnector(cnnstr) + require.NoError(t, err) + + _, err = conn.Connect(context.Background()) + require.Contains(t, err.Error(), fmt.Sprintf("socks connect tcp %s->127.0.0.1:1433", settings.ProxyAddress)) + }) + + t.Run("Open should use the connector that routes through the socks proxy to db", func(t *testing.T) { + connector, err := mssql.NewConnector(cnnstr) + require.NoError(t, err) + driver, err := newMSSQLProxyDriver(settings, connector) + require.NoError(t, err) + + _, err = driver.Open(cnnstr) + require.Contains(t, err.Error(), fmt.Sprintf("socks connect tcp %s->127.0.0.1:1433", settings.ProxyAddress)) + }) +} diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 8d39e7d6447..502a7163f54 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -54,6 +55,7 @@ func newInstanceSettings(cfg *setting.Cfg, httpClientProvider httpclient.Provide MaxOpenConns: 0, MaxIdleConns: 2, ConnMaxLifetime: 14400, + SecureDSProxy: false, } err := json.Unmarshal(settings.JSONData, &jsonData) @@ -82,6 +84,16 @@ func newInstanceSettings(cfg *setting.Cfg, httpClientProvider httpclient.Provide protocol = "unix" } + // register the secure socks proxy dialer context, if enabled + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && cfg.SecureSocksDSProxy.Enabled && jsonData.SecureDSProxy { + // UID is only unique per org, the only way to ensure uniqueness is to do it by connection information + uniqueIdentifier := dsInfo.User + dsInfo.DecryptedSecureJSONData["password"] + dsInfo.URL + dsInfo.Database + protocol, err = registerProxyDialerContext(&cfg.SecureSocksDSProxy, protocol, uniqueIdentifier) + if err != nil { + return nil, err + } + } + cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true", characterEscape(dsInfo.User, ":"), dsInfo.DecryptedSecureJSONData["password"], diff --git a/pkg/tsdb/mysql/proxy.go b/pkg/tsdb/mysql/proxy.go new file mode 100644 index 00000000000..a7890b6fc4b --- /dev/null +++ b/pkg/tsdb/mysql/proxy.go @@ -0,0 +1,57 @@ +package mysql + +import ( + "context" + "net" + + "github.com/go-sql-driver/mysql" + iproxy "github.com/grafana/grafana/pkg/infra/proxy" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "golang.org/x/net/proxy" +) + +// registerProxyDialerContext registers a new dialer context to be used by mysql when the proxy network is +// specified in the connection string +func registerProxyDialerContext(settings *setting.SecureSocksDSProxySettings, protocol, cnnstr string) (string, error) { + // the dialer contains the true network used behind the scenes + dialer, err := getProxyDialerContext(settings, protocol) + if err != nil { + return "", err + } + + // the dialer context can be updated everytime the datasource is updated + // have a unique network per connection string + hash, err := util.Md5SumString(cnnstr) + if err != nil { + return "", err + } + network := "proxy-" + hash + mysql.RegisterDialContext(network, dialer.DialContext) + + return network, nil +} + +// mySQLContextDialer turns a golang proxy driver into a MySQL proxy driver +type mySQLContextDialer struct { + dialer proxy.ContextDialer + network string +} + +// getProxyDialerContext returns a context dialer that will send the request through to the secure socks proxy +func getProxyDialerContext(cfg *setting.SecureSocksDSProxySettings, actualNetwork string) (*mySQLContextDialer, error) { + dialer, err := iproxy.NewSecureSocksProxyContextDialer(cfg) + if err != nil { + return nil, err + } + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, err + } + return &mySQLContextDialer{dialer: contextDialer, network: actualNetwork}, nil +} + +// DialContext implements the MySQL requirements for a proxy driver, and uses the underlying golang proxy driver with the assigned network +func (d *mySQLContextDialer) DialContext(ctx context.Context, addr string) (net.Conn, error) { + return d.dialer.DialContext(ctx, d.network, addr) +} diff --git a/pkg/tsdb/mysql/proxy_test.go b/pkg/tsdb/mysql/proxy_test.go new file mode 100644 index 00000000000..429768cf06d --- /dev/null +++ b/pkg/tsdb/mysql/proxy_test.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "context" + "fmt" + "testing" + + "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana/pkg/infra/proxy/proxyutil" + "github.com/stretchr/testify/require" +) + +func TestMySQLProxyDialer(t *testing.T) { + settings := proxyutil.SetupTestSecureSocksProxySettings(t) + + protocol := "tcp" + network, err := registerProxyDialerContext(settings, protocol, "1") + require.NoError(t, err) + driver := mysql.MySQLDriver{} + dbURL := "localhost:5432" + cnnstr := fmt.Sprintf("test:test@%s(%s)/db", + network, + dbURL, + ) + t.Run("Network is available", func(t *testing.T) { + _, err = driver.OpenConnector(cnnstr) + require.NoError(t, err) + }) + + t.Run("Multiple networks can be created", func(t *testing.T) { + network, err := registerProxyDialerContext(settings, protocol, "2") + require.NoError(t, err) + cnnstr2 := fmt.Sprintf("test:test@%s(%s)/db", + network, + dbURL, + ) + // both networks should exist + _, err = driver.OpenConnector(cnnstr) + require.NoError(t, err) + _, err = driver.OpenConnector(cnnstr2) + require.NoError(t, err) + }) + + t.Run("Connection should be routed through socks proxy to db", func(t *testing.T) { + conn, err := driver.OpenConnector(cnnstr) + require.NoError(t, err) + _, err = conn.Connect(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", protocol, settings.ProxyAddress, dbURL)) + }) +} diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index 2dcdff54816..6a931b6cbb3 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -60,6 +61,7 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto ConnMaxLifetime: 14400, Timescaledb: false, ConfigurationMethod: "file-path", + SecureDSProxy: false, } err := json.Unmarshal(settings.JSONData, &jsonData) @@ -92,8 +94,17 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto logger.Debug("GetEngine", "connection", cnnstr) } + driverName := "postgres" + // register a proxy driver if the secure socks proxy is enabled + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && cfg.SecureSocksDSProxy.Enabled && jsonData.SecureDSProxy { + driverName, err = createPostgresProxyDriver(&cfg.SecureSocksDSProxy, cnnstr) + if err != nil { + return "", nil + } + } + config := sqleng.DataPluginConfiguration{ - DriverName: "postgres", + DriverName: driverName, ConnectionString: cnnstr, DSInfo: dsInfo, MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, diff --git a/pkg/tsdb/postgres/proxy.go b/pkg/tsdb/postgres/proxy.go new file mode 100644 index 00000000000..ed5e17487cb --- /dev/null +++ b/pkg/tsdb/postgres/proxy.go @@ -0,0 +1,107 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "net" + "time" + + iproxy "github.com/grafana/grafana/pkg/infra/proxy" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/sqleng" + "github.com/grafana/grafana/pkg/util" + "github.com/lib/pq" + "golang.org/x/net/proxy" + "xorm.io/core" +) + +// createPostgresProxyDriver creates and registers a new sql driver that uses a postgres connector and updates the dialer to +// route connections through the secure socks proxy +func createPostgresProxyDriver(settings *setting.SecureSocksDSProxySettings, cnnstr string) (string, error) { + sqleng.XormDriverMu.Lock() + defer sqleng.XormDriverMu.Unlock() + + // create a unique driver per connection string + hash, err := util.Md5SumString(cnnstr) + if err != nil { + return "", err + } + driverName := "postgres-proxy-" + hash + + // only register the driver once + if core.QueryDriver(driverName) == nil { + connector, err := pq.NewConnector(cnnstr) + if err != nil { + return "", err + } + + driver, err := newPostgresProxyDriver(settings, connector) + if err != nil { + return "", err + } + + sql.Register(driverName, driver) + core.RegisterDriver(driverName, driver) + } + return driverName, nil +} + +// postgresProxyDriver is a regular postgres driver with an updated dialer. +// This is done because there is no way to save a dialer to the postgres driver in xorm +type postgresProxyDriver struct { + c *pq.Connector +} + +var _ driver.DriverContext = (*postgresProxyDriver)(nil) +var _ core.Driver = (*postgresProxyDriver)(nil) + +// newPostgresProxyDriver updates the dialer for a postgres connector with a dialer that proxys connections through the secure socks proxy +// and returns a new postgres driver to register +func newPostgresProxyDriver(cfg *setting.SecureSocksDSProxySettings, connector *pq.Connector) (*postgresProxyDriver, error) { + dialer, err := iproxy.NewSecureSocksProxyContextDialer(cfg) + if err != nil { + return nil, err + } + + // update the postgres dialer with the proxy dialer + connector.Dialer(&postgresProxyDialer{d: dialer}) + + return &postgresProxyDriver{connector}, nil +} + +// postgresProxyDialer implements the postgres dialer using a proxy dialer, as their functions differ slightly +type postgresProxyDialer struct { + d proxy.Dialer +} + +// Dial uses the normal proxy dial function with the updated dialer +func (p *postgresProxyDialer) Dial(network, addr string) (c net.Conn, err error) { + return p.d.Dial(network, addr) +} + +// DialTimeout uses the normal postgres dial timeout function with the updated dialer +func (p *postgresProxyDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + return p.d.(proxy.ContextDialer).DialContext(ctx, network, address) +} + +// Parse uses the xorm postgres dialect for the driver (this has to be implemented to register the driver with xorm) +func (d *postgresProxyDriver) Parse(a string, b string) (*core.Uri, error) { + sqleng.XormDriverMu.RLock() + defer sqleng.XormDriverMu.RUnlock() + + return core.QueryDriver("postgres").Parse(a, b) +} + +// OpenConnector returns the normal postgres connector that has the updated dialer context +func (d *postgresProxyDriver) OpenConnector(name string) (driver.Connector, error) { + return d.c, nil +} + +// Open uses the connector with the updated dialer to open a new connection +func (d *postgresProxyDriver) Open(dsn string) (driver.Conn, error) { + return d.c.Connect(context.Background()) +} diff --git a/pkg/tsdb/postgres/proxy_test.go b/pkg/tsdb/postgres/proxy_test.go new file mode 100644 index 00000000000..9da43f5073d --- /dev/null +++ b/pkg/tsdb/postgres/proxy_test.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/infra/proxy/proxyutil" + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "xorm.io/core" +) + +func TestPostgresProxyDriver(t *testing.T) { + dialect := "postgres" + settings := proxyutil.SetupTestSecureSocksProxySettings(t) + dbURL := "localhost:5432" + cnnstr := fmt.Sprintf("postgres://auser:password@%s/db?sslmode=disable", dbURL) + driverName, err := createPostgresProxyDriver(settings, cnnstr) + require.NoError(t, err) + + t.Run("Driver should not be registered more than once", func(t *testing.T) { + testDriver, err := createPostgresProxyDriver(settings, cnnstr) + require.NoError(t, err) + require.Equal(t, driverName, testDriver) + }) + + t.Run("A new driver should be created for a new connection string", func(t *testing.T) { + testDriver, err := createPostgresProxyDriver(settings, "server=localhost;user id=sa;password=yourStrong(!)Password;database=db2") + require.NoError(t, err) + require.NotEqual(t, driverName, testDriver) + }) + + t.Run("Parse should have the same result as xorm mssql parse", func(t *testing.T) { + xormDriver := core.QueryDriver(dialect) + xormResult, err := xormDriver.Parse(dialect, cnnstr) + require.NoError(t, err) + + xormNewDriver := core.QueryDriver(driverName) + xormNewResult, err := xormNewDriver.Parse(dialect, cnnstr) + require.NoError(t, err) + require.Equal(t, xormResult, xormNewResult) + }) + + t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { + connector, err := pq.NewConnector(cnnstr) + require.NoError(t, err) + driver, err := newPostgresProxyDriver(settings, connector) + require.NoError(t, err) + + conn, err := driver.OpenConnector(cnnstr) + require.NoError(t, err) + + _, err = conn.Connect(context.Background()) + require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", "tcp", settings.ProxyAddress, dbURL)) + }) + + t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) { + connector, err := pq.NewConnector(cnnstr) + require.NoError(t, err) + driver, err := newPostgresProxyDriver(settings, connector) + require.NoError(t, err) + + conn, err := driver.OpenConnector(cnnstr) + require.NoError(t, err) + + _, err = conn.Connect(context.Background()) + require.Contains(t, err.Error(), fmt.Sprintf("socks connect %s %s->%s", "tcp", settings.ProxyAddress, dbURL)) + }) +} diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index 5f7c8167a8c..3960aef8f1f 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -23,6 +23,9 @@ import ( "github.com/grafana/grafana/pkg/tsdb/intervalv2" ) +// XormDriverMu is used to allow safe concurrent registering and querying of drivers in xorm +var XormDriverMu sync.RWMutex + // MetaKeyExecutedQueryString is the key where the executed query should get stored const MetaKeyExecutedQueryString = "executedQueryString" @@ -67,6 +70,7 @@ type JsonData struct { Servername string `json:"servername"` TimeInterval string `json:"timeInterval"` Database string `json:"database"` + SecureDSProxy bool `json:"enableSecureSocksProxy"` } type DataSourceInfo struct { diff --git a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx index dd807efd3ff..e095e3173ad 100644 --- a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx @@ -21,8 +21,10 @@ import { SecretInput, Select, useStyles2, + SecureSocksProxySettings, } from '@grafana/ui'; import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; +import { config } from 'app/core/config'; import { ConnectionLimits } from 'app/features/plugins/sql/components/configuration/ConnectionLimits'; import { useMigrateDatabaseField } from 'app/features/plugins/sql/components/configuration/useMigrateDatabaseField'; @@ -154,6 +156,10 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} +
+ {config.featureToggles.secureSocksDatasourceProxy && ( + + )} {jsonData.tlsAuth || jsonData.tlsAuthWithCACert ? (
+ {config.featureToggles.secureSocksDatasourceProxy && ( +
+ + + onOptionsChange({ + ...options, + jsonData: { ...options.jsonData, enableSecureSocksProxy: event!.currentTarget.checked }, + }) + } + /> + +
+ )} {jsonData.sslmode !== PostgresTLSModes.disable ? (
{jsonData.tlsConfigurationMethod === PostgresTLSMethods.fileContent ? ( diff --git a/public/app/plugins/datasource/postgres/types.ts b/public/app/plugins/datasource/postgres/types.ts index 6bb435e4cbb..b1993310eec 100644 --- a/public/app/plugins/datasource/postgres/types.ts +++ b/public/app/plugins/datasource/postgres/types.ts @@ -19,6 +19,7 @@ export interface PostgresOptions extends SQLOptions { sslKeyFile?: string; postgresVersion?: number; timescaledb?: boolean; + enableSecureSocksProxy?: boolean; } export interface SecureJsonData {