diff --git a/conf/defaults.ini b/conf/defaults.ini index 49c4582a280..115cbc20a73 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1359,3 +1359,14 @@ index_update_interval = 10s # Move a specific app plugin page (referenced by its `path` field) to a specific navigation section # Format: =
[navigation.app_standalone_pages] + + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +enabled = false +root_ca_cert = +client_key = +client_cert = +server_name = +# The address of the socks5 proxy datasources should connect to +proxy_address = \ No newline at end of file diff --git a/conf/sample.ini b/conf/sample.ini index 7c27b34819b..d421d04c10a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1285,3 +1285,13 @@ [navigation.app_standalone_pages] # The following will move the page with the path "/a/my-app-id/starred-content" from `my-app-id` to the `starred` section # /a/my-app-id/starred-content = starred + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +; enabled = false +; root_ca_cert = +; client_key = +; client_cert = +; server_name = +# The address of the socks5 proxy datasources should connect to +; proxy_address = \ No newline at end of file diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f0f688c5b0e..e57ca1881ea 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -82,5 +82,6 @@ export interface FeatureToggles { nestedFolders?: boolean; accessTokenExpirationCheck?: boolean; elasticsearchBackendMigration?: boolean; + secureSocksDatasourceProxy?: boolean; authnService?: boolean; } diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 81d45aabaa9..054f045471d 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) @@ -50,10 +51,18 @@ func New(cfg *setting.Cfg, validator models.PluginRequestValidator, tracer traci return } datasourceLabelName, err := metricutil.SanitizeLabelName(datasourceName) - if err != nil { return } + + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && + cfg.SecureSocksDSProxy.Enabled && secureSocksProxyEnabledOnDS(opts) { + err = newSecureSocksProxy(&cfg.SecureSocksDSProxy, transport) + if err != nil { + logger.Error("Failed to enable secure socks proxy", "error", err.Error(), "datasource", datasourceName) + } + } + newConntrackRoundTripper(datasourceLabelName, transport) }, }) diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go new file mode 100644 index 00000000000..65e4da5bdff --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go @@ -0,0 +1,72 @@ +package httpclientprovider + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "os" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/setting" + "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 { + 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 + } + if !certPool.AppendCertsFromPEM(pem) { + return errors.New("failed to append CA certificate " + rootCAFile) + } + } + + cert, err := tls.LoadX509KeyPair(cfg.ClientCert, cfg.ClientKey) + if err != nil { + return err + } + + tlsDialer := &tls.Dialer{ + Config: &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: cfg.ServerName, + RootCAs: certPool, + }, + } + dialSocksProxy, err := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, tlsDialer) + 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 +} + +// 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 { + return false + } + + if val, ok := res.(bool); ok { + return val + } + + return false +} diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go new file mode 100644 index 00000000000..857141c5d03 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go @@ -0,0 +1,177 @@ +package httpclientprovider + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "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) { + 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), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + rootCACert := filepath.Join(tempDir, "ca.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + caCertFile, err := os.Create(rootCACert) + require.NoError(t, err) + err = pem.Encode(caCertFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }) + require.NoError(t, err) + + // generate test client cert & key + cert := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + SubjectKeyId: []byte{1, 2, 3, 4, 6}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + clientCert := filepath.Join(tempDir, "client.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + certFile, err := os.Create(clientCert) + require.NoError(t, err) + err = pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + require.NoError(t, err) + clientKey := filepath.Join(tempDir, "client.key") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + keyFile, err := os.Create(clientKey) + require.NoError(t, err) + err = pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), + }) + require.NoError(t, err) + + settings := &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/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7176751ccd5..6c27029ce24 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -367,6 +367,10 @@ var ( Description: "Use Elasticsearch as backend data source", State: FeatureStateAlpha, }, + { + Name: "secureSocksDatasourceProxy", + Description: "Enable secure socks tunneling for supported core datasources", + }, { Name: "authnService", Description: "Use new auth service to perform authentication", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3d95cf8a59e..74d7f9a1f42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -271,6 +271,10 @@ const ( // Use Elasticsearch as backend data source FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" + // FlagSecureSocksDatasourceProxy + // Enable secure socks tunneling for supported core datasources + FlagSecureSocksDatasourceProxy = "secureSocksDatasourceProxy" + // FlagAuthnService // Use new auth service to perform authentication FlagAuthnService = "authnService" diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4754d5c028e..70372fcd9f3 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -475,6 +475,8 @@ type Cfg struct { Search SearchSettings + SecureSocksDSProxy SecureSocksDSProxySettings + // Access Control RBACEnabled bool RBACPermissionCache bool @@ -1080,6 +1082,13 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.Storage = readStorageSettings(iniFile) cfg.Search = readSearchSettings(iniFile) + cfg.SecureSocksDSProxy, err = readSecureSocksDSProxySettings(iniFile) + if err != nil { + // if the proxy is misconfigured, disable it rather than crashing + cfg.SecureSocksDSProxy.Enabled = false + cfg.Logger.Error("secure_socks_datasource_proxy unable to start up", "err", err.Error()) + } + if VerifyEmailEnabled && !cfg.Smtp.Enabled { cfg.Logger.Warn("require_email_validation is enabled but smtp is disabled") } diff --git a/pkg/setting/setting_secure_socks_proxy.go b/pkg/setting/setting_secure_socks_proxy.go new file mode 100644 index 00000000000..83fbb156a9e --- /dev/null +++ b/pkg/setting/setting_secure_socks_proxy.go @@ -0,0 +1,44 @@ +package setting + +import ( + "errors" + + "gopkg.in/ini.v1" +) + +type SecureSocksDSProxySettings struct { + Enabled bool + ClientCert string + ClientKey string + RootCA string + ProxyAddress string + ServerName string +} + +func readSecureSocksDSProxySettings(iniFile *ini.File) (SecureSocksDSProxySettings, error) { + s := SecureSocksDSProxySettings{} + secureSocksProxySection := iniFile.Section("secure_socks_datasource_proxy") + s.Enabled = secureSocksProxySection.Key("enabled").MustBool(false) + s.ClientCert = secureSocksProxySection.Key("client_cert").MustString("") + s.ClientKey = secureSocksProxySection.Key("client_key").MustString("") + s.RootCA = secureSocksProxySection.Key("root_ca_cert").MustString("") + s.ProxyAddress = secureSocksProxySection.Key("proxy_address").MustString("") + s.ServerName = secureSocksProxySection.Key("server_name").MustString("") + + if !s.Enabled { + return s, nil + } + + // all fields must be specified to use the proxy + if s.RootCA == "" { + return s, errors.New("rootCA required") + } else if s.ClientCert == "" || s.ClientKey == "" { + return s, errors.New("client key pair required") + } else if s.ServerName == "" { + return s, errors.New("server name required") + } else if s.ProxyAddress == "" { + return s, errors.New("proxy address required") + } + + return s, nil +}