-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
525 additions
and
49 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
229 changes: 229 additions & 0 deletions
229
src/plugins/interactive_setup/server/elasticsearch/elasticsearch_service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
13
src/plugins/interactive_setup/server/elasticsearch/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.