Cumulative set of the preboot stage adjustments (#108514)

This commit is contained in:
Aleh Zasypkin 2021-08-23 15:01:46 +02:00 committed by GitHub
parent 8deaa573de
commit 3a0f209bde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 83 additions and 19 deletions

View file

@ -14,5 +14,6 @@ export declare type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'custo
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
ssl?: Partial<ElasticsearchConfig['ssl']>; ssl?: Partial<ElasticsearchConfig['ssl']>;
keepAlive?: boolean; keepAlive?: boolean;
caFingerprint?: ClientOptions['caFingerprint'];
}; };
``` ```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [HttpServicePreboot](./kibana-plugin-core-server.httpservicepreboot.md) &gt; [getServerInfo](./kibana-plugin-core-server.httpservicepreboot.getserverinfo.md)
## HttpServicePreboot.getServerInfo property
Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running preboot http server.
<b>Signature:</b>
```typescript
getServerInfo: () => HttpServerInfo;
```

View file

@ -73,6 +73,7 @@ httpPreboot.registerRoutes('my-plugin', (router) => {
| Property | Type | Description | | Property | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| [basePath](./kibana-plugin-core-server.httpservicepreboot.basepath.md) | <code>IBasePath</code> | Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md)<!-- -->. | | [basePath](./kibana-plugin-core-server.httpservicepreboot.basepath.md) | <code>IBasePath</code> | Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md)<!-- -->. |
| [getServerInfo](./kibana-plugin-core-server.httpservicepreboot.getserverinfo.md) | <code>() =&gt; HttpServerInfo</code> | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running preboot http server. |
## Methods ## Methods

View file

@ -137,7 +137,7 @@ describe('CoreApp', () => {
mockResponseFactory mockResponseFactory
); );
expect(mockResponseFactory.renderAnonymousCoreApp).toHaveBeenCalled(); expect(mockResponseFactory.renderCoreApp).toHaveBeenCalled();
}); });
}); });

View file

@ -64,7 +64,7 @@ export class CoreApp {
httpResources: corePreboot.httpResources.createRegistrar(router), httpResources: corePreboot.httpResources.createRegistrar(router),
router, router,
uiPlugins, uiPlugins,
onResourceNotFound: (res) => res.renderAnonymousCoreApp(), onResourceNotFound: (res) => res.renderCoreApp(),
}); });
}); });
} }

View file

@ -163,6 +163,12 @@ describe('parseClientOptions', () => {
] ]
`); `);
}); });
it('`caFingerprint` option', () => {
const options = parseClientOptions(createConfig({ caFingerprint: 'ab:cd:ef' }), false);
expect(options.caFingerprint).toBe('ab:cd:ef');
});
}); });
describe('authorization', () => { describe('authorization', () => {

View file

@ -35,6 +35,7 @@ export type ElasticsearchClientConfig = Pick<
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
ssl?: Partial<ElasticsearchConfig['ssl']>; ssl?: Partial<ElasticsearchConfig['ssl']>;
keepAlive?: boolean; keepAlive?: boolean;
caFingerprint?: ClientOptions['caFingerprint'];
}; };
/** /**
@ -96,6 +97,10 @@ export function parseClientOptions(
); );
} }
if (config.caFingerprint != null) {
clientOptions.caFingerprint = config.caFingerprint;
}
return clientOptions; return clientOptions;
} }

View file

@ -88,6 +88,7 @@ const createInternalPrebootContractMock = () => {
csp: CspConfig.DEFAULT, csp: CspConfig.DEFAULT,
externalUrl: ExternalUrlConfig.DEFAULT, externalUrl: ExternalUrlConfig.DEFAULT,
auth: createAuthMock(), auth: createAuthMock(),
getServerInfo: jest.fn(),
}; };
return mock; return mock;
}; };
@ -98,6 +99,7 @@ const createPrebootContractMock = () => {
const mock: HttpServicePrebootMock = { const mock: HttpServicePrebootMock = {
registerRoutes: internalMock.registerRoutes, registerRoutes: internalMock.registerRoutes,
basePath: createBasePathMock(), basePath: createBasePathMock(),
getServerInfo: jest.fn(),
}; };
return mock; return mock;

View file

@ -379,6 +379,7 @@ test('returns `preboot` http server contract on preboot', async () => {
auth: Symbol('auth'), auth: Symbol('auth'),
basePath: Symbol('basePath'), basePath: Symbol('basePath'),
csp: Symbol('csp'), csp: Symbol('csp'),
getServerInfo: jest.fn(),
}; };
mockHttpServer.mockImplementation(() => ({ mockHttpServer.mockImplementation(() => ({
@ -397,6 +398,7 @@ test('returns `preboot` http server contract on preboot', async () => {
registerRouteHandlerContext: expect.any(Function), registerRouteHandlerContext: expect.any(Function),
registerRoutes: expect.any(Function), registerRoutes: expect.any(Function),
registerStaticDir: expect.any(Function), registerStaticDir: expect.any(Function),
getServerInfo: expect.any(Function),
}); });
}); });

View file

@ -128,6 +128,7 @@ export class HttpService
prebootSetup.registerRouterAfterListening(router); prebootSetup.registerRouterAfterListening(router);
}, },
getServerInfo: prebootSetup.getServerInfo,
}; };
return this.internalPreboot; return this.internalPreboot;

View file

@ -142,6 +142,11 @@ export interface HttpServicePreboot {
* See {@link IBasePath}. * See {@link IBasePath}.
*/ */
basePath: IBasePath; basePath: IBasePath;
/**
* Provides common {@link HttpServerInfo | information} about the running preboot http server.
*/
getServerInfo: () => HttpServerInfo;
} }
/** @internal */ /** @internal */
@ -155,6 +160,7 @@ export interface InternalHttpServicePreboot
| 'registerStaticDir' | 'registerStaticDir'
| 'registerRouteHandlerContext' | 'registerRouteHandlerContext'
| 'server' | 'server'
| 'getServerInfo'
> { > {
registerRoutes(path: string, callback: (router: IRouter) => void): void; registerRoutes(path: string, callback: (router: IRouter) => void): void;
} }

