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'];
ssl?: Partial<ElasticsearchConfig['ssl']>;
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 |
| --- | --- | --- |
| [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

View file

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

View file

@ -64,7 +64,7 @@ export class CoreApp {
httpResources: corePreboot.httpResources.createRegistrar(router),
router,
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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,9 +34,7 @@ import { getDetailedErrorMessage } from './errors';
interface EnrollParameters {
apiKey: string;
hosts: string[];
// TODO: Integrate fingerprint check as soon core supports this new option:
// https://github.com/elastic/kibana/pull/108514
caFingerprint?: string;
caFingerprint: string;
}
export interface ElasticsearchServiceSetupDeps {
@ -141,10 +139,12 @@ export class ElasticsearchService {
* @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.
* @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(
elasticsearch: ElasticsearchServicePreboot,
{ apiKey, hosts }: EnrollParameters
{ apiKey, hosts, caFingerprint }: EnrollParameters
): Promise<EnrollResult> {
const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
const elasticsearchConfig: Partial<ElasticsearchClientConfig> = {
@ -153,10 +153,14 @@ export class ElasticsearchService {
// 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`);
this.logger.debug(
`Trying to enroll with "${host}" host using "${caFingerprint}" CA fingerprint.`
);
const enrollClient = elasticsearch.createClient('enroll', {
...elasticsearchConfig,
hosts: [host],
caFingerprint,
});
let enrollmentResponse;
@ -197,6 +201,7 @@ export class ElasticsearchService {
// Now try to use retrieved password and CA certificate to authenticate to this host.
const authenticateClient = elasticsearch.createClient('authenticate', {
caFingerprint,
hosts: [host],
serviceAccountToken: enrollResult.serviceAccountToken.value,
ssl: { certificateAuthorities: [enrollResult.ca] },

View file

@ -113,7 +113,7 @@ describe('Enroll routes', () => {
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
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({
@ -134,7 +134,7 @@ describe('Enroll routes', () => {
);
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({
@ -164,7 +164,7 @@ describe('Enroll routes', () => {
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false);
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({
@ -203,7 +203,7 @@ describe('Enroll routes', () => {
);
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({
@ -236,7 +236,7 @@ describe('Enroll routes', () => {
);
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({
@ -273,7 +273,7 @@ describe('Enroll routes', () => {
mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue();
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({
@ -286,7 +286,7 @@ describe('Enroll routes', () => {
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({
apiKey: 'some-key',
hosts: ['host1', 'host2'],
caFingerprint: 'ab:cd:ef',
caFingerprint: 'DE:AD:BE:EF',
});
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;
try {
enrollResult = await elasticsearch.enroll({
apiKey: request.body.apiKey,
hosts: request.body.hosts,
caFingerprint: request.body.caFingerprint,
caFingerprint: colonFormattedCaFingerprint,
});
} catch {
// For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment