diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 445fa6bff28007..77bbeabb7f73b0 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,7 +112,7 @@ pageLoadAssetSize: expressionImage: 19288 expressionMetric: 22238 expressionShape: 34008 - interactiveSetup: 18532 + interactiveSetup: 70000 expressionTagcloud: 27505 expressions: 239290 securitySolution: 231753 diff --git a/src/plugins/interactive_setup/common/index.ts b/src/plugins/interactive_setup/common/index.ts index f736d1e230122e..ab8c00cfa5a8ed 100644 --- a/src/plugins/interactive_setup/common/index.ts +++ b/src/plugins/interactive_setup/common/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export type { InteractiveSetupViewState, EnrollmentToken } from './types'; +export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types'; export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status'; diff --git a/src/plugins/interactive_setup/common/types.ts b/src/plugins/interactive_setup/common/types.ts index 4df7c8eaa97246..de3f54dbf9a28a 100644 --- a/src/plugins/interactive_setup/common/types.ts +++ b/src/plugins/interactive_setup/common/types.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { PeerCertificate } from 'tls'; + import type { ElasticsearchConnectionStatus } from './elasticsearch_connection_status'; /** @@ -43,3 +45,24 @@ export interface EnrollmentToken { */ key: string; } + +export interface Certificate { + issuer: Partial; + valid_from: PeerCertificate['valid_from']; + valid_to: PeerCertificate['valid_to']; + subject: Partial; + fingerprint256: PeerCertificate['fingerprint256']; + raw: string; +} + +export interface PingResult { + /** + * Indicates whether the cluster requires authentication. + */ + authRequired: boolean; + + /** + * Full certificate chain of cluster at requested address. Only present if cluster uses HTTPS. + */ + certificateChain?: Certificate[]; +} diff --git a/src/plugins/interactive_setup/public/app.scss b/src/plugins/interactive_setup/public/app.scss new file mode 100644 index 00000000000000..119a2377dd7d22 --- /dev/null +++ b/src/plugins/interactive_setup/public/app.scss @@ -0,0 +1,26 @@ +.interactiveSetup { + @include kibanaFullScreenGraphics; +} + +.interactiveSetup__header { + position: relative; + z-index: 10; + padding: $euiSizeXL; +} + +.interactiveSetup__logo { + @include kibanaCircleLogo; + @include euiBottomShadowMedium; + + margin-bottom: $euiSizeXL; +} + +.interactiveSetup__content { + position: relative; + z-index: 10; + margin: auto; + margin-bottom: $euiSizeXL; + max-width: map-get($euiBreakpoints, 's') - $euiSizeXL; + padding-left: $euiSizeXL; + padding-right: $euiSizeXL; +} diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx index 2b6b7089539723..0c206cb4fa215b 100644 --- a/src/plugins/interactive_setup/public/app.tsx +++ b/src/plugins/interactive_setup/public/app.tsx @@ -6,22 +6,76 @@ * Side Public License, v 1. */ -import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; -import React from 'react'; +import './app.scss'; + +import { EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ClusterAddressForm } from './cluster_address_form'; +import type { ClusterConfigurationFormProps } from './cluster_configuration_form'; +import { ClusterConfigurationForm } from './cluster_configuration_form'; +import { EnrollmentTokenForm } from './enrollment_token_form'; +import { ProgressIndicator } from './progress_indicator'; + +export const App: FunctionComponent = () => { + const [page, setPage] = useState<'token' | 'manual' | 'success'>('token'); + const [cluster, setCluster] = useState< + Omit + >(); -export const App = () => { return ( - - - Kibana server is not ready yet. - - +
+
+ + + + + +

+ +

+
+ +
+
+ + + + {page === 'success' && ( + window.location.replace(window.location.href)} /> + )} + +
+
); }; diff --git a/src/plugins/interactive_setup/public/cluster_address_form.test.tsx b/src/plugins/interactive_setup/public/cluster_address_form.test.tsx new file mode 100644 index 00000000000000..e063205a90433c --- /dev/null +++ b/src/plugins/interactive_setup/public/cluster_address_form.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; + +import { ClusterAddressForm } from './cluster_address_form'; +import { Providers } from './plugin'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('ClusterAddressForm', () => { + jest.setTimeout(20_000); + + it('calls enrollment API when submitting form', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue({}); + + const onSuccess = jest.fn(); + + const { findByRole, findByLabelText } = render( + + + + ); + fireEvent.change(await findByLabelText('Address'), { + target: { value: 'https://localhost' }, + }); + fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true })); + + await waitFor(() => { + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/ping', { + body: JSON.stringify({ + host: 'https://localhost:9200', + }), + }); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('validates form', async () => { + const coreStart = coreMock.createStart(); + const onSuccess = jest.fn(); + + const { findAllByText, findByRole, findByLabelText } = render( + + + + ); + + fireEvent.change(await findByLabelText('Address'), { + target: { value: 'localhost' }, + }); + + fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true })); + + await findAllByText(/Enter a valid address including protocol/i); + + expect(coreStart.http.post).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/interactive_setup/public/cluster_address_form.tsx b/src/plugins/interactive_setup/public/cluster_address_form.tsx new file mode 100644 index 00000000000000..ba7b1d46182a17 --- /dev/null +++ b/src/plugins/interactive_setup/public/cluster_address_form.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IHttpFetchError } from 'kibana/public'; + +import type { PingResult } from '../common'; +import type { ValidationErrors } from './use_form'; +import { useForm } from './use_form'; +import { useHttp } from './use_http'; + +export interface ClusterAddressFormValues { + host: string; +} + +export interface ClusterAddressFormProps { + defaultValues?: ClusterAddressFormValues; + onCancel?(): void; + onSuccess?(result: PingResult, values: ClusterAddressFormValues): void; +} + +export const ClusterAddressForm: FunctionComponent = ({ + defaultValues = { + host: 'https://localhost:9200', + }, + onCancel, + onSuccess, +}) => { + const http = useHttp(); + + const [form, eventHandlers] = useForm({ + defaultValues, + validate: async (values) => { + const errors: ValidationErrors = {}; + + if (!values.host) { + errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', { + defaultMessage: 'Enter an address.', + }); + } else { + try { + const url = new URL(values.host); + if (!url.protocol || !url.hostname) { + throw new Error(); + } + } catch (error) { + errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', { + defaultMessage: 'Enter a valid address including protocol.', + }); + } + } + + return errors; + }, + onSubmit: async (values) => { + const url = new URL(values.host); + const host = `${url.protocol}//${url.hostname}:${url.port || 9200}`; + + const result = await http.post('/internal/interactive_setup/ping', { + body: JSON.stringify({ host }), + }); + + onSuccess?.(result, { host }); + }, + }); + + return ( + + {form.submitError && ( + <> + + {(form.submitError as IHttpFetchError).body?.message} + + + + )} + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/plugins/interactive_setup/public/cluster_configuration_form.test.tsx b/src/plugins/interactive_setup/public/cluster_configuration_form.test.tsx new file mode 100644 index 00000000000000..93f3fa11a1ce6e --- /dev/null +++ b/src/plugins/interactive_setup/public/cluster_configuration_form.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; + +import { ClusterConfigurationForm } from './cluster_configuration_form'; +import { Providers } from './plugin'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +describe('ClusterConfigurationForm', () => { + jest.setTimeout(20_000); + + it('calls enrollment API when submitting form', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue({}); + + const onSuccess = jest.fn(); + + const { findByRole, findByLabelText } = render( + + + + ); + fireEvent.change(await findByLabelText('Username'), { + target: { value: 'kibana_system' }, + }); + fireEvent.change(await findByLabelText('Password'), { + target: { value: 'changeme' }, + }); + fireEvent.click(await findByLabelText('Certificate authority')); + fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true })); + + await waitFor(() => { + expect(coreStart.http.post).toHaveBeenLastCalledWith( + '/internal/interactive_setup/configure', + { + body: JSON.stringify({ + host: 'https://localhost:9200', + username: 'kibana_system', + password: 'changeme', + caCert: 'cert', + }), + } + ); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('validates form', async () => { + const coreStart = coreMock.createStart(); + const onSuccess = jest.fn(); + + const { findAllByText, findByRole, findByLabelText } = render( + + + + ); + + fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true })); + + await findAllByText(/Enter a password/i); + await findAllByText(/Confirm that you recognize and trust this certificate/i); + + fireEvent.change(await findByLabelText('Username'), { + target: { value: 'elastic' }, + }); + + await findAllByText(/User 'elastic' can't be used as Kibana system user/i); + + expect(coreStart.http.post).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/interactive_setup/public/cluster_configuration_form.tsx b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx new file mode 100644 index 00000000000000..cd3541fe0318f0 --- /dev/null +++ b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx @@ -0,0 +1,322 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCheckableCard, + EuiFieldPassword, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IHttpFetchError } from 'kibana/public'; + +import type { Certificate } from '../common'; +import { TextTruncate } from './text_truncate'; +import type { ValidationErrors } from './use_form'; +import { useForm } from './use_form'; +import { useHtmlId } from './use_html_id'; +import { useHttp } from './use_http'; + +export interface ClusterConfigurationFormValues { + username: string; + password: string; + caCert: string; +} + +export interface ClusterConfigurationFormProps { + host: string; + authRequired: boolean; + certificateChain?: Certificate[]; + defaultValues?: ClusterConfigurationFormValues; + onCancel?(): void; + onSuccess?(): void; +} + +export const ClusterConfigurationForm: FunctionComponent = ({ + host, + authRequired, + certificateChain, + defaultValues = { + username: 'kibana_system', + password: '', + caCert: '', + }, + onCancel, + onSuccess, +}) => { + const http = useHttp(); + + const [form, eventHandlers] = useForm({ + defaultValues, + validate: async (values) => { + const errors: ValidationErrors = {}; + + if (authRequired) { + if (!values.username) { + errors.username = i18n.translate( + 'interactiveSetup.clusterConfigurationForm.usernameRequiredError', + { + defaultMessage: 'Enter a username.', + } + ); + } else if (values.username === 'elastic') { + errors.username = i18n.translate( + 'interactiveSetup.clusterConfigurationForm.usernameReservedError', + { + defaultMessage: "User 'elastic' can't be used as Kibana system user.", + } + ); + } + + if (!values.password) { + errors.password = i18n.translate( + 'interactiveSetup.clusterConfigurationForm.passwordRequiredError', + { + defaultMessage: `Enter a password.`, + } + ); + } + } + + if (certificateChain && !values.caCert) { + errors.caCert = i18n.translate( + 'interactiveSetup.clusterConfigurationForm.caCertConfirmationRequiredError', + { + defaultMessage: 'Confirm that you recognize and trust this certificate.', + } + ); + } + + return errors; + }, + onSubmit: async (values) => { + await http.post('/internal/interactive_setup/configure', { + body: JSON.stringify({ + host, + username: values.username, + password: values.password, + caCert: values.caCert, + }), + }); + onSuccess?.(); + }, + }); + + const trustCaCertId = useHtmlId('clusterConfigurationForm', 'trustCaCert'); + + return ( + + {form.submitError && ( + <> + + {(form.submitError as IHttpFetchError).body?.message} + + + + )} + + + + + + + + {host} + + + + + + {authRequired ? ( + <> + + + + + + + + + ) : ( + <> + +

+ +

+

+ + + +

+
+ + + )} + + {certificateChain && certificateChain.length > 0 && ( + <> + + { + const intermediateCa = certificateChain[Math.min(1, certificateChain.length - 1)]; + form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw); + form.setTouched('caCert'); + }} + > + + + + + + )} + + + + + + + + + + + + + +
+ ); +}; + +export interface CertificatePanelProps { + certificate: Certificate; +} + +export const CertificatePanel: FunctionComponent = ({ certificate }) => { + return ( + + + + + + + +

{certificate.subject.O || certificate.subject.CN}

+
+ + + + + + +
+
+
+ ); +}; diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.test.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.test.tsx new file mode 100644 index 00000000000000..d2f08eac1fac52 --- /dev/null +++ b/src/plugins/interactive_setup/public/enrollment_token_form.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; + +import type { EnrollmentToken } from '../common'; +import { decodeEnrollmentToken, EnrollmentTokenForm } from './enrollment_token_form'; +import { Providers } from './plugin'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const token: EnrollmentToken = { + ver: '8.0.0', + adr: ['localhost:9200'], + fgr: + 'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36', + key: 'JH-36HoBo4EYIoVhHh2F:uEo4dksARMq_BSHaAHUr8Q', +}; + +describe('EnrollmentTokenForm', () => { + jest.setTimeout(20_000); + + it('calls enrollment API when submitting form', async () => { + const coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue({}); + + const onSuccess = jest.fn(); + + const { findByRole, findByLabelText } = render( + + + + ); + fireEvent.change(await findByLabelText('Enrollment token'), { + target: { value: btoa(JSON.stringify(token)) }, + }); + fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true })); + + await waitFor(() => { + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/enroll', { + body: JSON.stringify({ + hosts: [`https://${token.adr[0]}`], + apiKey: btoa(token.key), + caFingerprint: token.fgr, + }), + }); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('validates form', async () => { + const coreStart = coreMock.createStart(); + const onSuccess = jest.fn(); + + const { findAllByText, findByRole, findByLabelText } = render( + + + + ); + + fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true })); + + await findAllByText(/Enter an enrollment token/i); + + fireEvent.change(await findByLabelText('Enrollment token'), { + target: { value: 'invalid' }, + }); + + await findAllByText(/Enter a valid enrollment token/i); + }); +}); + +describe('decodeEnrollmentToken', () => { + it('should decode a valid token', () => { + expect(decodeEnrollmentToken(btoa(JSON.stringify(token)))).toEqual({ + adr: ['https://localhost:9200'], + fgr: + 'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36', + key: 'SkgtMzZIb0JvNEVZSW9WaEhoMkY6dUVvNGRrc0FSTXFfQlNIYUFIVXI4UQ==', + ver: '8.0.0', + }); + }); + + it('should not decode an invalid token', () => { + expect(decodeEnrollmentToken(JSON.stringify(token))).toBeUndefined(); + expect( + decodeEnrollmentToken( + btoa( + JSON.stringify({ + ver: [''], + adr: null, + fgr: false, + key: undefined, + }) + ) + ) + ).toBeUndefined(); + expect(decodeEnrollmentToken(btoa(JSON.stringify({})))).toBeUndefined(); + expect(decodeEnrollmentToken(btoa(JSON.stringify([])))).toBeUndefined(); + expect(decodeEnrollmentToken(btoa(JSON.stringify(null)))).toBeUndefined(); + expect(decodeEnrollmentToken(btoa(JSON.stringify('')))).toBeUndefined(); + }); +}); diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx new file mode 100644 index 00000000000000..3b5c751874a10a --- /dev/null +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiText, + EuiTextArea, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IHttpFetchError } from 'kibana/public'; + +import type { EnrollmentToken } from '../common'; +import { TextTruncate } from './text_truncate'; +import type { ValidationErrors } from './use_form'; +import { useForm } from './use_form'; +import { useHttp } from './use_http'; + +export interface EnrollmentTokenFormValues { + token: string; +} + +export interface EnrollmentTokenFormProps { + defaultValues?: EnrollmentTokenFormValues; + onCancel?(): void; + onSuccess?(): void; +} + +export const EnrollmentTokenForm: FunctionComponent = ({ + defaultValues = { + token: '', + }, + onCancel, + onSuccess, +}) => { + const http = useHttp(); + const [form, eventHandlers] = useForm({ + defaultValues, + validate: (values) => { + const errors: ValidationErrors = {}; + + if (!values.token) { + errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenRequiredError', { + defaultMessage: 'Enter an enrollment token.', + }); + } else { + const decoded = decodeEnrollmentToken(values.token); + if (!decoded) { + errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenInvalidError', { + defaultMessage: 'Enter a valid enrollment token.', + }); + } + } + + return errors; + }, + onSubmit: async (values) => { + const decoded = decodeEnrollmentToken(values.token)!; + await http.post('/internal/interactive_setup/enroll', { + body: JSON.stringify({ + hosts: decoded.adr, + apiKey: decoded.key, + caFingerprint: decoded.fgr, + }), + }); + onSuccess?.(); + }, + }); + + const enrollmentToken = decodeEnrollmentToken(form.values.token); + + return ( + + {form.submitError && ( + <> + + {(form.submitError as IHttpFetchError).body?.message} + + + + )} + + } + fullWidth + > + + + + + + + + + + + + + + + + + + ); +}; + +interface EnrollmentTokenDetailsProps { + token: EnrollmentToken; +} + +const EnrollmentTokenDetails: FunctionComponent = ({ token }) => ( + + + + + + + + + + {token.adr[0]} + + + + + + + + + +); + +export function decodeEnrollmentToken(enrollmentToken: string) { + try { + const json = JSON.parse(atob(enrollmentToken)) as EnrollmentToken; + if ( + !Array.isArray(json.adr) || + json.adr.some((adr) => typeof adr !== 'string') || + typeof json.fgr !== 'string' || + typeof json.key !== 'string' || + typeof json.ver !== 'string' + ) { + return; + } + return { + ...json, + adr: json.adr.map((host) => `https://${host}`), + key: btoa(json.key), + }; + } catch (error) {} // eslint-disable-line no-empty +} diff --git a/src/plugins/interactive_setup/public/index.ts b/src/plugins/interactive_setup/public/index.ts index 153bc92a0dd087..7855b90b810d21 100644 --- a/src/plugins/interactive_setup/public/index.ts +++ b/src/plugins/interactive_setup/public/index.ts @@ -6,6 +6,6 @@ * Side Public License, v 1. */ -import { UserSetupPlugin } from './plugin'; +import { InteractiveSetupPlugin } from './plugin'; -export const plugin = () => new UserSetupPlugin(); +export const plugin = () => new InteractiveSetupPlugin(); diff --git a/src/plugins/interactive_setup/public/plugin.tsx b/src/plugins/interactive_setup/public/plugin.tsx index 375f04e5047d51..00fd38d3e78a45 100644 --- a/src/plugins/interactive_setup/public/plugin.tsx +++ b/src/plugins/interactive_setup/public/plugin.tsx @@ -6,21 +6,30 @@ * Side Public License, v 1. */ +import type { FunctionComponent } from 'react'; import React from 'react'; import ReactDOM from 'react-dom'; -import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { I18nProvider } from '@kbn/i18n/react'; +import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public'; import { App } from './app'; +import { HttpProvider } from './use_http'; -export class UserSetupPlugin implements Plugin { +export class InteractiveSetupPlugin implements Plugin { public setup(core: CoreSetup) { core.application.register({ id: 'interactiveSetup', - title: 'Interactive Setup', + title: 'Configure Elastic to get started', + appRoute: '/', chromeless: true, mount: (params) => { - ReactDOM.render(, params.element); + ReactDOM.render( + + + , + params.element + ); return () => ReactDOM.unmountComponentAtNode(params.element); }, }); @@ -28,3 +37,13 @@ export class UserSetupPlugin implements Plugin { public start(core: CoreStart) {} } + +export interface ProvidersProps { + http: HttpSetup; +} + +export const Providers: FunctionComponent = ({ http, children }) => ( + + {children} + +); diff --git a/src/plugins/interactive_setup/public/progress_indicator.tsx b/src/plugins/interactive_setup/public/progress_indicator.tsx new file mode 100644 index 00000000000000..a6d499f6a57124 --- /dev/null +++ b/src/plugins/interactive_setup/public/progress_indicator.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EuiStepProps } from '@elastic/eui'; +import { EuiPanel, EuiSteps } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { useEffect } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import useTimeoutFn from 'react-use/lib/useTimeoutFn'; + +import { i18n } from '@kbn/i18n'; + +import { useHttp } from './use_http'; + +export interface ProgressIndicatorProps { + onSuccess?(): void; +} + +export const ProgressIndicator: FunctionComponent = ({ onSuccess }) => { + const http = useHttp(); + const [status, checkStatus] = useAsyncFn(async () => { + let isAvailable: boolean | undefined = false; + let isPastPreboot: boolean | undefined = false; + try { + const { response } = await http.get('/api/status', { asResponse: true }); + isAvailable = response ? response.status < 500 : undefined; + isPastPreboot = response?.headers.get('content-type')?.includes('application/json'); + } catch ({ response }) { + isAvailable = response ? response.status < 500 : undefined; + isPastPreboot = response?.headers.get('content-type')?.includes('application/json'); + } + return isAvailable === true && isPastPreboot === true + ? 'complete' + : isAvailable === false + ? 'unavailable' + : isAvailable === true && isPastPreboot === false + ? 'preboot' + : 'unknown'; + }); + + const [, cancelPolling, resetPolling] = useTimeoutFn(checkStatus, 1000); + + useEffect(() => { + if (status.value === 'complete') { + cancelPolling(); + onSuccess?.(); + } else if (status.loading === false) { + resetPolling(); + } + }, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + ); +}; + +type Optional = Omit & Partial; + +export interface LoadingStepsProps { + currentStepId?: string; + steps: Array>; +} + +export const LoadingSteps: FunctionComponent = ({ currentStepId, steps }) => { + const currentStepIndex = steps.findIndex((step) => step.id === currentStepId); + return ( + ({ + status: + i <= currentStepIndex + ? 'complete' + : steps[i - 1]?.id === currentStepId + ? 'loading' + : 'incomplete', + children: null, + ...step, + }))} + /> + ); +}; diff --git a/src/plugins/interactive_setup/public/text_truncate.tsx b/src/plugins/interactive_setup/public/text_truncate.tsx new file mode 100644 index 00000000000000..32736e80211aec --- /dev/null +++ b/src/plugins/interactive_setup/public/text_truncate.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiToolTip } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; + +export const TextTruncate: FunctionComponent = ({ children }) => { + const textRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + useLayoutEffect(() => { + if (textRef.current) { + const { clientWidth, scrollWidth } = textRef.current; + setShowTooltip(scrollWidth > clientWidth); + } + }, [children]); + + const truncated = ( + + {children} + + ); + + if (showTooltip) { + return ( + + {truncated} + + ); + } + + return truncated; +}; diff --git a/src/plugins/interactive_setup/public/use_form.ts b/src/plugins/interactive_setup/public/use_form.ts new file mode 100644 index 00000000000000..8ed1d89ea087e9 --- /dev/null +++ b/src/plugins/interactive_setup/public/use_form.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { set } from '@elastic/safer-lodash-set'; +import { cloneDeep, cloneDeepWith, get } from 'lodash'; +import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; +import { useRef } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +export type FormReturnTuple = [FormState, FormProps]; + +export interface FormProps { + onSubmit: ReactEventHandler; + onChange: ChangeEventHandler; + onBlur: FocusEventHandler; +} + +export interface FormOptions { + onSubmit: SubmitCallback; + validate: ValidateCallback; + defaultValues: Values; +} + +/** + * Returns state and {@link HTMLFormElement} event handlers useful for creating + * forms with inline validation. + * + * @see {@link useFormState} if you don't want to use {@link HTMLFormElement}. + * + * @example + * ```typescript + * const [form, eventHandlers] = useForm({ + * onSubmit: (values) => apiClient.create(values), + * validate: (values) => !values.email ? { email: 'Required' } : {} + * }); + * + * + * + * Submit + * + * ``` + */ +export function useForm( + options: FormOptions +): FormReturnTuple { + const form = useFormState(options); + + const eventHandlers: FormProps = { + onSubmit: (event) => { + event.preventDefault(); + form.submit(); + }, + onChange: (event) => { + const { name, type, checked, value } = event.target; + if (name) { + form.setValue(name, type === 'checkbox' ? checked : value); + } + }, + onBlur: (event) => { + const { name } = event.target; + if (name) { + form.setTouched(event.target.name); + } + }, + }; + + return [form, eventHandlers]; +} + +export type FormValues = Record; +export type SubmitCallback = (values: Values) => Promise; +export type ValidateCallback = ( + values: Values +) => ValidationErrors | Promise>; +export type ValidationErrors = DeepMap; +export type TouchedFields = DeepMap; + +export interface FormState { + setValue(name: string, value: any, revalidate?: boolean): Promise; + setError(name: string, message: string): void; + setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise; + reset(values: Values): void; + submit(): Promise; + validate(): Promise>; + values: Values; + errors: ValidationErrors; + touched: TouchedFields; + isValidating: boolean; + isSubmitting: boolean; + submitError: Error | undefined; + isInvalid: boolean; + isSubmitted: boolean; +} + +/** + * Returns state useful for creating forms with inline validation. + * + * @example + * ```typescript + * const form = useFormState({ + * onSubmit: (values) => apiClient.create(values), + * validate: (values) => !values.toggle ? { toggle: 'Required' } : {} + * }); + * + * form.setValue('toggle', e.target.checked)} + * onBlur={() => form.setTouched('toggle')} + * isInvalid={!!form.errors.toggle} + * /> + * + * Submit + * + * ``` + */ +export function useFormState({ + onSubmit, + validate, + defaultValues, +}: FormOptions): FormState { + const valuesRef = useRef(defaultValues); + const errorsRef = useRef>({}); + const touchedRef = useRef>({}); + const submitCountRef = useRef(0); + + const [validationState, validateForm] = useAsyncFn(async (formValues: Values) => { + const nextErrors = await validate(formValues); + errorsRef.current = nextErrors; + if (Object.keys(nextErrors).length === 0) { + submitCountRef.current = 0; + } + return nextErrors; + }, []); + + const [submitState, submitForm] = useAsyncFn(async (formValues: Values) => { + const nextErrors = await validateForm(formValues); + touchedRef.current = mapDeep(formValues, true); + submitCountRef.current += 1; + if (Object.keys(nextErrors).length === 0) { + return onSubmit(formValues); + } + }, []); + + return { + setValue: async (name, value, revalidate = true) => { + const nextValues = setDeep(valuesRef.current, name, value); + valuesRef.current = nextValues; + if (revalidate) { + await validateForm(nextValues); + } + }, + setTouched: async (name, touched = true, revalidate = true) => { + touchedRef.current = setDeep(touchedRef.current, name, touched); + if (revalidate) { + await validateForm(valuesRef.current); + } + }, + setError: (name, message) => { + errorsRef.current = setDeep(errorsRef.current, name, message); + touchedRef.current = setDeep(touchedRef.current, name, true); + }, + reset: (nextValues) => { + valuesRef.current = nextValues; + errorsRef.current = {}; + touchedRef.current = {}; + submitCountRef.current = 0; + }, + submit: () => submitForm(valuesRef.current), + validate: () => validateForm(valuesRef.current), + values: valuesRef.current, + errors: errorsRef.current, + touched: touchedRef.current, + isValidating: validationState.loading, + isSubmitting: submitState.loading, + submitError: submitState.error, + isInvalid: Object.keys(errorsRef.current).length > 0, + isSubmitted: submitCountRef.current > 0, + }; +} + +type DeepMap = { + [K in keyof T]?: T[K] extends any[] + ? T[K][number] extends object + ? Array> + : TValue + : T[K] extends object + ? DeepMap + : TValue; +}; + +function mapDeep(values: T, value: V): DeepMap { + return cloneDeepWith(values, (v) => { + if (typeof v !== 'object' && v !== null) { + return value; + } + }); +} + +function setDeep(values: T, name: string, value: V): T { + if (get(values, name) !== value) { + return set(cloneDeep(values), name, value); + } + return values; +} diff --git a/src/plugins/interactive_setup/public/use_html_id.ts b/src/plugins/interactive_setup/public/use_html_id.ts new file mode 100644 index 00000000000000..d2b568bf263199 --- /dev/null +++ b/src/plugins/interactive_setup/public/use_html_id.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { htmlIdGenerator } from '@elastic/eui'; +import { useMemo } from 'react'; + +/** + * Generates an ID that can be used for HTML elements. + * + * @param prefix Prefix of the id to be generated + * @param suffix Suffix of the id to be generated + * + * @example + * ```typescript + * const titleId = useHtmlId('changePasswordForm', 'title'); + * + * + *

Change password

+ *
+ * ``` + */ +export function useHtmlId(prefix?: string, suffix?: string) { + return useMemo(() => htmlIdGenerator(prefix)(suffix), [prefix, suffix]); +} diff --git a/src/plugins/interactive_setup/public/use_http.ts b/src/plugins/interactive_setup/public/use_http.ts new file mode 100644 index 00000000000000..6d2a9f03d4c73a --- /dev/null +++ b/src/plugins/interactive_setup/public/use_http.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import constate from 'constate'; + +import type { HttpSetup } from 'src/core/public'; + +export const [HttpProvider, useHttp] = constate(({ http }: { http: HttpSetup }) => { + return http; +}); diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts b/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts index 8bc7e4307e76f8..9b59ab59672614 100644 --- a/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts +++ b/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts @@ -16,5 +16,7 @@ export const elasticsearchServiceMock = { ElasticsearchConnectionStatus.Configured ), enroll: jest.fn(), + authenticate: jest.fn(), + ping: jest.fn(), }), }; diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.test.ts b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts index 546ab7ea8f9c09..ce4893112ebad5 100644 --- a/src/plugins/interactive_setup/server/elasticsearch_service.test.ts +++ b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts @@ -7,6 +7,7 @@ */ import { errors } from '@elastic/elasticsearch'; +import tls from 'tls'; import { nextTick } from '@kbn/test/jest'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; @@ -17,6 +18,10 @@ import type { ElasticsearchServiceSetup } from './elasticsearch_service'; import { ElasticsearchService } from './elasticsearch_service'; import { interactiveSetupMock } from './mocks'; +jest.mock('tls'); + +const tlsConnectMock = tls.connect as jest.MockedFunction; + describe('ElasticsearchService', () => { let service: ElasticsearchService; let mockElasticsearchPreboot: ReturnType; @@ -33,17 +38,21 @@ describe('ElasticsearchService', () => { let mockAuthenticateClient: ReturnType< typeof elasticsearchServiceMock.createCustomClusterClient >; + let mockPingClient: ReturnType; let setupContract: ElasticsearchServiceSetup; beforeEach(() => { mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient(); mockEnrollClient = elasticsearchServiceMock.createCustomClusterClient(); mockAuthenticateClient = elasticsearchServiceMock.createCustomClusterClient(); + mockPingClient = elasticsearchServiceMock.createCustomClusterClient(); mockElasticsearchPreboot.createClient.mockImplementation((type) => { switch (type) { case 'enroll': return mockEnrollClient; case 'authenticate': return mockAuthenticateClient; + case 'ping': + return mockPingClient; default: return mockConnectionStatusClient; } @@ -414,7 +423,7 @@ some weird+ca/with caFingerprint: 'DE:AD:BE:EF', }) ).resolves.toEqual({ - ca: expectedCa, + caCert: expectedCa, host: 'host2', serviceAccountToken: { name: 'some-name', @@ -478,6 +487,133 @@ some weird+ca/with expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1); }); }); + + describe('#authenticate()', () => { + it('fails if ping call fails', async () => { + mockAuthenticateClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + await expect( + setupContract.authenticate({ host: 'http://localhost:9200' }) + ).rejects.toMatchInlineSnapshot(`[ConnectionError: some-message]`); + }); + + it('succeeds if ping call succeeds', async () => { + mockAuthenticateClient.asInternalUser.ping.mockResolvedValue( + interactiveSetupMock.createApiResponse({ statusCode: 200, body: true }) + ); + + await expect( + setupContract.authenticate({ host: 'http://localhost:9200' }) + ).resolves.toEqual(undefined); + }); + }); + + describe('#ping()', () => { + it('fails if host is not reachable', async () => { + mockPingClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot( + `[ConnectionError: some-message]` + ); + }); + + it('fails if host is not supported', async () => { + mockPingClient.asInternalUser.ping.mockRejectedValue( + new errors.ProductNotSupportedError(interactiveSetupMock.createApiResponse({ body: {} })) + ); + + await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot( + `[ProductNotSupportedError: The client noticed that the server is not Elasticsearch and we do not support this unknown product.]` + ); + }); + + it('succeeds if host does not require authentication', async () => { + mockPingClient.asInternalUser.ping.mockResolvedValue( + interactiveSetupMock.createApiResponse({ statusCode: 200, body: true }) + ); + + await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({ + authRequired: false, + certificateChain: undefined, + }); + }); + + it('succeeds if host requires authentication', async () => { + mockPingClient.asInternalUser.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ); + + await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({ + authRequired: true, + certificateChain: undefined, + }); + }); + + it('succeeds if host requires SSL', async () => { + mockPingClient.asInternalUser.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ); + + tlsConnectMock.mockReturnValue(({ + once: jest.fn((event, fn) => { + if (event === 'secureConnect') { + fn(); + } + }), + getPeerCertificate: jest.fn().mockReturnValue({ raw: Buffer.from('cert') }), + destroy: jest.fn(), + } as unknown) as tls.TLSSocket); + + await expect(setupContract.ping('https://localhost:9200')).resolves.toEqual({ + authRequired: true, + certificateChain: [ + expect.objectContaining({ + raw: 'Y2VydA==', + }), + ], + }); + + expect(tlsConnectMock).toHaveBeenCalledWith({ + host: 'localhost', + port: 9200, + rejectUnauthorized: false, + }); + }); + + it('fails if peer certificate cannot be fetched', async () => { + mockPingClient.asInternalUser.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ); + + tlsConnectMock.mockReturnValue(({ + once: jest.fn((event, fn) => { + if (event === 'error') { + fn(new Error('some-message')); + } + }), + } as unknown) as tls.TLSSocket); + + await expect(setupContract.ping('https://localhost:9200')).rejects.toMatchInlineSnapshot( + `[Error: some-message]` + ); + }); + }); }); describe('#stop()', () => { @@ -489,7 +625,7 @@ some weird+ca/with const mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient(); mockElasticsearchPreboot.createClient.mockImplementation((type) => { switch (type) { - case 'ping': + case 'connectionStatus': return mockConnectionStatusClient; default: throw new Error(`Unexpected client type: ${type}`); diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.ts b/src/plugins/interactive_setup/server/elasticsearch_service.ts index c88ac0f0798c9a..edfe203df8e48f 100644 --- a/src/plugins/interactive_setup/server/elasticsearch_service.ts +++ b/src/plugins/interactive_setup/server/elasticsearch_service.ts @@ -19,9 +19,9 @@ import { shareReplay, takeWhile, } from 'rxjs/operators'; +import tls from 'tls'; import type { - ElasticsearchClientConfig, ElasticsearchServicePreboot, ICustomClusterClient, Logger, @@ -29,14 +29,22 @@ import type { } from 'src/core/server'; import { ElasticsearchConnectionStatus } from '../common'; -import { getDetailedErrorMessage } from './errors'; +import type { Certificate, PingResult } from '../common'; +import { getDetailedErrorMessage, getErrorStatusCode } from './errors'; -interface EnrollParameters { +export interface EnrollParameters { apiKey: string; hosts: string[]; caFingerprint: string; } +export interface AuthenticateParameters { + host: string; + username?: string; + password?: string; + caCert?: string; +} + export interface ElasticsearchServiceSetupDeps { /** * Core Elasticsearch service preboot contract; @@ -63,6 +71,16 @@ export interface ElasticsearchServiceSetup { * to point to exactly same Elasticsearch node, potentially available via different network interfaces. */ enroll: (params: EnrollParameters) => Promise; + + /** + * Tries to authenticate specified user with cluster. + */ + authenticate: (params: AuthenticateParameters) => Promise; + + /** + * Tries to connect to specified cluster and fetches certificate chain. + */ + ping: (host: string) => Promise; } /** @@ -76,13 +94,20 @@ export interface EnrollResult { /** * PEM CA certificate for the Elasticsearch HTTP certificates. */ - ca: string; + caCert: string; /** * Service account token for the "elastic/kibana" service account. */ serviceAccountToken: { name: string; value: string }; } +export interface AuthenticateResult { + host: string; + username?: string; + password?: string; + caCert?: string; +} + export class ElasticsearchService { /** * Elasticsearch client used to check Elasticsearch connection status. @@ -95,7 +120,7 @@ export class ElasticsearchService { connectionCheckInterval, }: ElasticsearchServiceSetupDeps): ElasticsearchServiceSetup { const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient( - 'ping' + 'connectionStatus' )); return { @@ -120,6 +145,8 @@ export class ElasticsearchService { shareReplay({ refCount: true, bufferSize: 1 }) ), enroll: this.enroll.bind(this, elasticsearch), + authenticate: this.authenticate.bind(this, elasticsearch), + ping: this.ping.bind(this, elasticsearch), }; } @@ -145,11 +172,8 @@ export class ElasticsearchService { private async enroll( elasticsearch: ElasticsearchServicePreboot, { apiKey, hosts, caFingerprint }: EnrollParameters - ): Promise { + ) { const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } }; - const elasticsearchConfig: Partial = { - ssl: { verificationMode: 'none' }, - }; // We should iterate through all provided hosts until we find an accessible one. for (const host of hosts) { @@ -158,9 +182,9 @@ export class ElasticsearchService { ); const enrollClient = elasticsearch.createClient('enroll', { - ...elasticsearchConfig, hosts: [host], caFingerprint, + ssl: { verificationMode: 'none' }, }); let enrollmentResponse; @@ -176,51 +200,51 @@ export class ElasticsearchService { // that enrollment will fail for any other host and we should bail out. if (err instanceof errors.ConnectionError || err instanceof errors.TimeoutError) { this.logger.error( - `Unable to connect to "${host}" host, will proceed to the next host if available: ${getDetailedErrorMessage( + `Unable to connect to host "${host}", will proceed to the next host if available: ${getDetailedErrorMessage( err )}` ); continue; } - this.logger.error(`Failed to enroll with "${host}" host: ${getDetailedErrorMessage(err)}`); + this.logger.error(`Failed to enroll with host "${host}": ${getDetailedErrorMessage(err)}`); throw err; } finally { await enrollClient.close(); } this.logger.debug( - `Successfully enrolled with "${host}" host, token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}` + `Successfully enrolled with host "${host}", token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}` ); - const enrollResult = { + const enrollResult: EnrollResult = { host, - ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca), + caCert: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca), serviceAccountToken: enrollmentResponse.body.token, }; - // Now try to use retrieved password and CA certificate to authenticate to this host. + // Now try to use retrieved service account and CA certificate to authenticate to this host. const authenticateClient = elasticsearch.createClient('authenticate', { caFingerprint, hosts: [host], serviceAccountToken: enrollResult.serviceAccountToken.value, - ssl: { certificateAuthorities: [enrollResult.ca] }, + ssl: { certificateAuthorities: [enrollResult.caCert] }, }); this.logger.debug( - `Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to "${host}" host.` + `Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to host "${host}".` ); try { await authenticateClient.asInternalUser.security.authenticate(); this.logger.debug( - `Successfully authenticated "${enrollmentResponse.body.token.name}" token to "${host}" host.` + `Successfully authenticated "${enrollmentResponse.body.token.name}" token to host "${host}".` ); } catch (err) { this.logger.error( `Failed to authenticate "${ enrollmentResponse.body.token.name - }" token to "${host}" host: ${getDetailedErrorMessage(err)}.` + }" token to host "${host}": ${getDetailedErrorMessage(err)}.` ); throw err; } finally { @@ -233,7 +257,114 @@ export class ElasticsearchService { throw new Error('Unable to connect to any of the provided hosts.'); } - private static createPemCertificate(derCaString: string) { + private async authenticate( + elasticsearch: ElasticsearchServicePreboot, + { host, username, password, caCert }: AuthenticateParameters + ) { + const client = elasticsearch.createClient('authenticate', { + hosts: [host], + username, + password, + ssl: caCert ? { certificateAuthorities: [caCert] } : undefined, + }); + + try { + // Using `ping` instead of `authenticate` allows us to verify clusters with both + // security enabled and disabled. + await client.asInternalUser.ping(); + } catch (error) { + this.logger.error( + `Failed to authenticate with host "${host}": ${getDetailedErrorMessage(error)}` + ); + throw error; + } finally { + await client.close(); + } + } + + private async ping(elasticsearch: ElasticsearchServicePreboot, host: string) { + const client = elasticsearch.createClient('ping', { + hosts: [host], + username: '', + password: '', + ssl: { verificationMode: 'none' }, + }); + + let authRequired = false; + try { + await client.asInternalUser.ping(); + } catch (error) { + if ( + error instanceof errors.ConnectionError || + error instanceof errors.TimeoutError || + error instanceof errors.ProductNotSupportedError + ) { + this.logger.error(`Unable to connect to host "${host}": ${getDetailedErrorMessage(error)}`); + throw error; + } + + authRequired = getErrorStatusCode(error) === 401; + } finally { + await client.close(); + } + + let certificateChain: Certificate[] | undefined; + const { protocol, hostname, port } = new URL(host); + if (protocol === 'https:') { + try { + const cert = await ElasticsearchService.fetchPeerCertificate(hostname, port); + certificateChain = ElasticsearchService.flattenCertificateChain(cert).map( + ElasticsearchService.getCertificate + ); + } catch (error) { + this.logger.error( + `Failed to fetch peer certificate from host "${host}": ${getDetailedErrorMessage(error)}` + ); + throw error; + } + } + + return { + authRequired, + certificateChain, + }; + } + + private static fetchPeerCertificate(host: string, port: string | number) { + return new Promise((resolve, reject) => { + const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: false }); + socket.once('secureConnect', () => { + const cert = socket.getPeerCertificate(true); + socket.destroy(); + resolve(cert); + }); + socket.once('error', reject); + }); + } + + private static flattenCertificateChain( + cert: tls.DetailedPeerCertificate, + accumulator: tls.DetailedPeerCertificate[] = [] + ) { + accumulator.push(cert); + if (cert.issuerCertificate && cert.fingerprint256 !== cert.issuerCertificate.fingerprint256) { + ElasticsearchService.flattenCertificateChain(cert.issuerCertificate, accumulator); + } + return accumulator; + } + + private static getCertificate(cert: tls.DetailedPeerCertificate): Certificate { + return { + issuer: cert.issuer, + valid_from: cert.valid_from, + valid_to: cert.valid_to, + subject: cert.subject, + fingerprint256: cert.fingerprint256, + raw: cert.raw.toString('base64'), + }; + } + + public static createPemCertificate(derCaString: string) { // Use `X509Certificate` class once we upgrade to Node v16. return `-----BEGIN CERTIFICATE-----\n${derCaString .replace(/_/g, '/') diff --git a/src/plugins/interactive_setup/server/index.ts b/src/plugins/interactive_setup/server/index.ts index 018c6875b3c048..2f9a2cf3adec0c 100644 --- a/src/plugins/interactive_setup/server/index.ts +++ b/src/plugins/interactive_setup/server/index.ts @@ -14,7 +14,7 @@ import type { } from 'src/core/server'; import { ConfigSchema } from './config'; -import { UserSetupPlugin } from './plugin'; +import { InteractiveSetupPlugin } from './plugin'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, @@ -22,4 +22,4 @@ export const config: PluginConfigDescriptor> = { export const plugin: PluginInitializer = ( initializerContext: PluginInitializerContext -) => new UserSetupPlugin(initializerContext); +) => new InteractiveSetupPlugin(initializerContext); diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts index 7ae98157ba1563..7dc119b87f20ae 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts @@ -74,7 +74,7 @@ describe('KibanaConfigWriter', () => { await expect( kibanaConfigWriter.writeConfig({ - ca: 'ca-content', + caCert: 'ca-content', host: '', serviceAccountToken: { name: '', value: '' }, }) @@ -90,7 +90,7 @@ describe('KibanaConfigWriter', () => { await expect( kibanaConfigWriter.writeConfig({ - ca: 'ca-content', + caCert: 'ca-content', host: 'some-host', serviceAccountToken: { name: 'some-token', value: 'some-value' }, }) @@ -103,7 +103,7 @@ describe('KibanaConfigWriter', () => { '/some/path/kibana.yml', ` -# This section was automatically generated during setup (service account token name is "some-token"). +# This section was automatically generated during setup. elasticsearch.hosts: [some-host] elasticsearch.serviceAccountToken: some-value elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] @@ -112,10 +112,10 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] ); }); - it('can successfully write CA certificate and elasticsearch config to the disk', async () => { + it('can successfully write CA certificate and elasticsearch config with service token', async () => { await expect( kibanaConfigWriter.writeConfig({ - ca: 'ca-content', + caCert: 'ca-content', host: 'some-host', serviceAccountToken: { name: 'some-token', value: 'some-value' }, }) @@ -128,11 +128,62 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] '/some/path/kibana.yml', ` -# This section was automatically generated during setup (service account token name is "some-token"). +# This section was automatically generated during setup. elasticsearch.hosts: [some-host] elasticsearch.serviceAccountToken: some-value elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] +` + ); + }); + + it('can successfully write CA certificate and elasticsearch config with credentials', async () => { + await expect( + kibanaConfigWriter.writeConfig({ + caCert: 'ca-content', + host: 'some-host', + username: 'username', + password: 'password', + }) + ).resolves.toBeUndefined(); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content'); + expect(mockAppendFile).toHaveBeenCalledTimes(1); + expect(mockAppendFile).toHaveBeenCalledWith( + '/some/path/kibana.yml', + ` + +# This section was automatically generated during setup. +elasticsearch.hosts: [some-host] +elasticsearch.password: password +elasticsearch.username: username +elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] + +` + ); + }); + + it('can successfully write elasticsearch config without CA certificate', async () => { + await expect( + kibanaConfigWriter.writeConfig({ + host: 'some-host', + username: 'username', + password: 'password', + }) + ).resolves.toBeUndefined(); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledTimes(1); + expect(mockAppendFile).toHaveBeenCalledWith( + '/some/path/kibana.yml', + ` + +# This section was automatically generated during setup. +elasticsearch.hosts: [some-host] +elasticsearch.password: password +elasticsearch.username: username + ` ); }); diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts index b3178d9a909bd3..a59aa7640caa6e 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.ts @@ -15,11 +15,19 @@ import type { Logger } from 'src/core/server'; import { getDetailedErrorMessage } from './errors'; -export interface WriteConfigParameters { +export type WriteConfigParameters = { host: string; - ca: string; - serviceAccountToken: { name: string; value: string }; -} + caCert?: string; +} & ( + | { + username: string; + password: string; + } + | { + serviceAccountToken: { name: string; value: string }; + } + | {} +); export class KibanaConfigWriter { constructor(private readonly configPath: string, private readonly logger: Logger) {} @@ -54,31 +62,37 @@ export class KibanaConfigWriter { public async writeConfig(params: WriteConfigParameters) { const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`); - this.logger.debug(`Writing CA certificate to ${caPath}.`); - try { - await fs.writeFile(caPath, params.ca); - this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`); - } catch (err) { - this.logger.error( - `Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.` - ); - throw err; + if (params.caCert) { + this.logger.debug(`Writing CA certificate to ${caPath}.`); + try { + await fs.writeFile(caPath, params.caCert); + this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`); + } catch (err) { + this.logger.error( + `Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.` + ); + throw err; + } + } + + const config: Record = { 'elasticsearch.hosts': [params.host] }; + if ('serviceAccountToken' in params) { + config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value; + } else if ('username' in params) { + config['elasticsearch.password'] = params.password; + config['elasticsearch.username'] = params.username; + } + if (params.caCert) { + config['elasticsearch.ssl.certificateAuthorities'] = [caPath]; } this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`); try { await fs.appendFile( this.configPath, - `\n\n# This section was automatically generated during setup (service account token name is "${ - params.serviceAccountToken.name - }").\n${yaml.safeDump( - { - 'elasticsearch.hosts': [params.host], - 'elasticsearch.serviceAccountToken': params.serviceAccountToken.value, - 'elasticsearch.ssl.certificateAuthorities': [caPath], - }, - { flowLevel: 1 } - )}\n` + `\n\n# This section was automatically generated during setup.\n${yaml.safeDump(config, { + flowLevel: 1, + })}\n` ); this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`); } catch (err) { diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 06ece32ba9c4e3..91a151e17b697b 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import chalk from 'chalk'; import type { Subscription } from 'rxjs'; import type { TypeOf } from '@kbn/config-schema'; @@ -17,13 +18,11 @@ import { ElasticsearchService } from './elasticsearch_service'; import { KibanaConfigWriter } from './kibana_config_writer'; import { defineRoutes } from './routes'; -export class UserSetupPlugin implements PrebootPlugin { +export class InteractiveSetupPlugin implements PrebootPlugin { readonly #logger: Logger; + readonly #elasticsearch: ElasticsearchService; #elasticsearchConnectionStatusSubscription?: Subscription; - readonly #elasticsearch = new ElasticsearchService( - this.initializerContext.logger.get('elasticsearch') - ); #configSubscription?: Subscription; #config?: ConfigType; @@ -36,6 +35,9 @@ export class UserSetupPlugin implements PrebootPlugin { constructor(private readonly initializerContext: PluginInitializerContext) { this.#logger = this.initializerContext.logger.get(); + this.#elasticsearch = new ElasticsearchService( + this.initializerContext.logger.get('elasticsearch') + ); } public setup(core: CorePreboot) { @@ -90,6 +92,14 @@ export class UserSetupPlugin implements PrebootPlugin { this.#logger.debug( 'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.' ); + const serverInfo = core.http.getServerInfo(); + const url = `${serverInfo.protocol}://${serverInfo.hostname}:${serverInfo.port}`; + this.#logger.info(` + +${chalk.whiteBright.bold(`${chalk.cyanBright('i')} Kibana has not been configured.`)} + +Go to ${chalk.cyanBright.underline(url)} to get started. +`); } } ); diff --git a/src/plugins/interactive_setup/server/routes/configure.test.ts b/src/plugins/interactive_setup/server/routes/configure.test.ts new file mode 100644 index 00000000000000..d6b7404fce5161 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/configure.test.ts @@ -0,0 +1,276 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; + +import type { ObjectType } from '@kbn/config-schema'; +import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { ElasticsearchConnectionStatus } from '../../common'; +import { interactiveSetupMock } from '../mocks'; +import { defineConfigureRoute } from './configure'; +import { routeDefinitionParamsMock } from './index.mock'; + +describe('Configure routes', () => { + let router: jest.Mocked; + let mockRouteParams: ReturnType; + let mockContext: RequestHandlerContext; + beforeEach(() => { + mockRouteParams = routeDefinitionParamsMock.create(); + router = mockRouteParams.router; + + mockContext = ({} as unknown) as RequestHandlerContext; + + defineConfigureRoute(mockRouteParams); + }); + + describe('#configure', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + beforeEach(() => { + const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/interactive_setup/configure' + )!; + + routeConfig = configureRouteConfig; + routeHandler = configureRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[host]: expected value of type [string] but got [undefined]."` + ); + expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot( + `"[host]: \\"host\\" is not allowed to be empty"` + ); + expect(() => + bodySchema.validate({ host: 'localhost:9200' }) + ).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`); + expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError(); + expect(() => + bodySchema.validate({ host: 'http://localhost:9200', username: 'elastic' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[username]: value of \\"elastic\\" is forbidden. This is a superuser account that can obfuscate privilege-related issues. You should use the \\"kibana_system\\" user instead."` + ); + expect(() => + bodySchema.validate({ host: 'http://localhost:9200', username: 'kibana_system' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[password]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodySchema.validate({ host: 'http://localhost:9200', password: 'password' }) + ).toThrowErrorMatchingInlineSnapshot(`"[password]: a value wasn't expected to be present"`); + expect(() => + bodySchema.validate({ + host: 'http://localhost:9200', + username: 'kibana_system', + password: '', + }) + ).not.toThrowError(); + expect(() => + bodySchema.validate({ host: 'https://localhost:9200' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[caCert]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' }) + ).not.toThrowError(); + }); + + it('fails if setup is not on hold.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 400, + options: { body: 'Cannot process request outside of preboot stage.' }, + payload: 'Cannot process request outside of preboot stage.', + }); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if Elasticsearch connection is already configured.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.Configured + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 400, + options: { + body: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }, + payload: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if Kibana config is not writable.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + statusCode: 500, + }, + payload: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + }); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if authenticate call fails.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.elasticsearch.authenticate.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ + statusCode: 401, + body: { message: 'some-secret-message' }, + }) + ) + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } }, + statusCode: 500, + }, + payload: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } }, + }); + + expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if cannot write configuration to the disk.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.kibanaConfigWriter.writeConfig.mockRejectedValue( + new Error('Some error with sensitive path') + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + statusCode: 500, + }, + payload: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + }); + + expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('can successfully authenticate and save configuration to the disk.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue(); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host', username: 'username', password: 'password', caCert: 'der' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 204, + options: {}, + payload: undefined, + }); + + expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1); + expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledWith({ + host: 'host', + username: 'username', + password: 'password', + caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n', + }); + + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledWith({ + host: 'host', + username: 'username', + password: 'password', + caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n', + }); + + expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledTimes(1); + expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledWith({ + shouldReloadConfig: true, + }); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts new file mode 100644 index 00000000000000..a34af0296ea047 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { first } from 'rxjs/operators'; + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '.'; +import { ElasticsearchConnectionStatus } from '../../common'; +import type { AuthenticateParameters } from '../elasticsearch_service'; +import { ElasticsearchService } from '../elasticsearch_service'; +import type { WriteConfigParameters } from '../kibana_config_writer'; + +export function defineConfigureRoute({ + router, + logger, + kibanaConfigWriter, + elasticsearch, + preboot, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/configure', + validate: { + query: schema.object({ + code: schema.maybe(schema.string()), + }), + body: schema.object({ + host: schema.uri({ scheme: ['http', 'https'] }), + username: schema.maybe( + schema.string({ + validate: (value: string) => { + if (value === 'elastic') { + return ( + 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + + 'privilege-related issues. You should use the "kibana_system" user instead.' + ); + } + }, + }) + ), + password: schema.conditional( + schema.siblingRef('username'), + schema.string(), + schema.string(), + schema.never() + ), + caCert: schema.conditional( + schema.siblingRef('host'), + schema.uri({ scheme: 'https' }), + schema.string(), + schema.never() + ), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!preboot.isSetupOnHold()) { + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); + return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); + } + + const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise(); + if (connectionStatus === ElasticsearchConnectionStatus.Configured) { + logger.error( + `Invalid request to [path=${request.url.pathname}], Elasticsearch connection is already configured.` + ); + return response.badRequest({ + body: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }); + } + + // The most probable misconfiguration case is when Kibana process isn't allowed to write to the + // Kibana configuration file. We'll still have to handle possible filesystem access errors + // when we actually write to the disk, but this preliminary check helps us to avoid unnecessary + // enrollment call and communicate that to the user early. + const isConfigWritable = await kibanaConfigWriter.isConfigWritable(); + if (!isConfigWritable) { + logger.error('Kibana process does not have enough permissions to write to config file'); + return response.customError({ + statusCode: 500, + body: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + }); + } + + const configToWrite: WriteConfigParameters & AuthenticateParameters = { + host: request.body.host, + username: request.body.username, + password: request.body.password, + caCert: request.body.caCert + ? ElasticsearchService.createPemCertificate(request.body.caCert) + : undefined, + }; + + try { + await elasticsearch.authenticate(configToWrite); + } catch { + // For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment + // request or we just couldn't connect to any of the provided hosts. + return response.customError({ + statusCode: 500, + body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } }, + }); + } + + try { + await kibanaConfigWriter.writeConfig(configToWrite); + } catch { + // For security reasons, we shouldn't leak any filesystem related errors. + return response.customError({ + statusCode: 500, + body: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + }); + } + + preboot.completeSetup({ shouldReloadConfig: true }); + + return response.noContent(); + } + ); +} diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index 6a2da28787a4da..41291246802e65 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -12,6 +12,7 @@ import { schema } from '@kbn/config-schema'; import { ElasticsearchConnectionStatus } from '../../common'; import type { EnrollResult } from '../elasticsearch_service'; +import type { WriteConfigParameters } from '../kibana_config_writer'; import type { RouteDefinitionParams } from './'; /** @@ -81,9 +82,9 @@ export function defineEnrollRoutes({ .match(/.{1,2}/g) ?.join(':') ?? ''; - let enrollResult: EnrollResult; + let configToWrite: WriteConfigParameters & EnrollResult; try { - enrollResult = await elasticsearch.enroll({ + configToWrite = await elasticsearch.enroll({ apiKey: request.body.apiKey, hosts: request.body.hosts, caFingerprint: colonFormattedCaFingerprint, @@ -98,7 +99,7 @@ export function defineEnrollRoutes({ } try { - await kibanaConfigWriter.writeConfig(enrollResult); + await kibanaConfigWriter.writeConfig(configToWrite); } catch { // For security reasons, we shouldn't leak any filesystem related errors. return response.customError({ diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts index 752c5828ecb59f..75c383176e7e9d 100644 --- a/src/plugins/interactive_setup/server/routes/index.ts +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -12,7 +12,9 @@ import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core import type { ConfigType } from '../config'; import type { ElasticsearchServiceSetup } from '../elasticsearch_service'; import type { KibanaConfigWriter } from '../kibana_config_writer'; +import { defineConfigureRoute } from './configure'; import { defineEnrollRoutes } from './enroll'; +import { definePingRoute } from './ping'; /** * Describes parameters used to define HTTP routes. @@ -31,4 +33,6 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineEnrollRoutes(params); + defineConfigureRoute(params); + definePingRoute(params); } diff --git a/src/plugins/interactive_setup/server/routes/ping.test.ts b/src/plugins/interactive_setup/server/routes/ping.test.ts new file mode 100644 index 00000000000000..295ad3b612992f --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/ping.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; + +import type { ObjectType } from '@kbn/config-schema'; +import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { interactiveSetupMock } from '../mocks'; +import { routeDefinitionParamsMock } from './index.mock'; +import { definePingRoute } from './ping'; + +describe('Configure routes', () => { + let router: jest.Mocked; + let mockRouteParams: ReturnType; + let mockContext: RequestHandlerContext; + beforeEach(() => { + mockRouteParams = routeDefinitionParamsMock.create(); + router = mockRouteParams.router; + + mockContext = ({} as unknown) as RequestHandlerContext; + + definePingRoute(mockRouteParams); + }); + + describe('#ping', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + beforeEach(() => { + const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/interactive_setup/ping' + )!; + + routeConfig = configureRouteConfig; + routeHandler = configureRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[host]: expected value of type [string] but got [undefined]."` + ); + expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot( + `"[host]: \\"host\\" is not allowed to be empty"` + ); + expect(() => + bodySchema.validate({ host: 'localhost:9200' }) + ).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`); + expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError(); + }); + + it('fails if setup is not on hold.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 400, + options: { body: 'Cannot process request outside of preboot stage.' }, + payload: 'Cannot process request outside of preboot stage.', + }); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if ping call fails.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ + statusCode: 401, + body: { message: 'some-secret-message' }, + }) + ) + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } }, + statusCode: 500, + }, + payload: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } }, + }); + + expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('can successfully ping.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + options: {}, + payload: undefined, + }); + + expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1); + expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledWith('host'); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/routes/ping.ts b/src/plugins/interactive_setup/server/routes/ping.ts new file mode 100644 index 00000000000000..50ed7514bab699 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/ping.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '.'; +import type { PingResult } from '../../common/types'; + +export function definePingRoute({ router, logger, elasticsearch, preboot }: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/ping', + validate: { + body: schema.object({ + host: schema.uri({ scheme: ['http', 'https'] }), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!preboot.isSetupOnHold()) { + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); + return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); + } + + let result: PingResult; + try { + result = await elasticsearch.ping(request.body.host); + } catch { + return response.customError({ + statusCode: 500, + body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } }, + }); + } + + return response.ok({ body: result }); + } + ); +}