View file

@ -115,6 +115,7 @@ export function createPluginPrebootSetupContext(
http: { http: {
registerRoutes: deps.http.registerRoutes, registerRoutes: deps.http.registerRoutes,
basePath: deps.http.basePath, basePath: deps.http.basePath,
getServerInfo: deps.http.getServerInfo,
}, },
preboot: { preboot: {
isSetupOnHold: deps.preboot.isSetupOnHold, isSetupOnHold: deps.preboot.isSetupOnHold,

View file

@ -807,6 +807,7 @@ export type ElasticsearchClientConfig = Pick<ElasticsearchConfig, 'customHeaders
requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout'];
ssl?: Partial<ElasticsearchConfig['ssl']>; ssl?: Partial<ElasticsearchConfig['ssl']>;
keepAlive?: boolean; keepAlive?: boolean;
caFingerprint?: ClientOptions['caFingerprint'];
}; };
// @public // @public
@ -1003,6 +1004,7 @@ export interface HttpServerInfo {
// @public // @public
export interface HttpServicePreboot { export interface HttpServicePreboot {
basePath: IBasePath; basePath: IBasePath;
getServerInfo: () => HttpServerInfo;
registerRoutes(path: string, callback: (router: IRouter) => void): void; registerRoutes(path: string, callback: (router: IRouter) => void): void;
} }

View file

@ -308,7 +308,7 @@ describe('ElasticsearchService', () => {
mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient); mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient);
await expect( await expect(
setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] }) setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'], caFingerprint: 'DE:AD:BE:EF' })
).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`); ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`);
expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1); expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1);
@ -327,7 +327,11 @@ describe('ElasticsearchService', () => {
mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient); mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient);
await expect( await expect(
setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] }) setupContract.enroll({
apiKey: 'apiKey',
hosts: ['host1', 'host2'],
caFingerprint: 'DE:AD:BE:EF',
})
).rejects.toMatchInlineSnapshot(`[Error: Unable to connect to any of the provided hosts.]`); ).rejects.toMatchInlineSnapshot(`[Error: Unable to connect to any of the provided hosts.]`);
expect(mockEnrollClient.close).toHaveBeenCalledTimes(2); expect(mockEnrollClient.close).toHaveBeenCalledTimes(2);
@ -351,7 +355,7 @@ describe('ElasticsearchService', () => {
); );
await expect( await expect(
setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] }) setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'], caFingerprint: 'DE:AD:BE:EF' })
).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`); ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`);
expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1); expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1);
@ -404,7 +408,11 @@ some weird+ca/with
`; `;
await expect( await expect(
setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] }) setupContract.enroll({
apiKey: 'apiKey',
hosts: ['host1', 'host2'],
caFingerprint: 'DE:AD:BE:EF',
})
).resolves.toEqual({ ).resolves.toEqual({
ca: expectedCa, ca: expectedCa,
host: 'host2', host: 'host2',
@ -417,14 +425,17 @@ some weird+ca/with
// Check that we created clients with the right parameters // Check that we created clients with the right parameters
expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledTimes(3); expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledTimes(3);
expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', { expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', {
caFingerprint: 'DE:AD:BE:EF',
hosts: ['host1'], hosts: ['host1'],
ssl: { verificationMode: 'none' }, ssl: { verificationMode: 'none' },
}); });
expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', { expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', {
caFingerprint: 'DE:AD:BE:EF',
hosts: ['host2'], hosts: ['host2'],
ssl: { verificationMode: 'none' }, ssl: { verificationMode: 'none' },
}); });
expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('authenticate', { expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('authenticate', {
caFingerprint: 'DE:AD:BE:EF',
hosts: ['host2'], hosts: ['host2'],
serviceAccountToken: 'some-value', serviceAccountToken: 'some-value',
ssl: { certificateAuthorities: [expectedCa] }, ssl: { certificateAuthorities: [expectedCa] },

View file

@ -34,9 +34,7 @@ import { getDetailedErrorMessage } from './errors';
interface EnrollParameters { interface EnrollParameters {
apiKey: string; apiKey: string;
hosts: string[]; hosts: string[];
// TODO: Integrate fingerprint check as soon core supports this new option: caFingerprint: string;
// https://github.com/elastic/kibana/pull/108514
caFingerprint?: string;
} }
export interface ElasticsearchServiceSetupDeps { export interface ElasticsearchServiceSetupDeps {
@ -141,10 +139,12 @@ export class ElasticsearchService {
* @param apiKey The ApiKey to use to authenticate Kibana enrollment request. * @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 * @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. * to point to exactly same Elasticsearch node, potentially available via different network interfaces.
* @param caFingerprint The fingerprint of the root CA certificate that is supposed to sign certificate presented by
* the Elasticsearch node we're enrolling with. Should be in a form of a hex colon-delimited string in upper case.
*/ */
private async enroll( private async enroll(
elasticsearch: ElasticsearchServicePreboot, elasticsearch: ElasticsearchServicePreboot,
{ apiKey, hosts }: EnrollParameters { apiKey, hosts, caFingerprint }: EnrollParameters
): Promise<EnrollResult> { ): Promise<EnrollResult> {
const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } }; const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
const elasticsearchConfig: Partial<ElasticsearchClientConfig> = { const elasticsearchConfig: Partial<ElasticsearchClientConfig> = {
@ -153,10 +153,14 @@ export class ElasticsearchService {
// We should iterate through all provided hosts until we find an accessible one. // We should iterate through all provided hosts until we find an accessible one.
for (const host of hosts) { for (const host of hosts) {
this.logger.debug(`Trying to enroll with "${host}" host`); this.logger.debug(
`Trying to enroll with "${host}" host using "${caFingerprint}" CA fingerprint.`
);
const enrollClient = elasticsearch.createClient('enroll', { const enrollClient = elasticsearch.createClient('enroll', {
...elasticsearchConfig, ...elasticsearchConfig,
hosts: [host], hosts: [host],
caFingerprint,
}); });
let enrollmentResponse; let enrollmentResponse;
@ -197,6 +201,7 @@ export class ElasticsearchService {
// Now try to use retrieved password and CA certificate to authenticate to this host. // Now try to use retrieved password and CA certificate to authenticate to this host.
const authenticateClient = elasticsearch.createClient('authenticate', { const authenticateClient = elasticsearch.createClient('authenticate', {
caFingerprint,
hosts: [host], hosts: [host],
serviceAccountToken: enrollResult.serviceAccountToken.value, serviceAccountToken: enrollResult.serviceAccountToken.value,
ssl: { certificateAuthorities: [enrollResult.ca] }, ssl: { certificateAuthorities: [enrollResult.ca] },

View file

@ -113,7 +113,7 @@ describe('Enroll routes', () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false); mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -134,7 +134,7 @@ describe('Enroll routes', () => {
); );
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -164,7 +164,7 @@ describe('Enroll routes', () => {
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false); mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false);
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -203,7 +203,7 @@ describe('Enroll routes', () => {
); );
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -236,7 +236,7 @@ describe('Enroll routes', () => {
); );
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -273,7 +273,7 @@ describe('Enroll routes', () => {
mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue(); mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue();
const mockRequest = httpServerMock.createKibanaRequest({ const mockRequest = httpServerMock.createKibanaRequest({
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
}); });
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
@ -286,7 +286,7 @@ describe('Enroll routes', () => {
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({ expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({
apiKey: 'some-key', apiKey: 'some-key',
hosts: ['host1', 'host2'], hosts: ['host1', 'host2'],
caFingerprint: 'ab:cd:ef', caFingerprint: 'DE:AD:BE:EF',
}); });
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1); expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);

View file

@ -73,12 +73,20 @@ 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 enrollResult: EnrollResult; let enrollResult: EnrollResult;
try { try {
enrollResult = await elasticsearch.enroll({ enrollResult = await elasticsearch.enroll({
apiKey: request.body.apiKey, apiKey: request.body.apiKey,
hosts: request.body.hosts, hosts: request.body.hosts,
caFingerprint: request.body.caFingerprint, caFingerprint: colonFormattedCaFingerprint,
}); });
} catch { } catch {
// For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment // For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment