Add interactive setup CLI (#114493)

* Add interactive setup CLI

* Added tsconfig

* ignore all CLI dev.js files when building

* add cli_init to the root TS project and setup necessary ref

* Fix type errors

* Added suggestions from code review

* ts fix

* fixed build dependencies

* Added suggestions from code review

* fix type definitions

* fix types

* upgraded commander to fix ts issues

* Revert "upgraded commander to fix ts issues"

This reverts commit 52b8943222.

* upgraded commander

Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Thom Heymann 2021-10-20 22:17:45 +01:00 committed by GitHub
parent abd5e9ffa8
commit b879a9a497
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 442 additions and 30 deletions

View file

@ -197,7 +197,7 @@
"chroma-js": "^1.4.1",
"classnames": "2.2.6",
"color": "1.0.3",
"commander": "^3.0.2",
"commander": "^4.1.1",
"compare-versions": "3.5.1",
"concat-stream": "1.6.2",
"constate": "^1.3.2",
@ -248,6 +248,7 @@
"idx": "^2.5.6",
"immer": "^9.0.6",
"inline-style": "^2.0.0",
"inquirer": "^7.3.3",
"intl": "^1.2.5",
"intl-format-cache": "^2.1.0",
"intl-messageformat": "^2.2.0",
@ -297,6 +298,7 @@
"object-hash": "^1.3.1",
"object-path-immutable": "^3.1.1",
"opn": "^5.5.0",
"ora": "^4.0.4",
"p-limit": "^3.0.1",
"p-map": "^4.0.0",
"p-retry": "^4.2.0",
@ -720,7 +722,6 @@
"html": "1.0.0",
"html-loader": "^0.5.5",
"http-proxy": "^1.18.1",
"inquirer": "^7.3.3",
"is-glob": "^4.0.1",
"is-path-inside": "^3.0.2",
"istanbul-instrumenter-loader": "^3.0.1",
@ -762,7 +763,6 @@
"null-loader": "^3.0.0",
"nyc": "^15.0.1",
"oboe": "^2.1.4",
"ora": "^4.0.4",
"parse-link-header": "^1.0.1",
"pbf": "3.2.1",
"pirates": "^4.0.1",

9
scripts/kibana_setup.js Normal file
View file

@ -0,0 +1,9 @@
/*
* 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.
*/
require('../src/cli_setup/dev');

20
src/cli_plugin/lib/logger.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
interface LoggerOptions {
silent?: boolean;
quiet?: boolean;
}
export declare class Logger {
constructor(settings?: LoggerOptions);
log(data: string, sameLine?: boolean): void;
error(data: string): void;
}

118
src/cli_setup/cli_setup.ts Normal file
View file

@ -0,0 +1,118 @@
/*
* 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 { kibanaPackageJson } from '@kbn/utils';
import chalk from 'chalk';
import ora from 'ora';
import { Command } from 'commander';
import { getConfigPath } from '@kbn/utils';
import {
ElasticsearchService,
EnrollResult,
} from '../plugins/interactive_setup/server/elasticsearch_service';
import { getDetailedErrorMessage } from '../plugins/interactive_setup/server/errors';
import {
promptToken,
getCommand,
decodeEnrollmentToken,
kibanaConfigWriter,
elasticsearch,
} from './utils';
import { Logger } from '../cli_plugin/lib/logger';
const program = new Command('bin/kibana-setup');
program
.version(kibanaPackageJson.version)
.description(
'This command walks you through all required steps to securely connect Kibana with Elasticsearch'
)
.option('-t, --token <token>', 'Elasticsearch enrollment token')
.option('-s, --silent', 'Prevent all logging');
program.parse(process.argv);
interface SetupOptions {
token?: string;
silent?: boolean;
}
const options = program.opts() as SetupOptions;
const spinner = ora();
const logger = new Logger(options);
async function initCommand() {
const token = decodeEnrollmentToken(
options.token ?? (options.silent ? undefined : await promptToken())
);
if (!token) {
logger.error(chalk.red('Invalid enrollment token provided.'));
logger.error('');
logger.error('To generate a new enrollment token run:');
logger.error(` ${getCommand('elasticsearch-create-enrollment-token', '-s kibana')}`);
process.exit(1);
}
if (!(await kibanaConfigWriter.isConfigWritable())) {
logger.error(chalk.red('Kibana does not have enough permissions to write to the config file.'));
logger.error('');
logger.error('To grant write access run:');
logger.error(` chmod +w ${getConfigPath()}`);
process.exit(1);
}
logger.log('');
if (!options.silent) {
spinner.start(chalk.dim('Configuring Kibana...'));
}
let configToWrite: EnrollResult;
try {
configToWrite = await elasticsearch.enroll({
hosts: token.adr,
apiKey: token.key,
caFingerprint: ElasticsearchService.formatFingerprint(token.fgr),
});
} catch (error) {
if (!options.silent) {
spinner.fail(
`${chalk.bold('Unable to enroll with Elasticsearch:')} ${chalk.red(
`${getDetailedErrorMessage(error)}`
)}`
);
}
logger.error('');
logger.error('To generate a new enrollment token run:');
logger.error(` ${getCommand('elasticsearch-create-enrollment-token', '-s kibana')}`);
process.exit(1);
}
try {
await kibanaConfigWriter.writeConfig(configToWrite);
} catch (error) {
if (!options.silent) {
spinner.fail(
`${chalk.bold('Unable to configure Kibana:')} ${chalk.red(
`${getDetailedErrorMessage(error)}`
)}`
);
}
logger.error(chalk.red(`${getDetailedErrorMessage(error)}`));
process.exit(1);
}
if (!options.silent) {
spinner.succeed(chalk.bold('Kibana configured successfully.'));
}
logger.log('');
logger.log('To start Kibana run:');
logger.log(` ${getCommand('kibana')}`);
}
initCommand();

10
src/cli_setup/dev.js Normal file
View file

@ -0,0 +1,10 @@
/*
* 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.
*/
require('../setup_node_env');
require('./cli_setup');

10
src/cli_setup/dist.js Normal file
View file

@ -0,0 +1,10 @@
/*
* 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.
*/
require('../setup_node_env/dist');
require('./cli_setup');

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/src/cli_setup'],
};

View file

@ -0,0 +1,76 @@
/*
* 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 { decodeEnrollmentToken, getCommand } from './utils';
import type { EnrollmentToken } from '../plugins/interactive_setup/common';
describe('kibana setup cli', () => {
describe('getCommand', () => {
const originalPlatform = process.platform;
it('should format windows correctly', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
});
expect(getCommand('kibana')).toEqual('bin\\kibana.bat');
expect(getCommand('kibana', '--silent')).toEqual('bin\\kibana.bat --silent');
});
it('should format unix correctly', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
expect(getCommand('kibana')).toEqual('bin/kibana');
expect(getCommand('kibana', '--silent')).toEqual('bin/kibana --silent');
});
afterAll(function () {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
});
});
describe('decodeEnrollmentToken', () => {
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',
};
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();
});
});
});

91
src/cli_setup/utils.ts Normal file
View file

@ -0,0 +1,91 @@
/*
* 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 { getConfigPath } from '@kbn/utils';
import inquirer from 'inquirer';
import { duration } from 'moment';
import { merge } from 'lodash';
import { Logger } from '../core/server';
import { ClusterClient } from '../core/server/elasticsearch/client';
import { configSchema } from '../core/server/elasticsearch';
import { ElasticsearchService } from '../plugins/interactive_setup/server/elasticsearch_service';
import { KibanaConfigWriter } from '../plugins/interactive_setup/server/kibana_config_writer';
import type { EnrollmentToken } from '../plugins/interactive_setup/common';
const noop = () => {};
const logger: Logger = {
debug: noop,
error: noop,
warn: noop,
trace: noop,
info: noop,
fatal: noop,
log: noop,
get: () => logger,
};
export const kibanaConfigWriter = new KibanaConfigWriter(getConfigPath(), logger);
export const elasticsearch = new ElasticsearchService(logger).setup({
connectionCheckInterval: duration(Infinity),
elasticsearch: {
createClient: (type, config) => {
const defaults = configSchema.validate({});
return new ClusterClient(
merge(
defaults,
{
hosts: Array.isArray(defaults.hosts) ? defaults.hosts : [defaults.hosts],
},
config
),
logger,
type
);
},
},
});
export async function promptToken() {
const answers = await inquirer.prompt({
type: 'input',
name: 'token',
message: 'Enter enrollment token:',
validate: (value = '') => (decodeEnrollmentToken(value) ? true : 'Invalid enrollment token'),
});
return answers.token;
}
export function decodeEnrollmentToken(enrollmentToken: string): EnrollmentToken | undefined {
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((adr) => `https://${adr}`), key: btoa(json.key) };
} catch (error) {} // eslint-disable-line no-empty
}
function btoa(str: string) {
return Buffer.from(str, 'binary').toString('base64');
}
function atob(str: string) {
return Buffer.from(str, 'base64').toString('binary');
}
export function getCommand(command: string, args?: string) {
const isWindows = process.platform === 'win32';
return `${isWindows ? `bin\\${command}.bat` : `bin/${command}`}${args ? ` ${args}` : ''}`;
}

View file

@ -0,0 +1,29 @@
#!/bin/sh
SCRIPT=$0
# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path.
while [ -h "$SCRIPT" ] ; do
ls=$(ls -ld "$SCRIPT")
# Drop everything prior to ->
link=$(expr "$ls" : '.*-> \(.*\)$')
if expr "$link" : '/.*' > /dev/null; then
SCRIPT="$link"
else
SCRIPT=$(dirname "$SCRIPT")/"$link"
fi
done
DIR="$(dirname "${SCRIPT}")/.."
CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"}
NODE="${DIR}/node/bin/node"
test -x "$NODE"
if [ ! -x "$NODE" ]; then
echo "unable to find usable node.js executable."
exit 1
fi
if [ -f "${CONFIG_DIR}/node.options" ]; then
KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)"
fi
NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" "${NODE}" "${DIR}/src/cli_setup/dist" "$@"

View file

@ -0,0 +1,35 @@
@echo off
SETLOCAL ENABLEDELAYEDEXPANSION
set SCRIPT_DIR=%~dp0
for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI
set NODE=%DIR%\node\node.exe
If Not Exist "%NODE%" (
Echo unable to find usable node.js executable.
Exit /B 1
)
set CONFIG_DIR=%KBN_PATH_CONF%
If ["%KBN_PATH_CONF%"] == [] (
set "CONFIG_DIR=%DIR%\config"
)
IF EXIST "%CONFIG_DIR%\node.options" (
for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do (
If [!NODE_OPTIONS!] == [] (
set "NODE_OPTIONS=%%i"
) Else (
set "NODE_OPTIONS=!NODE_OPTIONS! %%i"
)
)
)
TITLE Kibana Setup
"%NODE%" "%DIR%\src\cli_setup\dist" %*
:finally
ENDLOCAL

View file

@ -26,7 +26,7 @@ export const CopySource: Task = {
'!src/test_utils/**',
'!src/fixtures/**',
'!src/cli/repl/**',
'!src/cli/dev.js',
'!src/cli*/dev.js',
'!src/functional_test_runner/**',
'!src/dev/**',
'!**/jest.config.js',

View file

@ -22,11 +22,7 @@ async function getDependencies(cwd: string, entries: string[]) {
export async function findUsedDependencies(listedPkgDependencies: any, baseDir: any) {
// Define the entry points for the server code in order to
// start here later looking for the server side dependencies
const mainCodeEntries = [
Path.resolve(baseDir, `src/cli/dist.js`),
Path.resolve(baseDir, `src/cli_keystore/dist.js`),
Path.resolve(baseDir, `src/cli_plugin/dist.js`),
];
const mainCodeEntries = await globby(normalize(Path.resolve(baseDir, `src/cli*/dist.js`)));
const discoveredPluginEntries = await globby([
normalize(Path.resolve(baseDir, `src/plugins/**/server/index.js`)),

View file

@ -193,7 +193,7 @@ const EnrollmentTokenDetails: FunctionComponent<EnrollmentTokenDetailsProps> = (
</EuiText>
);
export function decodeEnrollmentToken(enrollmentToken: string) {
export function decodeEnrollmentToken(enrollmentToken: string): EnrollmentToken | undefined {
try {
const json = JSON.parse(atob(enrollmentToken)) as EnrollmentToken;
if (

View file

@ -34,7 +34,7 @@ import { getDetailedErrorMessage, getErrorStatusCode } from './errors';
export interface EnrollParameters {
apiKey: string;
hosts: string[];
hosts: readonly string[];
caFingerprint: string;
}
@ -49,7 +49,7 @@ export interface ElasticsearchServiceSetupDeps {
/**
* Core Elasticsearch service preboot contract;
*/
elasticsearch: ElasticsearchServicePreboot;
elasticsearch: Pick<ElasticsearchServicePreboot, 'createClient'>;
/**
* Interval for the Elasticsearch connection check (whether it's configured or not).
@ -169,7 +169,7 @@ export class ElasticsearchService {
* the Elasticsearch node we're enrolling with. Should be in a form of a hex colon-delimited string in upper case.
*/
private async enroll(
elasticsearch: ElasticsearchServicePreboot,
elasticsearch: Pick<ElasticsearchServicePreboot, 'createClient'>,
{ apiKey, hosts, caFingerprint }: EnrollParameters
) {
const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
@ -257,7 +257,7 @@ export class ElasticsearchService {
}
private async authenticate(
elasticsearch: ElasticsearchServicePreboot,
elasticsearch: Pick<ElasticsearchServicePreboot, 'createClient'>,
{ host, username, password, caCert }: AuthenticateParameters
) {
const client = elasticsearch.createClient('authenticate', {
@ -281,7 +281,10 @@ export class ElasticsearchService {
}
}
private async ping(elasticsearch: ElasticsearchServicePreboot, host: string) {
private async ping(
elasticsearch: Pick<ElasticsearchServicePreboot, 'createClient'>,
host: string
) {
const client = elasticsearch.createClient('ping', {
hosts: [host],
username: '',
@ -393,4 +396,15 @@ export class ElasticsearchService {
.replace(/([^\n]{1,65})/g, '$1\n')
.replace(/\n$/g, '')}\n-----END CERTIFICATE-----\n`;
}
public static formatFingerprint(caFingerprint: string) {
// Convert a plain hex string returned in the enrollment token to a format that ES client
// expects, i.e. to a colon delimited hex string in upper case: deadbeef -> DE:AD:BE:EF.
return (
caFingerprint
.toUpperCase()
.match(/.{1,2}/g)
?.join(':') ?? ''
);
}
}

View file

@ -18,6 +18,7 @@ import {
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
} from '../../common';
import { ElasticsearchService } from '../elasticsearch_service';
import type { EnrollResult } from '../elasticsearch_service';
import type { WriteConfigParameters } from '../kibana_config_writer';
import type { RouteDefinitionParams } from './';
@ -92,20 +93,12 @@ export function defineEnrollRoutes({
});
}
// Convert a plain hex string returned in the enrollment token to a format that ES client
// expects, i.e. to a colon delimited hex string in upper case: deadbeef -> DE:AD:BE:EF.
const colonFormattedCaFingerprint =
request.body.caFingerprint
.toUpperCase()
.match(/.{1,2}/g)
?.join(':') ?? '';
let configToWrite: WriteConfigParameters & EnrollResult;
try {
configToWrite = await elasticsearch.enroll({
apiKey: request.body.apiKey,
hosts: request.body.hosts,
caFingerprint: colonFormattedCaFingerprint,
caFingerprint: ElasticsearchService.formatFingerprint(request.body.caFingerprint),
});
} catch {
// For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment

View file

@ -8,6 +8,8 @@
"typings/**/*",
"src/cli/**/*",
"src/cli_setup/**/*",
"src/cli_plugin/**/*",
"src/dev/**/*",
"src/fixtures/**/*",
@ -17,6 +19,7 @@
"references": [
{ "path": "./src/core/tsconfig.json" },
{ "path": "./src/plugins/usage_collection/tsconfig.json" },
{ "path": "./src/plugins/interactive_setup/tsconfig.json" },
{ "path": "./x-pack/plugins/reporting/tsconfig.json" },
]
}
}

View file

@ -10806,11 +10806,6 @@ commander@2.17.x, commander@~2.17.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
commander@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e"
integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==
commander@^4.0.1, commander@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"