Skip to content

Commit

Permalink
Introduce Enroll API endpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Aug 17, 2021
1 parent 7888c9c commit 0ef271f
Show file tree
Hide file tree
Showing 9 changed files with 525 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@
* Describes current status of the Elasticsearch connection.
*/
export enum ElasticsearchConnectionStatus {
/**
* Indicates that Kibana hasn't figured out yet if existing Elasticsearch connection configuration is valid.
*/
Unknown = 'unknown',

/**
* Indicates that current Elasticsearch connection configuration valid and sufficient.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* 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 { ApiResponse } from '@elastic/elasticsearch';
import { errors } from '@elastic/elasticsearch';
import type { Observable } from 'rxjs';
import { from, of, timer } from 'rxjs';
import {
catchError,
distinctUntilChanged,
exhaustMap,
map,
shareReplay,
takeWhile,
} from 'rxjs/operators';

import type {
ElasticsearchClientConfig,
ElasticsearchServicePreboot,
ICustomClusterClient,
Logger,
ScopeableRequest,
} from 'src/core/server';

import { ElasticsearchConnectionStatus } from '../../common';
import { getDetailedErrorMessage } from '../errors';

interface EnrollParameters {
apiKey: string;
hosts: string[];
// TODO: Integrate fingerprint check as soon ES client is upgraded:
// https://github.com/elastic/kibana/pull/107536
caFingerprint?: string;
}

/**
* Result of the enrollment request.
*/
export interface EnrollResult {
/**
* Host address of the Elasticsearch node that successfully processed enrollment request.
*/
host: string;
/**
* PEM CA certificate for the Elasticsearch HTTP certificates.
*/
ca: string;
/**
* Username of the internal Kibana system user.
*/
username: string;
/**
* Password of the internal Kibana system user.
*/
password: string;
}

export interface ElasticsearchServiceSetup {
/**
* Observable that yields the last result of the Elasticsearch connection status check.
*/
connectionStatus$: Observable<ElasticsearchConnectionStatus>;

/**
* Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using
* the specified {@param apiKey}.
* @param apiKey The ApiKey to use to authenticate Kibana enrollment request.
* @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed
* to point to exactly same Elasticsearch node, potentially available via different network interfaces.
*/
enroll: (params: EnrollParameters) => Promise<EnrollResult>;
}

export class ElasticsearchService {
/**
* Elasticsearch client used to check Elasticsearch connection status.
*/
private connectionStatusClient?: ICustomClusterClient;
constructor(private readonly logger: Logger) {}

public setup(elasticsearch: ElasticsearchServicePreboot): ElasticsearchServiceSetup {
const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient(
'ping'
));

return {
connectionStatus$: timer(0, 5000).pipe(
exhaustMap(() => {
return from(connectionStatusClient.asInternalUser.ping()).pipe(
map(() => ElasticsearchConnectionStatus.Configured),
catchError((pingError) =>
of(
pingError instanceof errors.ConnectionError
? ElasticsearchConnectionStatus.NotConfigured
: ElasticsearchConnectionStatus.Configured
)
)
);
}),
takeWhile(
(status) => status !== ElasticsearchConnectionStatus.Configured,
/* inclusive */ true
),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 })
),
enroll: this.enroll.bind(this, elasticsearch),
};
}

public stop() {
if (this.connectionStatusClient) {
this.connectionStatusClient.close().catch((err) => {
this.logger.debug(`Failed to stop Elasticsearch service: ${getDetailedErrorMessage(err)}`);
});
this.connectionStatusClient = undefined;
}
}

/**
* Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using
* the specified {@param apiKey}.
* @param elasticsearch Core Elasticsearch service preboot contract.
* @param apiKey The ApiKey to use to authenticate Kibana enrollment request.
* @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed
* to point to exactly same Elasticsearch node, potentially available via different network interfaces.
*/
private async enroll(
elasticsearch: ElasticsearchServicePreboot,
{ apiKey, hosts }: 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) {
this.logger.debug(`Trying to enroll with "${host}" host`);
const enrollClient = elasticsearch.createClient('enroll', {
...elasticsearchConfig,
hosts: [host],
});

let enrollmentResponse;
try {
enrollmentResponse = (await enrollClient
.asScoped(scopeableRequest)
.asCurrentUser.transport.request({
method: 'GET',
path: '/_security/enroll/kibana',
})) as ApiResponse<{ password: string; http_ca: string }>;
} catch (err) {
// We expect that all hosts belong to exactly same node and any non-connection error for one host would mean
// 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(
err
)}`
);
continue;
}

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, CA certificate: ${enrollmentResponse.body.http_ca}`
);

const enrollResult = {
host,
ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
username: 'kibana_system',
password: enrollmentResponse.body.password,
};

// Now try to use retrieved password and CA certificate to authenticate to this host.
const authenticateClient = elasticsearch.createClient('authenticate', {
hosts: [host],
username: enrollResult.username,
password: enrollResult.password,
ssl: { certificateAuthorities: [enrollResult.ca] },
});

this.logger.debug(
`Verifying if can authenticate "${enrollResult.username}" to "${host}" host.`
);

try {
await authenticateClient.asInternalUser.security.authenticate();
this.logger.debug(
`Successfully authenticated "${enrollResult.username}" to "${host}" host.`
);
} catch (err) {
this.logger.error(
`Failed to authenticate "${
enrollResult.username
}" to "${host}" host: ${getDetailedErrorMessage(err)}.`
);
throw err;
} finally {
await authenticateClient.close();
}

return enrollResult;
}

throw new Error('Unable to connect to any of the provided hosts.');
}

private static createPemCertificate(derCaString: string) {
// Use `X509Certificate` class once we upgrade to Node v16.
return `-----BEGIN CERTIFICATE-----\n${derCaString
.replace(/_/g, '/')
.replace(/-/g, '+')
.replace(/([^\n]{1,65})/g, '$1\n')
.replace(/\n$/g, '')}\n-----END CERTIFICATE-----\n`;
}
}
13 changes: 13 additions & 0 deletions src/plugins/interactive_setup/server/elasticsearch/index.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

export {
ElasticsearchService,
EnrollResult,
ElasticsearchServiceSetup,
} from './elasticsearch_service';
35 changes: 35 additions & 0 deletions src/plugins/interactive_setup/server/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { errors } from '@elastic/elasticsearch';

/**
* Extracts error code from Boom and Elasticsearch "native" errors.
* @param error Error instance to extract status code from.
*/
export function getErrorStatusCode(error: any): number {
if (error instanceof errors.ResponseError) {
return error.statusCode;
}

return error.statusCode || error.status;
}

/**
* Extracts detailed error message from Boom and Elasticsearch "native" errors. It's supposed to be
* only logged on the server side and never returned to the client as it may contain sensitive
* information.
* @param error Error instance to extract message from.
*/
export function getDetailedErrorMessage(error: any): string {
if (error instanceof errors.ResponseError) {
return JSON.stringify(error.body);
}

return error.message;
}
92 changes: 92 additions & 0 deletions src/plugins/interactive_setup/server/kibana_config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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 { constants } from 'fs';
import fs from 'fs/promises';
import yaml from 'js-yaml';
import path from 'path';

import type { Logger } from 'src/core/server';

import { getDetailedErrorMessage } from './errors';

export interface WriteConfigParameters {
host: string;
ca: string;
username: string;
password: string;
}

export class KibanaConfig {
constructor(private readonly configPath: string, private readonly logger: Logger) {}

/**
* Checks if we can write to the Kibana configuration file and configuration directory.
*/
public async isConfigWritable() {
try {
// We perform two separate checks here:
// 1. If we can write to config directory to add a new CA certificate file and potentially Kibana configuration
// file if it doesn't exist for some reason.
// 2. If we can write to the Kibana configuration file if it exists.
const canWriteToConfigDirectory = fs.access(path.dirname(this.configPath), constants.W_OK);
await Promise.all([
canWriteToConfigDirectory,
fs.access(this.configPath, constants.F_OK).then(
() => fs.access(this.configPath, constants.W_OK),
() => canWriteToConfigDirectory
),
]);
return true;
} catch {
return false;
}
}

/**
* Writes Elasticsearch configuration to the disk.
* @param params
*/
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;
}

this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`);
try {
await fs.appendFile(
this.configPath,
`\n\n# This section was automatically generated during setup.\n${yaml.dump({
elasticsearch: {
hosts: [params.host],
username: params.username,
password: params.password,
ssl: { certificateAuthorities: [caPath] },
},
})}\n`
);
this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`);
} catch (err) {
this.logger.error(
`Failed to write Elasticsearch configuration to ${
this.configPath
}: ${getDetailedErrorMessage(err)}.`
);
throw err;
}
}
}
9 changes: 9 additions & 0 deletions src/plugins/interactive_setup/server/kibana_config/index.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

export { KibanaConfigService } from './kibana_config_service';
Loading

0 comments on commit 0ef271f

Please sign in to comment.