[Fleet] Add instructions and generation of a service token for Fleet Server onboarding (#97585)

* Don't block standalone agent instructions when not using Fleet server yet

* Add service token instructions - UI only

* Add route for regenerating fleet server service token

* generate tokens instead of regenerate and add error catching and tests

* fix i18n typo

* i18n fix, add sudo, copy edits

* Fix commands

* Add missing test file

Co-authored-by: Nicolas Chaulet <nicolas.chaulet@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jen Huang 2021-04-20 10:53:18 -07:00 committed by GitHub
parent f0a05e8c81
commit 10e52bb582
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 321 additions and 52 deletions

View file

@ -76,6 +76,7 @@ export const SETTINGS_API_ROUTES = {
// App API routes
export const APP_API_ROUTES = {
CHECK_PERMISSIONS_PATTERN: `${API_ROOT}/check-permissions`,
GENERATE_SERVICE_TOKEN_PATTERN: `${API_ROOT}/service-tokens`,
};
// Agent API routes

View file

@ -164,6 +164,7 @@ export const settingsRoutesService = {
export const appRoutesService = {
getCheckPermissionsPath: () => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN,
getRegenerateServiceTokenPath: () => APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN,
};
export const enrollmentAPIKeyRouteService = {

View file

@ -9,3 +9,8 @@ export interface CheckPermissionsResponse {
error?: 'MISSING_SECURITY' | 'MISSING_SUPERUSER_ROLE';
success: boolean;
}
export interface GenerateServiceTokenResponse {
name: string;
value: string;
}

View file

@ -6,7 +6,7 @@
*/
import { appRoutesService } from '../../services';
import type { CheckPermissionsResponse } from '../../types';
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../types';
import { sendRequest } from './use_request';
@ -16,3 +16,10 @@ export const sendGetPermissionsCheck = () => {
method: 'get',
});
};
export const sendGenerateServiceToken = () => {
return sendRequest<GenerateServiceTokenResponse>({
path: appRoutesService.getRegenerateServiceTokenPath(),
method: 'post',
});
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import {
EuiButton,
EuiFlexGroup,
@ -16,62 +16,229 @@ import {
EuiText,
EuiLink,
EuiEmptyPrompt,
EuiSteps,
EuiCodeBlock,
EuiCallOut,
EuiSelect,
} from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from 'react-intl';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useStartServices } from '../../../hooks';
import { DownloadStep } from '../components/agent_enrollment_flyout/steps';
import { useStartServices, useGetOutputs, sendGenerateServiceToken } from '../../../hooks';
const FlexItemWithMinWidth = styled(EuiFlexItem)`
min-width: 0px;
max-width: 100%;
`;
export const ContentWrapper = styled(EuiFlexGroup)`
height: 100%;
margin: 0 auto;
max-width: 800px;
`;
function renderOnPremInstructions() {
// Otherwise the copy button is over the text
const CommandCode = styled.pre({
overflow: 'scroll',
});
type PLATFORM_TYPE = 'linux-mac' | 'windows' | 'rpm-deb';
const PLATFORM_OPTIONS: Array<{ text: string; value: PLATFORM_TYPE }> = [
{ text: 'Linux / macOS', value: 'linux-mac' },
{ text: 'Windows', value: 'windows' },
{ text: 'RPM / DEB', value: 'rpm-deb' },
];
const OnPremInstructions: React.FC = () => {
const outputsRequest = useGetOutputs();
const { notifications } = useStartServices();
const [serviceToken, setServiceToken] = useState<string>();
const [isLoadingServiceToken, setIsLoadingServiceToken] = useState<boolean>(false);
const [platform, setPlatform] = useState<PLATFORM_TYPE>('linux-mac');
const output = outputsRequest.data?.items?.[0];
const esHost = output?.hosts?.[0];
const installCommand = useMemo((): string => {
if (!serviceToken || !esHost) {
return '';
}
switch (platform) {
case 'linux-mac':
return `sudo ./elastic-agent install -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
case 'windows':
return `.\\elastic-agent.exe install --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
case 'rpm-deb':
return `sudo elastic-agent enroll -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
default:
return '';
}
}, [serviceToken, esHost, platform]);
const getServiceToken = useCallback(async () => {
setIsLoadingServiceToken(true);
try {
const { data } = await sendGenerateServiceToken();
if (data?.value) {
setServiceToken(data?.value);
}
} catch (err) {
notifications.toasts.addError(err, {
title: i18n.translate('xpack.fleet.fleetServerSetup.errorGeneratingTokenTitleText', {
defaultMessage: 'Error generating token',
}),
});
}
setIsLoadingServiceToken(false);
}, [notifications]);
return (
<EuiPanel
paddingSize="none"
grow={false}
hasShadow={false}
hasBorder={true}
className="eui-textCenter"
>
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupTitle"
defaultMessage="Add a Fleet Server"
/>
</h2>
}
body={
<EuiPanel paddingSize="l" grow={false} hasShadow={false} hasBorder={true}>
<EuiSpacer size="s" />
<EuiText className="eui-textCenter">
<h2>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupText"
defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. See the Fleet User Guide for instructions on how to add a Fleet Server."
id="xpack.fleet.fleetServerSetup.setupTitle"
defaultMessage="Add a Fleet Server"
/>
}
actions={
<EuiButton
iconSide="right"
iconType="popout"
fill
isLoading={false}
type="submit"
href="https://ela.st/add-fleet-server"
target="_blank"
>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupGuideLink"
defaultMessage="Fleet User Guide"
/>
</EuiButton>
}
</h2>
<EuiSpacer size="m" />
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupText"
defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. See the {userGuideLink} for more information."
values={{
userGuideLink: (
<EuiLink href="https://ela.st/add-fleet-server" external>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupGuideLink"
defaultMessage="Fleet User Guide"
/>
</EuiLink>
),
}}
/>
</EuiText>
<EuiSpacer size="l" />
<EuiSteps
className="eui-textLeft"
steps={[
DownloadStep(),
{
title: i18n.translate('xpack.fleet.fleetServerSetup.stepGenerateServiceTokenTitle', {
defaultMessage: 'Generate a service token',
}),
children: (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.generateServiceTokenDescription"
defaultMessage="A service token grants Fleet Server permissions to write to Elasticsearch."
/>
</EuiText>
<EuiSpacer size="m" />
{!serviceToken ? (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
fill
isLoading={isLoadingServiceToken}
isDisabled={isLoadingServiceToken}
onClick={() => {
getServiceToken();
}}
>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.generateServiceTokenButton"
defaultMessage="Generate service token"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<>
<EuiCallOut size="s">
<FormattedMessage
id="xpack.fleet.fleetServerSetup.saveServiceTokenDescription"
defaultMessage="Save your service token information. This will be shown only once."
/>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<strong>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.serviceTokenLabel"
defaultMessage="Service token"
/>
</strong>
</EuiFlexItem>
<FlexItemWithMinWidth>
<EuiCodeBlock paddingSize="m" isCopyable>
<CommandCode>{serviceToken}</CommandCode>
</EuiCodeBlock>
</FlexItemWithMinWidth>
</EuiFlexGroup>
</>
)}
</>
),
},
{
title: i18n.translate('xpack.fleet.fleetServerSetup.stepInstallAgentTitle', {
defaultMessage: 'Install the Elastic Agent as a Fleet Server',
}),
status: !serviceToken ? 'disabled' : undefined,
children: serviceToken ? (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.installAgentDescription"
defaultMessage="From the agent directory, run the appropriate command to install, enroll, and start an Elastic Agent as a Fleet Server. Requires administrator privileges."
/>
</EuiText>
<EuiSpacer size="l" />
<EuiSelect
prepend={
<EuiText>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.platformSelectLabel"
defaultMessage="Platform"
/>
</EuiText>
}
options={PLATFORM_OPTIONS}
value={platform}
onChange={(e) => setPlatform(e.target.value as PLATFORM_TYPE)}
aria-label={i18n.translate(
'xpack.fleet.fleetServerSetup.platformSelectAriaLabel',
{
defaultMessage: 'Platform',
}
)}
/>
<EuiSpacer size="s" />
<EuiCodeBlock
fontSize="m"
isCopyable={true}
paddingSize="m"
language="console"
whiteSpace="pre"
>
<CommandCode>{installCommand}</CommandCode>
</EuiCodeBlock>
</>
) : null,
},
]}
/>
</EuiPanel>
);
}
};
function renderCloudInstructions(deploymentUrl: string) {
const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl }) => {
return (
<EuiPanel
paddingSize="none"
@ -126,7 +293,7 @@ function renderCloudInstructions(deploymentUrl: string) {
/>
</EuiPanel>
);
}
};
export const FleetServerRequirementPage = () => {
const startService = useStartServices();
@ -134,11 +301,16 @@ export const FleetServerRequirementPage = () => {
return (
<>
<ContentWrapper justifyContent="center" alignItems="center">
<ContentWrapper gutterSize="l" justifyContent="center" alignItems="center" direction="column">
<FlexItemWithMinWidth grow={false}>
{deploymentUrl ? (
<CloudInstructions deploymentUrl={deploymentUrl} />
) : (
<OnPremInstructions />
)}
</FlexItemWithMinWidth>
<EuiFlexItem grow={false}>
{deploymentUrl ? renderCloudInstructions(deploymentUrl) : renderOnPremInstructions()}
<EuiSpacer size="l" />
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="center">
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>

View file

@ -129,12 +129,12 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
<EuiFlyoutBody
banner={
fleetServerHosts.length === 0 ? (
fleetServerHosts.length === 0 && mode === 'managed' ? (
<MissingFleetServerHostCallout onClose={onClose} />
) : undefined
}
>
{fleetServerHosts.length === 0 ? null : mode === 'managed' ? (
{fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? (
<ManagedInstructions agentPolicies={agentPolicies} />
) : (
<StandaloneInstructions agentPolicies={agentPolicies} />

View file

@ -88,6 +88,7 @@ export {
PutSettingsResponse,
// API schemas - app
CheckPermissionsResponse,
GenerateServiceTokenResponse,
// EPM types
AssetReference,
AssetsGroupedByServiceByType,

View file

@ -47,6 +47,7 @@ export class AgentUnenrollmentError extends IngestManagerError {}
export class AgentPolicyDeletionError extends IngestManagerError {}
export class FleetSetupError extends IngestManagerError {}
export class GenerateServiceTokenError extends IngestManagerError {}
export class ArtifactsClientError extends IngestManagerError {}
export class ArtifactsClientAccessDeniedError extends IngestManagerError {

View file

@ -7,9 +7,10 @@
import type { IRouter, RequestHandler } from 'src/core/server';
import { APP_API_ROUTES } from '../../constants';
import { PLUGIN_ID, APP_API_ROUTES } from '../../constants';
import { appContextService } from '../../services';
import type { CheckPermissionsResponse } from '../../../common';
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common';
import { defaultIngestErrorHandler, GenerateServiceTokenError } from '../../errors';
export const getCheckPermissionsHandler: RequestHandler = async (context, request, response) => {
const body: CheckPermissionsResponse = { success: true };
@ -35,6 +36,29 @@ export const getCheckPermissionsHandler: RequestHandler = async (context, reques
}
};
export const generateServiceTokenHandler: RequestHandler = async (context, request, response) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
try {
const { body: tokenResponse } = await esClient.transport.request({
method: 'POST',
path: `_security/service/elastic/fleet-server/credential/token/token-${Date.now()}`,
});
if (tokenResponse.created && tokenResponse.token) {
const body: GenerateServiceTokenResponse = tokenResponse.token;
return response.ok({
body,
});
} else {
const error = new GenerateServiceTokenError('Unable to generate service token');
return defaultIngestErrorHandler({ error, response });
}
} catch (e) {
const error = new GenerateServiceTokenError(e);
return defaultIngestErrorHandler({ error, response });
}
};
export const registerRoutes = (router: IRouter) => {
router.get(
{
@ -44,4 +68,13 @@ export const registerRoutes = (router: IRouter) => {
},
getCheckPermissionsHandler
);
router.post(
{
path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN,
validate: {},
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
generateServiceTokenHandler
);
};

View file

@ -43,5 +43,8 @@ export default function ({ loadTestFile }) {
// Preconfiguration
loadTestFile(require.resolve('./preconfiguration/index'));
// Service tokens
loadTestFile(require.resolve('./service_tokens'));
});
}

View file

@ -0,0 +1,45 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const esClient = getService('es');
describe('fleet_service_tokens', async () => {
before(async () => {
await esArchiver.load('empty_kibana');
});
after(async () => {
await esArchiver.unload('empty_kibana');
});
describe('POST /api/fleet/service-tokens', () => {
it('should create a valid service account token', async () => {
const { body: apiResponse } = await supertest
.post(`/api/fleet/service-tokens`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
expect(apiResponse).have.property('name');
expect(apiResponse).have.property('value');
const { body: tokensResponse } = await esClient.transport.request({
method: 'GET',
path: `_security/service/elastic/fleet-server/credential`,
});
expect(tokensResponse.tokens).have.property(apiResponse.name);
});
});
});
}