From caa9ad3ae65510e9de4c5775af8df10f3bf66550 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 14 Jul 2021 11:19:47 +0100 Subject: [PATCH 01/35] Interactive setup mode --- .../src/get_server_watch_paths.ts | 2 +- .../elasticsearch/elasticsearch_config.ts | 2 +- src/plugins/interactive_setup/README.md | 3 + src/plugins/interactive_setup/kibana.json | 17 + src/plugins/interactive_setup/public/app.tsx | 43 +++ .../interactive_setup/public/config.ts | 11 + .../public/decode_enrollment_token.ts | 35 ++ .../public/enrollment_token_form.tsx | 183 ++++++++++ .../public/get_fingerprint.ts | 58 +++ src/plugins/interactive_setup/public/index.ts | 13 + .../public/manual_configuration_form.tsx | 221 +++++++++++ .../interactive_setup/public/plugin.tsx | 59 +++ .../public/progress_indicator.tsx | 117 ++++++ .../interactive_setup/public/use_form.ts | 208 +++++++++++ .../interactive_setup/public/use_http.ts | 14 + .../interactive_setup/server/config.ts | 16 + src/plugins/interactive_setup/server/index.ts | 19 + .../interactive_setup/server/plugin.ts | 155 ++++++++ src/plugins/interactive_setup/tsconfig.json | 12 + src/plugins/user_setup/kibana.json | 5 +- src/plugins/user_setup/public/app.tsx | 343 +++++++++++++++++- .../public/decode_enrollment_token.ts | 35 ++ .../user_setup/public/get_fingerprint.ts | 58 +++ src/plugins/user_setup/public/plugin.tsx | 33 +- src/plugins/user_setup/public/use_form.ts | 208 +++++++++++ src/plugins/user_setup/server/plugin.ts | 85 ++++- src/plugins/user_setup/tsconfig.json | 5 +- 27 files changed, 1943 insertions(+), 17 deletions(-) create mode 100644 src/plugins/interactive_setup/README.md create mode 100644 src/plugins/interactive_setup/kibana.json create mode 100644 src/plugins/interactive_setup/public/app.tsx create mode 100644 src/plugins/interactive_setup/public/config.ts create mode 100644 src/plugins/interactive_setup/public/decode_enrollment_token.ts create mode 100644 src/plugins/interactive_setup/public/enrollment_token_form.tsx create mode 100644 src/plugins/interactive_setup/public/get_fingerprint.ts create mode 100644 src/plugins/interactive_setup/public/index.ts create mode 100644 src/plugins/interactive_setup/public/manual_configuration_form.tsx create mode 100644 src/plugins/interactive_setup/public/plugin.tsx create mode 100644 src/plugins/interactive_setup/public/progress_indicator.tsx create mode 100644 src/plugins/interactive_setup/public/use_form.ts create mode 100644 src/plugins/interactive_setup/public/use_http.ts create mode 100644 src/plugins/interactive_setup/server/config.ts create mode 100644 src/plugins/interactive_setup/server/index.ts create mode 100644 src/plugins/interactive_setup/server/plugin.ts create mode 100644 src/plugins/interactive_setup/tsconfig.json create mode 100644 src/plugins/user_setup/public/decode_enrollment_token.ts create mode 100644 src/plugins/user_setup/public/get_fingerprint.ts create mode 100644 src/plugins/user_setup/public/use_form.ts diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index b0773fd5676357..7ce6d4313bff88 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -43,7 +43,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { fromRoot('src/core'), fromRoot('src/legacy/server'), fromRoot('src/legacy/utils'), - fromRoot('config'), + // fromRoot('config'), ...pluginPaths, ...pluginScanDirs, ].map((path) => Path.resolve(path)) diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index e756d9da867b3d..4935d381369072 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -33,7 +33,7 @@ export const configSchema = schema.object({ }), sniffOnConnectionFault: schema.boolean({ defaultValue: false }), hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { - defaultValue: 'http://localhost:9200', + defaultValue: 'https://localhost:9200', }), username: schema.maybe( schema.conditional( diff --git a/src/plugins/interactive_setup/README.md b/src/plugins/interactive_setup/README.md new file mode 100644 index 00000000000000..9fd43eb0445b67 --- /dev/null +++ b/src/plugins/interactive_setup/README.md @@ -0,0 +1,3 @@ +# `interactiveSetup` plugin + +The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/interactive_setup/kibana.json b/src/plugins/interactive_setup/kibana.json new file mode 100644 index 00000000000000..7f69cb53694c3a --- /dev/null +++ b/src/plugins/interactive_setup/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "interactiveSetup", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin provides UI and APIs for the interactive setup mode.", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["interactiveSetup"], + "type": "preboot", + "server": true, + "ui": true, + "requiredPlugins": [], + "requiredBundles": [], + "extraPublicDirs": [] +} diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx new file mode 100644 index 00000000000000..c48dbf5d630566 --- /dev/null +++ b/src/plugins/interactive_setup/public/app.tsx @@ -0,0 +1,43 @@ +/* + * 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 { EuiPageTemplate } from '@elastic/eui'; +import React, { FunctionComponent, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EnrollmentTokenForm } from './enrollment_token_form'; +import { ManualConfigurationForm } from './manual_configuration_form'; +import { ProgressIndicator } from './progress_indicator'; + +export const App: FunctionComponent = () => { + const [page, setPage] = useState<'token' | 'manual' | 'success'>('token'); + return ( + + + + {page === 'success' && } + + ); +}; diff --git a/src/plugins/interactive_setup/public/config.ts b/src/plugins/interactive_setup/public/config.ts new file mode 100644 index 00000000000000..fc91296ce37249 --- /dev/null +++ b/src/plugins/interactive_setup/public/config.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export interface ConfigType { + token?: string; +} diff --git a/src/plugins/interactive_setup/public/decode_enrollment_token.ts b/src/plugins/interactive_setup/public/decode_enrollment_token.ts new file mode 100644 index 00000000000000..0b7447fc28e31f --- /dev/null +++ b/src/plugins/interactive_setup/public/decode_enrollment_token.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +export interface EnrollmentToken { + /** + * The version of the Elasticsearch node that generated this enrollment token. + */ + ver: string; + + /** + * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. + */ + adr: string[]; + + /** + * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. + */ + fgr: string; + + /** + * An Elasticsearch API key (not encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. + */ + key: string; +} + +export function decodeEnrollmentToken(enrollmentToken: string) { + try { + return JSON.parse(atob(enrollmentToken)) as EnrollmentToken; + } catch (error) {} // eslint-disable-line no-empty +} 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..cb3a5f4434b8bd --- /dev/null +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -0,0 +1,183 @@ +/* + * 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 { + EuiIcon, + EuiForm, + EuiButton, + EuiFormRow, + EuiTextArea, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, +} from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; +import { useForm, ValidationErrors } from './use_form'; +import { decodeEnrollmentToken, EnrollmentToken } from './decode_enrollment_token'; +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); + if (decoded) { + await Promise.all([ + http.post('/api/preboot/setup', { + body: JSON.stringify({ + hosts: decoded.adr, + apiKey: decoded.key, + caFingerprint: decoded.fgr, + }), + }), + new Promise((resolve) => setTimeout(resolve, 1600)), // Shorten perceived duration of preboot step + ]); + onSuccess?.(); + } + }, + }); + + const token = decodeEnrollmentToken(form.values.token); + + return ( + + } + > + + form.setValue( + 'token', + btoa( + JSON.stringify({ + 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', + }) + ) + ) + } + /> + + + + + + + + + + + + + + + + + ); +}; + +interface EnrollmentTokenDetailsProps { + token: EnrollmentToken; +} + +const EnrollmentTokenDetails: FunctionComponent = ({ token }) => ( + + + Connect to + + + + + + {token.adr[0]} + + + + + + {`Elasticsearch (v${token.ver})`} + + +); diff --git a/src/plugins/interactive_setup/public/get_fingerprint.ts b/src/plugins/interactive_setup/public/get_fingerprint.ts new file mode 100644 index 00000000000000..48b8c43b30d525 --- /dev/null +++ b/src/plugins/interactive_setup/public/get_fingerprint.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +enum ParseMode { + BEFORE_BEGIN, + AFTER_BEGIN, + AFTER_END, +} + +const BEGIN_TOKEN = '-----BEGIN'; +const END_TOKEN = '-----END'; + +// openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt +export async function getFingerprint( + cert: ArrayBuffer, + algorithm: AlgorithmIdentifier = 'SHA-256' +) { + // Use first certificate only + let mode = ParseMode.BEFORE_BEGIN; + const pemData = Array.from(new Uint8Array(cert)) + .map((char) => String.fromCharCode(char)) + .join('') + .split('\n') + .map((line) => line.trim()) + .filter((line) => { + if (mode === ParseMode.BEFORE_BEGIN && line.startsWith(BEGIN_TOKEN)) { + mode = ParseMode.AFTER_BEGIN; + return false; + } + if (mode === ParseMode.AFTER_BEGIN && line.startsWith(END_TOKEN)) { + mode = ParseMode.AFTER_END; + return false; + } + return mode === ParseMode.AFTER_BEGIN; + }) + .join(''); + + // Convert to DER + const derData = atob(pemData); + const derBuffer = new Uint8Array(new ArrayBuffer(derData.length)); + for (let i = 0; i < derData.length; i++) { + derBuffer[i] = derData.charCodeAt(i); + } + + // Calculate fingerprint + const hashBuffer = await crypto.subtle.digest(algorithm, derBuffer); + + // Convert to HEX + return Array.from(new Uint8Array(hashBuffer)) + .map((char) => char.toString(16).padStart(2, '0')) + .join(':') + .toUpperCase(); +} diff --git a/src/plugins/interactive_setup/public/index.ts b/src/plugins/interactive_setup/public/index.ts new file mode 100644 index 00000000000000..5c4c8a992afa08 --- /dev/null +++ b/src/plugins/interactive_setup/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; +import { InteractiveSetupPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new InteractiveSetupPlugin(initializerContext); diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx new file mode 100644 index 00000000000000..5166543dadfd18 --- /dev/null +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -0,0 +1,221 @@ +/* + * 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 { + EuiForm, + EuiButton, + EuiFormRow, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiFieldPassword, + EuiFilePicker, + EuiButtonEmpty, +} from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; +import { useForm, ValidationErrors } from './use_form'; +import { getFingerprint } from './get_fingerprint'; +import { useHttp } from './use_http'; + +export interface ManualConfigurationFormValues { + host: string; + username: string; + password: string; + caFingerprint: string; +} + +export interface ManualConfigurationFormProps { + defaultValues?: ManualConfigurationFormValues; + onCancel(): void; + onSuccess?(): void; +} + +export const ManualConfigurationForm: FunctionComponent = ({ + defaultValues = { + host: 'localhost:9200', + username: 'elastic', + password: '', + caFingerprint: '', + }, + onCancel, + onSuccess, +}) => { + const http = useHttp(); + + const [form, eventHandlers] = useForm({ + defaultValues, + validate: (values) => { + const errors: ValidationErrors = {}; + + if (!values.host) { + errors.host = i18n.translate('interactiveSetup.manualConfigurationForm.hostRequiredError', { + defaultMessage: 'Enter an address.', + }); + } + + if (!values.username) { + errors.username = i18n.translate( + 'interactiveSetup.manualConfigurationForm.usernameRequiredError', + { + defaultMessage: 'Enter a username.', + } + ); + } + + if (!values.password) { + errors.password = i18n.translate( + 'interactiveSetup.manualConfigurationForm.passwordRequiredError', + { + defaultMessage: 'Enter a password.', + } + ); + } + + if (!values.caFingerprint) { + errors.caFingerprint = i18n.translate( + 'interactiveSetup.manualConfigurationForm.caFingerprintRequiredError', + { + defaultMessage: 'Enter a CA certificate.', + } + ); + } + + return errors; + }, + onSubmit: async (values) => { + await http.post('/api/preboot/setup', { + body: JSON.stringify({ + hosts: [values.host], + username: values.username, + password: values.password, + caFingerprint: values.caFingerprint, + }), + }); + onSuccess?.(); + }, + }); + + return ( + + + + + + + + + + + + { + if (!files || !files.length) { + form.setValue('caFingerprint', ''); + return; + } + if (files[0].type !== 'application/x-x509-ca-cert') { + form.setError( + 'caFingerprint', + 'Invalid certificate, upload x509 CA cert in PEM format' + ); + return; + } + const cert = await readFile(files[0]); + const hash = await getFingerprint(cert); + form.setValue('caFingerprint', hash); + }} + /> + + + + + + + + + + + + + + + + + ); +}; + +function readFile(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as ArrayBuffer); + reader.onerror = (err) => reject(err); + reader.readAsArrayBuffer(file); + }); +} diff --git a/src/plugins/interactive_setup/public/plugin.tsx b/src/plugins/interactive_setup/public/plugin.tsx new file mode 100644 index 00000000000000..c9ffe299b4cc34 --- /dev/null +++ b/src/plugins/interactive_setup/public/plugin.tsx @@ -0,0 +1,59 @@ +/* + * 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 { + CoreSetup, + CoreStart, + HttpSetup, + Plugin, + PluginInitializerContext, +} from 'src/core/public'; +import React, { FunctionComponent } from 'react'; +import ReactDOM from 'react-dom'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { ConfigType } from './config'; +import { App } from './app'; +import { HttpProvider } from './use_http'; + +export class InteractiveSetupPlugin implements Plugin { + #config: ConfigType; + constructor(initializerContext: PluginInitializerContext) { + this.#config = initializerContext.config.get(); + } + + public setup(core: CoreSetup) { + core.application.register({ + id: 'interactiveSetup', + title: 'Configure Elastic to get started', + appRoute: '/', + chromeless: true, + mount: (params) => { + ReactDOM.render( + + + , + params.element + ); + return () => ReactDOM.unmountComponentAtNode(params.element); + }, + }); + } + + 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..dc7eccf92c8241 --- /dev/null +++ b/src/plugins/interactive_setup/public/progress_indicator.tsx @@ -0,0 +1,117 @@ +/* + * 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 { EuiSteps, EuiPanel, EuiStepProps, EuiCallOut } from '@elastic/eui'; +import React, { useEffect, FunctionComponent } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import useTimeoutFn from 'react-use/lib/useTimeoutFn'; +import useTimeout from 'react-use/lib/useTimeout'; +import { i18n } from '@kbn/i18n'; +import { useHttp } from './use_http'; + +export const ProgressIndicator: FunctionComponent = () => { + 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 [_, cancel, reset] = useTimeoutFn(checkStatus, 1000); + + useEffect(() => { + if (status.value === 'complete') { + cancel(); + window.location.replace('/'); + } else if (status.loading === false) { + reset(); + } + }, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps + + const [helpRequired, cancelHelp, resetHelp] = useTimeout(15000); + + useEffect(() => { + if (status.value === 'complete') { + cancelHelp(); + } else { + resetHelp(); + } + }, [status.value]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + {helpRequired() && ( + + Check terminal for cause of error. + + )} + + ); +}; + +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/use_form.ts b/src/plugins/interactive_setup/public/use_form.ts new file mode 100644 index 00000000000000..29bfd5b807244a --- /dev/null +++ b/src/plugins/interactive_setup/public/use_form.ts @@ -0,0 +1,208 @@ +/* + * 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 { cloneDeep, cloneDeepWith, get, set } from 'lodash'; +import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; +import { useState } 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): Promise; + setError(name: string, message: string): void; + setTouched(name: string): Promise; + reset(values: Values): void; + submit(): 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 [values, setValues] = useState(defaultValues); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); + + const [validationState, validateForm] = useAsyncFn( + async (formValues: Values) => { + const nextErrors = await validate(formValues); + setErrors(nextErrors); + if (Object.keys(nextErrors).length === 0) { + setSubmitCount(0); + } + return nextErrors; + }, + [validate] + ); + + const [submitState, submitForm] = useAsyncFn( + async (formValues: Values) => { + const nextErrors = await validateForm(formValues); + setTouched(mapDeep(formValues, true)); + setSubmitCount(submitCount + 1); + if (Object.keys(nextErrors).length === 0) { + return onSubmit(formValues); + } + }, + [validateForm, onSubmit] + ); + + return { + setValue: async (name, value) => { + const nextValues = setDeep(values, name, value); + setValues(nextValues); + await validateForm(nextValues); + }, + setTouched: async (name, value = true) => { + setTouched(setDeep(touched, name, value)); + await validateForm(values); + }, + setError: (name, message) => { + setErrors(setDeep(errors, name, message)); + setTouched(setDeep(touched, name, true)); + }, + reset: (nextValues) => { + setValues(nextValues); + setErrors({}); + setTouched({}); + setSubmitCount(0); + }, + submit: () => submitForm(values), + values, + errors, + touched, + isValidating: validationState.loading, + isSubmitting: submitState.loading, + submitError: submitState.error, + isInvalid: Object.keys(errors).length > 0, + isSubmitted: submitCount > 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_http.ts b/src/plugins/interactive_setup/public/use_http.ts new file mode 100644 index 00000000000000..9d2a5a1c2423f8 --- /dev/null +++ b/src/plugins/interactive_setup/public/use_http.ts @@ -0,0 +1,14 @@ +/* + * 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/config.ts b/src/plugins/interactive_setup/server/config.ts new file mode 100644 index 00000000000000..a3e050d09742d6 --- /dev/null +++ b/src/plugins/interactive_setup/server/config.ts @@ -0,0 +1,16 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + enabled: schema.maybe(schema.boolean()), +}); diff --git a/src/plugins/interactive_setup/server/index.ts b/src/plugins/interactive_setup/server/index.ts new file mode 100644 index 00000000000000..3437f9b1921485 --- /dev/null +++ b/src/plugins/interactive_setup/server/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; + +import { ConfigSchema } from './config'; +import { InteractiveSetupPlugin } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, +}; + +export const plugin = (context: PluginInitializerContext) => new InteractiveSetupPlugin(context); diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts new file mode 100644 index 00000000000000..a5e79b62a4dd2c --- /dev/null +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -0,0 +1,155 @@ +/* + * 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 { CorePreboot, PrebootPlugin, PluginInitializerContext } from 'src/core/server'; +import fs from 'fs/promises'; +import { errors } from '@elastic/elasticsearch'; +import Boom from '@hapi/boom'; +import chalk from 'chalk'; +import type { ConfigType } from './config'; + +export class InteractiveSetupPlugin implements PrebootPlugin { + readonly #initializerContext: PluginInitializerContext; + constructor(initializerContext: PluginInitializerContext) { + this.#initializerContext = initializerContext; + } + + public setup(core: CorePreboot) { + const { enabled } = this.#initializerContext.config.get(); + + const isDev = this.#initializerContext.env.mode.dev; + const isConfigured = + core.elasticsearch.config.credentialsSpecified && core.elasticsearch.config.hosts.length; + + /** + * env: dev -> enabled: false -> skip + * env: dev -> enabled: undefined -> skip + * env: dev -> enabled: true -> hold setup + * env: prod -> enabled: false -> skip + * env: prod -> enabled: undefined -> hold setup if not configured + * env: prod -> enabled: true -> hold setup if not configured + */ + const shouldHoldSetupPhase = isDev + ? enabled === true + : enabled !== false + ? !isConfigured + : false; + + if (!shouldHoldSetupPhase) { + return; + } + + core.http.registerRoutes('', (prebootRouter) => { + let completeSetup: (result: { shouldReloadConfig: boolean }) => void; + + prebootRouter.post( + { + path: '/api/preboot/setup', + validate: { + body: schema.oneOf([ + schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + apiKey: schema.string(), + caFingerprint: schema.string(), + }), + schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + username: schema.string(), + password: schema.string(), + caFingerprint: schema.string(), + }), + ]), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!core.preboot.isSetupOnHold()) { + return response.badRequest(); + } + + const client = core.elasticsearch + .createClient('data', { + hosts: request.body.hosts.map((host) => `https://${host}`), + ssl: { verificationMode: 'none' }, + // caFingerprint: request.body.caFingerprint, + }) + .asScoped({ + headers: { + authorization: + 'apiKey' in request.body + ? `ApiKey ${btoa(request.body.apiKey)}` + : `Basic ${btoa(`${request.body.username}:${request.body.password}`)}`, + }, + }); + + try { + const { body } = await client.asCurrentUser.transport.request({ + method: 'GET', + path: '/_security/enroll/kibana', + }); + + const configPath = isDev + ? this.#initializerContext.env.configs.find((path) => path.includes('dev')) + : this.#initializerContext.env.configs[0]; + + if (!configPath) { + return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); + } + + await fs.appendFile( + configPath, + ` + +elasticsearch.hosts: [${request.body.hosts.map((host) => `"https://${host}"`).join(', ')}] +elasticsearch.username: "kibana_system" +elasticsearch.password: "${body.password}" +elasticsearch.ssl.verificationMode: "none" +` + ); + + completeSetup({ shouldReloadConfig: true }); + + return response.noContent(); + } catch (err) { + return response.customError({ statusCode: 500, body: getDetailedErrorMessage(err) }); + } + } + ); + + core.preboot.holdSetupUntilResolved( + `\n\n${chalk.bold( + chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`) + )}\n\nGo to ${chalk.cyanBright( + chalk.underline('http://localhost:5601') + )} to get started.\n`, + new Promise((resolve) => { + completeSetup = resolve; + }) + ); + }); + } + + public stop() {} +} + +export function btoa(str: string) { + return Buffer.from(str, 'binary').toString('base64'); +} + +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + if (Boom.isBoom(error)) { + return JSON.stringify(error.output.payload); + } + + return error.message; +} diff --git a/src/plugins/interactive_setup/tsconfig.json b/src/plugins/interactive_setup/tsconfig.json new file mode 100644 index 00000000000000..d211a70f12df33 --- /dev/null +++ b/src/plugins/interactive_setup/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*", "server/**/*"], + "references": [{ "path": "../../core/tsconfig.json" }] +} diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json index 192fd42cd3e264..f41eb661f26771 100644 --- a/src/plugins/user_setup/kibana.json +++ b/src/plugins/user_setup/kibana.json @@ -9,5 +9,8 @@ "kibanaVersion": "kibana", "configPath": ["userSetup"], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx index 2b6b7089539723..e17fe24f670445 100644 --- a/src/plugins/user_setup/public/app.tsx +++ b/src/plugins/user_setup/public/app.tsx @@ -6,22 +6,349 @@ * Side Public License, v 1. */ -import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; -import React from 'react'; +import { + EuiPageTemplate, + EuiIcon, + EuiForm, + EuiButton, + EuiFormRow, + EuiLoadingSpinner, + EuiHorizontalRule, + EuiTextArea, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiFieldText, + EuiFieldPassword, + EuiFilePicker, + EuiButtonEmpty, +} from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { useForm, ValidationErrors } from './use_form'; +import { useKibana } from '../../../../src/plugins/kibana_react/public'; +import { getFingerprint } from './get_fingerprint'; +import { decodeEnrollmentToken } from './decode_enrollment_token'; export const App = () => { return ( - - Kibana server is not ready yet. - + +

Connect to cluster

+
+ +
); }; + +interface ConfigurationFormValues { + type: 'enrollmentToken' | 'manual' | string; + enrollmentToken: string; + address: string; + username: string; + password: string; + ca: string; +} + +interface ConfigurationFormProps { + defaultValues?: ConfigurationFormValues; + onCancel(): void; + onSuccess?(): void; +} + +const defaultDefaultValues: ConfigurationFormValues = { + type: 'enrollmentToken', + enrollmentToken: '', + address: 'localhost:9201', + username: 'elastic', + password: '', + ca: '', +}; + +const ConfigurationForm: FunctionComponent = ({ + defaultValues = defaultDefaultValues, + onSuccess, + onCancel, +}) => { + const { services } = useKibana(); + const [state, enrollKibana] = useAsyncFn((enrollmentToken: string) => + services.http!.post('/internal/user_setup', { + body: JSON.stringify({ enrollmentToken }), + }) + ); + const [form, eventHandlers] = useForm({ + onSubmit: async (values) => { + const decoded = decodeEnrollmentToken(values.enrollmentToken); + if (decoded) { + await enrollKibana(values.enrollmentToken); + // const result = await services.http!.get('/internal/security/enroll/kibana', { + // headers: { + // Authorization: `ApiKey ${btoa(decoded.key)}`, + // }, + // }); + } + }, + validate: (values) => { + const errors: ValidationErrors = {}; + + if (!values.enrollmentToken) { + errors.enrollmentToken = 'Required'; + } else { + const decoded = decodeEnrollmentToken(values.enrollmentToken); + if (!decoded) { + errors.enrollmentToken = 'Invalid'; + } + } + + return errors; + }, + defaultValues, + }); + + const decoded = decodeEnrollmentToken(form.values.enrollmentToken); + + return ( + + {/* + + + } + title="Connect using enrollment token" + description={false} + selectable={{ + onClick: () => { + form.setValue('type', 'enrollmentToken'); + }, + isSelected: form.values.type === 'enrollmentToken', + }} + /> + + + } + title="Configure manually" + description={false} + selectable={{ + onClick: () => { + form.setValue('type', 'manual'); + }, + isSelected: form.values.type === 'manual', + }} + /> + + + */} + + {form.values.type && ( + <> + {form.values.type === 'enrollmentToken' && ( + <> + + + Connect to + + + + + + {decoded.adr[0]} + + + + + + {`Elasticsearch (v${decoded.ver})`} + + + ) + } + > + + form.setValue( + 'enrollmentToken', + btoa( + JSON.stringify({ + ver: '8.0.0', + adr: ['localhost:9200'], + fgr: + '48:CC:6C:F8:76:43:3C:97:85:B6:24:45:5B:FF:BD:40:4B:D6:35:81:51:E7:A9:99:60:E4:0A:C8:8D:AE:5C:4D', + key: 'fECUqXoB0XsCTgc6NjlA:jo5CEpRURPGq7_Xi6G5uEA', + }) + ) + ) + } + /> + + + + )} + + {form.values.type === 'manual' && ( + <> + + + + + + + + + + + { + form.setTouched('ca'); + if (!files || !files.length) { + form.setValue('ca', ''); + return; + } + if (files[0].type !== 'application/x-x509-ca-cert') { + form.setError('ca', 'Invalid certificate, upload x509 CA cert'); + return false; + } + const cert = await readFile(files[0]); + const hash = await getFingerprint(cert); + form.setValue('ca', hash); + }} + /> + + + + )} + + + + + + + + + {form.values.type === 'manual' ? ( + form.setValue('type', 'enrollmentToken')} + > + Connect using enrollment token + + ) : ( + form.setValue('type', 'manual')}> + Configure manually + + )} + + + + )} + + {state.value && ( + <> + + Initializing Elastic... + + )} + + ); +}; + +function readFile(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as ArrayBuffer); + reader.onerror = (err) => reject(err); + reader.readAsArrayBuffer(file); + }); +} diff --git a/src/plugins/user_setup/public/decode_enrollment_token.ts b/src/plugins/user_setup/public/decode_enrollment_token.ts new file mode 100644 index 00000000000000..5816605cf61ab9 --- /dev/null +++ b/src/plugins/user_setup/public/decode_enrollment_token.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +interface EnrollmentToken { + /** + * The version of the Elasticsearch node that generated this enrollment token. + */ + ver: string; + + /** + * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. + */ + adr: string[]; + + /** + * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. + */ + fgr: string; + + /** + * An Elasticsearch API key (not encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. + */ + key: string; +} + +export function decodeEnrollmentToken(enrollmentToken: string) { + try { + return JSON.parse(atob(enrollmentToken)) as EnrollmentToken; + } catch (error) {} // eslint-disable-line no-empty +} diff --git a/src/plugins/user_setup/public/get_fingerprint.ts b/src/plugins/user_setup/public/get_fingerprint.ts new file mode 100644 index 00000000000000..48b8c43b30d525 --- /dev/null +++ b/src/plugins/user_setup/public/get_fingerprint.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +enum ParseMode { + BEFORE_BEGIN, + AFTER_BEGIN, + AFTER_END, +} + +const BEGIN_TOKEN = '-----BEGIN'; +const END_TOKEN = '-----END'; + +// openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt +export async function getFingerprint( + cert: ArrayBuffer, + algorithm: AlgorithmIdentifier = 'SHA-256' +) { + // Use first certificate only + let mode = ParseMode.BEFORE_BEGIN; + const pemData = Array.from(new Uint8Array(cert)) + .map((char) => String.fromCharCode(char)) + .join('') + .split('\n') + .map((line) => line.trim()) + .filter((line) => { + if (mode === ParseMode.BEFORE_BEGIN && line.startsWith(BEGIN_TOKEN)) { + mode = ParseMode.AFTER_BEGIN; + return false; + } + if (mode === ParseMode.AFTER_BEGIN && line.startsWith(END_TOKEN)) { + mode = ParseMode.AFTER_END; + return false; + } + return mode === ParseMode.AFTER_BEGIN; + }) + .join(''); + + // Convert to DER + const derData = atob(pemData); + const derBuffer = new Uint8Array(new ArrayBuffer(derData.length)); + for (let i = 0; i < derData.length; i++) { + derBuffer[i] = derData.charCodeAt(i); + } + + // Calculate fingerprint + const hashBuffer = await crypto.subtle.digest(algorithm, derBuffer); + + // Convert to HEX + return Array.from(new Uint8Array(hashBuffer)) + .map((char) => char.toString(16).padStart(2, '0')) + .join(':') + .toUpperCase(); +} diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx index 677c27cc456dca..bcd5908e8b9de3 100644 --- a/src/plugins/user_setup/public/plugin.tsx +++ b/src/plugins/user_setup/public/plugin.tsx @@ -6,11 +6,15 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import ReactDOM from 'react-dom'; import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Router } from 'react-router-dom'; +import type { History } from 'history'; import { App } from './app'; +import { KibanaContextProvider } from '../../kibana_react/public'; export class UserSetupPlugin implements Plugin { public setup(core: CoreSetup) { @@ -18,12 +22,33 @@ export class UserSetupPlugin implements Plugin { id: 'userSetup', title: 'User Setup', chromeless: true, - mount: (params) => { - ReactDOM.render(, params.element); - return () => ReactDOM.unmountComponentAtNode(params.element); + mount: async ({ element, history }) => { + const [[coreStart]] = await Promise.all([core.getStartServices()]); + + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); }, }); } public start(core: CoreStart) {} } + +export interface ProvidersProps { + services: CoreStart; + history: History; +} + +export const Providers: FunctionComponent = ({ services, history, children }) => ( + + + {children} + + +); diff --git a/src/plugins/user_setup/public/use_form.ts b/src/plugins/user_setup/public/use_form.ts new file mode 100644 index 00000000000000..29bfd5b807244a --- /dev/null +++ b/src/plugins/user_setup/public/use_form.ts @@ -0,0 +1,208 @@ +/* + * 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 { cloneDeep, cloneDeepWith, get, set } from 'lodash'; +import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; +import { useState } 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): Promise; + setError(name: string, message: string): void; + setTouched(name: string): Promise; + reset(values: Values): void; + submit(): 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 [values, setValues] = useState(defaultValues); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); + + const [validationState, validateForm] = useAsyncFn( + async (formValues: Values) => { + const nextErrors = await validate(formValues); + setErrors(nextErrors); + if (Object.keys(nextErrors).length === 0) { + setSubmitCount(0); + } + return nextErrors; + }, + [validate] + ); + + const [submitState, submitForm] = useAsyncFn( + async (formValues: Values) => { + const nextErrors = await validateForm(formValues); + setTouched(mapDeep(formValues, true)); + setSubmitCount(submitCount + 1); + if (Object.keys(nextErrors).length === 0) { + return onSubmit(formValues); + } + }, + [validateForm, onSubmit] + ); + + return { + setValue: async (name, value) => { + const nextValues = setDeep(values, name, value); + setValues(nextValues); + await validateForm(nextValues); + }, + setTouched: async (name, value = true) => { + setTouched(setDeep(touched, name, value)); + await validateForm(values); + }, + setError: (name, message) => { + setErrors(setDeep(errors, name, message)); + setTouched(setDeep(touched, name, true)); + }, + reset: (nextValues) => { + setValues(nextValues); + setErrors({}); + setTouched({}); + setSubmitCount(0); + }, + submit: () => submitForm(values), + values, + errors, + touched, + isValidating: validationState.loading, + isSubmitting: submitState.loading, + submitError: submitState.error, + isInvalid: Object.keys(errors).length > 0, + isSubmitted: submitCount > 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/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts index 918c9a20079352..fc25caa4fc3364 100644 --- a/src/plugins/user_setup/server/plugin.ts +++ b/src/plugins/user_setup/server/plugin.ts @@ -7,11 +7,94 @@ */ import type { CoreSetup, CoreStart, Plugin } from 'src/core/server'; +import type { IRouter, RequestHandlerContext } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { Client } from '@elastic/elasticsearch'; +// import { decodeEnrollmentToken } from '../public/app'; export class UserSetupPlugin implements Plugin { - public setup(core: CoreSetup) {} + public setup(core: CoreSetup) { + defineGetUserRoutes({ router: core.http.createRouter() }); + } public start(core: CoreStart) {} public stop() {} } + +interface RouteDefinitionParams { + router: IRouter; +} + +export function defineGetUserRoutes({ router }: RouteDefinitionParams) { + router.post( + { + path: '/internal/user_setup', + validate: { + body: schema.object({ enrollmentToken: schema.string() }), + }, + }, + async (context, request, response) => { + const decoded = decodeEnrollmentToken(request.body.enrollmentToken); + + if (decoded) { + const client = new Client({ + nodes: decoded.adr.map((adr) => `https://${adr}`), + auth: { + apiKey: btoa(decoded.key), + }, + ssl: { + rejectUnauthorized: false, + }, + }); + // TODO: Need to check if we can write to file before enrolling + // await client.transport.request({ + // method: 'GET', + // path: '/_security/enroll/kibana', + // }); + const result = await client.nodes.info(); + return response.ok({ + body: result.body, + }); + } + + return response.badRequest(); + } + ); +} + +interface EnrollmentToken { + /** + * The version of the Elasticsearch node that generated this enrollment token. + */ + ver: string; + + /** + * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. + */ + adr: string[]; + + /** + * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. + */ + fgr: string; + + /** + * An Elasticsearch API key (not base64 encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. + */ + key: string; +} + +export function decodeEnrollmentToken(enrollmentToken: string) { + try { + return JSON.parse(atob(enrollmentToken)) as EnrollmentToken; + } catch (error) {} // eslint-disable-line no-empty +} + +function btoa(str: string) { + return Buffer.from(str, 'binary').toString('base64'); +} + +function atob(str: string) { + return Buffer.from(str, 'base64').toString('binary'); +} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json index d211a70f12df33..f658c3e4ca583b 100644 --- a/src/plugins/user_setup/tsconfig.json +++ b/src/plugins/user_setup/tsconfig.json @@ -8,5 +8,8 @@ "declarationMap": true }, "include": ["public/**/*", "server/**/*"], - "references": [{ "path": "../../core/tsconfig.json" }] + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/kibana_react/tsconfig.json" } + ] } From 34672fc81ff8729b11fbf6d00f00f58e68166101 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 27 Jul 2021 18:10:41 +0100 Subject: [PATCH 02/35] remove first attempt --- src/plugins/user_setup/README.md | 3 - src/plugins/user_setup/jest.config.js | 13 - src/plugins/user_setup/kibana.json | 16 - src/plugins/user_setup/public/app.tsx | 354 ------------------ .../public/decode_enrollment_token.ts | 35 -- .../user_setup/public/get_fingerprint.ts | 58 --- src/plugins/user_setup/public/index.ts | 11 - src/plugins/user_setup/public/plugin.tsx | 54 --- src/plugins/user_setup/public/use_form.ts | 208 ---------- src/plugins/user_setup/server/config.ts | 16 - src/plugins/user_setup/server/index.ts | 19 - src/plugins/user_setup/server/plugin.ts | 100 ----- src/plugins/user_setup/tsconfig.json | 15 - 13 files changed, 902 deletions(-) delete mode 100644 src/plugins/user_setup/README.md delete mode 100644 src/plugins/user_setup/jest.config.js delete mode 100644 src/plugins/user_setup/kibana.json delete mode 100644 src/plugins/user_setup/public/app.tsx delete mode 100644 src/plugins/user_setup/public/decode_enrollment_token.ts delete mode 100644 src/plugins/user_setup/public/get_fingerprint.ts delete mode 100644 src/plugins/user_setup/public/index.ts delete mode 100644 src/plugins/user_setup/public/plugin.tsx delete mode 100644 src/plugins/user_setup/public/use_form.ts delete mode 100644 src/plugins/user_setup/server/config.ts delete mode 100644 src/plugins/user_setup/server/index.ts delete mode 100644 src/plugins/user_setup/server/plugin.ts delete mode 100644 src/plugins/user_setup/tsconfig.json diff --git a/src/plugins/user_setup/README.md b/src/plugins/user_setup/README.md deleted file mode 100644 index 61ec964f5bb80c..00000000000000 --- a/src/plugins/user_setup/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `userSetup` plugin - -The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/user_setup/jest.config.js b/src/plugins/user_setup/jest.config.js deleted file mode 100644 index 75e355e230c5db..00000000000000 --- a/src/plugins/user_setup/jest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../..', - roots: ['/src/plugins/user_setup'], -}; diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json deleted file mode 100644 index f41eb661f26771..00000000000000 --- a/src/plugins/user_setup/kibana.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "userSetup", - "owner": { - "name": "Platform Security", - "githubTeam": "kibana-security" - }, - "description": "This plugin provides UI and APIs for the interactive setup mode.", - "version": "8.0.0", - "kibanaVersion": "kibana", - "configPath": ["userSetup"], - "server": true, - "ui": true, - "requiredBundles": [ - "kibanaReact" - ] -} diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx deleted file mode 100644 index e17fe24f670445..00000000000000 --- a/src/plugins/user_setup/public/app.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* - * 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 { - EuiPageTemplate, - EuiIcon, - EuiForm, - EuiButton, - EuiFormRow, - EuiLoadingSpinner, - EuiHorizontalRule, - EuiTextArea, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiText, - EuiFieldText, - EuiFieldPassword, - EuiFilePicker, - EuiButtonEmpty, -} from '@elastic/eui'; -import React, { FunctionComponent } from 'react'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; -import useAsyncFn from 'react-use/lib/useAsyncFn'; -import { useForm, ValidationErrors } from './use_form'; -import { useKibana } from '../../../../src/plugins/kibana_react/public'; -import { getFingerprint } from './get_fingerprint'; -import { decodeEnrollmentToken } from './decode_enrollment_token'; - -export const App = () => { - return ( - - -

Connect to cluster

-
- - -
- ); -}; - -interface ConfigurationFormValues { - type: 'enrollmentToken' | 'manual' | string; - enrollmentToken: string; - address: string; - username: string; - password: string; - ca: string; -} - -interface ConfigurationFormProps { - defaultValues?: ConfigurationFormValues; - onCancel(): void; - onSuccess?(): void; -} - -const defaultDefaultValues: ConfigurationFormValues = { - type: 'enrollmentToken', - enrollmentToken: '', - address: 'localhost:9201', - username: 'elastic', - password: '', - ca: '', -}; - -const ConfigurationForm: FunctionComponent = ({ - defaultValues = defaultDefaultValues, - onSuccess, - onCancel, -}) => { - const { services } = useKibana(); - const [state, enrollKibana] = useAsyncFn((enrollmentToken: string) => - services.http!.post('/internal/user_setup', { - body: JSON.stringify({ enrollmentToken }), - }) - ); - const [form, eventHandlers] = useForm({ - onSubmit: async (values) => { - const decoded = decodeEnrollmentToken(values.enrollmentToken); - if (decoded) { - await enrollKibana(values.enrollmentToken); - // const result = await services.http!.get('/internal/security/enroll/kibana', { - // headers: { - // Authorization: `ApiKey ${btoa(decoded.key)}`, - // }, - // }); - } - }, - validate: (values) => { - const errors: ValidationErrors = {}; - - if (!values.enrollmentToken) { - errors.enrollmentToken = 'Required'; - } else { - const decoded = decodeEnrollmentToken(values.enrollmentToken); - if (!decoded) { - errors.enrollmentToken = 'Invalid'; - } - } - - return errors; - }, - defaultValues, - }); - - const decoded = decodeEnrollmentToken(form.values.enrollmentToken); - - return ( - - {/* - - - } - title="Connect using enrollment token" - description={false} - selectable={{ - onClick: () => { - form.setValue('type', 'enrollmentToken'); - }, - isSelected: form.values.type === 'enrollmentToken', - }} - /> - - - } - title="Configure manually" - description={false} - selectable={{ - onClick: () => { - form.setValue('type', 'manual'); - }, - isSelected: form.values.type === 'manual', - }} - /> - - - */} - - {form.values.type && ( - <> - {form.values.type === 'enrollmentToken' && ( - <> - - - Connect to - - - - - - {decoded.adr[0]} - - - - - - {`Elasticsearch (v${decoded.ver})`} - - - ) - } - > - - form.setValue( - 'enrollmentToken', - btoa( - JSON.stringify({ - ver: '8.0.0', - adr: ['localhost:9200'], - fgr: - '48:CC:6C:F8:76:43:3C:97:85:B6:24:45:5B:FF:BD:40:4B:D6:35:81:51:E7:A9:99:60:E4:0A:C8:8D:AE:5C:4D', - key: 'fECUqXoB0XsCTgc6NjlA:jo5CEpRURPGq7_Xi6G5uEA', - }) - ) - ) - } - /> - - - - )} - - {form.values.type === 'manual' && ( - <> - - - - - - - - - - - { - form.setTouched('ca'); - if (!files || !files.length) { - form.setValue('ca', ''); - return; - } - if (files[0].type !== 'application/x-x509-ca-cert') { - form.setError('ca', 'Invalid certificate, upload x509 CA cert'); - return false; - } - const cert = await readFile(files[0]); - const hash = await getFingerprint(cert); - form.setValue('ca', hash); - }} - /> - - - - )} - - - - - - - - - {form.values.type === 'manual' ? ( - form.setValue('type', 'enrollmentToken')} - > - Connect using enrollment token - - ) : ( - form.setValue('type', 'manual')}> - Configure manually - - )} - - - - )} - - {state.value && ( - <> - - Initializing Elastic... - - )} - - ); -}; - -function readFile(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as ArrayBuffer); - reader.onerror = (err) => reject(err); - reader.readAsArrayBuffer(file); - }); -} diff --git a/src/plugins/user_setup/public/decode_enrollment_token.ts b/src/plugins/user_setup/public/decode_enrollment_token.ts deleted file mode 100644 index 5816605cf61ab9..00000000000000 --- a/src/plugins/user_setup/public/decode_enrollment_token.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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. - */ - -interface EnrollmentToken { - /** - * The version of the Elasticsearch node that generated this enrollment token. - */ - ver: string; - - /** - * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. - */ - adr: string[]; - - /** - * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. - */ - fgr: string; - - /** - * An Elasticsearch API key (not encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. - */ - key: string; -} - -export function decodeEnrollmentToken(enrollmentToken: string) { - try { - return JSON.parse(atob(enrollmentToken)) as EnrollmentToken; - } catch (error) {} // eslint-disable-line no-empty -} diff --git a/src/plugins/user_setup/public/get_fingerprint.ts b/src/plugins/user_setup/public/get_fingerprint.ts deleted file mode 100644 index 48b8c43b30d525..00000000000000 --- a/src/plugins/user_setup/public/get_fingerprint.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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. - */ - -enum ParseMode { - BEFORE_BEGIN, - AFTER_BEGIN, - AFTER_END, -} - -const BEGIN_TOKEN = '-----BEGIN'; -const END_TOKEN = '-----END'; - -// openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt -export async function getFingerprint( - cert: ArrayBuffer, - algorithm: AlgorithmIdentifier = 'SHA-256' -) { - // Use first certificate only - let mode = ParseMode.BEFORE_BEGIN; - const pemData = Array.from(new Uint8Array(cert)) - .map((char) => String.fromCharCode(char)) - .join('') - .split('\n') - .map((line) => line.trim()) - .filter((line) => { - if (mode === ParseMode.BEFORE_BEGIN && line.startsWith(BEGIN_TOKEN)) { - mode = ParseMode.AFTER_BEGIN; - return false; - } - if (mode === ParseMode.AFTER_BEGIN && line.startsWith(END_TOKEN)) { - mode = ParseMode.AFTER_END; - return false; - } - return mode === ParseMode.AFTER_BEGIN; - }) - .join(''); - - // Convert to DER - const derData = atob(pemData); - const derBuffer = new Uint8Array(new ArrayBuffer(derData.length)); - for (let i = 0; i < derData.length; i++) { - derBuffer[i] = derData.charCodeAt(i); - } - - // Calculate fingerprint - const hashBuffer = await crypto.subtle.digest(algorithm, derBuffer); - - // Convert to HEX - return Array.from(new Uint8Array(hashBuffer)) - .map((char) => char.toString(16).padStart(2, '0')) - .join(':') - .toUpperCase(); -} diff --git a/src/plugins/user_setup/public/index.ts b/src/plugins/user_setup/public/index.ts deleted file mode 100644 index 153bc92a0dd087..00000000000000 --- a/src/plugins/user_setup/public/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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 { UserSetupPlugin } from './plugin'; - -export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx deleted file mode 100644 index bcd5908e8b9de3..00000000000000 --- a/src/plugins/user_setup/public/plugin.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 React, { FunctionComponent } from 'react'; -import ReactDOM from 'react-dom'; - -import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { I18nProvider } from '@kbn/i18n/react'; -import { Router } from 'react-router-dom'; -import type { History } from 'history'; -import { App } from './app'; -import { KibanaContextProvider } from '../../kibana_react/public'; - -export class UserSetupPlugin implements Plugin { - public setup(core: CoreSetup) { - core.application.register({ - id: 'userSetup', - title: 'User Setup', - chromeless: true, - mount: async ({ element, history }) => { - const [[coreStart]] = await Promise.all([core.getStartServices()]); - - ReactDOM.render( - - - , - element - ); - - return () => ReactDOM.unmountComponentAtNode(element); - }, - }); - } - - public start(core: CoreStart) {} -} - -export interface ProvidersProps { - services: CoreStart; - history: History; -} - -export const Providers: FunctionComponent = ({ services, history, children }) => ( - - - {children} - - -); diff --git a/src/plugins/user_setup/public/use_form.ts b/src/plugins/user_setup/public/use_form.ts deleted file mode 100644 index 29bfd5b807244a..00000000000000 --- a/src/plugins/user_setup/public/use_form.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * 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 { cloneDeep, cloneDeepWith, get, set } from 'lodash'; -import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; -import { useState } 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): Promise; - setError(name: string, message: string): void; - setTouched(name: string): Promise; - reset(values: Values): void; - submit(): 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 [values, setValues] = useState(defaultValues); - const [errors, setErrors] = useState>({}); - const [touched, setTouched] = useState>({}); - const [submitCount, setSubmitCount] = useState(0); - - const [validationState, validateForm] = useAsyncFn( - async (formValues: Values) => { - const nextErrors = await validate(formValues); - setErrors(nextErrors); - if (Object.keys(nextErrors).length === 0) { - setSubmitCount(0); - } - return nextErrors; - }, - [validate] - ); - - const [submitState, submitForm] = useAsyncFn( - async (formValues: Values) => { - const nextErrors = await validateForm(formValues); - setTouched(mapDeep(formValues, true)); - setSubmitCount(submitCount + 1); - if (Object.keys(nextErrors).length === 0) { - return onSubmit(formValues); - } - }, - [validateForm, onSubmit] - ); - - return { - setValue: async (name, value) => { - const nextValues = setDeep(values, name, value); - setValues(nextValues); - await validateForm(nextValues); - }, - setTouched: async (name, value = true) => { - setTouched(setDeep(touched, name, value)); - await validateForm(values); - }, - setError: (name, message) => { - setErrors(setDeep(errors, name, message)); - setTouched(setDeep(touched, name, true)); - }, - reset: (nextValues) => { - setValues(nextValues); - setErrors({}); - setTouched({}); - setSubmitCount(0); - }, - submit: () => submitForm(values), - values, - errors, - touched, - isValidating: validationState.loading, - isSubmitting: submitState.loading, - submitError: submitState.error, - isInvalid: Object.keys(errors).length > 0, - isSubmitted: submitCount > 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/user_setup/server/config.ts b/src/plugins/user_setup/server/config.ts deleted file mode 100644 index b16c51bcbda09d..00000000000000 --- a/src/plugins/user_setup/server/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 { TypeOf } from '@kbn/config-schema'; -import { schema } from '@kbn/config-schema'; - -export type ConfigType = TypeOf; - -export const ConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), -}); diff --git a/src/plugins/user_setup/server/index.ts b/src/plugins/user_setup/server/index.ts deleted file mode 100644 index 2a43cbbf65c9de..00000000000000 --- a/src/plugins/user_setup/server/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 { TypeOf } from '@kbn/config-schema'; -import type { PluginConfigDescriptor } from 'src/core/server'; - -import { ConfigSchema } from './config'; -import { UserSetupPlugin } from './plugin'; - -export const config: PluginConfigDescriptor> = { - schema: ConfigSchema, -}; - -export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts deleted file mode 100644 index fc25caa4fc3364..00000000000000 --- a/src/plugins/user_setup/server/plugin.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { CoreSetup, CoreStart, Plugin } from 'src/core/server'; -import type { IRouter, RequestHandlerContext } from 'src/core/server'; -import { schema } from '@kbn/config-schema'; -import { Client } from '@elastic/elasticsearch'; -// import { decodeEnrollmentToken } from '../public/app'; - -export class UserSetupPlugin implements Plugin { - public setup(core: CoreSetup) { - defineGetUserRoutes({ router: core.http.createRouter() }); - } - - public start(core: CoreStart) {} - - public stop() {} -} - -interface RouteDefinitionParams { - router: IRouter; -} - -export function defineGetUserRoutes({ router }: RouteDefinitionParams) { - router.post( - { - path: '/internal/user_setup', - validate: { - body: schema.object({ enrollmentToken: schema.string() }), - }, - }, - async (context, request, response) => { - const decoded = decodeEnrollmentToken(request.body.enrollmentToken); - - if (decoded) { - const client = new Client({ - nodes: decoded.adr.map((adr) => `https://${adr}`), - auth: { - apiKey: btoa(decoded.key), - }, - ssl: { - rejectUnauthorized: false, - }, - }); - // TODO: Need to check if we can write to file before enrolling - // await client.transport.request({ - // method: 'GET', - // path: '/_security/enroll/kibana', - // }); - const result = await client.nodes.info(); - return response.ok({ - body: result.body, - }); - } - - return response.badRequest(); - } - ); -} - -interface EnrollmentToken { - /** - * The version of the Elasticsearch node that generated this enrollment token. - */ - ver: string; - - /** - * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. - */ - adr: string[]; - - /** - * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. - */ - fgr: string; - - /** - * An Elasticsearch API key (not base64 encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. - */ - key: string; -} - -export function decodeEnrollmentToken(enrollmentToken: string) { - try { - return JSON.parse(atob(enrollmentToken)) as EnrollmentToken; - } catch (error) {} // eslint-disable-line no-empty -} - -function btoa(str: string) { - return Buffer.from(str, 'binary').toString('base64'); -} - -function atob(str: string) { - return Buffer.from(str, 'base64').toString('binary'); -} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json deleted file mode 100644 index f658c3e4ca583b..00000000000000 --- a/src/plugins/user_setup/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "outDir": "./target/types", - "emitDeclarationOnly": true, - "declaration": true, - "declarationMap": true - }, - "include": ["public/**/*", "server/**/*"], - "references": [ - { "path": "../../core/tsconfig.json" }, - { "path": "../../plugins/kibana_react/tsconfig.json" } - ] -} From 09d1f955e20de498b9ad00ec7d50ac892e4cbf81 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 28 Jul 2021 17:50:22 +0100 Subject: [PATCH 03/35] Added suggestions from code review --- .i18nrc.json | 1 + .../src/get_server_watch_paths.ts | 2 +- .../elasticsearch/elasticsearch_config.ts | 2 +- src/plugins/interactive_setup/kibana.json | 5 +- .../public/enrollment_token_form.tsx | 2 +- src/plugins/interactive_setup/public/index.ts | 2 +- .../public/manual_configuration_form.tsx | 2 +- .../public/progress_indicator.tsx | 6 +- .../interactive_setup/server/config.ts | 6 +- .../interactive_setup/server/plugin.ts | 122 +++++++++++------- 10 files changed, 91 insertions(+), 59 deletions(-) diff --git a/.i18nrc.json b/.i18nrc.json index 2709d5ad7a6716..d31fc969cd787b 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -26,6 +26,7 @@ "inputControl": "src/plugins/input_control_vis", "inspector": "src/plugins/inspector", "inspectorViews": "src/legacy/core_plugins/inspector_views", + "interactiveSetup": "src/plugins/interactiveSetup", "interpreter": "src/legacy/core_plugins/interpreter", "kbn": "src/legacy/core_plugins/kibana", "kbnDocViews": "src/legacy/core_plugins/kbn_doc_views", diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index 7ce6d4313bff88..b0773fd5676357 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -43,7 +43,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { fromRoot('src/core'), fromRoot('src/legacy/server'), fromRoot('src/legacy/utils'), - // fromRoot('config'), + fromRoot('config'), ...pluginPaths, ...pluginScanDirs, ].map((path) => Path.resolve(path)) diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 4935d381369072..e756d9da867b3d 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -33,7 +33,7 @@ export const configSchema = schema.object({ }), sniffOnConnectionFault: schema.boolean({ defaultValue: false }), hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { - defaultValue: 'https://localhost:9200', + defaultValue: 'http://localhost:9200', }), username: schema.maybe( schema.conditional( diff --git a/src/plugins/interactive_setup/kibana.json b/src/plugins/interactive_setup/kibana.json index 7f69cb53694c3a..91a44e68b519f8 100644 --- a/src/plugins/interactive_setup/kibana.json +++ b/src/plugins/interactive_setup/kibana.json @@ -10,8 +10,5 @@ "configPath": ["interactiveSetup"], "type": "preboot", "server": true, - "ui": true, - "requiredPlugins": [], - "requiredBundles": [], - "extraPublicDirs": [] + "ui": true } diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx index cb3a5f4434b8bd..a56cb4ece77928 100644 --- a/src/plugins/interactive_setup/public/enrollment_token_form.tsx +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -70,7 +70,7 @@ export const EnrollmentTokenForm: FunctionComponent = const decoded = decodeEnrollmentToken(values.token); if (decoded) { await Promise.all([ - http.post('/api/preboot/setup', { + http.post('/internal/interactive_setup/enroll/kibana', { body: JSON.stringify({ hosts: decoded.adr, apiKey: decoded.key, diff --git a/src/plugins/interactive_setup/public/index.ts b/src/plugins/interactive_setup/public/index.ts index 5c4c8a992afa08..b7c70c7a305111 100644 --- a/src/plugins/interactive_setup/public/index.ts +++ b/src/plugins/interactive_setup/public/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { PluginInitializerContext } from 'kibana/public'; +import { PluginInitializerContext } from 'src/core/public'; import { InteractiveSetupPlugin } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx index 5166543dadfd18..cde4e920daafc7 100644 --- a/src/plugins/interactive_setup/public/manual_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -93,7 +93,7 @@ export const ManualConfigurationForm: FunctionComponent { - await http.post('/api/preboot/setup', { + await http.post('/internal/interactive_setup/enroll/kibana', { body: JSON.stringify({ hosts: [values.host], username: values.username, diff --git a/src/plugins/interactive_setup/public/progress_indicator.tsx b/src/plugins/interactive_setup/public/progress_indicator.tsx index dc7eccf92c8241..5255c2b5d01bbb 100644 --- a/src/plugins/interactive_setup/public/progress_indicator.tsx +++ b/src/plugins/interactive_setup/public/progress_indicator.tsx @@ -47,7 +47,7 @@ export const ProgressIndicator: FunctionComponent = () => { } }, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps - const [helpRequired, cancelHelp, resetHelp] = useTimeout(15000); + const [helpRequired, cancelHelp, resetHelp] = useTimeout(20000); useEffect(() => { if (status.value === 'complete') { @@ -83,8 +83,8 @@ export const ProgressIndicator: FunctionComponent = () => { ]} /> {helpRequired() && ( - - Check terminal for cause of error. + + Check terminal for error cause. )} diff --git a/src/plugins/interactive_setup/server/config.ts b/src/plugins/interactive_setup/server/config.ts index a3e050d09742d6..e3c4fd8e72f49b 100644 --- a/src/plugins/interactive_setup/server/config.ts +++ b/src/plugins/interactive_setup/server/config.ts @@ -12,5 +12,9 @@ import { schema } from '@kbn/config-schema'; export type ConfigType = TypeOf; export const ConfigSchema = schema.object({ - enabled: schema.maybe(schema.boolean()), + enabled: schema.boolean({ defaultValue: true }), + holdSetup: schema.oneOf( + [schema.literal('auto'), schema.literal('always'), schema.literal('never')], + { defaultValue: 'auto' } + ), }); diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index a5e79b62a4dd2c..2635bdfd7132ca 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -9,6 +9,8 @@ import { schema } from '@kbn/config-schema'; import type { CorePreboot, PrebootPlugin, PluginInitializerContext } from 'src/core/server'; import fs from 'fs/promises'; +import { constants } from 'fs'; +import path from 'path'; import { errors } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import chalk from 'chalk'; @@ -21,27 +23,23 @@ export class InteractiveSetupPlugin implements PrebootPlugin { } public setup(core: CorePreboot) { - const { enabled } = this.#initializerContext.config.get(); + const config = this.#initializerContext.config.get(); + const logger = this.#initializerContext.logger.get('plugins', 'interactiveSetup'); const isDev = this.#initializerContext.env.mode.dev; - const isConfigured = - core.elasticsearch.config.credentialsSpecified && core.elasticsearch.config.hosts.length; - - /** - * env: dev -> enabled: false -> skip - * env: dev -> enabled: undefined -> skip - * env: dev -> enabled: true -> hold setup - * env: prod -> enabled: false -> skip - * env: prod -> enabled: undefined -> hold setup if not configured - * env: prod -> enabled: true -> hold setup if not configured - */ - const shouldHoldSetupPhase = isDev - ? enabled === true - : enabled !== false - ? !isConfigured - : false; - - if (!shouldHoldSetupPhase) { + const isManuallyConfigured = + core.elasticsearch.config.credentialsSpecified || + core.elasticsearch.config.hosts.length !== 1 || + core.elasticsearch.config.hosts[0] !== 'http://localhost:9200'; + const skipInteractiveSetup = + config.holdSetup === 'never' + ? true + : config.holdSetup === 'always' + ? false + : isManuallyConfigured; + + if (skipInteractiveSetup) { + logger.info('Skipping interactive setup'); return; } @@ -50,7 +48,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin { prebootRouter.post( { - path: '/api/preboot/setup', + path: '/internal/interactive_setup/enroll/kibana', validate: { body: schema.oneOf([ schema.object({ @@ -62,7 +60,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin { hosts: schema.arrayOf(schema.string(), { minSize: 1 }), username: schema.string(), password: schema.string(), - caFingerprint: schema.string(), + caCert: schema.string(), }), ]), }, @@ -70,6 +68,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin { }, async (context, request, response) => { if (!core.preboot.isSetupOnHold()) { + logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); return response.badRequest(); } @@ -88,46 +87,55 @@ export class InteractiveSetupPlugin implements PrebootPlugin { }, }); + const configPath = isDev + ? this.#initializerContext.env.configs.find((fpath) => + path.basename(fpath).includes('dev') + ) + : this.#initializerContext.env.configs[0]; + + if (!configPath) { + logger.error('Failed to setup Kibana due to missing config file'); + return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); + } + const caPath = path.join(path.dirname(configPath), 'ca.crt'); + try { + await Promise.all([ + fs.access(configPath, constants.W_OK), + fs.access(caPath, constants.W_OK), + ]); + const { body } = await client.asCurrentUser.transport.request({ method: 'GET', path: '/_security/enroll/kibana', }); - const configPath = isDev - ? this.#initializerContext.env.configs.find((path) => path.includes('dev')) - : this.#initializerContext.env.configs[0]; - - if (!configPath) { - return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); - } - - await fs.appendFile( - configPath, - ` - -elasticsearch.hosts: [${request.body.hosts.map((host) => `"https://${host}"`).join(', ')}] -elasticsearch.username: "kibana_system" -elasticsearch.password: "${body.password}" -elasticsearch.ssl.verificationMode: "none" -` - ); + await Promise.all([ + fs.appendFile(configPath, generateConfig(request.body.hosts, body.password, caPath)), + fs.writeFile(caPath, generateCertificate(body.http_ca)), + ]); completeSetup({ shouldReloadConfig: true }); return response.noContent(); - } catch (err) { - return response.customError({ statusCode: 500, body: getDetailedErrorMessage(err) }); + } catch (error) { + logger.error('Failed to setup Kibana', { + error, + }); + return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); } } ); + const holdSetupReason = ` + +${chalk.bold(chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`))} + +Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get started. +`; + core.preboot.holdSetupUntilResolved( - `\n\n${chalk.bold( - chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`) - )}\n\nGo to ${chalk.cyanBright( - chalk.underline('http://localhost:5601') - )} to get started.\n`, + holdSetupReason, new Promise((resolve) => { completeSetup = resolve; }) @@ -138,6 +146,28 @@ elasticsearch.ssl.verificationMode: "none" public stop() {} } +export function generateCertificate(pem: string) { + return `-----BEGIN CERTIFICATE----- +${pem + .replace(/_/g, '/') + .replace(/-/g, '+') + .replace(/([^\n]{1,65})/g, '$1\n') + .replace(/\n$/g, '')} +-----END CERTIFICATE----- +`; +} + +export function generateConfig(hosts: string[], password: string, caPath: string) { + return ` + +# This section was automatically generated during setup. +elasticsearch.hosts: [ ${hosts.map((host) => `"https://${host}"`).join(', ')} ] +elasticsearch.username: "kibana_system" +elasticsearch.password: "${password}" +elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] +`; +} + export function btoa(str: string) { return Buffer.from(str, 'binary').toString('base64'); } From 4177cef3ac6193699e3a24fab927bfe76a18543d Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 30 Jul 2021 10:31:26 +0100 Subject: [PATCH 04/35] Verify CA before writing config --- src/plugins/interactive_setup/public/app.tsx | 2 +- .../interactive_setup/public/config.ts | 11 ------- .../public/enrollment_token_form.tsx | 12 +++++++ src/plugins/interactive_setup/public/index.ts | 4 +-- .../interactive_setup/public/plugin.tsx | 14 +-------- .../public/progress_indicator.tsx | 14 ++++++--- .../interactive_setup/server/plugin.ts | 31 ++++++++++++++----- 7 files changed, 47 insertions(+), 41 deletions(-) delete mode 100644 src/plugins/interactive_setup/public/config.ts diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx index c48dbf5d630566..cd0d354d9ba849 100644 --- a/src/plugins/interactive_setup/public/app.tsx +++ b/src/plugins/interactive_setup/public/app.tsx @@ -37,7 +37,7 @@ export const App: FunctionComponent = () => { onSuccess={() => setPage('success')} /> - {page === 'success' && } + {page === 'success' && window.location.replace('/')} />} ); }; diff --git a/src/plugins/interactive_setup/public/config.ts b/src/plugins/interactive_setup/public/config.ts deleted file mode 100644 index fc91296ce37249..00000000000000 --- a/src/plugins/interactive_setup/public/config.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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. - */ - -export interface ConfigType { - token?: string; -} diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx index a56cb4ece77928..f82ca8023bce66 100644 --- a/src/plugins/interactive_setup/public/enrollment_token_form.tsx +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -12,6 +12,7 @@ import { EuiButton, EuiFormRow, EuiTextArea, + EuiCallOut, EuiSpacer, EuiFlexGroup, EuiFlexItem, @@ -19,6 +20,7 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; +import { errors as elasticsearchErrors } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -93,6 +95,16 @@ export const EnrollmentTokenForm: FunctionComponent = noValidate style={{ width: euiThemeVars.euiFormMaxWidth }} > + {form.submitError && ( + + {(form.submitError as elasticsearchErrors.ResponseError).body?.message} + + )} - new InteractiveSetupPlugin(initializerContext); +export const plugin = () => new InteractiveSetupPlugin(); diff --git a/src/plugins/interactive_setup/public/plugin.tsx b/src/plugins/interactive_setup/public/plugin.tsx index c9ffe299b4cc34..852dd5f0409886 100644 --- a/src/plugins/interactive_setup/public/plugin.tsx +++ b/src/plugins/interactive_setup/public/plugin.tsx @@ -6,27 +6,15 @@ * Side Public License, v 1. */ -import type { - CoreSetup, - CoreStart, - HttpSetup, - Plugin, - PluginInitializerContext, -} from 'src/core/public'; +import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public'; import React, { FunctionComponent } from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { ConfigType } from './config'; import { App } from './app'; import { HttpProvider } from './use_http'; export class InteractiveSetupPlugin implements Plugin { - #config: ConfigType; - constructor(initializerContext: PluginInitializerContext) { - this.#config = initializerContext.config.get(); - } - public setup(core: CoreSetup) { core.application.register({ id: 'interactiveSetup', diff --git a/src/plugins/interactive_setup/public/progress_indicator.tsx b/src/plugins/interactive_setup/public/progress_indicator.tsx index 5255c2b5d01bbb..65d58e7a72d4be 100644 --- a/src/plugins/interactive_setup/public/progress_indicator.tsx +++ b/src/plugins/interactive_setup/public/progress_indicator.tsx @@ -14,7 +14,11 @@ import useTimeout from 'react-use/lib/useTimeout'; import { i18n } from '@kbn/i18n'; import { useHttp } from './use_http'; -export const ProgressIndicator: FunctionComponent = () => { +export interface ProgressIndicatorProps { + onSuccess?(): void; +} + +export const ProgressIndicator: FunctionComponent = ({ onSuccess }) => { const http = useHttp(); const [status, checkStatus] = useAsyncFn(async () => { let isAvailable: boolean | undefined = false; @@ -36,14 +40,14 @@ export const ProgressIndicator: FunctionComponent = () => { : 'unknown'; }); - const [_, cancel, reset] = useTimeoutFn(checkStatus, 1000); + const [, cancelPolling, resetPolling] = useTimeoutFn(checkStatus, 1000); useEffect(() => { if (status.value === 'complete') { - cancel(); - window.location.replace('/'); + cancelPolling(); + onSuccess?.(); } else if (status.loading === false) { - reset(); + resetPolling(); } }, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 2635bdfd7132ca..14d99685e07aeb 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -39,7 +39,11 @@ export class InteractiveSetupPlugin implements PrebootPlugin { : isManuallyConfigured; if (skipInteractiveSetup) { - logger.info('Skipping interactive setup'); + if (isManuallyConfigured) { + logger.debug( + 'Skipping interactive setup since Elasticsearch connection has been configured' + ); + } return; } @@ -73,7 +77,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin { } const client = core.elasticsearch - .createClient('data', { + .createClient('enrollment', { hosts: request.body.hosts.map((host) => `https://${host}`), ssl: { verificationMode: 'none' }, // caFingerprint: request.body.caFingerprint, @@ -94,15 +98,16 @@ export class InteractiveSetupPlugin implements PrebootPlugin { : this.#initializerContext.env.configs[0]; if (!configPath) { - logger.error('Failed to setup Kibana due to missing config file'); + logger.error('Cannot find config file'); return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); } + // TODO: Use fingerprint/timestamp as file name const caPath = path.join(path.dirname(configPath), 'ca.crt'); try { await Promise.all([ fs.access(configPath, constants.W_OK), - fs.access(caPath, constants.W_OK), + fs.access(path.dirname(caPath), constants.W_OK), ]); const { body } = await client.asCurrentUser.transport.request({ @@ -110,18 +115,28 @@ export class InteractiveSetupPlugin implements PrebootPlugin { path: '/_security/enroll/kibana', }); + const certificateAuthority = generateCertificate(body.http_ca); + + // Ensure we can connect using CA before writing config + const verifyConnectionClient = core.elasticsearch.createClient('verifyConnection', { + hosts: request.body.hosts.map((host) => `https://${host}`), + username: 'kibana_system', + password: body.password, + ssl: { certificateAuthorities: [certificateAuthority] }, + }); + + await verifyConnectionClient.asInternalUser.security.authenticate(); + await Promise.all([ fs.appendFile(configPath, generateConfig(request.body.hosts, body.password, caPath)), - fs.writeFile(caPath, generateCertificate(body.http_ca)), + fs.writeFile(caPath, certificateAuthority), ]); completeSetup({ shouldReloadConfig: true }); return response.noContent(); } catch (error) { - logger.error('Failed to setup Kibana', { - error, - }); + logger.error(error); return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); } } From 185660d8149e5156f703d729320302dd48252320 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 30 Jul 2021 12:00:38 +0100 Subject: [PATCH 05/35] fix i18n path --- .i18nrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.i18nrc.json b/.i18nrc.json index d31fc969cd787b..677731d2469e78 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -26,7 +26,7 @@ "inputControl": "src/plugins/input_control_vis", "inspector": "src/plugins/inspector", "inspectorViews": "src/legacy/core_plugins/inspector_views", - "interactiveSetup": "src/plugins/interactiveSetup", + "interactiveSetup": "src/plugins/interactive_setup", "interpreter": "src/legacy/core_plugins/interpreter", "kbn": "src/legacy/core_plugins/kibana", "kbnDocViews": "src/legacy/core_plugins/kbn_doc_views", From 4e7b041e34f1bc8a558f827314ebd4dfab981937 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 30 Jul 2021 12:01:36 +0100 Subject: [PATCH 06/35] updated plugin list --- docs/developer/plugin-list.asciidoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index ba594b1f312e98..9acfc4de4145bc 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -136,6 +136,10 @@ for use in their own application. in Kibana, e.g. visualizations. It has the form of a flyout panel. +|{kib-repo}blob/{branch}/src/plugins/interactive_setup/README.md[interactiveSetup] +|The plugin provides UI and APIs for the interactive setup mode. + + |{kib-repo}blob/{branch}/src/plugins/kibana_legacy/README.md[kibanaLegacy] |This plugin contains several helpers and services to integrate pieces of the legacy Kibana app with the new Kibana platform. @@ -276,10 +280,6 @@ In general this plugin provides: |The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. -|{kib-repo}blob/{branch}/src/plugins/user_setup/README.md[userSetup] -|The plugin provides UI and APIs for the interactive setup mode. - - |{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] |The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. From 5223dfa1a95e70aee7531543a48f796bf45caa61 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 30 Jul 2021 12:02:53 +0100 Subject: [PATCH 07/35] Updated page bundle limits --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index b135f2d65c6fa3..298a6e2a02a2be 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -117,4 +117,4 @@ pageLoadAssetSize: expressionImage: 19288 expressionMetric: 22238 expressionShape: 30033 - userSetup: 18532 + interactiveSetup: 18532 From decec2785e6029bf05730afa1cba16455021de98 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 2 Aug 2021 09:11:36 +0100 Subject: [PATCH 08/35] added manual configuration --- .../public/enrollment_token_form.tsx | 4 +- .../public/manual_configuration_form.tsx | 105 ++++++++---- .../interactive_setup/server/plugin.ts | 156 ++--------------- .../server/routes/configure.ts | 115 +++++++++++++ .../interactive_setup/server/routes/enroll.ts | 159 ++++++++++++++++++ .../interactive_setup/server/routes/index.ts | 26 +++ 6 files changed, 388 insertions(+), 177 deletions(-) create mode 100644 src/plugins/interactive_setup/server/routes/configure.ts create mode 100644 src/plugins/interactive_setup/server/routes/enroll.ts create mode 100644 src/plugins/interactive_setup/server/routes/index.ts diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx index f82ca8023bce66..ab76a5ae9990bc 100644 --- a/src/plugins/interactive_setup/public/enrollment_token_form.tsx +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -72,9 +72,9 @@ export const EnrollmentTokenForm: FunctionComponent = const decoded = decodeEnrollmentToken(values.token); if (decoded) { await Promise.all([ - http.post('/internal/interactive_setup/enroll/kibana', { + http.post('/internal/interactive_setup/enroll', { body: JSON.stringify({ - hosts: decoded.adr, + hosts: decoded.adr.map((host) => `https://${host}`), apiKey: decoded.key, caFingerprint: decoded.fgr, }), diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx index cde4e920daafc7..5ecbd8c82204f6 100644 --- a/src/plugins/interactive_setup/public/manual_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -24,14 +24,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; import { useForm, ValidationErrors } from './use_form'; -import { getFingerprint } from './get_fingerprint'; import { useHttp } from './use_http'; export interface ManualConfigurationFormValues { host: string; username: string; password: string; - caFingerprint: string; + caCert: string; } export interface ManualConfigurationFormProps { @@ -42,10 +41,10 @@ export interface ManualConfigurationFormProps { export const ManualConfigurationForm: FunctionComponent = ({ defaultValues = { - host: 'localhost:9200', - username: 'elastic', + host: 'https://localhost:9200', + username: 'kibana_system', password: '', - caFingerprint: '', + caCert: '', }, onCancel, onSuccess, @@ -61,6 +60,20 @@ export const ManualConfigurationForm: FunctionComponent { - await http.post('/internal/interactive_setup/enroll/kibana', { - body: JSON.stringify({ - hosts: [values.host], - username: values.username, - password: values.password, - caFingerprint: values.caFingerprint, - }), - }); - onSuccess?.(); + try { + await http.post('/internal/interactive_setup/configure', { + body: JSON.stringify({ + hosts: [values.host], + username: values.username, + password: values.password, + caCert: values.caCert, + }), + }); + onSuccess?.(); + } catch (error) { + if (error.body?.message.includes('self signed certificate in certificate chain')) { + form.setError('caCert', 'CA did not match Elasticsearch cluster'); + } else if (error.body?.message.includes('unable to authenticate user')) { + form.setError('username', 'unable to authenticate user'); + form.setError('password', 'unable to authenticate user'); + } else { + throw error; + } + } }, }); @@ -123,11 +154,16 @@ export const ManualConfigurationForm: FunctionComponent { + if (!form.touched.host || !form.errors.host) { + form.setValue('host', resolveAddress(event.target.value)); + } + }} /> { + form.setTouched('caCert'); if (!files || !files.length) { - form.setValue('caFingerprint', ''); + form.setValue('caCert', ''); return; } if (files[0].type !== 'application/x-x509-ca-cert') { - form.setError( - 'caFingerprint', - 'Invalid certificate, upload x509 CA cert in PEM format' - ); + form.setError('caCert', 'Invalid certificate, upload x509 CA cert in PEM format'); return; } const cert = await readFile(files[0]); - const hash = await getFingerprint(cert); - form.setValue('caFingerprint', hash); + form.setValue('caCert', new TextDecoder().decode(cert)); }} + disabled={!form.values.host.startsWith('https://')} /> @@ -211,6 +245,15 @@ export const ManualConfigurationForm: FunctionComponent((resolve, reject) => { const reader = new FileReader(); diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 14d99685e07aeb..5ea5a00930207e 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -6,15 +6,10 @@ * Side Public License, v 1. */ -import { schema } from '@kbn/config-schema'; import type { CorePreboot, PrebootPlugin, PluginInitializerContext } from 'src/core/server'; -import fs from 'fs/promises'; -import { constants } from 'fs'; -import path from 'path'; -import { errors } from '@elastic/elasticsearch'; -import Boom from '@hapi/boom'; import chalk from 'chalk'; import type { ConfigType } from './config'; +import { defineRoutes } from './routes'; export class InteractiveSetupPlugin implements PrebootPlugin { readonly #initializerContext: PluginInitializerContext; @@ -26,7 +21,6 @@ export class InteractiveSetupPlugin implements PrebootPlugin { const config = this.#initializerContext.config.get(); const logger = this.#initializerContext.logger.get('plugins', 'interactiveSetup'); - const isDev = this.#initializerContext.env.mode.dev; const isManuallyConfigured = core.elasticsearch.config.credentialsSpecified || core.elasticsearch.config.hosts.length !== 1 || @@ -47,102 +41,8 @@ export class InteractiveSetupPlugin implements PrebootPlugin { return; } - core.http.registerRoutes('', (prebootRouter) => { - let completeSetup: (result: { shouldReloadConfig: boolean }) => void; - - prebootRouter.post( - { - path: '/internal/interactive_setup/enroll/kibana', - validate: { - body: schema.oneOf([ - schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), - apiKey: schema.string(), - caFingerprint: schema.string(), - }), - schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), - username: schema.string(), - password: schema.string(), - caCert: schema.string(), - }), - ]), - }, - options: { authRequired: false }, - }, - async (context, request, response) => { - if (!core.preboot.isSetupOnHold()) { - logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); - return response.badRequest(); - } - - const client = core.elasticsearch - .createClient('enrollment', { - hosts: request.body.hosts.map((host) => `https://${host}`), - ssl: { verificationMode: 'none' }, - // caFingerprint: request.body.caFingerprint, - }) - .asScoped({ - headers: { - authorization: - 'apiKey' in request.body - ? `ApiKey ${btoa(request.body.apiKey)}` - : `Basic ${btoa(`${request.body.username}:${request.body.password}`)}`, - }, - }); - - const configPath = isDev - ? this.#initializerContext.env.configs.find((fpath) => - path.basename(fpath).includes('dev') - ) - : this.#initializerContext.env.configs[0]; - - if (!configPath) { - logger.error('Cannot find config file'); - return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); - } - // TODO: Use fingerprint/timestamp as file name - const caPath = path.join(path.dirname(configPath), 'ca.crt'); - - try { - await Promise.all([ - fs.access(configPath, constants.W_OK), - fs.access(path.dirname(caPath), constants.W_OK), - ]); - - const { body } = await client.asCurrentUser.transport.request({ - method: 'GET', - path: '/_security/enroll/kibana', - }); - - const certificateAuthority = generateCertificate(body.http_ca); - - // Ensure we can connect using CA before writing config - const verifyConnectionClient = core.elasticsearch.createClient('verifyConnection', { - hosts: request.body.hosts.map((host) => `https://${host}`), - username: 'kibana_system', - password: body.password, - ssl: { certificateAuthorities: [certificateAuthority] }, - }); - - await verifyConnectionClient.asInternalUser.security.authenticate(); - - await Promise.all([ - fs.appendFile(configPath, generateConfig(request.body.hosts, body.password, caPath)), - fs.writeFile(caPath, certificateAuthority), - ]); - - completeSetup({ shouldReloadConfig: true }); - - return response.noContent(); - } catch (error) { - logger.error(error); - return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); - } - } - ); - - const holdSetupReason = ` + core.http.registerRoutes('', (router) => { + const reason = ` ${chalk.bold(chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`))} @@ -150,9 +50,15 @@ Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get start `; core.preboot.holdSetupUntilResolved( - holdSetupReason, - new Promise((resolve) => { - completeSetup = resolve; + reason, + new Promise((completeSetup) => { + defineRoutes({ + router, + completeSetup, + initializerContext: this.#initializerContext, + core, + logger, + }); }) ); }); @@ -160,41 +66,3 @@ Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get start public stop() {} } - -export function generateCertificate(pem: string) { - return `-----BEGIN CERTIFICATE----- -${pem - .replace(/_/g, '/') - .replace(/-/g, '+') - .replace(/([^\n]{1,65})/g, '$1\n') - .replace(/\n$/g, '')} ------END CERTIFICATE----- -`; -} - -export function generateConfig(hosts: string[], password: string, caPath: string) { - return ` - -# This section was automatically generated during setup. -elasticsearch.hosts: [ ${hosts.map((host) => `"https://${host}"`).join(', ')} ] -elasticsearch.username: "kibana_system" -elasticsearch.password: "${password}" -elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] -`; -} - -export function btoa(str: string) { - return Buffer.from(str, 'binary').toString('base64'); -} - -export function getDetailedErrorMessage(error: any): string { - if (error instanceof errors.ResponseError) { - return JSON.stringify(error.body); - } - - if (Boom.isBoom(error)) { - return JSON.stringify(error.output.payload); - } - - return error.message; -} 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..df23fde614faad --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -0,0 +1,115 @@ +/* + * 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 fs from 'fs/promises'; +import { constants } from 'fs'; +import path from 'path'; +import { errors } from '@elastic/elasticsearch'; +import Boom from '@hapi/boom'; +import type { RouteDefinitionParams } from '.'; + +export function defineConfigureRoute({ + router, + core, + initializerContext, + logger, + completeSetup, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/configure', + validate: { + body: schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + username: schema.string(), + password: schema.string(), + caCert: schema.string(), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!core.preboot.isSetupOnHold()) { + logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); + return response.badRequest(); + } + + const client = core.elasticsearch.createClient('configure', { + hosts: request.body.hosts, + username: request.body.username, + password: request.body.password, + ssl: { certificateAuthorities: [request.body.caCert] }, + }); + + const configPath = initializerContext.env.mode.dev + ? initializerContext.env.configs.find((fpath) => path.basename(fpath).includes('dev')) + : initializerContext.env.configs[0]; + + if (!configPath) { + logger.error('Cannot find config file'); + return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); + } + // TODO: Use fingerprint/timestamp as file name + const caPath = path.join(path.dirname(configPath), 'ca.crt'); + + try { + await Promise.all([ + fs.access(configPath, constants.W_OK), + fs.access(path.dirname(caPath), constants.W_OK), + ]); + + // TODO: validate kibana system user permissions + await client.asInternalUser.security.authenticate(); + + await Promise.all([ + fs.appendFile( + configPath, + generateConfig(request.body.hosts, request.body.username, request.body.password, caPath) + ), + fs.writeFile(caPath, request.body.caCert), + ]); + + completeSetup({ shouldReloadConfig: true }); + + return response.noContent(); + } catch (error) { + logger.error(error); + return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); + } + } + ); +} + +export function generateConfig( + hosts: string[], + username: string, + password: string, + caPath: string +) { + return ` + +# This section was automatically generated during setup. +elasticsearch.hosts: [ "${hosts.join('", "')}" ] +elasticsearch.username: "${username}" +elasticsearch.password: "${password}" +elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] +`; +} + +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + if (Boom.isBoom(error)) { + return JSON.stringify(error.output.payload); + } + + return error.message; +} diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts new file mode 100644 index 00000000000000..9fb06fc04e21d4 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -0,0 +1,159 @@ +/* + * 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 fs from 'fs/promises'; +import { constants } from 'fs'; +import path from 'path'; +import { errors } from '@elastic/elasticsearch'; +import Boom from '@hapi/boom'; +import type { RouteDefinitionParams } from '.'; + +export function defineEnrollRoute({ + router, + core, + initializerContext, + logger, + completeSetup, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/enroll', + validate: { + body: schema.oneOf([ + schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + apiKey: schema.string(), + caFingerprint: schema.string(), + }), + schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + username: schema.string(), + password: schema.string(), + caCert: schema.string(), + }), + ]), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!core.preboot.isSetupOnHold()) { + logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); + return response.badRequest(); + } + + const client = core.elasticsearch + .createClient('enrollment', { + hosts: request.body.hosts, + ssl: { verificationMode: 'none' }, + // caFingerprint: request.body.caFingerprint, + }) + .asScoped({ + headers: { + authorization: + 'apiKey' in request.body + ? `ApiKey ${btoa(request.body.apiKey)}` + : `Basic ${btoa(`${request.body.username}:${request.body.password}`)}`, + }, + }); + + const configPath = initializerContext.env.mode.dev + ? initializerContext.env.configs.find((fpath) => path.basename(fpath).includes('dev')) + : initializerContext.env.configs[0]; + + if (!configPath) { + logger.error('Cannot find config file'); + return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); + } + // TODO: Use fingerprint/timestamp as file name + const caPath = path.join(path.dirname(configPath), 'ca.crt'); + + try { + await Promise.all([ + fs.access(configPath, constants.W_OK), + fs.access(path.dirname(caPath), constants.W_OK), + ]); + + const { body } = await client.asCurrentUser.transport.request({ + method: 'GET', + path: '/_security/enroll/kibana', + }); + + const certificateAuthority = generateCertificate(body.http_ca); + + // Ensure we can connect using CA before writing config + const verifyConnectionClient = core.elasticsearch.createClient('verifyConnection', { + hosts: request.body.hosts, + username: 'kibana_system', + password: body.password, + ssl: { certificateAuthorities: [certificateAuthority] }, + }); + + await verifyConnectionClient.asInternalUser.security.authenticate(); + + await Promise.all([ + fs.appendFile( + configPath, + generateConfig(request.body.hosts, 'kibana_system', body.password, caPath) + ), + fs.writeFile(caPath, certificateAuthority), + ]); + + completeSetup({ shouldReloadConfig: true }); + + return response.noContent(); + } catch (error) { + logger.error(error); + return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); + } + } + ); +} + +export function generateCertificate(pem: string) { + return `-----BEGIN CERTIFICATE----- +${pem + .replace(/_/g, '/') + .replace(/-/g, '+') + .replace(/([^\n]{1,65})/g, '$1\n') + .replace(/\n$/g, '')} +-----END CERTIFICATE----- +`; +} + +export function generateConfig( + hosts: string[], + username: string, + password: string, + caPath: string +) { + return ` + +# This section was automatically generated during setup. +elasticsearch.hosts: [ "${hosts.join('", "')}" ] +elasticsearch.username: "${username}" +elasticsearch.password: "${password}" +elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] +`; +} + +export function btoa(str: string) { + return Buffer.from(str, 'binary').toString('base64'); +} + +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + if (Boom.isBoom(error)) { + return JSON.stringify(error.output.payload); + } + + return error.message; +} diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts new file mode 100644 index 00000000000000..8769beaee4328b --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { CorePreboot, IRouter, Logger, PluginInitializerContext } from 'src/core/server'; + +import { defineEnrollRoute } from './enroll'; +import { defineConfigureRoute } from './configure'; +import type { ConfigType } from '../config'; + +export interface RouteDefinitionParams { + router: IRouter; + core: CorePreboot; + initializerContext: PluginInitializerContext; + logger: Logger; + completeSetup: (result: { shouldReloadConfig: boolean }) => void; +} + +export function defineRoutes(params: RouteDefinitionParams) { + defineEnrollRoute(params); + defineConfigureRoute(params); +} From 56b068b1f7f1f2679cef19d570753d3ddacf403b Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 3 Aug 2021 23:37:18 +0100 Subject: [PATCH 09/35] fetch certificate chain --- .../public/manual_configuration_form.tsx | 321 ++++++++++++------ .../server/routes/configure.ts | 7 +- .../interactive_setup/server/routes/enroll.ts | 3 + .../interactive_setup/server/routes/index.ts | 2 + .../interactive_setup/server/routes/ping.ts | 106 ++++++ 5 files changed, 337 insertions(+), 102 deletions(-) create mode 100644 src/plugins/interactive_setup/server/routes/ping.ts diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx index 5ecbd8c82204f6..7b32253c948ad5 100644 --- a/src/plugins/interactive_setup/public/manual_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -11,18 +11,25 @@ import { EuiButton, EuiFormRow, EuiSpacer, + EuiPanel, + EuiIcon, + EuiTitle, EuiFlexGroup, EuiFlexItem, EuiFieldText, + EuiCheckableCard, EuiFieldPassword, EuiFilePicker, EuiButtonEmpty, + EuiLink, + EuiText, } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; import { useForm, ValidationErrors } from './use_form'; import { useHttp } from './use_http'; @@ -51,11 +58,39 @@ export const ManualConfigurationForm: FunctionComponent { const http = useHttp(); + const [state, pingCluster] = useAsyncFn(async (values: ManualConfigurationFormValues) => { + const requiresCert = values.host.trim().startsWith('https://'); + let requiresAuth = false; + let body: any; + + try { + const response = await http.post('/internal/interactive_setup/ping', { + body: JSON.stringify({ + hosts: [values.host], + }), + asResponse: true, + }); + body = response.body; + } catch (error) { + if (error.response?.status !== 401) { + throw error; + } + requiresAuth = true; + body = error.body; + } + + return { requiresCert, requiresAuth, ...body }; + }); + const [form, eventHandlers] = useForm({ defaultValues, - validate: (values) => { + validate: async (values) => { const errors: ValidationErrors = {}; + let requiresAuth = false; + let requiresCert = false; + let needsToUploadCert = false; + if (!values.host) { errors.host = i18n.translate('interactiveSetup.manualConfigurationForm.hostRequiredError', { defaultMessage: 'Enter an address.', @@ -66,6 +101,9 @@ export const ManualConfigurationForm: FunctionComponent { - if (!form.touched.host || !form.errors.host) { - form.setValue('host', resolveAddress(event.target.value)); - } - }} - /> - - - - - - - - - { - form.setTouched('caCert'); - if (!files || !files.length) { - form.setValue('caCert', ''); - return; - } - if (files[0].type !== 'application/x-x509-ca-cert') { - form.setError('caCert', 'Invalid certificate, upload x509 CA cert in PEM format'); - return; - } - const cert = await readFile(files[0]); - form.setValue('caCert', new TextDecoder().decode(cert)); - }} - disabled={!form.values.host.startsWith('https://')} + isLoading={form.isValidating} + append={ + state.loading || !state.value ? undefined : state.error || + state.value?.statusCode !== 401 ? ( + + ) : ( + + ) + } /> - + {state.value && ( + <> + {state.value?.statusCode === 401 && ( + <> + + + + + + + + )} + {form.values.host.trim().startsWith('https://') && ( + + {state.value.certificateChain[0] ? ( + { + await form.setValue( + 'caCert', + event.target.checked + ? state.value.certificateChain[state.value.certificateChain.length - 1].raw + : '' + ); + form.setTouched('caCert'); + }} + > + + + ) : ( + { + if (!files || !files.length) { + await form.setValue('caCert', ''); + return; + } + if (files[0].type !== 'application/x-x509-ca-cert') { + form.setError( + 'caCert', + 'Invalid certificate, upload x509 CA cert in PEM format' + ); + return; + } + const cert = await readFile(files[0]); + await form.setValue('caCert', cert); + form.setTouched('caCert'); + }} + /> + )} + + )} + + )} @@ -228,7 +332,8 @@ export const ManualConfigurationForm: FunctionComponent form.setTouched('host')} isLoading={form.isSubmitting} isDisabled={form.isSubmitted && form.isInvalid} fill @@ -245,20 +350,36 @@ export const ManualConfigurationForm: FunctionComponent = ({ certificate }) => { + return ( + + + + + + + +

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

+
+ + Issued by: {certificate.issuer.O || certificate.issuer.CN} + + {`Expires: ${certificate.valid_to}`} +
+
+
+ ); +}; + function readFile(file: File) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as ArrayBuffer); - reader.onerror = (err) => reject(err); + reader.onloadend = () => resolve(new TextDecoder().decode(reader.result as ArrayBuffer)); + reader.onerror = reject; reader.readAsArrayBuffer(file); }); } diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts index df23fde614faad..e6ef0c9e2347db 100644 --- a/src/plugins/interactive_setup/server/routes/configure.ts +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -13,6 +13,7 @@ import path from 'path'; import { errors } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import type { RouteDefinitionParams } from '.'; +import { generateCertificate } from './enroll'; export function defineConfigureRoute({ router, @@ -40,11 +41,13 @@ export function defineConfigureRoute({ return response.badRequest(); } + const certificateAuthority = generateCertificate(request.body.caCert); + const client = core.elasticsearch.createClient('configure', { hosts: request.body.hosts, username: request.body.username, password: request.body.password, - ssl: { certificateAuthorities: [request.body.caCert] }, + ssl: { certificateAuthorities: [certificateAuthority] }, }); const configPath = initializerContext.env.mode.dev @@ -72,7 +75,7 @@ export function defineConfigureRoute({ configPath, generateConfig(request.body.hosts, request.body.username, request.body.password, caPath) ), - fs.writeFile(caPath, request.body.caCert), + fs.writeFile(caPath, certificateAuthority), ]); completeSetup({ shouldReloadConfig: true }); diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index 9fb06fc04e21d4..12b713a2325aab 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -116,6 +116,9 @@ export function defineEnrollRoute({ } export function generateCertificate(pem: string) { + if (pem.startsWith('-----BEGIN')) { + return pem; + } return `-----BEGIN CERTIFICATE----- ${pem .replace(/_/g, '/') diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts index 8769beaee4328b..d046e82e784291 100644 --- a/src/plugins/interactive_setup/server/routes/index.ts +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -10,6 +10,7 @@ import type { CorePreboot, IRouter, Logger, PluginInitializerContext } from 'src import { defineEnrollRoute } from './enroll'; import { defineConfigureRoute } from './configure'; +import { definePingRoute } from './ping'; import type { ConfigType } from '../config'; export interface RouteDefinitionParams { @@ -23,4 +24,5 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineEnrollRoute(params); defineConfigureRoute(params); + definePingRoute(params); } 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..ab6d90cfb7a079 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/ping.ts @@ -0,0 +1,106 @@ +/* + * 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 tls from 'tls'; +import { errors } from '@elastic/elasticsearch'; +import Boom from '@hapi/boom'; +import type { RouteDefinitionParams } from '.'; + +export function definePingRoute({ router, core, logger }: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/ping', + validate: { + body: schema.object({ + hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!core.preboot.isSetupOnHold()) { + logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); + return response.badRequest(); + } + + let statusCode = 200; + let certificateChain: any[] | undefined; + try { + // Get certificate chain + const { protocol, hostname, port } = new URL(request.body.hosts[0]); + if (protocol === 'https:') { + const peerCert = await fetchPeerCertificate(hostname, parseInt(port, 10)); + certificateChain = flattenCertificateChain(peerCert).map((cert) => ({ + issuer: cert.issuer, + valid_from: cert.valid_from, + valid_to: cert.valid_to, + subject: cert.subject, + subjectaltname: cert.subjectaltname, + fingerprint256: cert.fingerprint256, + raw: cert.raw.toString('base64'), + })); + } + + // Ping cluster to determine if auth is required + const client = core.elasticsearch.createClient('ping', { + hosts: request.body.hosts, + username: '', + password: '', + ssl: { verificationMode: 'none' }, + }); + await client.asInternalUser.ping(); + } catch (error) { + statusCode = error.statusCode || 400; + } + + return response.custom({ + statusCode, + body: { statusCode, certificateChain }, + bypassErrorFormat: true, + }); + } + ); +} + +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + if (Boom.isBoom(error)) { + return JSON.stringify(error.output.payload); + } + + return error.message; +} + +function fetchPeerCertificate(host: string, port: number) { + return new Promise((resolve, reject) => { + const socket = tls.connect({ host, port, rejectUnauthorized: false }); + socket.once('secureConnect', function () { + resolve(socket.getPeerCertificate(true)); + socket.destroy(); + }); + socket.once('error', reject); + }); +} + +function flattenCertificateChain( + certificate: tls.DetailedPeerCertificate, + chain: tls.DetailedPeerCertificate[] = [] +) { + chain.push(certificate); + if ( + certificate.issuerCertificate && + certificate.fingerprint256 !== certificate.issuerCertificate.fingerprint256 + ) { + flattenCertificateChain(certificate.issuerCertificate, chain); + } + return chain; +} From 309dd389ecbc42bbbb0dcfc362c2e2ffd0fcfb40 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 4 Aug 2021 15:18:55 +0100 Subject: [PATCH 10/35] Fix race condition when calling multiple form methods --- .../public/manual_configuration_form.tsx | 2 +- .../interactive_setup/public/use_form.ts | 100 +++++++++--------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx index 9f7b9b57b5b1a2..d4560d45b7e66a 100644 --- a/src/plugins/interactive_setup/public/manual_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -349,7 +349,7 @@ export const ManualConfigurationForm: FunctionComponent form.setTouched('host')} - isLoading={form.isSubmitting} + isLoading={state.value ? form.isSubmitting : form.isSubmitting || form.isValidating} isDisabled={form.isSubmitted && form.isInvalid} fill > diff --git a/src/plugins/interactive_setup/public/use_form.ts b/src/plugins/interactive_setup/public/use_form.ts index 29bfd5b807244a..90b79898993271 100644 --- a/src/plugins/interactive_setup/public/use_form.ts +++ b/src/plugins/interactive_setup/public/use_form.ts @@ -8,7 +8,7 @@ import { cloneDeep, cloneDeepWith, get, set } from 'lodash'; import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; -import { useState } from 'react'; +import { useRef } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; export type FormReturnTuple = [FormState, FormProps]; @@ -80,11 +80,12 @@ export type ValidationErrors = DeepMap; export type TouchedFields = DeepMap; export interface FormState { - setValue(name: string, value: any): Promise; + setValue(name: string, value: any, revalidate?: boolean): Promise; setError(name: string, message: string): void; - setTouched(name: string): Promise; + setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise; reset(values: Values): void; submit(): Promise; + validate(): Promise>; values: Values; errors: ValidationErrors; touched: TouchedFields; @@ -121,64 +122,63 @@ export function useFormState({ validate, defaultValues, }: FormOptions): FormState { - const [values, setValues] = useState(defaultValues); - const [errors, setErrors] = useState>({}); - const [touched, setTouched] = useState>({}); - const [submitCount, setSubmitCount] = useState(0); - - const [validationState, validateForm] = useAsyncFn( - async (formValues: Values) => { - const nextErrors = await validate(formValues); - setErrors(nextErrors); - if (Object.keys(nextErrors).length === 0) { - setSubmitCount(0); - } - return nextErrors; - }, - [validate] - ); - - const [submitState, submitForm] = useAsyncFn( - async (formValues: Values) => { - const nextErrors = await validateForm(formValues); - setTouched(mapDeep(formValues, true)); - setSubmitCount(submitCount + 1); - if (Object.keys(nextErrors).length === 0) { - return onSubmit(formValues); - } - }, - [validateForm, onSubmit] - ); + 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) => { - const nextValues = setDeep(values, name, value); - setValues(nextValues); - await validateForm(nextValues); + setValue: async (name, value, revalidate = true) => { + const nextValues = setDeep(valuesRef.current, name, value); + valuesRef.current = nextValues; + if (revalidate) { + await validateForm(nextValues); + } }, - setTouched: async (name, value = true) => { - setTouched(setDeep(touched, name, value)); - await validateForm(values); + setTouched: async (name, touched = true, revalidate = true) => { + touchedRef.current = setDeep(touchedRef.current, name, touched); + if (revalidate) { + await validateForm(valuesRef.current); + } }, setError: (name, message) => { - setErrors(setDeep(errors, name, message)); - setTouched(setDeep(touched, name, true)); + errorsRef.current = setDeep(errorsRef.current, name, message); + touchedRef.current = setDeep(touchedRef.current, name, true); }, reset: (nextValues) => { - setValues(nextValues); - setErrors({}); - setTouched({}); - setSubmitCount(0); + valuesRef.current = nextValues; + errorsRef.current = {}; + touchedRef.current = {}; + submitCountRef.current = 0; }, - submit: () => submitForm(values), - values, - errors, - touched, + 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(errors).length > 0, - isSubmitted: submitCount > 0, + isInvalid: Object.keys(errorsRef.current).length > 0, + isSubmitted: submitCountRef.current > 0, }; } From da3daf930d4dfe6e8a1e216ae7d5f2d2bbf095fb Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 4 Aug 2021 23:28:45 +0100 Subject: [PATCH 11/35] Fix i18n errors --- .../public/enrollment_token_form.tsx | 6 +- .../public/manual_configuration_form.tsx | 81 +++++++++++-------- 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx index a35da89729a623..644f1b520c4269 100644 --- a/src/plugins/interactive_setup/public/enrollment_token_form.tsx +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -124,10 +124,6 @@ export const EnrollmentTokenForm: FunctionComponent = placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', { defaultMessage: 'Paste enrollment token from terminal', })} - style={{ - fontFamily: euiThemeVars.euiCodeFontFamily, - fontSize: euiThemeVars.euiFontSizeXS, - }} onKeyUp={() => form.setValue( 'token', @@ -148,7 +144,7 @@ export const EnrollmentTokenForm: FunctionComponent = - + {state.value?.statusCode === 200 && ( <> - + +

Anyone with the address could access your data.

- Anyone with the address can access your data. Learn how to enable security features.

@@ -238,19 +237,11 @@ export const ManualConfigurationForm: FunctionComponent - ) : ( - - ) - } />
{state.value && ( <> - {state.value?.statusCode === 401 && ( + {state.value.statusCode === 401 && ( <> - {state.value.certificateChain[0] ? ( + {state.value.certificateChain ? ( - + - form.setTouched('host')} - isLoading={state.value ? form.isSubmitting : form.isSubmitting || form.isValidating} - isDisabled={form.isSubmitted && form.isInvalid} - fill - > - - + {state.value ? ( + + {state.value.statusCode === 200 ? ( + + ) : ( + + )} + + ) : ( + form.setTouched('host')} + isLoading={form.isValidating} + fill + > + + + )}
From 44229d4f4fc02fbf0dbcba68eff5c6efd6d16fa8 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 9 Aug 2021 20:53:48 +0100 Subject: [PATCH 12/35] added types and refactored slightly --- src/plugins/interactive_setup/common/types.ts | 23 ++ .../public/decode_enrollment_token.ts | 22 +- .../public/enrollment_token_form.tsx | 88 ++++---- .../public/get_fingerprint.ts | 58 ----- .../public/manual_configuration_form.tsx | 211 ++++++++++-------- .../interactive_setup/server/plugin.ts | 26 +-- .../server/routes/configure.ts | 102 ++++----- .../interactive_setup/server/routes/enroll.ts | 116 +++++----- .../interactive_setup/server/routes/ping.ts | 94 ++++---- 9 files changed, 341 insertions(+), 399 deletions(-) delete mode 100644 src/plugins/interactive_setup/public/get_fingerprint.ts diff --git a/src/plugins/interactive_setup/common/types.ts b/src/plugins/interactive_setup/common/types.ts index 4df7c8eaa97246..09ae8057c6a736 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: PeerCertificate['issuer']; + valid_from: PeerCertificate['valid_from']; + valid_to: PeerCertificate['valid_to']; + subject: PeerCertificate['subject']; + fingerprint256: PeerCertificate['fingerprint256']; + raw: string; +} + +export interface PingResponse { + /** + * Response status code of ping request. + */ + statusCode: number; + + /** + * Full certificate chain of cluster at requested address. Only present if cluster uses HTTPS. + */ + certificateChain?: Certificate[]; +} diff --git a/src/plugins/interactive_setup/public/decode_enrollment_token.ts b/src/plugins/interactive_setup/public/decode_enrollment_token.ts index 0b7447fc28e31f..ac1e29aa6dcc66 100644 --- a/src/plugins/interactive_setup/public/decode_enrollment_token.ts +++ b/src/plugins/interactive_setup/public/decode_enrollment_token.ts @@ -6,27 +6,7 @@ * Side Public License, v 1. */ -export interface EnrollmentToken { - /** - * The version of the Elasticsearch node that generated this enrollment token. - */ - ver: string; - - /** - * An array of addresses in the form of `:` or `:` where the Elasticsearch node is listening for HTTP connections. - */ - adr: string[]; - - /** - * The SHA-256 fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for HTTP over TLS connections. - */ - fgr: string; - - /** - * An Elasticsearch API key (not encoded) that can be used as credentials authorized to call the enrollment related APIs in Elasticsearch. - */ - key: string; -} +import type { EnrollmentToken } from '../common/types'; export function decodeEnrollmentToken(enrollmentToken: string) { try { diff --git a/src/plugins/interactive_setup/public/enrollment_token_form.tsx b/src/plugins/interactive_setup/public/enrollment_token_form.tsx index 644f1b520c4269..27d8b36f8e3092 100644 --- a/src/plugins/interactive_setup/public/enrollment_token_form.tsx +++ b/src/plugins/interactive_setup/public/enrollment_token_form.tsx @@ -27,7 +27,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; -import type { EnrollmentToken } from './decode_enrollment_token'; +import type { EnrollmentToken } from '../common/types'; import { decodeEnrollmentToken } from './decode_enrollment_token'; import type { ValidationErrors } from './use_form'; import { useForm } from './use_form'; @@ -51,7 +51,6 @@ export const EnrollmentTokenForm: FunctionComponent = onSuccess, }) => { const http = useHttp(); - const [form, eventHandlers] = useForm({ defaultValues, validate: (values) => { @@ -73,24 +72,19 @@ export const EnrollmentTokenForm: FunctionComponent = return errors; }, onSubmit: async (values) => { - const decoded = decodeEnrollmentToken(values.token); - if (decoded) { - await Promise.all([ - http.post('/internal/interactive_setup/enroll', { - body: JSON.stringify({ - hosts: decoded.adr.map((host) => `https://${host}`), - apiKey: decoded.key, - caFingerprint: decoded.fgr, - }), - }), - new Promise((resolve) => setTimeout(resolve, 1600)), // Shorten perceived duration of preboot step - ]); - onSuccess?.(); - } + const decoded = decodeEnrollmentToken(values.token)!; + await http.post('/internal/interactive_setup/enroll', { + body: JSON.stringify({ + hosts: decoded.adr.map((host) => `https://${host}`), + apiKey: decoded.key, + caFingerprint: decoded.fgr, + }), + }); + onSuccess?.(); }, }); - const token = decodeEnrollmentToken(form.values.token); + const enrollmentToken = decodeEnrollmentToken(form.values.token); return ( = style={{ width: euiThemeVars.euiFormMaxWidth }} > {form.submitError && ( - - {(form.submitError as elasticsearchErrors.ResponseError).body?.message} - + <> + + {(form.submitError as elasticsearchErrors.ResponseError).body?.message} + + + )} + } + helpText={enrollmentToken && } > = placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', { defaultMessage: 'Paste enrollment token from terminal', })} - onKeyUp={() => - form.setValue( - 'token', - btoa( - JSON.stringify({ - 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', - }) - ) - ) - } /> - + @@ -175,9 +159,19 @@ interface EnrollmentTokenDetailsProps { } const EnrollmentTokenDetails: FunctionComponent = ({ token }) => ( - + - Connect to + + + @@ -189,7 +183,13 @@ const EnrollmentTokenDetails: FunctionComponent = ( - {`Elasticsearch (v${token.ver})`} + + + ); diff --git a/src/plugins/interactive_setup/public/get_fingerprint.ts b/src/plugins/interactive_setup/public/get_fingerprint.ts deleted file mode 100644 index 48b8c43b30d525..00000000000000 --- a/src/plugins/interactive_setup/public/get_fingerprint.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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. - */ - -enum ParseMode { - BEFORE_BEGIN, - AFTER_BEGIN, - AFTER_END, -} - -const BEGIN_TOKEN = '-----BEGIN'; -const END_TOKEN = '-----END'; - -// openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt -export async function getFingerprint( - cert: ArrayBuffer, - algorithm: AlgorithmIdentifier = 'SHA-256' -) { - // Use first certificate only - let mode = ParseMode.BEFORE_BEGIN; - const pemData = Array.from(new Uint8Array(cert)) - .map((char) => String.fromCharCode(char)) - .join('') - .split('\n') - .map((line) => line.trim()) - .filter((line) => { - if (mode === ParseMode.BEFORE_BEGIN && line.startsWith(BEGIN_TOKEN)) { - mode = ParseMode.AFTER_BEGIN; - return false; - } - if (mode === ParseMode.AFTER_BEGIN && line.startsWith(END_TOKEN)) { - mode = ParseMode.AFTER_END; - return false; - } - return mode === ParseMode.AFTER_BEGIN; - }) - .join(''); - - // Convert to DER - const derData = atob(pemData); - const derBuffer = new Uint8Array(new ArrayBuffer(derData.length)); - for (let i = 0; i < derData.length; i++) { - derBuffer[i] = derData.charCodeAt(i); - } - - // Calculate fingerprint - const hashBuffer = await crypto.subtle.digest(algorithm, derBuffer); - - // Convert to HEX - return Array.from(new Uint8Array(hashBuffer)) - .map((char) => char.toString(16).padStart(2, '0')) - .join(':') - .toUpperCase(); -} diff --git a/src/plugins/interactive_setup/public/manual_configuration_form.tsx b/src/plugins/interactive_setup/public/manual_configuration_form.tsx index 05b3b91a583d6f..92cc84abecb67f 100644 --- a/src/plugins/interactive_setup/public/manual_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/manual_configuration_form.tsx @@ -33,6 +33,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; +import type { Certificate, PingResponse } from '../common/types'; import type { ValidationErrors } from './use_form'; import { useForm } from './use_form'; import { useHttp } from './use_http'; @@ -65,16 +66,16 @@ export const ManualConfigurationForm: FunctionComponent { const requiresCert = values.host.trim().startsWith('https://'); let requiresAuth = false; - let body: any; + let body: PingResponse; try { - const response = await http.post('/internal/interactive_setup/ping', { + const response = await http.post('/internal/interactive_setup/ping', { body: JSON.stringify({ hosts: [values.host], }), asResponse: true, }); - body = response.body; + body = response.body!; } catch (error) { if (error.response?.status !== 401) { throw error; @@ -216,10 +217,25 @@ export const ManualConfigurationForm: FunctionComponent {state.value?.statusCode === 200 && ( <> - -

Anyone with the address could access your data.

+

- Learn how to enable security features. + +

+

+ + +

@@ -239,95 +255,92 @@ export const ManualConfigurationForm: FunctionComponent - {state.value && ( + + {state.value?.statusCode === 401 && ( <> - {state.value.statusCode === 401 && ( - <> - - - - - - - - )} - {form.values.host.trim().startsWith('https://') && ( - + + + + + + + )} + + {state.value && form.values.host.trim().startsWith('https://') && ( + + {state.value.certificateChain ? ( + { + const intermediateCa = + state.value?.certificateChain?.[ + Math.min(1, state.value.certificateChain.length - 1) + ].raw; + await form.setValue('caCert', event.target.checked ? intermediateCa ?? '' : ''); + form.setTouched('caCert'); + }} > - {state.value.certificateChain ? ( - { - await form.setValue( - 'caCert', - event.target.checked - ? state.value.certificateChain[state.value.certificateChain.length - 1].raw - : '' - ); - form.setTouched('caCert'); - }} - > - - - ) : ( - { - if (!files || !files.length) { - await form.setValue('caCert', ''); - return; - } - if (files[0].type !== 'application/x-x509-ca-cert') { - form.setError( - 'caCert', - 'Invalid certificate, upload x509 CA cert in PEM format' - ); - return; - } - const cert = await readFile(files[0]); - await form.setValue('caCert', cert); - form.setTouched('caCert'); - }} - /> - )} - + + + ) : ( + { + if (!files || !files.length) { + await form.setValue('caCert', ''); + return; + } + if (files[0].type !== 'application/x-x509-ca-cert') { + form.setError('caCert', 'Invalid certificate, upload x509 CA cert in PEM format'); + return; + } + const cert = await readFile(files[0]); + await form.setValue('caCert', cert); + form.setTouched('caCert'); + }} + /> )} - + )} + @@ -381,7 +394,7 @@ export const ManualConfigurationForm: FunctionComponent = ({ certificate }) => { @@ -396,9 +409,23 @@ export const CertificatePanel: FunctionComponent = ({ cer

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

- Issued by: {certificate.issuer.O || certificate.issuer.CN} + {certificate.issuer.O || certificate.issuer.CN}, + }} + /> + + + - {`Expires: ${certificate.valid_to}`}
diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index ece4cc3e4ccf6f..65b84f64d70901 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -72,6 +72,12 @@ export class InteractiveSetupPlugin implements PrebootPlugin { // We should check if we can connect to Elasticsearch with default configuration to know if we // need to activate interactive setup. This check can take some time, so we should register our // routes to let interactive setup UI to handle user requests until the check is complete. + const setupInstructions = ` + +${chalk.bold(chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`))} + +Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get started. +`; core.elasticsearch .createClient('ping') .asInternalUser.ping() @@ -88,14 +94,7 @@ export class InteractiveSetupPlugin implements PrebootPlugin { 'Kibana is not properly configured to connect to Elasticsearch. Interactive setup mode will be activated.' ); this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured; - this.#logger.info( - ` - -${chalk.bold(chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`))} - -Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get started. -` - ); + this.#logger.info(setupInstructions); } }, () => { @@ -103,14 +102,7 @@ Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get start // Do we want to constantly ping ES if interactive mode UI isn't active? Just in case user runs Kibana and then // configure Elasticsearch so that it can eventually connect to it without any configuration changes? this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured; - this.#logger.info( - ` - -${chalk.bold(chalk.whiteBright(`${chalk.cyanBright('i')} Kibana has not been configured.`))} - -Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get started. -` - ); + this.#logger.info(setupInstructions); } ); @@ -121,7 +113,7 @@ Go to ${chalk.underline(chalk.cyanBright('http://localhost:5601'))} to get start logger: this.#logger.get('routes'), getConfig: this.#getConfig.bind(this), getElasticsearchConnectionStatus: this.#getElasticsearchConnectionStatus.bind(this), - completeSetup: (result) => completeSetup(result), + completeSetup, core, initializerContext: this.initializerContext, }); diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts index 107e4543361526..1159f94b88094e 100644 --- a/src/plugins/interactive_setup/server/routes/configure.ts +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -6,16 +6,13 @@ * Side Public License, v 1. */ -import { errors } from '@elastic/elasticsearch'; -import Boom from '@hapi/boom'; -import { constants } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '.'; -import { generateCertificate } from './enroll'; +import { createCertificate, createConfig, isWriteable } from './enroll'; export function defineConfigureRoute({ router, @@ -28,28 +25,41 @@ export function defineConfigureRoute({ { path: '/internal/interactive_setup/configure', validate: { - body: schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), - username: schema.string(), - password: schema.string(), - caCert: schema.string(), - }), + body: schema.oneOf([ + schema.object({ + hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), { + minSize: 1, + maxSize: 1, + }), + username: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + caCert: schema.string(), + }), + schema.object({ + hosts: schema.arrayOf(schema.uri({ scheme: 'http' }), { + minSize: 1, + maxSize: 1, + }), + username: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + ]), }, options: { authRequired: false }, }, async (context, request, response) => { if (!core.preboot.isSetupOnHold()) { - logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot phase`); return response.badRequest(); } - const certificateAuthority = generateCertificate(request.body.caCert); + const ca = 'caCert' in request.body ? createCertificate(request.body.caCert) : undefined; const client = core.elasticsearch.createClient('configure', { hosts: request.body.hosts, username: request.body.username, password: request.body.password, - ssl: { certificateAuthorities: [certificateAuthority] }, + ssl: ca ? { certificateAuthorities: [ca] } : undefined, }); const configPath = initializerContext.env.mode.dev @@ -60,61 +70,35 @@ export function defineConfigureRoute({ logger.error('Cannot find config file'); return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); } - // TODO: Use fingerprint/timestamp as file name - const caPath = path.join(path.dirname(configPath), 'ca.crt'); + const caPath = path.join(path.dirname(configPath), `ca_${Date.now()}.crt`); try { - await Promise.all([ - fs.access(configPath, constants.W_OK), - fs.access(path.dirname(caPath), constants.W_OK), - ]); - - // TODO: validate kibana system user permissions - await client.asInternalUser.security.authenticate(); - - await Promise.all([ - fs.appendFile( - configPath, - generateConfig(request.body.hosts, request.body.username, request.body.password, caPath) - ), - fs.writeFile(caPath, certificateAuthority), - ]); + await Promise.all([isWriteable(configPath), isWriteable(caPath)]); + + await client.asInternalUser.ping(); + + if (ca) { + await fs.writeFile(caPath, ca); + } + await fs.appendFile( + configPath, + createConfig({ + elasticsearch: { + hosts: request.body.hosts, + username: request.body.username, + password: request.body.password, + ssl: { certificateAuthorities: [caPath] }, + }, + }) + ); completeSetup({ shouldReloadConfig: true }); return response.noContent(); } catch (error) { logger.error(error); - return response.customError({ statusCode: 500, body: getDetailedErrorMessage(error) }); + return response.customError({ statusCode: 500 }); } } ); } - -export function generateConfig( - hosts: string[], - username: string, - password: string, - caPath: string -) { - return ` - -# This section was automatically generated during setup. -elasticsearch.hosts: [ "${hosts.join('", "')}" ] -elasticsearch.username: "${username}" -elasticsearch.password: "${password}" -elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] -`; -} - -export function getDetailedErrorMessage(error: any): string { - if (error instanceof errors.ResponseError) { - return JSON.stringify(error.body); - } - - if (Boom.isBoom(error)) { - return JSON.stringify(error.output.payload); - } - - return error.message; -} diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index f46a41e9d53812..d8862f990fd38f 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -8,6 +8,8 @@ import { constants } from 'fs'; import fs from 'fs/promises'; +import yaml from 'js-yaml'; +import forge from 'node-forge'; import path from 'path'; import { schema } from '@kbn/config-schema'; @@ -30,22 +32,44 @@ export function defineEnrollRoutes({ validate: { body: schema.oneOf([ schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), { + minSize: 1, + maxSize: 1, + }), apiKey: schema.string(), caFingerprint: schema.string(), }), schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), { + minSize: 1, + maxSize: 1, + }), username: schema.string(), password: schema.string(), - caCert: schema.string(), + caFingerprint: schema.string(), }), ]), }, options: { authRequired: false }, }, async (context, request, response) => { - const client = core.elasticsearch + if (!core.preboot.isSetupOnHold()) { + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot phase`); + return response.badRequest(); + } + + const configPath = initializerContext.env.mode.dev + ? initializerContext.env.configs.find((fpath) => path.basename(fpath).includes('dev')) + : initializerContext.env.configs[0]; + if (!configPath) { + logger.error('Cannot find config file'); + return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); + } + const caPath = path.join(path.dirname(configPath), `ca_${Date.now()}.crt`); + + await Promise.all([isWriteable(configPath), isWriteable(caPath)]); + + const enrollClient = core.elasticsearch .createClient('enroll', { hosts: request.body.hosts, ssl: { verificationMode: 'none' }, @@ -60,47 +84,34 @@ export function defineEnrollRoutes({ }, }); - const configPath = initializerContext.env.mode.dev - ? initializerContext.env.configs.find((fpath) => path.basename(fpath).includes('dev')) - : initializerContext.env.configs[0]; - - if (!configPath) { - logger.error('Cannot find config file'); - return response.customError({ statusCode: 500, body: 'Cannot find config file.' }); - } - // TODO: Use fingerprint/timestamp as file name - const caPath = path.join(path.dirname(configPath), 'ca.crt'); - try { - await Promise.all([ - fs.access(configPath, constants.W_OK), - fs.access(path.dirname(caPath), constants.W_OK), - ]); - - const { body } = await client.asCurrentUser.transport.request({ + const enrollResponse = await enrollClient.asCurrentUser.transport.request({ method: 'GET', path: '/_security/enroll/kibana', }); - const certificateAuthority = generateCertificate(body.http_ca); + const ca = createCertificate(enrollResponse.body.http_ca); - // Ensure we can connect using CA before writing config const verifyConnectionClient = core.elasticsearch.createClient('verifyConnection', { hosts: request.body.hosts, username: 'kibana_system', - password: body.password, - ssl: { certificateAuthorities: [certificateAuthority] }, + password: enrollResponse.body.password, + ssl: { certificateAuthorities: [ca] }, }); - await verifyConnectionClient.asInternalUser.security.authenticate(); - await Promise.all([ - fs.appendFile( - configPath, - generateConfig(request.body.hosts, 'kibana_system', body.password, caPath) - ), - fs.writeFile(caPath, certificateAuthority), - ]); + await fs.writeFile(caPath, ca); + await fs.appendFile( + configPath, + createConfig({ + elasticsearch: { + hosts: request.body.hosts, + username: 'kibana_system', + password: enrollResponse.body.password, + ssl: { certificateAuthorities: [caPath] }, + }, + }) + ); completeSetup({ shouldReloadConfig: true }); @@ -113,34 +124,27 @@ export function defineEnrollRoutes({ ); } -export function generateCertificate(pem: string) { +export function isWriteable(fpath: string) { + return fs.access(fpath, constants.F_OK).then( + () => fs.access(fpath, constants.W_OK), + () => fs.access(path.dirname(fpath), constants.W_OK) + ); +} + +// Use X509Certificate once we upgraded to Node v16 +export function createCertificate(pem: string) { if (pem.startsWith('-----BEGIN')) { return pem; } - return `-----BEGIN CERTIFICATE----- -${pem - .replace(/_/g, '/') - .replace(/-/g, '+') - .replace(/([^\n]{1,65})/g, '$1\n') - .replace(/\n$/g, '')} ------END CERTIFICATE----- -`; + return `-----BEGIN CERTIFICATE-----\n${pem + .replace(/_/g, '/') + .replace(/-/g, '+') + .replace(/([^\n]{1,65})/g, '$1\n') + .replace(/\n$/g, '')}\n-----END CERTIFICATE-----\n`; } -export function generateConfig( - hosts: string[], - username: string, - password: string, - caPath: string -) { - return ` - -# This section was automatically generated during setup. -elasticsearch.hosts: [ "${hosts.join('", "')}" ] -elasticsearch.username: "${username}" -elasticsearch.password: "${password}" -elasticsearch.ssl.certificateAuthorities: [ "${caPath}" ] -`; +export function createConfig(config: any) { + return `\n\n# This section was automatically generated during setup.\n${yaml.dump(config)}\n`; } export function btoa(str: string) { diff --git a/src/plugins/interactive_setup/server/routes/ping.ts b/src/plugins/interactive_setup/server/routes/ping.ts index 5b33f713e3bd55..426bc6cacc54af 100644 --- a/src/plugins/interactive_setup/server/routes/ping.ts +++ b/src/plugins/interactive_setup/server/routes/ping.ts @@ -6,13 +6,12 @@ * Side Public License, v 1. */ -import { errors } from '@elastic/elasticsearch'; -import Boom from '@hapi/boom'; import tls from 'tls'; import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '.'; +import type { Certificate, PingResponse } from '../../common/types'; export function definePingRoute({ router, core, logger }: RouteDefinitionParams) { router.post( @@ -20,48 +19,42 @@ export function definePingRoute({ router, core, logger }: RouteDefinitionParams) path: '/internal/interactive_setup/ping', validate: { body: schema.object({ - hosts: schema.arrayOf(schema.string(), { minSize: 1 }), + hosts: schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + minSize: 1, + maxSize: 1, + }), }), }, options: { authRequired: false }, }, async (context, request, response) => { if (!core.preboot.isSetupOnHold()) { - logger.error('Invalid attempt to access enrollment endpoint outside of preboot phase'); + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot phase`); return response.badRequest(); } + let certificateChain: Certificate[] | undefined; + const { protocol, hostname, port } = new URL(request.body.hosts[0]); + if (protocol === 'https:') { + const cert = await fetchDetailedPeerCertificate(hostname, port); + certificateChain = flattenCertificateChain(cert).map(getCertificate); + } + + const client = core.elasticsearch.createClient('ping', { + hosts: request.body.hosts, + username: undefined, + password: undefined, + ssl: { verificationMode: 'none' }, + }); + let statusCode = 200; - let certificateChain: any[] | undefined; try { - // Get certificate chain - const { protocol, hostname, port } = new URL(request.body.hosts[0]); - if (protocol === 'https:') { - const peerCert = await fetchPeerCertificate(hostname, parseInt(port, 10)); - certificateChain = flattenCertificateChain(peerCert).map((cert) => ({ - issuer: cert.issuer, - valid_from: cert.valid_from, - valid_to: cert.valid_to, - subject: cert.subject, - subjectaltname: cert.subjectaltname, - fingerprint256: cert.fingerprint256, - raw: cert.raw.toString('base64'), - })); - } - - // Ping cluster to determine if auth is required - const client = core.elasticsearch.createClient('ping', { - hosts: request.body.hosts, - username: '', - password: '', - ssl: { verificationMode: 'none' }, - }); await client.asInternalUser.ping(); } catch (error) { statusCode = error.statusCode || 400; } - return response.custom({ + return response.custom({ statusCode, body: { statusCode, certificateChain }, bypassErrorFormat: true, @@ -70,39 +63,36 @@ export function definePingRoute({ router, core, logger }: RouteDefinitionParams) ); } -export function getDetailedErrorMessage(error: any): string { - if (error instanceof errors.ResponseError) { - return JSON.stringify(error.body); - } - - if (Boom.isBoom(error)) { - return JSON.stringify(error.output.payload); - } - - return error.message; -} - -function fetchPeerCertificate(host: string, port: number) { +function fetchDetailedPeerCertificate(host: string, port: string | number) { return new Promise((resolve, reject) => { - const socket = tls.connect({ host, port, rejectUnauthorized: false }); + const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: false }); socket.once('secureConnect', function () { - resolve(socket.getPeerCertificate(true)); + const cert = socket.getPeerCertificate(true); socket.destroy(); + resolve(cert); }); socket.once('error', reject); }); } function flattenCertificateChain( - certificate: tls.DetailedPeerCertificate, - chain: tls.DetailedPeerCertificate[] = [] + cert: tls.DetailedPeerCertificate, + accumulator: tls.DetailedPeerCertificate[] = [] ) { - chain.push(certificate); - if ( - certificate.issuerCertificate && - certificate.fingerprint256 !== certificate.issuerCertificate.fingerprint256 - ) { - flattenCertificateChain(certificate.issuerCertificate, chain); + accumulator.push(cert); + if (cert.issuerCertificate && cert.fingerprint256 !== cert.issuerCertificate.fingerprint256) { + flattenCertificateChain(cert.issuerCertificate, accumulator); } - return chain; + return accumulator; +} + +function 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'), + }; } From 7a3c2d91830f8850b6fe0d91bdedd40b1814f2fb Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 9 Aug 2021 21:35:02 +0100 Subject: [PATCH 13/35] welcome page styling --- src/plugins/interactive_setup/public/app.scss | 25 +++++++ src/plugins/interactive_setup/public/app.tsx | 66 ++++++++++++------- .../public/enrollment_token_form.tsx | 8 +-- .../public/manual_configuration_form.tsx | 8 +-- 4 files changed, 69 insertions(+), 38 deletions(-) create mode 100644 src/plugins/interactive_setup/public/app.scss diff --git a/src/plugins/interactive_setup/public/app.scss b/src/plugins/interactive_setup/public/app.scss new file mode 100644 index 00000000000000..19e66fc524cff5 --- /dev/null +++ b/src/plugins/interactive_setup/public/app.scss @@ -0,0 +1,25 @@ +.interactiveSetup { + @include kibanaFullScreenGraphics; +} + +.interactiveSetup__header { + position: relative; + padding: $euiSizeXL; + z-index: 10; +} + +.interactiveSetup__logo { + @include kibanaCircleLogo; + @include euiBottomShadowMedium; + + margin-bottom: $euiSizeXL; +} + +.interactiveSetup__content { + position: relative; + margin: auto; + max-width: 512px; + padding-left: $euiSizeXL; + padding-right: $euiSizeXL; + z-index: 10; +} diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx index 078bb196289b59..9e316073123589 100644 --- a/src/plugins/interactive_setup/public/app.tsx +++ b/src/plugins/interactive_setup/public/app.tsx @@ -6,11 +6,13 @@ * Side Public License, v 1. */ -import { EuiPageTemplate } from '@elastic/eui'; +import './app.scss'; + +import { EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EnrollmentTokenForm } from './enrollment_token_form'; import { ManualConfigurationForm } from './manual_configuration_form'; @@ -18,29 +20,45 @@ import { ProgressIndicator } from './progress_indicator'; export const App: FunctionComponent = () => { const [page, setPage] = useState<'token' | 'manual' | 'success'>('token'); + return ( - - -