diff --git a/pkg/plugin/driver.go b/pkg/plugin/driver.go index efd80791..4eb9db83 100644 --- a/pkg/plugin/driver.go +++ b/pkg/plugin/driver.go @@ -160,6 +160,7 @@ func (h *Clickhouse) Connect(config backend.DataSourceInstanceSettings, message Compression: &clickhouse.Compression{ Method: compression, }, + HttpHeaders: settings.HttpHeaders, DialTimeout: time.Duration(t) * time.Second, ReadTimeout: time.Duration(qt) * time.Second, Protocol: protocol, diff --git a/pkg/plugin/settings.go b/pkg/plugin/settings.go index e6c19854..10ecc5df 100644 --- a/pkg/plugin/settings.go +++ b/pkg/plugin/settings.go @@ -3,6 +3,7 @@ package plugin import ( "encoding/json" "fmt" + "github.com/ClickHouse/clickhouse-go/v2" "strconv" "strings" "time" @@ -34,7 +35,8 @@ type Settings struct { DialTimeout string `json:"dialTimeout,omitempty"` QueryTimeout string `json:"queryTimeout,omitempty"` - CustomSettings []CustomSetting `json:"customSettings"` + HttpHeaders map[string]string `json:"-"` + CustomSettings []CustomSetting `json:"customSettings"` ProxyOptions *proxy.Options } @@ -43,6 +45,8 @@ type CustomSetting struct { Value string `json:"value"` } +const secureHeaderKeyPrefix = "secureHttpHeaders." + func (settings *Settings) isValid() (err error) { if settings.Host == "" { return ErrorMessageInvalidHost @@ -187,8 +191,12 @@ func LoadSettings(config backend.DataSourceInstanceSettings) (settings Settings, settings.TlsClientKey = tlsClientKey } - // proxy options are only able to be loaded via environment variables - // currently, so we pass `nil` here so they are loaded with defaults + if settings.Protocol == clickhouse.HTTP.String() { + settings.HttpHeaders = loadHttpHeaders(jsonData, config.DecryptedSecureJSONData) + } + + // proxy options are currently only able to load via environment variables, + // so we pass `nil` here so that they are loaded with defaults proxyOpts, err := config.ProxyOptions(nil) if err == nil && proxyOpts != nil { @@ -203,3 +211,30 @@ func LoadSettings(config backend.DataSourceInstanceSettings) (settings Settings, return settings, settings.isValid() } + +// loadHttpHeaders loads secure and plain text headers from the config +func loadHttpHeaders(jsonData map[string]interface{}, secureJsonData map[string]string) map[string]string { + httpHeaders := make(map[string]string) + + if jsonData["httpHeaders"] != nil { + httpHeadersRaw := jsonData["httpHeaders"].([]interface{}) + + for _, rawHeader := range httpHeadersRaw { + header := rawHeader.(map[string]interface{}) + headerName := header["name"].(string) + headerValue := header["value"].(string) + if headerName != "" && headerValue != "" { + httpHeaders[headerName] = headerValue + } + } + } + + for k, v := range secureJsonData { + if v != "" && strings.HasPrefix(k, secureHeaderKeyPrefix) { + headerName := k[len(secureHeaderKeyPrefix):] + httpHeaders[headerName] = v + } + } + + return httpHeaders +} diff --git a/pkg/plugin/settings_test.go b/pkg/plugin/settings_test.go index 6805f3b9..176cd6a7 100644 --- a/pkg/plugin/settings_test.go +++ b/pkg/plugin/settings_test.go @@ -3,6 +3,7 @@ package plugin import ( "errors" "fmt" + "github.com/ClickHouse/clickhouse-go/v2" "reflect" "testing" "time" @@ -27,15 +28,29 @@ func TestLoadSettings(t *testing.T) { name: "should parse and set all json fields correctly", args: args{ config: backend.DataSourceInstanceSettings{ - UID: "ds-uid", - JSONData: []byte(`{ "host": "foo", "port": 443, "path": "custom-path", "username": "baz", "defaultDatabase":"example", "tlsSkipVerify": true, "tlsAuth" : true, "tlsAuthWithCACert": true, "dialTimeout": "10", "enableSecureSocksProxy": true}`), - DecryptedSecureJSONData: map[string]string{"password": "bar", "tlsCACert": "caCert", "tlsClientCert": "clientCert", "tlsClientKey": "clientKey", "secureSocksProxyPassword": "test"}, + UID: "ds-uid", + JSONData: []byte(`{ + "host": "foo", "port": 443, + "path": "custom-path", "protocol": "http", + "username": "baz", + "defaultDatabase":"example", "tlsSkipVerify": true, "tlsAuth" : true, + "tlsAuthWithCACert": true, "dialTimeout": "10", "enableSecureSocksProxy": true, + "httpHeaders": [{ "name": "test-plain-1", "value": "value-1", "secure": false }] + }`), + DecryptedSecureJSONData: map[string]string{ + "password": "bar", + "tlsCACert": "caCert", "tlsClientCert": "clientCert", "tlsClientKey": "clientKey", + "secureSocksProxyPassword": "test", + "secureHttpHeaders.test-secure-2": "value-2", + "secureHttpHeaders.test-secure-3": "value-3", + }, }, }, wantSettings: Settings{ Host: "foo", Port: 443, Path: "custom-path", + Protocol: clickhouse.HTTP.String(), Username: "baz", DefaultDatabase: "example", InsecureSkipVerify: true, @@ -47,6 +62,11 @@ func TestLoadSettings(t *testing.T) { TlsClientKey: "clientKey", DialTimeout: "10", QueryTimeout: "60", + HttpHeaders: map[string]string{ + "test-plain-1": "value-1", + "test-secure-2": "value-2", + "test-secure-3": "value-3", + }, ProxyOptions: &proxy.Options{ Enabled: true, Auth: &proxy.AuthOptions{ diff --git a/src/__mocks__/ConfigEditor.ts b/src/__mocks__/ConfigEditor.ts index 9f43d6f2..6abe9a2c 100644 --- a/src/__mocks__/ConfigEditor.ts +++ b/src/__mocks__/ConfigEditor.ts @@ -12,7 +12,7 @@ export const mockConfigEditorProps = (overrides?: Partial): ConfigEdit port: 443, path: '', username: 'user', - protocol: 'native', + protocol: 'http', ...overrides, }, }, diff --git a/src/components/configEditor/HttpHeadersConfig.test.tsx b/src/components/configEditor/HttpHeadersConfig.test.tsx new file mode 100644 index 00000000..7e29cad8 --- /dev/null +++ b/src/components/configEditor/HttpHeadersConfig.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { render, fireEvent, renderHook } from '@testing-library/react'; +import { HttpHeadersConfig, useConfiguredSecureHttpHeaders } from './HttpHeadersConfig'; +import { selectors as allSelectors } from 'selectors'; +import { CHHttpHeader } from 'types/config'; +import { KeyValue } from '@grafana/data'; + +describe('HttpHeadersConfig', () => { + const selectors = allSelectors.components.Config.HttpHeaderConfig; + + it('should render', () => { + const result = render( {}} />); + expect(result.container.firstChild).not.toBeNull(); + }); + + it('should not call onHttpHeadersChange when header is added', () => { + const onHttpHeadersChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const addHeaderButton = result.getByTestId(selectors.addHeaderButton); + expect(addHeaderButton).toBeInTheDocument(); + fireEvent.click(addHeaderButton); + + expect(onHttpHeadersChange).toHaveBeenCalledTimes(0); + }); + + it('should call onHttpHeadersChange when header is updated', () => { + const onHttpHeadersChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const addHeaderButton = result.getByTestId(selectors.addHeaderButton); + expect(addHeaderButton).toBeInTheDocument(); + fireEvent.click(addHeaderButton); + + const headerEditor = result.getByTestId(selectors.headerEditor); + expect(headerEditor).toBeInTheDocument(); + + const headerNameInput = result.getByTestId(selectors.headerNameInput); + expect(headerNameInput).toBeInTheDocument(); + fireEvent.change(headerNameInput, { target: { value: 'x-test' } }); + fireEvent.blur(headerNameInput); + expect(headerNameInput).toHaveValue('x-test'); + expect(onHttpHeadersChange).toHaveBeenCalledTimes(1); + + const headerValueInput = result.getByTestId(selectors.headerValueInput); + expect(headerValueInput).toBeInTheDocument(); + fireEvent.change(headerValueInput, { target: { value: 'test value' } }); + fireEvent.blur(headerValueInput); + expect(headerValueInput).toHaveValue('test value'); + expect(onHttpHeadersChange).toHaveBeenCalledTimes(2); + + const headerSecureSwitch = result.getByTestId(selectors.headerSecureSwitch); + expect(headerSecureSwitch).toBeInTheDocument(); + fireEvent.click(headerSecureSwitch); + fireEvent.blur(headerSecureSwitch); + expect(onHttpHeadersChange).toHaveBeenCalledTimes(3); + + const expected: CHHttpHeader[] = [ + { name: 'x-test', value: 'test value', secure: true } + ]; + expect(onHttpHeadersChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('should call onHttpHeadersChange when header is removed', () => { + const onHttpHeadersChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const removeHeaderButton = result.getAllByTestId(selectors.removeHeaderButton)[0]; // Get 1st + expect(removeHeaderButton).toBeInTheDocument(); + fireEvent.click(removeHeaderButton); + + const expected: CHHttpHeader[] = [ + { name: 'x-test-2', value: 'test value 2', secure: false } + ]; + expect(onHttpHeadersChange).toHaveBeenCalledTimes(1); + expect(onHttpHeadersChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); +}); + + +describe('useConfiguredSecureHttpHeaders', () => { + it('returns unique set of secure header keys', async () => { + const fields: KeyValue = { + 'otherKey': true, + 'otherOtherKey': false, + 'secureHttpHeaders.a': true, + 'secureHttpHeaders.b': true, + 'secureHttpHeaders.c': false, + }; + + const hook = renderHook(() => useConfiguredSecureHttpHeaders(fields)); + const result = hook.result.current; + + expect(result.size).toBe(2); + expect(result.has('a')).toBe(true); + expect(result.has('b')).toBe(true); + expect(result.has('c')).toBe(false); + }); +}); diff --git a/src/components/configEditor/HttpHeadersConfig.tsx b/src/components/configEditor/HttpHeadersConfig.tsx new file mode 100644 index 00000000..87e7a997 --- /dev/null +++ b/src/components/configEditor/HttpHeadersConfig.tsx @@ -0,0 +1,174 @@ +import React, { ChangeEvent, useMemo, useState } from 'react'; +import { ConfigSection } from '@grafana/experimental'; +import { Input, Field, HorizontalGroup, Switch, SecretInput, Button } from '@grafana/ui'; +import { CHHttpHeader } from 'types/config'; +import allLabels from 'labels'; +import { styles } from 'styles'; +import { selectors as allSelectors } from 'selectors'; +import { KeyValue } from '@grafana/data'; + +interface HttpHeadersConfigProps { + headers?: CHHttpHeader[]; + secureFields: KeyValue; + onHttpHeadersChange: (v: CHHttpHeader[]) => void; +} + +export const HttpHeadersConfig = (props: HttpHeadersConfigProps) => { + const { secureFields, onHttpHeadersChange } = props; + const configuredSecureHeaders = useConfiguredSecureHttpHeaders(secureFields); + const [headers, setHeaders] = useState(props.headers || []); + const labels = allLabels.components.Config.HttpHeadersConfig; + const selectors = allSelectors.components.Config.HttpHeaderConfig; + + const addHeader = () => setHeaders([...headers, { name: '', value: '', secure: false }]); + const removeHeader = (index: number) => { + const nextHeaders: CHHttpHeader[] = headers.slice(); + nextHeaders.splice(index, 1); + setHeaders(nextHeaders); + onHttpHeadersChange(nextHeaders); + }; + const updateHeader = (index: number, header: CHHttpHeader) => { + const nextHeaders: CHHttpHeader[] = headers.slice(); + nextHeaders[index] = header; + setHeaders(nextHeaders); + onHttpHeadersChange(nextHeaders); + }; + + return ( + + {headers.map((header, index) => ( + updateHeader(index, header)} + onRemove={() => removeHeader(index)} + /> + ))} + + + ); +} + +interface HttpHeaderEditorProps { + name: string; + value: string; + secure: boolean; + isSecureConfigured: boolean; + onHeaderChange: (v: CHHttpHeader) => void; + onRemove?: () => void; +} + +const HttpHeaderEditor = (props: HttpHeaderEditorProps) => { + const { onHeaderChange, onRemove } = props; + const [name, setName] = useState(props.name); + const [value, setValue] = useState(props.value); + const [secure, setSecure] = useState(props.secure); + const [isSecureConfigured, setSecureConfigured] = useState(props.isSecureConfigured); + const labels = allLabels.components.Config.HttpHeadersConfig; + const selectors = allSelectors.components.Config.HttpHeaderConfig; + + const onUpdate = () => { + onHeaderChange({ + name, + value, + secure + }); + } + + let valueInput; + if (secure) { + valueInput = setSecureConfigured(false)} + onChange={(e: ChangeEvent) => setValue(e.target.value)} + onBlur={() => onUpdate()} + />; + } else { + valueInput = ) => setValue(e.target.value)} + onBlur={() => onUpdate()} + />; + } + + const headerValueLabel = secure ? labels.secureHeaderValueLabel : labels.insecureHeaderValueLabel; + return ( +
+ + + ) => setName(e.target.value)} + onBlur={() => onUpdate()} + /> + + + {valueInput} + + { !isSecureConfigured && + + setSecure(e.currentTarget.checked)} + onBlur={() => onUpdate()} + /> + + } + { onRemove && +
+ ); +} + +/** + * Returns a Set of all secured headers that are configured + */ +export const useConfiguredSecureHttpHeaders = (secureJsonFields: KeyValue): Set => { + return useMemo(() => { + const secureHeaders = new Set(); + for (let key in secureJsonFields) { + if (key.startsWith('secureHttpHeaders.') && secureJsonFields[key]) { + secureHeaders.add(key.substring(key.indexOf('.') + 1)); + } + } + return secureHeaders; + }, [secureJsonFields]); +}; diff --git a/src/labels.ts b/src/labels.ts index 7e891655..19d68402 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -1,6 +1,84 @@ export default { components: { Config: { + ConfigEditor: { + serverAddress: { + label: 'Server address', + placeholder: 'Server address', + tooltip: 'ClickHouse host address', + error: 'Server address required' + }, + serverPort: { + label: 'Server port', + insecureNativePort: '9000', + insecureHttpPort: '8123', + secureNativePort: '9440', + secureHttpPort: '8443', + tooltip: 'ClickHouse server port', + error: 'Port is required' + }, + path: { + label: 'HTTP URL Path', + tooltip: 'Additional URL path for HTTP requests', + placeholder: 'additional-path' + }, + protocol: { + label: 'Protocol', + tooltip: 'Native or HTTP for server protocol', + }, + username: { + label: 'Username', + placeholder: 'default', + tooltip: 'ClickHouse username', + }, + password: { + label: 'Password', + placeholder: 'password', + tooltip: 'ClickHouse password', + }, + tlsSkipVerify: { + label: 'Skip TLS Verify', + tooltip: 'Skip TLS Verify', + }, + tlsClientAuth: { + label: 'TLS Client Auth', + tooltip: 'TLS Client Auth', + }, + tlsAuthWithCACert: { + label: 'With CA Cert', + tooltip: 'Needed for verifying self-signed TLS Certs', + }, + tlsCACert: { + label: 'CA Cert', + placeholder: 'CA Cert. Begins with -----BEGIN CERTIFICATE-----', + }, + tlsClientCert: { + label: 'Client Cert', + placeholder: 'Client Cert. Begins with -----BEGIN CERTIFICATE-----', + }, + tlsClientKey: { + label: 'Client Key', + placeholder: 'Client Key. Begins with -----BEGIN RSA PRIVATE KEY-----', + }, + secure: { + label: 'Secure Connection', + tooltip: 'Toggle on if the connection is secure', + }, + secureSocksProxy: { + label: 'Enable Secure Socks Proxy', + tooltip: 'Enable proxying the datasource connection through the secure socks proxy to a different network.', + }, + }, + HttpHeadersConfig: { + title: 'HTTP Headers', + description: 'Add HTTP headers when querying the database', + headerNameLabel: 'Header Name', + headerNamePlaceholder: 'X-Custom-Header', + insecureHeaderValueLabel: 'Header Value', + secureHeaderValueLabel: 'Secure Header Value', + secureLabel: 'Secure', + addHeaderLabel: 'Add Header' + }, DefaultDatabaseTableConfig: { title: 'Default DB and table', database: { diff --git a/src/selectors.ts b/src/selectors.ts index 3f522401..7261c0eb 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -1,68 +1,5 @@ import { E2ESelectors } from '@grafana/e2e-selectors'; export const Components = { - ConfigEditor: { - ServerAddress: { - label: 'Server address', - placeholder: 'Server TCP address', - tooltip: 'ClickHouse native TCP server address', - }, - ServerPort: { - label: 'Server port', - placeholder: (secure: string) => `Typically ${secure === 'true' ? '9440' : '9000'}`, - tooltip: 'ClickHouse native TCP port. Typically 9000 for unsecure, 9440 for secure', - }, - Path: { - label: 'Path', - placeholder: 'Additional URL path for HTTP requests', - tooltip: 'Additional URL path for HTTP requests', - }, - Protocol: { - label: 'Protocol', - tooltip: 'Native or HTTP for transport', - }, - Username: { - label: 'Username', - placeholder: 'Username', - tooltip: 'ClickHouse username', - }, - Password: { - label: 'Password', - placeholder: 'Password', - tooltip: 'ClickHouse password', - }, - TLSSkipVerify: { - label: 'Skip TLS Verify', - tooltip: 'Skip TLS Verify', - }, - TLSClientAuth: { - label: 'TLS Client Auth', - tooltip: 'TLS Client Auth', - }, - TLSAuthWithCACert: { - label: 'With CA Cert', - tooltip: 'Needed for verifying self-signed TLS Certs', - }, - TLSCACert: { - label: 'CA Cert', - placeholder: 'CA Cert. Begins with -----BEGIN CERTIFICATE-----', - }, - TLSClientCert: { - label: 'Client Cert', - placeholder: 'Client Cert. Begins with -----BEGIN CERTIFICATE-----', - }, - TLSClientKey: { - label: 'Client Key', - placeholder: 'Client Key. Begins with -----BEGIN RSA PRIVATE KEY-----', - }, - Secure: { - label: 'Secure Connection', - tooltip: 'Toggle on if the connection is secure', - }, - SecureSocksProxy: { - label: 'Enable Secure Socks Proxy', - tooltip: 'Enable proxying the datasource connection through the secure socks proxy to a different network.', - }, - }, QueryEditor: { CodeEditor: { input: () => '.monaco-editor textarea', @@ -176,6 +113,16 @@ export const Components = { }, }, }, + Config: { + HttpHeaderConfig: { + headerEditor: 'config__http-header-config__header-editor', + addHeaderButton: 'config__http-header-config__add-header-button', + removeHeaderButton: 'config__http-header-config__remove-header-button', + headerSecureSwitch: 'config__http-header-config__header-secure-switch', + headerNameInput: 'config__http-header-config__header-name-input', + headerValueInput: 'config__http-header-config__header-value-input' + } + }, QueryBuilder: { AggregateEditor: { sectionLabel: 'query-builder__aggregate-editor__section-label', diff --git a/src/types/config.ts b/src/types/config.ts index 9ed7ee84..a808b66c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,4 +1,4 @@ -import { DataSourceJsonData } from '@grafana/data'; +import { DataSourceJsonData, KeyValue } from '@grafana/data'; export interface CHConfig extends DataSourceJsonData { host: string; @@ -23,21 +23,32 @@ export interface CHConfig extends DataSourceJsonData { logs?: CHLogsConfig; traces?: CHTracesConfig; + httpHeaders?: CHHttpHeader[]; + customSettings?: CHCustomSetting[]; enableSecureSocksProxy?: boolean; } -export interface CHCustomSetting { - setting: string; - value: string; -} +interface CHSecureConfigProperties { + password?: string; -export interface CHSecureConfig { - password: string; tlsCACert?: string; tlsClientCert?: string; tlsClientKey?: string; } +export type CHSecureConfig = CHSecureConfigProperties | KeyValue; + +export interface CHHttpHeader { + name: string; + value: string; + secure: boolean; +} + +export interface CHCustomSetting { + setting: string; + value: string; +} + export interface CHLogsConfig { defaultDatabase?: string; diff --git a/src/views/CHConfigEditor.test.tsx b/src/views/CHConfigEditor.test.tsx index 27c73f7e..040d6763 100644 --- a/src/views/CHConfigEditor.test.tsx +++ b/src/views/CHConfigEditor.test.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ConfigEditor } from './CHConfigEditor'; import { mockConfigEditorProps } from '__mocks__/ConfigEditor'; -import { Components } from 'selectors'; import '@testing-library/jest-dom'; import { Protocol } from 'types/config'; +import allLabels from 'labels'; jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); @@ -23,13 +23,15 @@ jest.mock('@grafana/runtime', () => { }); describe('ConfigEditor', () => { + const labels = allLabels.components.Config.ConfigEditor; + it('new editor', () => { render(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.ServerAddress.placeholder)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.ServerPort.placeholder('false'))).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.Username.placeholder)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.Password.placeholder)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.Path.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.serverAddress.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.serverPort.insecureHttpPort)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.username.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.password.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.path.placeholder)).toBeInTheDocument(); }); it('with password', async () => { render( @@ -42,9 +44,9 @@ describe('ConfigEditor', () => { }} /> ); - expect(screen.getByPlaceholderText(Components.ConfigEditor.ServerAddress.placeholder)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.ServerPort.placeholder('false'))).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.Username.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.serverAddress.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.serverPort.insecureHttpPort)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.username.placeholder)).toBeInTheDocument(); const a = screen.getByText('Reset'); expect(a).toBeInTheDocument(); }); @@ -55,11 +57,11 @@ describe('ConfigEditor', () => { {...mockConfigEditorProps()} options={{ ...mockConfigEditorProps().options, - jsonData: { ...mockConfigEditorProps().options.jsonData, path }, + jsonData: { ...mockConfigEditorProps().options.jsonData, path, protocol: Protocol.Http }, }} /> ); - expect(screen.queryByPlaceholderText(Components.ConfigEditor.Path.placeholder)).toHaveValue(path); + expect(screen.queryByPlaceholderText(labels.path.placeholder)).toHaveValue(path); }); it('with secure connection', async () => { render( @@ -71,7 +73,7 @@ describe('ConfigEditor', () => { }} /> ); - expect(screen.queryByPlaceholderText(Components.ConfigEditor.ServerPort.placeholder('true'))).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(labels.serverPort.secureHttpPort)).toBeInTheDocument(); }); it('with protocol', async () => { render( @@ -88,7 +90,7 @@ describe('ConfigEditor', () => { }); it('without tlsCACert', async () => { render(); - expect(screen.queryByPlaceholderText(Components.ConfigEditor.TLSCACert.placeholder)).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(labels.tlsCACert.placeholder)).not.toBeInTheDocument(); }); it('with tlsCACert', async () => { render( @@ -100,12 +102,12 @@ describe('ConfigEditor', () => { }} /> ); - expect(screen.getByPlaceholderText(Components.ConfigEditor.TLSCACert.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.tlsCACert.placeholder)).toBeInTheDocument(); }); it('without tlsAuth', async () => { render(); - expect(screen.queryByPlaceholderText(Components.ConfigEditor.TLSClientCert.placeholder)).not.toBeInTheDocument(); - expect(screen.queryByPlaceholderText(Components.ConfigEditor.TLSClientKey.placeholder)).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(labels.tlsClientCert.placeholder)).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(labels.tlsClientKey.placeholder)).not.toBeInTheDocument(); }); it('with tlsAuth', async () => { render( @@ -117,8 +119,8 @@ describe('ConfigEditor', () => { }} /> ); - expect(screen.getByPlaceholderText(Components.ConfigEditor.TLSClientCert.placeholder)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(Components.ConfigEditor.TLSClientKey.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.tlsClientCert.placeholder)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(labels.tlsClientKey.placeholder)).toBeInTheDocument(); }); it('with additional properties', async () => { const jsonDataOverrides = { @@ -130,7 +132,7 @@ describe('ConfigEditor', () => { customSettings: [{ setting: 'test-setting', value: 'test-value' }], }; render(); - expect(screen.getByText(Components.ConfigEditor.SecureSocksProxy.label)).toBeInTheDocument(); + expect(screen.getByText(labels.secureSocksProxy.label)).toBeInTheDocument(); expect(screen.getByDisplayValue(jsonDataOverrides.customSettings[0].setting)).toBeInTheDocument(); expect(screen.getByDisplayValue(jsonDataOverrides.customSettings[0].value)).toBeInTheDocument(); }); diff --git a/src/views/CHConfigEditor.tsx b/src/views/CHConfigEditor.tsx index 83c2b53a..7fde1c09 100644 --- a/src/views/CHConfigEditor.tsx +++ b/src/views/CHConfigEditor.tsx @@ -6,7 +6,6 @@ import { } from '@grafana/data'; import { RadioButtonGroup, Switch, Input, SecretInput, Button, Field, HorizontalGroup } from '@grafana/ui'; import { CertificationKey } from '../components/ui/CertificationKey'; -import { Components } from 'selectors'; import { CHConfig, CHCustomSetting, CHSecureConfig, CHLogsConfig, Protocol, CHTracesConfig } from 'types/config'; import { gte as versionGte } from 'semver'; import { ConfigSection, ConfigSubSection, DataSourceDescription } from '@grafana/experimental'; @@ -17,14 +16,16 @@ import { DefaultDatabaseTableConfig } from 'components/configEditor/DefaultDatab import { QuerySettingsConfig } from 'components/configEditor/QuerySettingsConfig'; import { LogsConfig } from 'components/configEditor/LogsConfig'; import { TracesConfig } from 'components/configEditor/TracesConfig'; -import { useMigrateV3Config } from './CHConfigEditorHooks'; +import { onHttpHeadersChange, useMigrateV3Config } from './CHConfigEditorHooks'; +import { HttpHeadersConfig } from 'components/configEditor/HttpHeadersConfig'; +import allLabels from 'labels'; -export interface ConfigEditorProps extends DataSourcePluginOptionsEditorProps {} +export interface ConfigEditorProps extends DataSourcePluginOptionsEditorProps {} export const ConfigEditor: React.FC = (props) => { const { options, onOptionsChange } = props; const { jsonData, secureJsonFields } = options; - const labels = Components.ConfigEditor; + const labels = allLabels.components.Config.ConfigEditor; const secureJsonData = (options.secureJsonData || {}) as CHSecureConfig; const hasTLSCACert = secureJsonFields && secureJsonFields.tlsCACert; const hasTLSClientCert = secureJsonFields && secureJsonFields.tlsClientCert; @@ -164,6 +165,11 @@ export const ConfigEditor: React.FC = (props) => { options.jsonData.traces ); + const defaultPort = jsonData.secure ? + (jsonData.protocol === Protocol.Native ? labels.serverPort.secureNativePort : labels.serverPort.secureHttpPort) : + (jsonData.protocol === Protocol.Native ? labels.serverPort.insecureNativePort : labels.serverPort.insecureHttpPort); + const portDescription = `${labels.serverPort.tooltip} (default for ${jsonData.secure ? 'secure' : ''} ${jsonData.protocol}: ${defaultPort})` + return ( <> = (props) => { onPortChange(e.currentTarget.value)} - label={labels.ServerPort.label} - aria-label={labels.ServerPort.label} - placeholder={labels.ServerPort.placeholder(jsonData.secure?.toString() || 'false')} + onChange={e => onPortChange(e.currentTarget.value)} + label={labels.serverPort.label} + aria-label={labels.serverPort.label} + placeholder={defaultPort} /> - - - - + + options={protocolOptions} disabledOptions={[]} @@ -227,7 +223,7 @@ export const ConfigEditor: React.FC = (props) => { onChange={(e) => onProtocolToggle(e!)} /> - + = (props) => { onChange={(e) => onSwitchToggle('secure', e.currentTarget.checked)} /> + + { jsonData.protocol === Protocol.Http && + + + + } + { jsonData.protocol === Protocol.Http && + onHttpHeadersChange(headers, options, onOptionsChange)} + /> + } + = (props) => { /> = (props) => { /> = (props) => { onCertificateChangeFactory('tlsCACert', e.currentTarget.value)} - placeholder={labels.TLSCACert.placeholder} - label={labels.TLSCACert.label} + placeholder={labels.tlsCACert.placeholder} + label={labels.tlsCACert.label} onClick={() => onResetClickFactory('tlsCACert')} /> )} @@ -283,14 +301,14 @@ export const ConfigEditor: React.FC = (props) => { onCertificateChangeFactory('tlsClientCert', e.currentTarget.value)} - placeholder={labels.TLSClientCert.placeholder} - label={labels.TLSClientCert.label} + placeholder={labels.tlsClientCert.placeholder} + label={labels.tlsClientCert.label} onClick={() => onResetClickFactory('tlsClientCert')} /> onCertificateChangeFactory('tlsClientKey', e.currentTarget.value)} onClick={() => onResetClickFactory('tlsClientKey')} /> @@ -301,26 +319,26 @@ export const ConfigEditor: React.FC = (props) => { - + = (props) => { {config.featureToggles['secureSocksDSProxyEnabled'] && versionGte(config.buildInfo.version, '10.0.0') && ( { it('should not call onOptionsChange if no v3 fields are present', async () => { @@ -62,3 +62,88 @@ describe('useMigrateV3Config', () => { expect(onOptionsChange).toHaveBeenCalledWith(expect.objectContaining(expectedOptions)); }); }); + +describe('onHttpHeadersChange', () => { + it('should properly sort headers into secure/plain config fields', async () => { + const onOptionsChange = jest.fn(); + const headers: CHHttpHeader[] = [ + { + name: 'X-Existing-Auth-Header', + value: '', + secure: true + }, + { + name: 'X-Existing-Header', + value: 'existing value', + secure: false + }, + { + name: 'Authorization', + value: 'secret1234', + secure: true + }, + { + name: 'X-Custom-Header', + value: 'plain text value', + secure: false + }, + ]; + const opts = { + jsonData: { + httpHeaders: [ + { + name: 'X-Existing-Auth-Header', + value: '', + secure: true + }, + { + name: 'X-Existing-Header', + value: 'existing value', + secure: false + }, + ] + }, + secureJsonFields: { + 'secureHttpHeaders.X-Existing-Auth-Header': true + }, + } as any as DataSourceSettings; + + onHttpHeadersChange(headers, opts, onOptionsChange); + + const expectedOptions = { + jsonData: { + httpHeaders: [ + { + name: 'X-Existing-Auth-Header', + value: '', + secure: true + }, + { + name: 'X-Existing-Header', + value: 'existing value', + secure: false + }, + { + name: 'Authorization', + value: '', + secure: true + }, + { + name: 'X-Custom-Header', + value: 'plain text value', + secure: false + }, + ] + }, + secureJsonFields: { + 'secureHttpHeaders.X-Existing-Auth-Header': true, + 'secureHttpHeaders.Authorization': true + }, + secureJsonData: { + 'secureHttpHeaders.Authorization': 'secret1234' + } + }; + expect(onOptionsChange).toHaveBeenCalledTimes(1); + expect(onOptionsChange).toHaveBeenCalledWith(expect.objectContaining(expectedOptions)); + }); +}); diff --git a/src/views/CHConfigEditorHooks.ts b/src/views/CHConfigEditorHooks.ts index eea4908c..24e5d501 100644 --- a/src/views/CHConfigEditorHooks.ts +++ b/src/views/CHConfigEditorHooks.ts @@ -1,13 +1,13 @@ -import { DataSourceSettings } from "@grafana/data"; +import { DataSourceSettings, KeyValue } from "@grafana/data"; import { useEffect, useRef } from "react"; -import { CHConfig } from "types/config"; +import { CHConfig, CHHttpHeader, CHSecureConfig } from "types/config"; /** * Migrates v3 config to latest config schema. * Copies and removes old "server" to "host" field * Copies and removes old "timeout" to "dialTimeout" field */ -export const useMigrateV3Config = (options: DataSourceSettings, onOptionsChange: (opts: DataSourceSettings) => void) => { +export const useMigrateV3Config = (options: DataSourceSettings, onOptionsChange: (opts: DataSourceSettings) => void) => { const { jsonData } = options; const v3ServerField = (jsonData as any)['server']; const v3TimeoutField = (jsonData as any)['timeout']; @@ -35,3 +35,59 @@ export const useMigrateV3Config = (options: DataSourceSettings, onOpti } }, [v3ServerField, v3TimeoutField, options, onOptionsChange]); }; + +/** + * Handles saving HTTP headers to Grafana config. + * + * All header keys go to the unsecure config. + * If the header is marked as secure, its value goes to the + * secure json config where it is hidden. + */ +export const onHttpHeadersChange = (headers: CHHttpHeader[], options: DataSourceSettings, onOptionsChange: (opts: DataSourceSettings) => void) => { + const httpHeaders: CHHttpHeader[] = []; + const secureHttpHeaderKeys: KeyValue = {}; + const secureHttpHeaderValues: KeyValue = {}; + + for (let header of headers) { + if (!header.name) { + continue; + } + + if (header.secure) { + const key = `secureHttpHeaders.${header.name}`; + secureHttpHeaderKeys[key] = true; + + if (header.value) { + secureHttpHeaderValues[key] = header.value; + header.value = ''; + } + } + + httpHeaders.push(header); + } + + const currentSecureJsonFields: KeyValue = { ...options.secureJsonFields }; + for (let key in currentSecureJsonFields) { + if (!secureHttpHeaderKeys[key] && key.startsWith('secureHttpHeaders.')) { + // Remove key from secureJsonData when it is no longer present in header config + secureHttpHeaderKeys[key] = false; + secureHttpHeaderValues[key] = ''; + } + } + + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + httpHeaders + }, + secureJsonFields: { + ...options.secureJsonFields, + ...secureHttpHeaderKeys + }, + secureJsonData: { + ...options.secureJsonData, + ...secureHttpHeaderValues + }, + }); +};