Interactive setup mode (#106881)

* Interactive setup mode

* remove first attempt

* Added suggestions from code review

* Verify CA before writing config

* fix i18n path

* updated plugin list

* Updated page bundle limits

* added manual configuration

* fetch certificate chain

* Fix race condition when calling multiple form methods

* Fix i18n errors

* added types and refactored slightly

* welcome page styling

* Remove holdsetup config option

* typo

* fix build errors

* Updated manual configuration form

* Remove issuer link

* Add tests for decode enrollment token

* Removed unused class names

* fix issue where credentials got inherited from base config

* Added tooltips and text overflow

* styling fixes

* refactored text truncate

* Added unit tests

* added suggestions from code review

* Fixed typo and tests

* Styling fixes

* Fix i18n errors

* Added suggestions from code review

* Added route tests

* Explicit type exports

* Fix server url

* Added unit tests

* Added product not supported scenario
This commit is contained in:
Thom Heymann 2021-08-30 15:23:10 +01:00 committed by GitHub
parent 234f7f6085
commit 035937a5c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 2503 additions and 84 deletions

View file

@ -112,7 +112,7 @@ pageLoadAssetSize:
expressionImage: 19288
expressionMetric: 22238
expressionShape: 34008
interactiveSetup: 18532
interactiveSetup: 70000
expressionTagcloud: 27505
expressions: 239290
securitySolution: 231753

View file

@ -6,5 +6,5 @@
* Side Public License, v 1.
*/
export type { InteractiveSetupViewState, EnrollmentToken } from './types';
export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types';
export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import type { PeerCertificate } from 'tls';
import type { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
/**
@ -43,3 +45,24 @@ export interface EnrollmentToken {
*/
key: string;
}
export interface Certificate {
issuer: Partial<PeerCertificate['issuer']>;
valid_from: PeerCertificate['valid_from'];
valid_to: PeerCertificate['valid_to'];
subject: Partial<PeerCertificate['subject']>;
fingerprint256: PeerCertificate['fingerprint256'];
raw: string;
}
export interface PingResult {
/**
* Indicates whether the cluster requires authentication.
*/
authRequired: boolean;
/**
* Full certificate chain of cluster at requested address. Only present if cluster uses HTTPS.
*/
certificateChain?: Certificate[];
}

View file

@ -0,0 +1,26 @@
.interactiveSetup {
@include kibanaFullScreenGraphics;
}
.interactiveSetup__header {
position: relative;
z-index: 10;
padding: $euiSizeXL;
}
.interactiveSetup__logo {
@include kibanaCircleLogo;
@include euiBottomShadowMedium;
margin-bottom: $euiSizeXL;
}
.interactiveSetup__content {
position: relative;
z-index: 10;
margin: auto;
margin-bottom: $euiSizeXL;
max-width: map-get($euiBreakpoints, 's') - $euiSizeXL;
padding-left: $euiSizeXL;
padding-right: $euiSizeXL;
}

View file

@ -6,22 +6,76 @@
* Side Public License, v 1.
*/
import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui';
import React from 'react';
import './app.scss';
import { EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ClusterAddressForm } from './cluster_address_form';
import type { ClusterConfigurationFormProps } from './cluster_configuration_form';
import { ClusterConfigurationForm } from './cluster_configuration_form';
import { EnrollmentTokenForm } from './enrollment_token_form';
import { ProgressIndicator } from './progress_indicator';
export const App: FunctionComponent = () => {
const [page, setPage] = useState<'token' | 'manual' | 'success'>('token');
const [cluster, setCluster] = useState<
Omit<ClusterConfigurationFormProps, 'onCancel' | 'onSuccess'>
>();
export const App = () => {
return (
<EuiPageTemplate
restrictWidth={false}
template="empty"
pageHeader={{
iconType: 'logoElastic',
pageTitle: 'Welcome to Elastic',
}}
>
<EuiPanel>
<EuiText>Kibana server is not ready yet.</EuiText>
</EuiPanel>
</EuiPageTemplate>
<div className="interactiveSetup">
<header className="interactiveSetup__header eui-textCenter">
<EuiSpacer size="xxl" />
<span className="interactiveSetup__logo">
<EuiIcon type="logoElastic" size="xxl" />
</span>
<EuiTitle size="m">
<h1>
<FormattedMessage
id="interactiveSetup.app.pageTitle"
defaultMessage="Configure Elastic to get started"
/>
</h1>
</EuiTitle>
<EuiSpacer size="xl" />
</header>
<div className="interactiveSetup__content">
<EuiPanel paddingSize="l">
<div hidden={page !== 'token'}>
<EnrollmentTokenForm
onCancel={() => setPage('manual')}
onSuccess={() => setPage('success')}
/>
</div>
<div hidden={page !== 'manual'}>
{cluster ? (
<ClusterConfigurationForm
onCancel={() => setCluster(undefined)}
onSuccess={() => setPage('success')}
{...cluster}
/>
) : (
<ClusterAddressForm
onCancel={() => setPage('token')}
onSuccess={(result, values) =>
setCluster({
host: values.host,
authRequired: result.authRequired,
certificateChain: result.certificateChain,
})
}
/>
)}
</div>
{page === 'success' && (
<ProgressIndicator onSuccess={() => window.location.replace(window.location.href)} />
)}
</EuiPanel>
</div>
</div>
);
};

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { coreMock } from 'src/core/public/mocks';
import { ClusterAddressForm } from './cluster_address_form';
import { Providers } from './plugin';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
describe('ClusterAddressForm', () => {
jest.setTimeout(20_000);
it('calls enrollment API when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.change(await findByLabelText('Address'), {
target: { value: 'https://localhost' },
});
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/ping', {
body: JSON.stringify({
host: 'https://localhost:9200',
}),
});
expect(onSuccess).toHaveBeenCalled();
});
});
it('validates form', async () => {
const coreStart = coreMock.createStart();
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.change(await findByLabelText('Address'), {
target: { value: 'localhost' },
});
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
await findAllByText(/Enter a valid address including protocol/i);
expect(coreStart.http.post).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiSpacer,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import type { PingResult } from '../common';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';
export interface ClusterAddressFormValues {
host: string;
}
export interface ClusterAddressFormProps {
defaultValues?: ClusterAddressFormValues;
onCancel?(): void;
onSuccess?(result: PingResult, values: ClusterAddressFormValues): void;
}
export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
defaultValues = {
host: 'https://localhost:9200',
},
onCancel,
onSuccess,
}) => {
const http = useHttp();
const [form, eventHandlers] = useForm({
defaultValues,
validate: async (values) => {
const errors: ValidationErrors<typeof values> = {};
if (!values.host) {
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', {
defaultMessage: 'Enter an address.',
});
} else {
try {
const url = new URL(values.host);
if (!url.protocol || !url.hostname) {
throw new Error();
}
} catch (error) {
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', {
defaultMessage: 'Enter a valid address including protocol.',
});
}
}
return errors;
},
onSubmit: async (values) => {
const url = new URL(values.host);
const host = `${url.protocol}//${url.hostname}:${url.port || 9200}`;
const result = await http.post<PingResult>('/internal/interactive_setup/ping', {
body: JSON.stringify({ host }),
});
onSuccess?.(result, { host });
},
});
return (
<EuiForm component="form" noValidate {...eventHandlers}>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
defaultMessage: "Couldn't check address",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<EuiSpacer />
</>
)}
<EuiFormRow
label={i18n.translate('interactiveSetup.clusterAddressForm.hostLabel', {
defaultMessage: 'Address',
})}
error={form.errors.host}
isInvalid={form.touched.host && !!form.errors.host}
fullWidth
>
<EuiFieldText
name="host"
value={form.values.host}
isInvalid={form.touched.host && !!form.errors.host}
fullWidth
/>
</EuiFormRow>
<EuiSpacer />
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" iconType="arrowLeft" onClick={onCancel}>
<FormattedMessage
id="interactiveSetup.clusterAddressForm.cancelButton"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
isLoading={form.isSubmitting}
isDisabled={form.isSubmitted && form.isInvalid}
fill
>
<FormattedMessage
id="interactiveSetup.clusterAddressForm.submitButton"
defaultMessage="{isSubmitting, select, true{Checking address…} other{Check address}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
};

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { coreMock } from 'src/core/public/mocks';
import { ClusterConfigurationForm } from './cluster_configuration_form';
import { Providers } from './plugin';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
describe('ClusterConfigurationForm', () => {
jest.setTimeout(20_000);
it('calls enrollment API when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<ClusterConfigurationForm
host="https://localhost:9200"
authRequired
certificateChain={[
{
issuer: {},
valid_from: '',
valid_to: '',
subject: {},
fingerprint256: '',
raw: 'cert',
},
]}
onSuccess={onSuccess}
/>
</Providers>
);
fireEvent.change(await findByLabelText('Username'), {
target: { value: 'kibana_system' },
});
fireEvent.change(await findByLabelText('Password'), {
target: { value: 'changeme' },
});
fireEvent.click(await findByLabelText('Certificate authority'));
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith(
'/internal/interactive_setup/configure',
{
body: JSON.stringify({
host: 'https://localhost:9200',
username: 'kibana_system',
password: 'changeme',
caCert: 'cert',
}),
}
);
expect(onSuccess).toHaveBeenCalled();
});
});
it('validates form', async () => {
const coreStart = coreMock.createStart();
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<ClusterConfigurationForm
host="https://localhost:9200"
authRequired
certificateChain={[
{
issuer: {},
valid_from: '',
valid_to: '',
subject: {},
fingerprint256: '',
raw: 'cert',
},
]}
onSuccess={onSuccess}
/>
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
await findAllByText(/Enter a password/i);
await findAllByText(/Confirm that you recognize and trust this certificate/i);
fireEvent.change(await findByLabelText('Username'), {
target: { value: 'elastic' },
});
await findAllByText(/User 'elastic' can't be used as Kibana system user/i);
expect(coreStart.http.post).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,322 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCheckableCard,
EuiFieldPassword,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIcon,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import type { Certificate } from '../common';
import { TextTruncate } from './text_truncate';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHtmlId } from './use_html_id';
import { useHttp } from './use_http';
export interface ClusterConfigurationFormValues {
username: string;
password: string;
caCert: string;
}
export interface ClusterConfigurationFormProps {
host: string;
authRequired: boolean;
certificateChain?: Certificate[];
defaultValues?: ClusterConfigurationFormValues;
onCancel?(): void;
onSuccess?(): void;
}
export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFormProps> = ({
host,
authRequired,
certificateChain,
defaultValues = {
username: 'kibana_system',
password: '',
caCert: '',
},
onCancel,
onSuccess,
}) => {
const http = useHttp();
const [form, eventHandlers] = useForm({
defaultValues,
validate: async (values) => {
const errors: ValidationErrors<ClusterConfigurationFormValues> = {};
if (authRequired) {
if (!values.username) {
errors.username = i18n.translate(
'interactiveSetup.clusterConfigurationForm.usernameRequiredError',
{
defaultMessage: 'Enter a username.',
}
);
} else if (values.username === 'elastic') {
errors.username = i18n.translate(
'interactiveSetup.clusterConfigurationForm.usernameReservedError',
{
defaultMessage: "User 'elastic' can't be used as Kibana system user.",
}
);
}
if (!values.password) {
errors.password = i18n.translate(
'interactiveSetup.clusterConfigurationForm.passwordRequiredError',
{
defaultMessage: `Enter a password.`,
}
);
}
}
if (certificateChain && !values.caCert) {
errors.caCert = i18n.translate(
'interactiveSetup.clusterConfigurationForm.caCertConfirmationRequiredError',
{
defaultMessage: 'Confirm that you recognize and trust this certificate.',
}
);
}
return errors;
},
onSubmit: async (values) => {
await http.post('/internal/interactive_setup/configure', {
body: JSON.stringify({
host,
username: values.username,
password: values.password,
caCert: values.caCert,
}),
});
onSuccess?.();
},
});
const trustCaCertId = useHtmlId('clusterConfigurationForm', 'trustCaCert');
return (
<EuiForm component="form" noValidate {...eventHandlers}>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.clusterConfigurationForm.submitErrorTitle', {
defaultMessage: "Couldn't connect to cluster",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<EuiSpacer />
</>
)}
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<EuiFlexItem grow={false} className="eui-textNoWrap">
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.connectTo"
defaultMessage="Connect to"
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ overflow: 'hidden' }}>
<TextTruncate>
<strong>{host}</strong>
</TextTruncate>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{authRequired ? (
<>
<EuiFormRow
label={i18n.translate('interactiveSetup.clusterConfigurationForm.usernameLabel', {
defaultMessage: 'Username',
})}
error={form.errors.username}
isInvalid={form.touched.username && !!form.errors.username}
fullWidth
>
<EuiFieldText
icon="user"
name="username"
value={form.values.username}
isInvalid={form.touched.username && !!form.errors.username}
fullWidth
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('interactiveSetup.clusterConfigurationForm.passwordLabel', {
defaultMessage: 'Password',
})}
error={form.errors.password}
isInvalid={form.touched.password && !!form.errors.password}
fullWidth
>
<EuiFieldPassword
type="dual"
name="password"
value={form.values.password}
isInvalid={form.touched.password && !!form.errors.password}
fullWidth
/>
</EuiFormRow>
<EuiSpacer />
</>
) : (
<>
<EuiCallOut
color="warning"
title={i18n.translate(
'interactiveSetup.clusterConfigurationForm.insecureClusterTitle',
{
defaultMessage: 'This cluster is not secure.',
}
)}
>
<p>
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
defaultMessage="Anyone with the address could access your data."
/>
</p>
<p>
<EuiLink color="warning">
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
defaultMessage="Learn how to enable security features."
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer />
</>
)}
{certificateChain && certificateChain.length > 0 && (
<>
<EuiFormRow
label={i18n.translate('interactiveSetup.clusterConfigurationForm.caCertLabel', {
defaultMessage: 'Certificate authority',
})}
error={form.errors.caCert}
isInvalid={form.touched.caCert && !!form.errors.caCert}
fullWidth
>
<EuiCheckableCard
id={trustCaCertId}
label={i18n.translate('interactiveSetup.clusterConfigurationForm.trustCaCertLabel', {
defaultMessage: 'I recognize and trust this certificate:',
})}
checkableType="checkbox"
value="true"
checked={!!form.values.caCert}
onChange={() => {
const intermediateCa = certificateChain[Math.min(1, certificateChain.length - 1)];
form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw);
form.setTouched('caCert');
}}
>
<CertificatePanel certificate={certificateChain[0]} />
</EuiCheckableCard>
</EuiFormRow>
<EuiSpacer />
</>
)}
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" iconType="arrowLeft" onClick={onCancel}>
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.cancelButton"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
isLoading={form.isSubmitting}
isDisabled={form.isSubmitted && form.isInvalid}
color="primary"
fill
>
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.submitButton"
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
};
export interface CertificatePanelProps {
certificate: Certificate;
}
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({ certificate }) => {
return (
<EuiPanel color="subdued">
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiIcon type="document" size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xxs">
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
</EuiTitle>
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.issuer"
defaultMessage="Issued by: {issuer}"
values={{
issuer: certificate.issuer.O || certificate.issuer.CN,
}}
/>
</EuiText>
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.validTo"
defaultMessage="Expires: {validTo}"
values={{
validTo: certificate.valid_to,
}}
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { coreMock } from 'src/core/public/mocks';
import type { EnrollmentToken } from '../common';
import { decodeEnrollmentToken, EnrollmentTokenForm } from './enrollment_token_form';
import { Providers } from './plugin';
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));
const token: EnrollmentToken = {
ver: '8.0.0',
adr: ['localhost:9200'],
fgr:
'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36',
key: 'JH-36HoBo4EYIoVhHh2F:uEo4dksARMq_BSHaAHUr8Q',
};
describe('EnrollmentTokenForm', () => {
jest.setTimeout(20_000);
it('calls enrollment API when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<EnrollmentTokenForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.change(await findByLabelText('Enrollment token'), {
target: { value: btoa(JSON.stringify(token)) },
});
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/enroll', {
body: JSON.stringify({
hosts: [`https://${token.adr[0]}`],
apiKey: btoa(token.key),
caFingerprint: token.fgr,
}),
});
expect(onSuccess).toHaveBeenCalled();
});
});
it('validates form', async () => {
const coreStart = coreMock.createStart();
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<EnrollmentTokenForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
await findAllByText(/Enter an enrollment token/i);
fireEvent.change(await findByLabelText('Enrollment token'), {
target: { value: 'invalid' },
});
await findAllByText(/Enter a valid enrollment token/i);
});
});
describe('decodeEnrollmentToken', () => {
it('should decode a valid token', () => {
expect(decodeEnrollmentToken(btoa(JSON.stringify(token)))).toEqual({
adr: ['https://localhost:9200'],
fgr:
'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36',
key: 'SkgtMzZIb0JvNEVZSW9WaEhoMkY6dUVvNGRrc0FSTXFfQlNIYUFIVXI4UQ==',
ver: '8.0.0',
});
});
it('should not decode an invalid token', () => {
expect(decodeEnrollmentToken(JSON.stringify(token))).toBeUndefined();
expect(
decodeEnrollmentToken(
btoa(
JSON.stringify({
ver: [''],
adr: null,
fgr: false,
key: undefined,
})
)
)
).toBeUndefined();
expect(decodeEnrollmentToken(btoa(JSON.stringify({})))).toBeUndefined();
expect(decodeEnrollmentToken(btoa(JSON.stringify([])))).toBeUndefined();
expect(decodeEnrollmentToken(btoa(JSON.stringify(null)))).toBeUndefined();
expect(decodeEnrollmentToken(btoa(JSON.stringify('')))).toBeUndefined();
});
});

View file

@ -0,0 +1,204 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIcon,
EuiSpacer,
EuiText,
EuiTextArea,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import type { EnrollmentToken } from '../common';
import { TextTruncate } from './text_truncate';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';
export interface EnrollmentTokenFormValues {
token: string;
}
export interface EnrollmentTokenFormProps {
defaultValues?: EnrollmentTokenFormValues;
onCancel?(): void;
onSuccess?(): void;
}
export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> = ({
defaultValues = {
token: '',
},
onCancel,
onSuccess,
}) => {
const http = useHttp();
const [form, eventHandlers] = useForm({
defaultValues,
validate: (values) => {
const errors: ValidationErrors<EnrollmentTokenFormValues> = {};
if (!values.token) {
errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenRequiredError', {
defaultMessage: 'Enter an enrollment token.',
});
} else {
const decoded = decodeEnrollmentToken(values.token);
if (!decoded) {
errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenInvalidError', {
defaultMessage: 'Enter a valid enrollment token.',
});
}
}
return errors;
},
onSubmit: async (values) => {
const decoded = decodeEnrollmentToken(values.token)!;
await http.post('/internal/interactive_setup/enroll', {
body: JSON.stringify({
hosts: decoded.adr,
apiKey: decoded.key,
caFingerprint: decoded.fgr,
}),
});
onSuccess?.();
},
});
const enrollmentToken = decodeEnrollmentToken(form.values.token);
return (
<EuiForm component="form" noValidate {...eventHandlers}>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
defaultMessage: "Couldn't connect to cluster",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<EuiSpacer />
</>
)}
<EuiFormRow
label={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenLabel', {
defaultMessage: 'Enrollment token',
})}
error={form.errors.token}
isInvalid={form.touched.token && !!form.errors.token}
helpText={enrollmentToken && <EnrollmentTokenDetails token={enrollmentToken} />}
fullWidth
>
<EuiTextArea
name="token"
value={form.values.token}
isInvalid={form.touched.token && !!form.errors.token}
placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', {
defaultMessage: 'Paste enrollment token from terminal',
})}
fullWidth
/>
</EuiFormRow>
<EuiSpacer />
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" iconType="gear" onClick={onCancel}>
<FormattedMessage
id="interactiveSetup.enrollmentTokenForm.cancelButton"
defaultMessage="Configure manually"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
isLoading={form.isSubmitting}
isDisabled={form.isSubmitted && form.isInvalid}
fill
>
<FormattedMessage
id="interactiveSetup.enrollmentTokenForm.submitButton"
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
};
interface EnrollmentTokenDetailsProps {
token: EnrollmentToken;
}
const EnrollmentTokenDetails: FunctionComponent<EnrollmentTokenDetailsProps> = ({ token }) => (
<EuiText size="xs">
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<EuiFlexItem grow={false} className="eui-textNoWrap">
<FormattedMessage
id="interactiveSetup.enrollmentTokenDetails.connectTo"
defaultMessage="Connect to"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="lock" />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ overflow: 'hidden' }}>
<TextTruncate>{token.adr[0]}</TextTruncate>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="logoElasticsearch" />
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textNoWrap">
<FormattedMessage
id="interactiveSetup.enrollmentTokenDetails.elasticsearchVersion"
defaultMessage="Elasticsearch (v{version})"
values={{ version: token.ver }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
);
export function decodeEnrollmentToken(enrollmentToken: string) {
try {
const json = JSON.parse(atob(enrollmentToken)) as EnrollmentToken;
if (
!Array.isArray(json.adr) ||
json.adr.some((adr) => typeof adr !== 'string') ||
typeof json.fgr !== 'string' ||
typeof json.key !== 'string' ||
typeof json.ver !== 'string'
) {
return;
}
return {
...json,
adr: json.adr.map((host) => `https://${host}`),
key: btoa(json.key),
};
} catch (error) {} // eslint-disable-line no-empty
}

View file

@ -6,6 +6,6 @@
* Side Public License, v 1.
*/
import { UserSetupPlugin } from './plugin';
import { InteractiveSetupPlugin } from './plugin';
export const plugin = () => new UserSetupPlugin();
export const plugin = () => new InteractiveSetupPlugin();

View file

@ -6,21 +6,30 @@
* Side Public License, v 1.
*/
import type { FunctionComponent } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import type { CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { I18nProvider } from '@kbn/i18n/react';
import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public';
import { App } from './app';
import { HttpProvider } from './use_http';
export class UserSetupPlugin implements Plugin {
export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
public setup(core: CoreSetup) {
core.application.register({
id: 'interactiveSetup',
title: 'Interactive Setup',
title: 'Configure Elastic to get started',
appRoute: '/',
chromeless: true,
mount: (params) => {
ReactDOM.render(<App />, params.element);
ReactDOM.render(
<Providers http={core.http}>
<App />
</Providers>,
params.element
);
return () => ReactDOM.unmountComponentAtNode(params.element);
},
});
@ -28,3 +37,13 @@ export class UserSetupPlugin implements Plugin {
public start(core: CoreStart) {}
}
export interface ProvidersProps {
http: HttpSetup;
}
export const Providers: FunctionComponent<ProvidersProps> = ({ http, children }) => (
<I18nProvider>
<HttpProvider http={http}>{children}</HttpProvider>
</I18nProvider>
);

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiStepProps } from '@elastic/eui';
import { EuiPanel, EuiSteps } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useEffect } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useTimeoutFn from 'react-use/lib/useTimeoutFn';
import { i18n } from '@kbn/i18n';
import { useHttp } from './use_http';
export interface ProgressIndicatorProps {
onSuccess?(): void;
}
export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ onSuccess }) => {
const http = useHttp();
const [status, checkStatus] = useAsyncFn(async () => {
let isAvailable: boolean | undefined = false;
let isPastPreboot: boolean | undefined = false;
try {
const { response } = await http.get('/api/status', { asResponse: true });
isAvailable = response ? response.status < 500 : undefined;
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
} catch ({ response }) {
isAvailable = response ? response.status < 500 : undefined;
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
}
return isAvailable === true && isPastPreboot === true
? 'complete'
: isAvailable === false
? 'unavailable'
: isAvailable === true && isPastPreboot === false
? 'preboot'
: 'unknown';
});
const [, cancelPolling, resetPolling] = useTimeoutFn(checkStatus, 1000);
useEffect(() => {
if (status.value === 'complete') {
cancelPolling();
onSuccess?.();
} else if (status.loading === false) {
resetPolling();
}
}, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<EuiPanel color="transparent">
<LoadingSteps
currentStepId={status.value}
steps={[
{
id: 'preboot',
title: i18n.translate('interactiveSetup.progressIndicator.prebootStepTitle', {
defaultMessage: 'Saving settings',
}),
},
{
id: 'unavailable',
title: i18n.translate('interactiveSetup.progressIndicator.unavailableStepTitle', {
defaultMessage: 'Starting Elastic',
}),
},
{
id: 'complete',
title: i18n.translate('interactiveSetup.progressIndicator.completeStepTitle', {
defaultMessage: 'Completing setup',
}),
},
]}
/>
</EuiPanel>
);
};
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<T>;
export interface LoadingStepsProps {
currentStepId?: string;
steps: Array<Optional<EuiStepProps, 'status' | 'children'>>;
}
export const LoadingSteps: FunctionComponent<LoadingStepsProps> = ({ currentStepId, steps }) => {
const currentStepIndex = steps.findIndex((step) => step.id === currentStepId);
return (
<EuiSteps
steps={steps.map((step, i) => ({
status:
i <= currentStepIndex
? 'complete'
: steps[i - 1]?.id === currentStepId
? 'loading'
: 'incomplete',
children: null,
...step,
}))}
/>
);
};

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiToolTip } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
export const TextTruncate: FunctionComponent = ({ children }) => {
const textRef = useRef<HTMLSpanElement>(null);
const [showTooltip, setShowTooltip] = useState(false);
useLayoutEffect(() => {
if (textRef.current) {
const { clientWidth, scrollWidth } = textRef.current;
setShowTooltip(scrollWidth > clientWidth);
}
}, [children]);
const truncated = (
<span ref={textRef} className="eui-displayBlock eui-textTruncate">
{children}
</span>
);
if (showTooltip) {
return (
<EuiToolTip position="top" content={children} anchorClassName="eui-displayBlock">
{truncated}
</EuiToolTip>
);
}
return truncated;
};

View file

@ -0,0 +1,209 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { set } from '@elastic/safer-lodash-set';
import { cloneDeep, cloneDeepWith, get } from 'lodash';
import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react';
import { useRef } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
export type FormReturnTuple<Values, Result> = [FormState<Values, Result>, FormProps];
export interface FormProps {
onSubmit: ReactEventHandler;
onChange: ChangeEventHandler<HTMLFormElement & HTMLInputElement>;
onBlur: FocusEventHandler<HTMLFormElement & HTMLInputElement>;
}
export interface FormOptions<Values, Result> {
onSubmit: SubmitCallback<Values, Result>;
validate: ValidateCallback<Values>;
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' } : {}
* });
*
* <EuiForm component="form" {...eventHandlers}>
* <EuiFieldText name="email" isInvalid={form.touched.email && form.errors.email} />
* <EuiButton type="submit">Submit</EuiButton>
* <EuiForm>
* ```
*/
export function useForm<Values extends FormValues, Result>(
options: FormOptions<Values, Result>
): FormReturnTuple<Values, Result> {
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<string, any>;
export type SubmitCallback<Values, Result> = (values: Values) => Promise<Result>;
export type ValidateCallback<Values> = (
values: Values
) => ValidationErrors<Values> | Promise<ValidationErrors<Values>>;
export type ValidationErrors<Values> = DeepMap<Values, string>;
export type TouchedFields<Values> = DeepMap<Values, boolean>;
export interface FormState<Values, Result> {
setValue(name: string, value: any, revalidate?: boolean): Promise<void>;
setError(name: string, message: string): void;
setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise<void>;
reset(values: Values): void;
submit(): Promise<Result | undefined>;
validate(): Promise<ValidationErrors<Values>>;
values: Values;
errors: ValidationErrors<Values>;
touched: TouchedFields<Values>;
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' } : {}
* });
*
* <EuiSwitch
* checked={form.values.toggle}
* onChange={(e) => form.setValue('toggle', e.target.checked)}
* onBlur={() => form.setTouched('toggle')}
* isInvalid={!!form.errors.toggle}
* />
* <EuiButton onClick={form.submit}>
* Submit
* </EuiButton>
* ```
*/
export function useFormState<Values extends FormValues, Result>({
onSubmit,
validate,
defaultValues,
}: FormOptions<Values, Result>): FormState<Values, Result> {
const valuesRef = useRef<Values>(defaultValues);
const errorsRef = useRef<ValidationErrors<Values>>({});
const touchedRef = useRef<TouchedFields<Values>>({});
const submitCountRef = useRef(0);
const [validationState, validateForm] = useAsyncFn(async (formValues: Values) => {
const nextErrors = await validate(formValues);
errorsRef.current = nextErrors;
if (Object.keys(nextErrors).length === 0) {
submitCountRef.current = 0;
}
return nextErrors;
}, []);
const [submitState, submitForm] = useAsyncFn(async (formValues: Values) => {
const nextErrors = await validateForm(formValues);
touchedRef.current = mapDeep(formValues, true);
submitCountRef.current += 1;
if (Object.keys(nextErrors).length === 0) {
return onSubmit(formValues);
}
}, []);
return {
setValue: async (name, value, revalidate = true) => {
const nextValues = setDeep(valuesRef.current, name, value);
valuesRef.current = nextValues;
if (revalidate) {
await validateForm(nextValues);
}
},
setTouched: async (name, touched = true, revalidate = true) => {
touchedRef.current = setDeep(touchedRef.current, name, touched);
if (revalidate) {
await validateForm(valuesRef.current);
}
},
setError: (name, message) => {
errorsRef.current = setDeep(errorsRef.current, name, message);
touchedRef.current = setDeep(touchedRef.current, name, true);
},
reset: (nextValues) => {
valuesRef.current = nextValues;
errorsRef.current = {};
touchedRef.current = {};
submitCountRef.current = 0;
},
submit: () => submitForm(valuesRef.current),
validate: () => validateForm(valuesRef.current),
values: valuesRef.current,
errors: errorsRef.current,
touched: touchedRef.current,
isValidating: validationState.loading,
isSubmitting: submitState.loading,
submitError: submitState.error,
isInvalid: Object.keys(errorsRef.current).length > 0,
isSubmitted: submitCountRef.current > 0,
};
}
type DeepMap<T, TValue> = {
[K in keyof T]?: T[K] extends any[]
? T[K][number] extends object
? Array<DeepMap<T[K][number], TValue>>
: TValue
: T[K] extends object
? DeepMap<T[K], TValue>
: TValue;
};
function mapDeep<T, V>(values: T, value: V): DeepMap<T, V> {
return cloneDeepWith(values, (v) => {
if (typeof v !== 'object' && v !== null) {
return value;
}
});
}
function setDeep<T extends object, V>(values: T, name: string, value: V): T {
if (get(values, name) !== value) {
return set(cloneDeep(values), name, value);
}
return values;
}

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { htmlIdGenerator } from '@elastic/eui';
import { useMemo } from 'react';
/**
* Generates an ID that can be used for HTML elements.
*
* @param prefix Prefix of the id to be generated
* @param suffix Suffix of the id to be generated
*
* @example
* ```typescript
* const titleId = useHtmlId('changePasswordForm', 'title');
*
* <EuiForm aria-labelledby={titleId}>
* <h2 id={titleId}>Change password</h2>
* </EuiForm>
* ```
*/
export function useHtmlId(prefix?: string, suffix?: string) {
return useMemo(() => htmlIdGenerator(prefix)(suffix), [prefix, suffix]);
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import constate from 'constate';
import type { HttpSetup } from 'src/core/public';
export const [HttpProvider, useHttp] = constate(({ http }: { http: HttpSetup }) => {
return http;
});

View file

@ -16,5 +16,7 @@ export const elasticsearchServiceMock = {
ElasticsearchConnectionStatus.Configured
),
enroll: jest.fn(),
authenticate: jest.fn(),
ping: jest.fn(),
}),
};

View file

@ -7,6 +7,7 @@
*/
import { errors } from '@elastic/elasticsearch';
import tls from 'tls';
import { nextTick } from '@kbn/test/jest';
import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
@ -17,6 +18,10 @@ import type { ElasticsearchServiceSetup } from './elasticsearch_service';
import { ElasticsearchService } from './elasticsearch_service';
import { interactiveSetupMock } from './mocks';
jest.mock('tls');
const tlsConnectMock = tls.connect as jest.MockedFunction<typeof tls.connect>;
describe('ElasticsearchService', () => {
let service: ElasticsearchService;
let mockElasticsearchPreboot: ReturnType<typeof elasticsearchServiceMock.createPreboot>;
@ -33,17 +38,21 @@ describe('ElasticsearchService', () => {
let mockAuthenticateClient: ReturnType<
typeof elasticsearchServiceMock.createCustomClusterClient
>;
let mockPingClient: ReturnType<typeof elasticsearchServiceMock.createCustomClusterClient>;
let setupContract: ElasticsearchServiceSetup;
beforeEach(() => {
mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
mockEnrollClient = elasticsearchServiceMock.createCustomClusterClient();
mockAuthenticateClient = elasticsearchServiceMock.createCustomClusterClient();
mockPingClient = elasticsearchServiceMock.createCustomClusterClient();
mockElasticsearchPreboot.createClient.mockImplementation((type) => {
switch (type) {
case 'enroll':
return mockEnrollClient;
case 'authenticate':
return mockAuthenticateClient;
case 'ping':
return mockPingClient;
default:
return mockConnectionStatusClient;
}
@ -414,7 +423,7 @@ some weird+ca/with
caFingerprint: 'DE:AD:BE:EF',
})
).resolves.toEqual({
ca: expectedCa,
caCert: expectedCa,
host: 'host2',
serviceAccountToken: {
name: 'some-name',
@ -478,6 +487,133 @@ some weird+ca/with
expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1);
});
});
describe('#authenticate()', () => {
it('fails if ping call fails', async () => {
mockAuthenticateClient.asInternalUser.ping.mockRejectedValue(
new errors.ConnectionError(
'some-message',
interactiveSetupMock.createApiResponse({ body: {} })
)
);
await expect(
setupContract.authenticate({ host: 'http://localhost:9200' })
).rejects.toMatchInlineSnapshot(`[ConnectionError: some-message]`);
});
it('succeeds if ping call succeeds', async () => {
mockAuthenticateClient.asInternalUser.ping.mockResolvedValue(
interactiveSetupMock.createApiResponse({ statusCode: 200, body: true })
);
await expect(
setupContract.authenticate({ host: 'http://localhost:9200' })
).resolves.toEqual(undefined);
});
});
describe('#ping()', () => {
it('fails if host is not reachable', async () => {
mockPingClient.asInternalUser.ping.mockRejectedValue(
new errors.ConnectionError(
'some-message',
interactiveSetupMock.createApiResponse({ body: {} })
)
);
await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot(
`[ConnectionError: some-message]`
);
});
it('fails if host is not supported', async () => {
mockPingClient.asInternalUser.ping.mockRejectedValue(
new errors.ProductNotSupportedError(interactiveSetupMock.createApiResponse({ body: {} }))
);
await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot(
`[ProductNotSupportedError: The client noticed that the server is not Elasticsearch and we do not support this unknown product.]`
);
});
it('succeeds if host does not require authentication', async () => {
mockPingClient.asInternalUser.ping.mockResolvedValue(
interactiveSetupMock.createApiResponse({ statusCode: 200, body: true })
);
await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({
authRequired: false,
certificateChain: undefined,
});
});
it('succeeds if host requires authentication', async () => {
mockPingClient.asInternalUser.ping.mockRejectedValue(
new errors.ResponseError(
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
)
);
await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({
authRequired: true,
certificateChain: undefined,
});
});
it('succeeds if host requires SSL', async () => {
mockPingClient.asInternalUser.ping.mockRejectedValue(
new errors.ResponseError(
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
)
);
tlsConnectMock.mockReturnValue(({
once: jest.fn((event, fn) => {
if (event === 'secureConnect') {
fn();
}
}),
getPeerCertificate: jest.fn().mockReturnValue({ raw: Buffer.from('cert') }),
destroy: jest.fn(),
} as unknown) as tls.TLSSocket);
await expect(setupContract.ping('https://localhost:9200')).resolves.toEqual({
authRequired: true,
certificateChain: [
expect.objectContaining({
raw: 'Y2VydA==',
}),
],
});
expect(tlsConnectMock).toHaveBeenCalledWith({
host: 'localhost',
port: 9200,
rejectUnauthorized: false,
});
});
it('fails if peer certificate cannot be fetched', async () => {
mockPingClient.asInternalUser.ping.mockRejectedValue(
new errors.ResponseError(
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
)
);
tlsConnectMock.mockReturnValue(({
once: jest.fn((event, fn) => {
if (event === 'error') {
fn(new Error('some-message'));
}
}),
} as unknown) as tls.TLSSocket);
await expect(setupContract.ping('https://localhost:9200')).rejects.toMatchInlineSnapshot(
`[Error: some-message]`
);
});
});
});
describe('#stop()', () => {
@ -489,7 +625,7 @@ some weird+ca/with
const mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
mockElasticsearchPreboot.createClient.mockImplementation((type) => {
switch (type) {
case 'ping':
case 'connectionStatus':
return mockConnectionStatusClient;
default:
throw new Error(`Unexpected client type: ${type}`);

View file

@ -19,9 +19,9 @@ import {
shareReplay,
takeWhile,
} from 'rxjs/operators';
import tls from 'tls';
import type {
ElasticsearchClientConfig,
ElasticsearchServicePreboot,
ICustomClusterClient,
Logger,
@ -29,14 +29,22 @@ import type {
} from 'src/core/server';
import { ElasticsearchConnectionStatus } from '../common';
import { getDetailedErrorMessage } from './errors';
import type { Certificate, PingResult } from '../common';
import { getDetailedErrorMessage, getErrorStatusCode } from './errors';
interface EnrollParameters {
export interface EnrollParameters {
apiKey: string;
hosts: string[];
caFingerprint: string;
}
export interface AuthenticateParameters {
host: string;
username?: string;
password?: string;
caCert?: string;
}
export interface ElasticsearchServiceSetupDeps {
/**
* Core Elasticsearch service preboot contract;
@ -63,6 +71,16 @@ export interface ElasticsearchServiceSetup {
* to point to exactly same Elasticsearch node, potentially available via different network interfaces.
*/
enroll: (params: EnrollParameters) => Promise<EnrollResult>;
/**
* Tries to authenticate specified user with cluster.
*/
authenticate: (params: AuthenticateParameters) => Promise<void>;
/**
* Tries to connect to specified cluster and fetches certificate chain.
*/
ping: (host: string) => Promise<PingResult>;
}
/**
@ -76,13 +94,20 @@ export interface EnrollResult {
/**
* PEM CA certificate for the Elasticsearch HTTP certificates.
*/
ca: string;
caCert: string;
/**
* Service account token for the "elastic/kibana" service account.
*/
serviceAccountToken: { name: string; value: string };
}
export interface AuthenticateResult {
host: string;
username?: string;
password?: string;
caCert?: string;
}
export class ElasticsearchService {
/**
* Elasticsearch client used to check Elasticsearch connection status.
@ -95,7 +120,7 @@ export class ElasticsearchService {
connectionCheckInterval,
}: ElasticsearchServiceSetupDeps): ElasticsearchServiceSetup {
const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient(
'ping'
'connectionStatus'
));
return {
@ -120,6 +145,8 @@ export class ElasticsearchService {
shareReplay({ refCount: true, bufferSize: 1 })
),
enroll: this.enroll.bind(this, elasticsearch),
authenticate: this.authenticate.bind(this, elasticsearch),
ping: this.ping.bind(this, elasticsearch),
};
}
@ -145,11 +172,8 @@ export class ElasticsearchService {
private async enroll(
elasticsearch: ElasticsearchServicePreboot,
{ apiKey, hosts, caFingerprint }: EnrollParameters
): Promise<EnrollResult> {
) {
const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
const elasticsearchConfig: Partial<ElasticsearchClientConfig> = {
ssl: { verificationMode: 'none' },
};
// We should iterate through all provided hosts until we find an accessible one.
for (const host of hosts) {
@ -158,9 +182,9 @@ export class ElasticsearchService {
);
const enrollClient = elasticsearch.createClient('enroll', {
...elasticsearchConfig,
hosts: [host],
caFingerprint,
ssl: { verificationMode: 'none' },
});
let enrollmentResponse;
@ -176,51 +200,51 @@ export class ElasticsearchService {
// that enrollment will fail for any other host and we should bail out.
if (err instanceof errors.ConnectionError || err instanceof errors.TimeoutError) {
this.logger.error(
`Unable to connect to "${host}" host, will proceed to the next host if available: ${getDetailedErrorMessage(
`Unable to connect to host "${host}", will proceed to the next host if available: ${getDetailedErrorMessage(
err
)}`
);
continue;
}
this.logger.error(`Failed to enroll with "${host}" host: ${getDetailedErrorMessage(err)}`);
this.logger.error(`Failed to enroll with host "${host}": ${getDetailedErrorMessage(err)}`);
throw err;
} finally {
await enrollClient.close();
}
this.logger.debug(
`Successfully enrolled with "${host}" host, token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}`
`Successfully enrolled with host "${host}", token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}`
);
const enrollResult = {
const enrollResult: EnrollResult = {
host,
ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
caCert: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
serviceAccountToken: enrollmentResponse.body.token,
};
// Now try to use retrieved password and CA certificate to authenticate to this host.
// Now try to use retrieved service account and CA certificate to authenticate to this host.
const authenticateClient = elasticsearch.createClient('authenticate', {
caFingerprint,
hosts: [host],
serviceAccountToken: enrollResult.serviceAccountToken.value,
ssl: { certificateAuthorities: [enrollResult.ca] },
ssl: { certificateAuthorities: [enrollResult.caCert] },
});
this.logger.debug(
`Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to "${host}" host.`
`Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to host "${host}".`
);
try {
await authenticateClient.asInternalUser.security.authenticate();
this.logger.debug(
`Successfully authenticated "${enrollmentResponse.body.token.name}" token to "${host}" host.`
`Successfully authenticated "${enrollmentResponse.body.token.name}" token to host "${host}".`
);
} catch (err) {
this.logger.error(
`Failed to authenticate "${
enrollmentResponse.body.token.name
}" token to "${host}" host: ${getDetailedErrorMessage(err)}.`
}" token to host "${host}": ${getDetailedErrorMessage(err)}.`
);
throw err;
} finally {
@ -233,7 +257,114 @@ export class ElasticsearchService {
throw new Error('Unable to connect to any of the provided hosts.');
}
private static createPemCertificate(derCaString: string) {
private async authenticate(
elasticsearch: ElasticsearchServicePreboot,
{ host, username, password, caCert }: AuthenticateParameters
) {
const client = elasticsearch.createClient('authenticate', {
hosts: [host],
username,
password,
ssl: caCert ? { certificateAuthorities: [caCert] } : undefined,
});
try {
// Using `ping` instead of `authenticate` allows us to verify clusters with both
// security enabled and disabled.
await client.asInternalUser.ping();
} catch (error) {
this.logger.error(
`Failed to authenticate with host "${host}": ${getDetailedErrorMessage(error)}`
);
throw error;
} finally {
await client.close();
}
}
private async ping(elasticsearch: ElasticsearchServicePreboot, host: string) {
const client = elasticsearch.createClient('ping', {
hosts: [host],
username: '',
password: '',
ssl: { verificationMode: 'none' },
});
let authRequired = false;
try {
await client.asInternalUser.ping();
} catch (error) {
if (
error instanceof errors.ConnectionError ||
error instanceof errors.TimeoutError ||
error instanceof errors.ProductNotSupportedError
) {
this.logger.error(`Unable to connect to host "${host}": ${getDetailedErrorMessage(error)}`);
throw error;
}
authRequired = getErrorStatusCode(error) === 401;
} finally {
await client.close();
}
let certificateChain: Certificate[] | undefined;
const { protocol, hostname, port } = new URL(host);
if (protocol === 'https:') {
try {
const cert = await ElasticsearchService.fetchPeerCertificate(hostname, port);
certificateChain = ElasticsearchService.flattenCertificateChain(cert).map(
ElasticsearchService.getCertificate
);
} catch (error) {
this.logger.error(
`Failed to fetch peer certificate from host "${host}": ${getDetailedErrorMessage(error)}`
);
throw error;
}
}
return {
authRequired,
certificateChain,
};
}
private static fetchPeerCertificate(host: string, port: string | number) {
return new Promise<tls.DetailedPeerCertificate>((resolve, reject) => {
const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: false });
socket.once('secureConnect', () => {
const cert = socket.getPeerCertificate(true);
socket.destroy();
resolve(cert);
});
socket.once('error', reject);
});
}
private static flattenCertificateChain(
cert: tls.DetailedPeerCertificate,
accumulator: tls.DetailedPeerCertificate[] = []
) {
accumulator.push(cert);
if (cert.issuerCertificate && cert.fingerprint256 !== cert.issuerCertificate.fingerprint256) {
ElasticsearchService.flattenCertificateChain(cert.issuerCertificate, accumulator);
}
return accumulator;
}
private static getCertificate(cert: tls.DetailedPeerCertificate): Certificate {
return {
issuer: cert.issuer,
valid_from: cert.valid_from,
valid_to: cert.valid_to,
subject: cert.subject,
fingerprint256: cert.fingerprint256,
raw: cert.raw.toString('base64'),
};
}
public static createPemCertificate(derCaString: string) {
// Use `X509Certificate` class once we upgrade to Node v16.
return `-----BEGIN CERTIFICATE-----\n${derCaString
.replace(/_/g, '/')

View file

@ -14,7 +14,7 @@ import type {
} from 'src/core/server';
import { ConfigSchema } from './config';
import { UserSetupPlugin } from './plugin';
import { InteractiveSetupPlugin } from './plugin';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
schema: ConfigSchema,
@ -22,4 +22,4 @@ export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
export const plugin: PluginInitializer<void, never> = (
initializerContext: PluginInitializerContext
) => new UserSetupPlugin(initializerContext);
) => new InteractiveSetupPlugin(initializerContext);

View file

@ -74,7 +74,7 @@ describe('KibanaConfigWriter', () => {
await expect(
kibanaConfigWriter.writeConfig({
ca: 'ca-content',
caCert: 'ca-content',
host: '',
serviceAccountToken: { name: '', value: '' },
})
@ -90,7 +90,7 @@ describe('KibanaConfigWriter', () => {
await expect(
kibanaConfigWriter.writeConfig({
ca: 'ca-content',
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
@ -103,7 +103,7 @@ describe('KibanaConfigWriter', () => {
'/some/path/kibana.yml',
`
# This section was automatically generated during setup (service account token name is "some-token").
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
@ -112,10 +112,10 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
);
});
it('can successfully write CA certificate and elasticsearch config to the disk', async () => {
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
await expect(
kibanaConfigWriter.writeConfig({
ca: 'ca-content',
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
@ -128,11 +128,62 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
'/some/path/kibana.yml',
`
# This section was automatically generated during setup (service account token name is "some-token").
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
it('can successfully write elasticsearch config without CA certificate', async () => {
await expect(
kibanaConfigWriter.writeConfig({
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).not.toHaveBeenCalled();
expect(mockAppendFile).toHaveBeenCalledTimes(1);
expect(mockAppendFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
`
);
});

View file

@ -15,11 +15,19 @@ import type { Logger } from 'src/core/server';
import { getDetailedErrorMessage } from './errors';
export interface WriteConfigParameters {
export type WriteConfigParameters = {
host: string;
ca: string;
serviceAccountToken: { name: string; value: string };
}
caCert?: string;
} & (
| {
username: string;
password: string;
}
| {
serviceAccountToken: { name: string; value: string };
}
| {}
);
export class KibanaConfigWriter {
constructor(private readonly configPath: string, private readonly logger: Logger) {}
@ -54,31 +62,37 @@ export class KibanaConfigWriter {
public async writeConfig(params: WriteConfigParameters) {
const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`);
this.logger.debug(`Writing CA certificate to ${caPath}.`);
try {
await fs.writeFile(caPath, params.ca);
this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`);
} catch (err) {
this.logger.error(
`Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.`
);
throw err;
if (params.caCert) {
this.logger.debug(`Writing CA certificate to ${caPath}.`);
try {
await fs.writeFile(caPath, params.caCert);
this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`);
} catch (err) {
this.logger.error(
`Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.`
);
throw err;
}
}
const config: Record<string, any> = { 'elasticsearch.hosts': [params.host] };
if ('serviceAccountToken' in params) {
config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value;
} else if ('username' in params) {
config['elasticsearch.password'] = params.password;
config['elasticsearch.username'] = params.username;
}
if (params.caCert) {
config['elasticsearch.ssl.certificateAuthorities'] = [caPath];
}
this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`);
try {
await fs.appendFile(
this.configPath,
`\n\n# This section was automatically generated during setup (service account token name is "${
params.serviceAccountToken.name
}").\n${yaml.safeDump(
{
'elasticsearch.hosts': [params.host],
'elasticsearch.serviceAccountToken': params.serviceAccountToken.value,
'elasticsearch.ssl.certificateAuthorities': [caPath],
},
{ flowLevel: 1 }
)}\n`
`\n\n# This section was automatically generated during setup.\n${yaml.safeDump(config, {
flowLevel: 1,
})}\n`
);
this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`);
} catch (err) {

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import chalk from 'chalk';
import type { Subscription } from 'rxjs';
import type { TypeOf } from '@kbn/config-schema';
@ -17,13 +18,11 @@ import { ElasticsearchService } from './elasticsearch_service';
import { KibanaConfigWriter } from './kibana_config_writer';
import { defineRoutes } from './routes';
export class UserSetupPlugin implements PrebootPlugin {
export class InteractiveSetupPlugin implements PrebootPlugin {
readonly #logger: Logger;
readonly #elasticsearch: ElasticsearchService;
#elasticsearchConnectionStatusSubscription?: Subscription;
readonly #elasticsearch = new ElasticsearchService(
this.initializerContext.logger.get('elasticsearch')
);
#configSubscription?: Subscription;
#config?: ConfigType;
@ -36,6 +35,9 @@ export class UserSetupPlugin implements PrebootPlugin {
constructor(private readonly initializerContext: PluginInitializerContext) {
this.#logger = this.initializerContext.logger.get();
this.#elasticsearch = new ElasticsearchService(
this.initializerContext.logger.get('elasticsearch')
);
}
public setup(core: CorePreboot) {
@ -90,6 +92,14 @@ export class UserSetupPlugin implements PrebootPlugin {
this.#logger.debug(
'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.'
);
const serverInfo = core.http.getServerInfo();
const url = `${serverInfo.protocol}://${serverInfo.hostname}:${serverInfo.port}`;
this.#logger.info(`
${chalk.whiteBright.bold(`${chalk.cyanBright('i')} Kibana has not been configured.`)}
Go to ${chalk.cyanBright.underline(url)} to get started.
`);
}
}
);

View file

@ -0,0 +1,276 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { errors } from '@elastic/elasticsearch';
import type { ObjectType } from '@kbn/config-schema';
import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server';
import { kibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { ElasticsearchConnectionStatus } from '../../common';
import { interactiveSetupMock } from '../mocks';
import { defineConfigureRoute } from './configure';
import { routeDefinitionParamsMock } from './index.mock';
describe('Configure routes', () => {
let router: jest.Mocked<IRouter>;
let mockRouteParams: ReturnType<typeof routeDefinitionParamsMock.create>;
let mockContext: RequestHandlerContext;
beforeEach(() => {
mockRouteParams = routeDefinitionParamsMock.create();
router = mockRouteParams.router;
mockContext = ({} as unknown) as RequestHandlerContext;
defineConfigureRoute(mockRouteParams);
});
describe('#configure', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
beforeEach(() => {
const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find(
([{ path }]) => path === '/internal/interactive_setup/configure'
)!;
routeConfig = configureRouteConfig;
routeHandler = configureRouteHandler;
});
it('correctly defines route.', () => {
expect(routeConfig.options).toEqual({ authRequired: false });
const bodySchema = (routeConfig.validate as any).body as ObjectType;
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[host]: expected value of type [string] but got [undefined]."`
);
expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot(
`"[host]: \\"host\\" is not allowed to be empty"`
);
expect(() =>
bodySchema.validate({ host: 'localhost:9200' })
).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`);
expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError();
expect(() =>
bodySchema.validate({ host: 'http://localhost:9200', username: 'elastic' })
).toThrowErrorMatchingInlineSnapshot(
`"[username]: value of \\"elastic\\" is forbidden. This is a superuser account that can obfuscate privilege-related issues. You should use the \\"kibana_system\\" user instead."`
);
expect(() =>
bodySchema.validate({ host: 'http://localhost:9200', username: 'kibana_system' })
).toThrowErrorMatchingInlineSnapshot(
`"[password]: expected value of type [string] but got [undefined]"`
);
expect(() =>
bodySchema.validate({ host: 'http://localhost:9200', password: 'password' })
).toThrowErrorMatchingInlineSnapshot(`"[password]: a value wasn't expected to be present"`);
expect(() =>
bodySchema.validate({
host: 'http://localhost:9200',
username: 'kibana_system',
password: '',
})
).not.toThrowError();
expect(() =>
bodySchema.validate({ host: 'https://localhost:9200' })
).toThrowErrorMatchingInlineSnapshot(
`"[caCert]: expected value of type [string] but got [undefined]"`
);
expect(() =>
bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' })
).not.toThrowError();
});
it('fails if setup is not on hold.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: { body: 'Cannot process request outside of preboot stage.' },
payload: 'Cannot process request outside of preboot stage.',
});
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('fails if Elasticsearch connection is already configured.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.connectionStatus$.next(
ElasticsearchConnectionStatus.Configured
);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: {
body: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
},
},
payload: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
},
});
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('fails if Kibana config is not writable.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.connectionStatus$.next(
ElasticsearchConnectionStatus.NotConfigured
);
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
},
statusCode: 500,
},
payload: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
},
});
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('fails if authenticate call fails.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.connectionStatus$.next(
ElasticsearchConnectionStatus.NotConfigured
);
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
mockRouteParams.elasticsearch.authenticate.mockRejectedValue(
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
statusCode: 401,
body: { message: 'some-secret-message' },
})
)
);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
statusCode: 500,
},
payload: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
});
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('fails if cannot write configuration to the disk.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.connectionStatus$.next(
ElasticsearchConnectionStatus.NotConfigured
);
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
mockRouteParams.kibanaConfigWriter.writeConfig.mockRejectedValue(
new Error('Some error with sensitive path')
);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
},
statusCode: 500,
},
payload: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
},
});
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('can successfully authenticate and save configuration to the disk.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.connectionStatus$.next(
ElasticsearchConnectionStatus.NotConfigured
);
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue();
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host', username: 'username', password: 'password', caCert: 'der' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 204,
options: {},
payload: undefined,
});
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledWith({
host: 'host',
username: 'username',
password: 'password',
caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n',
});
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledWith({
host: 'host',
username: 'username',
password: 'password',
caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n',
});
expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledTimes(1);
expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledWith({
shouldReloadConfig: true,
});
});
});
});

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { first } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '.';
import { ElasticsearchConnectionStatus } from '../../common';
import type { AuthenticateParameters } from '../elasticsearch_service';
import { ElasticsearchService } from '../elasticsearch_service';
import type { WriteConfigParameters } from '../kibana_config_writer';
export function defineConfigureRoute({
router,
logger,
kibanaConfigWriter,
elasticsearch,
preboot,
}: RouteDefinitionParams) {
router.post(
{
path: '/internal/interactive_setup/configure',
validate: {
query: schema.object({
code: schema.maybe(schema.string()),
}),
body: schema.object({
host: schema.uri({ scheme: ['http', 'https'] }),
username: schema.maybe(
schema.string({
validate: (value: string) => {
if (value === 'elastic') {
return (
'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' +
'privilege-related issues. You should use the "kibana_system" user instead.'
);
}
},
})
),
password: schema.conditional(
schema.siblingRef('username'),
schema.string(),
schema.string(),
schema.never()
),
caCert: schema.conditional(
schema.siblingRef('host'),
schema.uri({ scheme: 'https' }),
schema.string(),
schema.never()
),
}),
},
options: { authRequired: false },
},
async (context, request, response) => {
if (!preboot.isSetupOnHold()) {
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
}
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
if (connectionStatus === ElasticsearchConnectionStatus.Configured) {
logger.error(
`Invalid request to [path=${request.url.pathname}], Elasticsearch connection is already configured.`
);
return response.badRequest({
body: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
},
});
}
// The most probable misconfiguration case is when Kibana process isn't allowed to write to the
// Kibana configuration file. We'll still have to handle possible filesystem access errors
// when we actually write to the disk, but this preliminary check helps us to avoid unnecessary
// enrollment call and communicate that to the user early.
const isConfigWritable = await kibanaConfigWriter.isConfigWritable();
if (!isConfigWritable) {
logger.error('Kibana process does not have enough permissions to write to config file');
return response.customError({
statusCode: 500,
body: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
},
});
}
const configToWrite: WriteConfigParameters & AuthenticateParameters = {
host: request.body.host,
username: request.body.username,
password: request.body.password,
caCert: request.body.caCert
? ElasticsearchService.createPemCertificate(request.body.caCert)
: undefined,
};
try {
await elasticsearch.authenticate(configToWrite);
} catch {
// For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment
// request or we just couldn't connect to any of the provided hosts.
return response.customError({
statusCode: 500,
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
});
}
try {
await kibanaConfigWriter.writeConfig(configToWrite);
} catch {
// For security reasons, we shouldn't leak any filesystem related errors.
return response.customError({
statusCode: 500,
body: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
},
});
}
preboot.completeSetup({ shouldReloadConfig: true });
return response.noContent();
}
);
}

View file

@ -12,6 +12,7 @@ import { schema } from '@kbn/config-schema';
import { ElasticsearchConnectionStatus } from '../../common';
import type { EnrollResult } from '../elasticsearch_service';
import type { WriteConfigParameters } from '../kibana_config_writer';
import type { RouteDefinitionParams } from './';
/**
@ -81,9 +82,9 @@ export function defineEnrollRoutes({
.match(/.{1,2}/g)
?.join(':') ?? '';
let enrollResult: EnrollResult;
let configToWrite: WriteConfigParameters & EnrollResult;
try {
enrollResult = await elasticsearch.enroll({
configToWrite = await elasticsearch.enroll({
apiKey: request.body.apiKey,
hosts: request.body.hosts,
caFingerprint: colonFormattedCaFingerprint,
@ -98,7 +99,7 @@ export function defineEnrollRoutes({
}
try {
await kibanaConfigWriter.writeConfig(enrollResult);
await kibanaConfigWriter.writeConfig(configToWrite);
} catch {
// For security reasons, we shouldn't leak any filesystem related errors.
return response.customError({

View file

@ -12,7 +12,9 @@ import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core
import type { ConfigType } from '../config';
import type { ElasticsearchServiceSetup } from '../elasticsearch_service';
import type { KibanaConfigWriter } from '../kibana_config_writer';
import { defineConfigureRoute } from './configure';
import { defineEnrollRoutes } from './enroll';
import { definePingRoute } from './ping';
/**
* Describes parameters used to define HTTP routes.
@ -31,4 +33,6 @@ export interface RouteDefinitionParams {
export function defineRoutes(params: RouteDefinitionParams) {
defineEnrollRoutes(params);
defineConfigureRoute(params);
definePingRoute(params);
}

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { errors } from '@elastic/elasticsearch';
import type { ObjectType } from '@kbn/config-schema';
import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server';
import { kibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { interactiveSetupMock } from '../mocks';
import { routeDefinitionParamsMock } from './index.mock';
import { definePingRoute } from './ping';
describe('Configure routes', () => {
let router: jest.Mocked<IRouter>;
let mockRouteParams: ReturnType<typeof routeDefinitionParamsMock.create>;
let mockContext: RequestHandlerContext;
beforeEach(() => {
mockRouteParams = routeDefinitionParamsMock.create();
router = mockRouteParams.router;
mockContext = ({} as unknown) as RequestHandlerContext;
definePingRoute(mockRouteParams);
});
describe('#ping', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
beforeEach(() => {
const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find(
([{ path }]) => path === '/internal/interactive_setup/ping'
)!;
routeConfig = configureRouteConfig;
routeHandler = configureRouteHandler;
});
it('correctly defines route.', () => {
expect(routeConfig.options).toEqual({ authRequired: false });
const bodySchema = (routeConfig.validate as any).body as ObjectType;
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[host]: expected value of type [string] but got [undefined]."`
);
expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot(
`"[host]: \\"host\\" is not allowed to be empty"`
);
expect(() =>
bodySchema.validate({ host: 'localhost:9200' })
).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`);
expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError();
});
it('fails if setup is not on hold.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: { body: 'Cannot process request outside of preboot stage.' },
payload: 'Cannot process request outside of preboot stage.',
});
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('fails if ping call fails.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
mockRouteParams.elasticsearch.ping.mockRejectedValue(
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
statusCode: 401,
body: { message: 'some-secret-message' },
})
)
);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
statusCode: 500,
},
payload: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
});
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
it('can successfully ping.', async () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
const mockRequest = httpServerMock.createKibanaRequest({
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 200,
options: {},
payload: undefined,
});
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledWith('host');
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '.';
import type { PingResult } from '../../common/types';
export function definePingRoute({ router, logger, elasticsearch, preboot }: RouteDefinitionParams) {
router.post(
{
path: '/internal/interactive_setup/ping',
validate: {
body: schema.object({
host: schema.uri({ scheme: ['http', 'https'] }),
}),
},
options: { authRequired: false },
},
async (context, request, response) => {
if (!preboot.isSetupOnHold()) {
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
}
let result: PingResult;
try {
result = await elasticsearch.ping(request.body.host);
} catch {
return response.customError({
statusCode: 500,
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
});
}
return response.ok({ body: result });
}
);
}