mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[HTTP Server] support TLS config hot reload via SIGHUP
(#171823)
## Summary Fix https://github.com/elastic/kibana/issues/54368 Add support for hot reloading the Kibana server's TLS configuration, using the same `SIGHUP`-based reload signal, as already implemented for other parts of the Kibana configuration (e.g `logging`) **Note:** - hot reloading is only supported for the server TLS configuration (`server.ssl`), not for the whole `server.*` config prefix - swaping the certificate files (without modifying the kibana config itself) is supported - it is not possible to toggle TLS (enabling or disabling) without restarting Kibana - hot reloading requires to force the process to reload its configuration by sending a `SIGHUP` signal ### Example / how to test #### Before ```yaml server.ssl.enabled: true server.ssl.certificate: /path-to-kibana/packages/kbn-dev-utils/certs/kibana.crt server.ssl.key: /path-to-kibana/packages/kbn-dev-utils/certs/kibana.key ``` <img width="550" alt="Screenshot 2023-11-23 at 15 11 28" src="1226d161
-a9f2-4d62-a3de-37161829f187"> #### Changing the config ```yaml server.ssl.enabled: true server.ssl.certificate: /path-to-kibana/packages/kbn-dev-utils/certs/elasticsearch.crt server.ssl.key: /path-to-kibana/packages/kbn-dev-utils/certs/elasticsearch.key ``` ```bash kill -SIGHUP {KIBANA_PID} ``` <img width="865" alt="Screenshot 2023-11-23 at 15 18 21" src="c9412b2e
-d70e-4cf0-8eaf-4db70a45af60"> #### After <img width="547" alt="Screenshot 2023-11-23 at 15 18 43" src="c839f04f
-4adb-456d-a174-4f0ebd5c234c"> ## Release notes It is now possible to hot reload Kibana's TLS (`server.ssl`) configuration by updating it and then sending a `SIGHUP` signal to the Kibana process. Note that TLS cannot be toggled (disabled/enabled) that way, and that hot reload only works for the TLS configuration, not other properties of the `server` config prefix. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
8af928e56f
commit
87213e7efe
18 changed files with 728 additions and 118 deletions
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 const setTlsConfigMock = jest.fn();
|
||||
|
||||
jest.doMock('@kbn/server-http-tools', () => {
|
||||
const actual = jest.requireActual('@kbn/server-http-tools');
|
||||
return {
|
||||
...actual,
|
||||
setTlsConfig: setTlsConfigMock,
|
||||
createServer: jest.fn(actual.createServer),
|
||||
};
|
||||
});
|
|
@ -6,20 +6,12 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
jest.mock('@kbn/server-http-tools', () => {
|
||||
const module = jest.requireActual('@kbn/server-http-tools');
|
||||
return {
|
||||
...module,
|
||||
createServer: jest.fn(module.createServer),
|
||||
};
|
||||
});
|
||||
|
||||
import { setTlsConfigMock } from './http_server.test.mocks';
|
||||
import { Server } from 'http';
|
||||
import { rm, mkdtemp, readFile, writeFile } from 'fs/promises';
|
||||
import supertest from 'supertest';
|
||||
import { omit } from 'lodash';
|
||||
import { join } from 'path';
|
||||
|
||||
import { ByteSizeValue, schema } from '@kbn/config-schema';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import type {
|
||||
|
@ -37,7 +29,7 @@ import { HttpServer } from './http_server';
|
|||
import { Readable } from 'stream';
|
||||
import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
|
||||
import moment from 'moment';
|
||||
import { of } from 'rxjs';
|
||||
import { of, Observable, BehaviorSubject } from 'rxjs';
|
||||
|
||||
const routerOptions: RouterOptions = {
|
||||
isDev: false,
|
||||
|
@ -52,8 +44,12 @@ const cookieOptions = {
|
|||
};
|
||||
|
||||
let server: HttpServer;
|
||||
|
||||
let config: HttpConfig;
|
||||
let config$: Observable<HttpConfig>;
|
||||
|
||||
let configWithSSL: HttpConfig;
|
||||
let configWithSSL$: Observable<HttpConfig>;
|
||||
|
||||
const loggingService = loggingSystemMock.create();
|
||||
const logger = loggingService.get();
|
||||
|
@ -85,6 +81,7 @@ beforeEach(() => {
|
|||
cdn: {},
|
||||
shutdownTimeout: moment.duration(500, 'ms'),
|
||||
} as any;
|
||||
config$ = of(config);
|
||||
|
||||
configWithSSL = {
|
||||
...config,
|
||||
|
@ -97,6 +94,7 @@ beforeEach(() => {
|
|||
redirectHttpFromPort: config.port + 1,
|
||||
},
|
||||
} as HttpConfig;
|
||||
configWithSSL$ = of(configWithSSL);
|
||||
|
||||
server = new HttpServer(loggingService, 'tests', of(config.shutdownTimeout));
|
||||
});
|
||||
|
@ -109,7 +107,7 @@ afterEach(async () => {
|
|||
test('log listening address after started', async () => {
|
||||
expect(server.isListening()).toBe(false);
|
||||
|
||||
await server.setup(config);
|
||||
await server.setup({ config$ });
|
||||
await server.start();
|
||||
|
||||
expect(server.isListening()).toBe(true);
|
||||
|
@ -125,7 +123,7 @@ test('log listening address after started', async () => {
|
|||
test('log listening address after started when configured with BasePath and rewriteBasePath = false', async () => {
|
||||
expect(server.isListening()).toBe(false);
|
||||
|
||||
await server.setup({ ...config, basePath: '/bar', rewriteBasePath: false });
|
||||
await server.setup({ config$: of({ ...config, basePath: '/bar', rewriteBasePath: false }) });
|
||||
await server.start();
|
||||
|
||||
expect(server.isListening()).toBe(true);
|
||||
|
@ -141,7 +139,7 @@ test('log listening address after started when configured with BasePath and rewr
|
|||
test('log listening address after started when configured with BasePath and rewriteBasePath = true', async () => {
|
||||
expect(server.isListening()).toBe(false);
|
||||
|
||||
await server.setup({ ...config, basePath: '/bar', rewriteBasePath: true });
|
||||
await server.setup({ config$: of({ ...config, basePath: '/bar', rewriteBasePath: true }) });
|
||||
await server.start();
|
||||
|
||||
expect(server.isListening()).toBe(true);
|
||||
|
@ -157,7 +155,7 @@ test('log listening address after started when configured with BasePath and rewr
|
|||
test('does not allow router registration after server is listening', async () => {
|
||||
expect(server.isListening()).toBe(false);
|
||||
|
||||
const { registerRouter } = await server.setup(config);
|
||||
const { registerRouter } = await server.setup({ config$ });
|
||||
|
||||
const router1 = new Router('/foo', logger, enhanceWithContext, routerOptions);
|
||||
expect(() => registerRouter(router1)).not.toThrowError();
|
||||
|
@ -175,7 +173,7 @@ test('does not allow router registration after server is listening', async () =>
|
|||
test('allows router registration after server is listening via `registerRouterAfterListening`', async () => {
|
||||
expect(server.isListening()).toBe(false);
|
||||
|
||||
const { registerRouterAfterListening } = await server.setup(config);
|
||||
const { registerRouterAfterListening } = await server.setup({ config$ });
|
||||
|
||||
const router1 = new Router('/foo', logger, enhanceWithContext, routerOptions);
|
||||
expect(() => registerRouterAfterListening(router1)).not.toThrowError();
|
||||
|
@ -205,7 +203,7 @@ test('valid params', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -235,7 +233,7 @@ test('invalid params', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -270,7 +268,7 @@ test('valid query', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -300,7 +298,7 @@ test('invalid query', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -335,7 +333,7 @@ test('valid body', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -373,7 +371,7 @@ test('valid body with validate function', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -416,7 +414,7 @@ test('not inline validation - specifying params', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -459,7 +457,7 @@ test('not inline validation - specifying validation handler', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -509,7 +507,7 @@ test('not inline handler - KibanaRequest', async () => {
|
|||
handler
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -558,7 +556,7 @@ test('not inline handler - RequestHandler', async () => {
|
|||
handler
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -592,7 +590,7 @@ test('invalid body', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -627,7 +625,7 @@ test('handles putting', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -658,7 +656,7 @@ test('handles deleting', async () => {
|
|||
}
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -688,7 +686,9 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => {
|
|||
res.ok({ body: 'value:/foo' })
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(configWithBasePath);
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
config$: of(configWithBasePath),
|
||||
});
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -743,7 +743,9 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => {
|
|||
res.ok({ body: 'value:/foo' })
|
||||
);
|
||||
|
||||
const { registerRouter, server: innerServer } = await server.setup(configWithBasePath);
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
config$: of(configWithBasePath),
|
||||
});
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -790,7 +792,7 @@ test('with defined `redirectHttpFromPort`', async () => {
|
|||
const router = new Router('/', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'value:/' }));
|
||||
|
||||
const { registerRouter } = await server.setup(configWithSSL);
|
||||
const { registerRouter } = await server.setup({ config$: configWithSSL$ });
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
@ -801,7 +803,7 @@ test('returns server and connection options on start', async () => {
|
|||
...config,
|
||||
port: 12345,
|
||||
};
|
||||
const { server: innerServer } = await server.setup(configWithPort);
|
||||
const { server: innerServer } = await server.setup({ config$: of(configWithPort) });
|
||||
|
||||
expect(innerServer).toBeDefined();
|
||||
expect(innerServer).toBe((server as any).server);
|
||||
|
@ -815,7 +817,7 @@ test('throws an error if starts without set up', async () => {
|
|||
|
||||
test('allows attaching metadata to attach meta-data tag strings to a route', async () => {
|
||||
const tags = ['my:tag'];
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/with-tags', validate: false, options: { tags } }, (context, req, res) =>
|
||||
|
@ -834,7 +836,7 @@ test('allows attaching metadata to attach meta-data tag strings to a route', asy
|
|||
|
||||
test('allows declaring route access to flag a route as public or internal', async () => {
|
||||
const access = 'internal';
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/with-access', validate: false, options: { access } }, (context, req, res) =>
|
||||
|
@ -852,7 +854,7 @@ test('allows declaring route access to flag a route as public or internal', asyn
|
|||
});
|
||||
|
||||
test(`sets access flag to 'internal' if not defined`, async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/internal/foo', validate: false }, (context, req, res) =>
|
||||
|
@ -883,7 +885,7 @@ test(`sets access flag to 'internal' if not defined`, async () => {
|
|||
});
|
||||
|
||||
test('exposes route details of incoming request to a route handler', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route }));
|
||||
|
@ -907,7 +909,9 @@ test('exposes route details of incoming request to a route handler', async () =>
|
|||
|
||||
describe('conditional compression', () => {
|
||||
async function setupServer(innerConfig: HttpConfig) {
|
||||
const { registerRouter, server: innerServer } = await server.setup(innerConfig);
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
config$: of(innerConfig),
|
||||
});
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
// we need the large body here so that compression would normally be used
|
||||
const largeRequest = {
|
||||
|
@ -1012,8 +1016,10 @@ describe('conditional compression', () => {
|
|||
describe('response headers', () => {
|
||||
test('allows to configure "keep-alive" header', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
...config,
|
||||
keepaliveTimeout: 100_000,
|
||||
config$: of({
|
||||
...config,
|
||||
keepaliveTimeout: 100_000,
|
||||
}),
|
||||
});
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
|
@ -1031,7 +1037,7 @@ describe('response headers', () => {
|
|||
});
|
||||
|
||||
test('default headers', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route }));
|
||||
|
@ -1053,7 +1059,7 @@ describe('response headers', () => {
|
|||
});
|
||||
|
||||
test('exposes route details of incoming request to a route handler (POST + payload options)', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1093,7 +1099,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo
|
|||
|
||||
describe('body options', () => {
|
||||
test('should reject the request because the Content-Type in the request is not valid', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1115,7 +1121,7 @@ describe('body options', () => {
|
|||
});
|
||||
|
||||
test('should reject the request because the payload is too large', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1137,7 +1143,7 @@ describe('body options', () => {
|
|||
});
|
||||
|
||||
test('should not parse the content in the request', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1166,7 +1172,7 @@ describe('body options', () => {
|
|||
describe('timeout options', () => {
|
||||
describe('payload timeout', () => {
|
||||
test('POST routes set the payload timeout', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1200,7 +1206,7 @@ describe('timeout options', () => {
|
|||
});
|
||||
|
||||
test('DELETE routes set the payload timeout', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.delete(
|
||||
|
@ -1233,7 +1239,7 @@ describe('timeout options', () => {
|
|||
});
|
||||
|
||||
test('PUT routes set the payload timeout and automatically adjusts the idle socket timeout', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.put(
|
||||
|
@ -1266,7 +1272,7 @@ describe('timeout options', () => {
|
|||
});
|
||||
|
||||
test('PATCH routes set the payload timeout and automatically adjusts the idle socket timeout', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.patch(
|
||||
|
@ -1302,8 +1308,10 @@ describe('timeout options', () => {
|
|||
describe('idleSocket timeout', () => {
|
||||
test('uses server socket timeout when not specified in the route', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
...config,
|
||||
socketTimeout: 11000,
|
||||
config$: of({
|
||||
...config,
|
||||
socketTimeout: 11000,
|
||||
}),
|
||||
});
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
|
@ -1335,8 +1343,10 @@ describe('timeout options', () => {
|
|||
|
||||
test('sets the socket timeout when specified in the route', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
...config,
|
||||
socketTimeout: 11000,
|
||||
config$: of({
|
||||
...config,
|
||||
socketTimeout: 11000,
|
||||
}),
|
||||
});
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
|
@ -1368,7 +1378,7 @@ describe('timeout options', () => {
|
|||
});
|
||||
|
||||
test('idleSocket timeout can be smaller than the payload timeout', async () => {
|
||||
const { registerRouter } = await server.setup(config);
|
||||
const { registerRouter } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.post(
|
||||
|
@ -1395,7 +1405,7 @@ describe('timeout options', () => {
|
|||
});
|
||||
|
||||
test('should return a stream in the body', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
router.put(
|
||||
|
@ -1421,8 +1431,10 @@ test('should return a stream in the body', async () => {
|
|||
|
||||
test('closes sockets on timeout', async () => {
|
||||
const { registerRouter, server: innerServer } = await server.setup({
|
||||
...config,
|
||||
socketTimeout: 1000,
|
||||
config$: of({
|
||||
...config,
|
||||
socketTimeout: 1000,
|
||||
}),
|
||||
});
|
||||
const router = new Router('', logger, enhanceWithContext, routerOptions);
|
||||
|
||||
|
@ -1446,14 +1458,14 @@ test('closes sockets on timeout', async () => {
|
|||
describe('setup contract', () => {
|
||||
describe('#createSessionStorage', () => {
|
||||
test('creates session storage factory', async () => {
|
||||
const { createCookieSessionStorageFactory } = await server.setup(config);
|
||||
const { createCookieSessionStorageFactory } = await server.setup({ config$ });
|
||||
const sessionStorageFactory = await createCookieSessionStorageFactory(cookieOptions);
|
||||
|
||||
expect(sessionStorageFactory.asScoped).toBeDefined();
|
||||
});
|
||||
|
||||
test('creates session storage factory only once', async () => {
|
||||
const { createCookieSessionStorageFactory } = await server.setup(config);
|
||||
const { createCookieSessionStorageFactory } = await server.setup({ config$ });
|
||||
const create = async () => await createCookieSessionStorageFactory(cookieOptions);
|
||||
|
||||
await create();
|
||||
|
@ -1461,7 +1473,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { createCookieSessionStorageFactory } = await server.setup(config);
|
||||
const { createCookieSessionStorageFactory } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
createCookieSessionStorageFactory(cookieOptions);
|
||||
|
@ -1471,7 +1483,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#getServerInfo', () => {
|
||||
test('returns correct information', async () => {
|
||||
let { getServerInfo } = await server.setup(config);
|
||||
let { getServerInfo } = await server.setup({ config$ });
|
||||
|
||||
expect(getServerInfo()).toEqual({
|
||||
hostname: '127.0.0.1',
|
||||
|
@ -1481,10 +1493,12 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
({ getServerInfo } = await server.setup({
|
||||
...config,
|
||||
port: 12345,
|
||||
name: 'custom-name',
|
||||
host: 'localhost',
|
||||
config$: of({
|
||||
...config,
|
||||
port: 12345,
|
||||
name: 'custom-name',
|
||||
host: 'localhost',
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(getServerInfo()).toEqual({
|
||||
|
@ -1496,7 +1510,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('returns correct protocol when ssl is enabled', async () => {
|
||||
const { getServerInfo } = await server.setup(configWithSSL);
|
||||
const { getServerInfo } = await server.setup({ config$: configWithSSL$ });
|
||||
|
||||
expect(getServerInfo().protocol).toEqual('https');
|
||||
});
|
||||
|
@ -1517,7 +1531,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('registers routes with expected options', async () => {
|
||||
const { registerStaticDir } = await server.setup(config);
|
||||
const { registerStaticDir } = await server.setup({ config$ });
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const [{ value: myServer }] = (createServer as jest.Mock).mock.results;
|
||||
jest.spyOn(myServer, 'route');
|
||||
|
@ -1540,7 +1554,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerStaticDir } = await server.setup(config);
|
||||
const { registerStaticDir } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerStaticDir('/path1/{path*}', '/path/to/resource');
|
||||
|
@ -1548,7 +1562,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('returns correct headers for static assets', async () => {
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
registerStaticDir('/static/{path*}', assetFolder);
|
||||
|
||||
|
@ -1562,7 +1576,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('returns compressed version if present', async () => {
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
registerStaticDir('/static/{path*}', assetFolder);
|
||||
|
||||
|
@ -1578,7 +1592,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('returns uncompressed version if compressed asset is not available', async () => {
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
registerStaticDir('/static/{path*}', assetFolder);
|
||||
|
||||
|
@ -1594,7 +1608,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('returns a 304 if etag value matches', async () => {
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
registerStaticDir('/static/{path*}', assetFolder);
|
||||
|
||||
|
@ -1613,7 +1627,7 @@ describe('setup contract', () => {
|
|||
});
|
||||
|
||||
test('serves content if etag values does not match', async () => {
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
|
||||
registerStaticDir('/static/{path*}', assetFolder);
|
||||
|
||||
|
@ -1628,7 +1642,7 @@ describe('setup contract', () => {
|
|||
test('dynamically updates depending on the content of the file', async () => {
|
||||
const tempFile = join(tempDir, 'some_file.json');
|
||||
|
||||
const { registerStaticDir, server: innerServer } = await server.setup(config);
|
||||
const { registerStaticDir, server: innerServer } = await server.setup({ config$ });
|
||||
registerStaticDir('/static/{path*}', tempDir);
|
||||
|
||||
await server.start();
|
||||
|
@ -1655,7 +1669,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#registerOnPreRouting', () => {
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerOnPreRouting } = await server.setup(config);
|
||||
const { registerOnPreRouting } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerOnPreRouting((req, res) => res.unauthorized());
|
||||
|
@ -1665,7 +1679,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#registerOnPreAuth', () => {
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerOnPreAuth } = await server.setup(config);
|
||||
const { registerOnPreAuth } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerOnPreAuth((req, res) => res.unauthorized());
|
||||
|
@ -1675,7 +1689,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#registerOnPostAuth', () => {
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerOnPostAuth } = await server.setup(config);
|
||||
const { registerOnPostAuth } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerOnPostAuth((req, res) => res.unauthorized());
|
||||
|
@ -1685,7 +1699,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#registerOnPreResponse', () => {
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerOnPreResponse } = await server.setup(config);
|
||||
const { registerOnPreResponse } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerOnPreResponse((req, res, t) => t.next());
|
||||
|
@ -1695,7 +1709,7 @@ describe('setup contract', () => {
|
|||
|
||||
describe('#registerAuth', () => {
|
||||
test('does not throw if called after stop', async () => {
|
||||
const { registerAuth } = await server.setup(config);
|
||||
const { registerAuth } = await server.setup({ config$ });
|
||||
await server.stop();
|
||||
expect(() => {
|
||||
registerAuth((req, res) => res.unauthorized());
|
||||
|
@ -1703,3 +1717,52 @@ describe('setup contract', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration change', () => {
|
||||
it('logs a warning in case of incompatible config change', async () => {
|
||||
const configSubject = new BehaviorSubject(configWithSSL);
|
||||
|
||||
await server.setup({ config$: configSubject });
|
||||
await server.start();
|
||||
|
||||
const nextConfig = {
|
||||
...configWithSSL,
|
||||
ssl: {
|
||||
...configWithSSL.ssl,
|
||||
getSecureOptions: () => 0,
|
||||
enabled: false,
|
||||
},
|
||||
} as HttpConfig;
|
||||
|
||||
configSubject.next(nextConfig);
|
||||
|
||||
expect(loggingService.get().warn).toHaveBeenCalledWith(
|
||||
'Incompatible TLS config change detected - TLS cannot be toggled without a full server reboot.'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setTlsConfig and logs an info message when config changes', async () => {
|
||||
const configSubject = new BehaviorSubject(configWithSSL);
|
||||
|
||||
const { server: innerServer } = await server.setup({ config$: configSubject });
|
||||
await server.start();
|
||||
|
||||
const nextConfig = {
|
||||
...configWithSSL,
|
||||
ssl: {
|
||||
...configWithSSL.ssl,
|
||||
isEqualTo: () => false,
|
||||
getSecureOptions: () => 0,
|
||||
},
|
||||
} as HttpConfig;
|
||||
|
||||
configSubject.next(nextConfig);
|
||||
|
||||
expect(setTlsConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(setTlsConfigMock).toHaveBeenCalledWith(innerServer, nextConfig.ssl);
|
||||
|
||||
expect(loggingService.get().info).toHaveBeenCalledWith(
|
||||
'TLS configuration change detected - reloading TLS configuration.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,12 +14,13 @@ import {
|
|||
createServer,
|
||||
getListenerOptions,
|
||||
getServerOptions,
|
||||
setTlsConfig,
|
||||
getRequestId,
|
||||
} from '@kbn/server-http-tools';
|
||||
|
||||
import type { Duration } from 'moment';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { firstValueFrom, Observable, Subscription } from 'rxjs';
|
||||
import { take, pairwise } from 'rxjs/operators';
|
||||
import apm from 'elastic-apm-node';
|
||||
// @ts-expect-error no type definition
|
||||
import Brok from 'brok';
|
||||
|
@ -160,9 +161,15 @@ export type LifecycleRegistrar = Pick<
|
|||
| 'registerOnPreResponse'
|
||||
>;
|
||||
|
||||
export interface HttpServerSetupOptions {
|
||||
config$: Observable<HttpConfig>;
|
||||
executionContext?: InternalExecutionContextSetup;
|
||||
}
|
||||
|
||||
export class HttpServer {
|
||||
private server?: Server;
|
||||
private config?: HttpConfig;
|
||||
private subscriptions: Subscription[] = [];
|
||||
private registeredRouters = new Set<IRouter>();
|
||||
private authRegistered = false;
|
||||
private cookieSessionStorageCreated = false;
|
||||
|
@ -209,13 +216,16 @@ export class HttpServer {
|
|||
}
|
||||
}
|
||||
|
||||
public async setup(
|
||||
config: HttpConfig,
|
||||
executionContext?: InternalExecutionContextSetup
|
||||
): Promise<HttpServerSetup> {
|
||||
public async setup({
|
||||
config$,
|
||||
executionContext,
|
||||
}: HttpServerSetupOptions): Promise<HttpServerSetup> {
|
||||
const config = await firstValueFrom(config$);
|
||||
this.config = config;
|
||||
|
||||
const serverOptions = getServerOptions(config);
|
||||
const listenerOptions = getListenerOptions(config);
|
||||
this.config = config;
|
||||
|
||||
this.server = createServer(serverOptions, listenerOptions);
|
||||
await this.server.register([HapiStaticFiles]);
|
||||
if (config.compression.brotli.enabled) {
|
||||
|
@ -227,6 +237,29 @@ export class HttpServer {
|
|||
});
|
||||
}
|
||||
|
||||
// only hot-reloading TLS config - don't need to subscribe if TLS is initially disabled,
|
||||
// given we can't hot-switch from/to enabled/disabled.
|
||||
if (config.ssl.enabled) {
|
||||
const configSubscription = config$
|
||||
.pipe(pairwise())
|
||||
.subscribe(([{ ssl: prevSslConfig }, { ssl: newSslConfig }]) => {
|
||||
if (prevSslConfig.enabled !== newSslConfig.enabled) {
|
||||
this.log.warn(
|
||||
'Incompatible TLS config change detected - TLS cannot be toggled without a full server reboot.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sameConfig = newSslConfig.isEqualTo(prevSslConfig);
|
||||
|
||||
if (!sameConfig) {
|
||||
this.log.info('TLS configuration change detected - reloading TLS configuration.');
|
||||
setTlsConfig(this.server!, newSslConfig);
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(configSubscription);
|
||||
}
|
||||
|
||||
// It's important to have setupRequestStateAssignment call the very first, otherwise context passing will be broken.
|
||||
// That's the only reason why context initialization exists in this method.
|
||||
this.setupRequestStateAssignment(config, executionContext);
|
||||
|
|
|
@ -71,7 +71,7 @@ export class HttpService
|
|||
this.env = env;
|
||||
this.log = logger.get('http');
|
||||
this.config$ = combineLatest([
|
||||
configService.atPath<HttpConfigType>(httpConfig.path),
|
||||
configService.atPath<HttpConfigType>(httpConfig.path, { ignoreUnchanged: false }),
|
||||
configService.atPath<CspConfigType>(cspConfig.path),
|
||||
configService.atPath<ExternalUrlConfigType>(externalUrlConfig.path),
|
||||
]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl)));
|
||||
|
@ -85,7 +85,9 @@ export class HttpService
|
|||
this.log.debug('setting up preboot server');
|
||||
const config = await firstValueFrom(this.config$);
|
||||
|
||||
const prebootSetup = await this.prebootServer.setup(config);
|
||||
const prebootSetup = await this.prebootServer.setup({
|
||||
config$: this.config$,
|
||||
});
|
||||
prebootSetup.server.route({
|
||||
path: '/{p*}',
|
||||
method: '*',
|
||||
|
@ -157,10 +159,10 @@ export class HttpService
|
|||
|
||||
const config = await firstValueFrom(this.config$);
|
||||
|
||||
const { registerRouter, ...serverContract } = await this.httpServer.setup(
|
||||
config,
|
||||
deps.executionContext
|
||||
);
|
||||
const { registerRouter, ...serverContract } = await this.httpServer.setup({
|
||||
config$: this.config$,
|
||||
executionContext: deps.executionContext,
|
||||
});
|
||||
|
||||
registerCoreHandlers(serverContract, config, this.env, this.log);
|
||||
|
||||
|
|
|
@ -131,6 +131,23 @@ test("does not push new configs when reloading if config at path hasn't changed"
|
|||
expect(valuesReceived).toEqual(['value']);
|
||||
});
|
||||
|
||||
test("does push new configs when reloading when config at path hasn't changed if ignoreUnchanged is false", async () => {
|
||||
const rawConfig$ = new BehaviorSubject<Record<string, any>>({ key: 'value' });
|
||||
const rawConfigProvider = createRawConfigServiceMock({ rawConfig$ });
|
||||
|
||||
const configService = new ConfigService(rawConfigProvider, defaultEnv, logger);
|
||||
await configService.setSchema('key', schema.string());
|
||||
|
||||
const valuesReceived: any[] = [];
|
||||
configService.atPath('key', { ignoreUnchanged: false }).subscribe((value) => {
|
||||
valuesReceived.push(value);
|
||||
});
|
||||
|
||||
rawConfig$.next({ key: 'value' });
|
||||
|
||||
expect(valuesReceived).toEqual(['value', 'value']);
|
||||
});
|
||||
|
||||
test('pushes new config when reloading and config at path has changed', async () => {
|
||||
const rawConfig$ = new BehaviorSubject<Record<string, any>>({ key: 'value' });
|
||||
const rawConfigProvider = createRawConfigServiceMock({ rawConfig$ });
|
||||
|
|
|
@ -10,7 +10,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
import { SchemaTypeError, Type, ValidationError } from '@kbn/config-schema';
|
||||
import { cloneDeep, isEqual, merge } from 'lodash';
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
import { BehaviorSubject, combineLatest, firstValueFrom, Observable } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, firstValueFrom, Observable, identity } from 'rxjs';
|
||||
import { distinctUntilChanged, first, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { Logger, LoggerFactory } from '@kbn/logging';
|
||||
import { getDocLinks, DocLinks } from '@kbn/doc-links';
|
||||
|
@ -159,9 +159,13 @@ export class ConfigService {
|
|||
* against its registered schema.
|
||||
*
|
||||
* @param path - The path to the desired subset of the config.
|
||||
* @param ignoreUnchanged - If true (default), will not emit if the config at path did not change.
|
||||
*/
|
||||
public atPath<TSchema>(path: ConfigPath) {
|
||||
return this.getValidatedConfigAtPath$(path) as Observable<TSchema>;
|
||||
public atPath<TSchema>(
|
||||
path: ConfigPath,
|
||||
{ ignoreUnchanged = true }: { ignoreUnchanged?: boolean } = {}
|
||||
) {
|
||||
return this.getValidatedConfigAtPath$(path, { ignoreUnchanged }) as Observable<TSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -310,10 +314,13 @@ export class ConfigService {
|
|||
);
|
||||
}
|
||||
|
||||
private getValidatedConfigAtPath$(path: ConfigPath) {
|
||||
private getValidatedConfigAtPath$(
|
||||
path: ConfigPath,
|
||||
{ ignoreUnchanged = true }: { ignoreUnchanged?: boolean } = {}
|
||||
) {
|
||||
return this.config$.pipe(
|
||||
map((config) => config.get(path)),
|
||||
distinctUntilChanged(isEqual),
|
||||
ignoreUnchanged ? distinctUntilChanged(isEqual) : identity,
|
||||
map((config) => this.validateAtPath(path, config))
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ export type { IHttpConfig, ISslConfig, ICorsConfig } from './src/types';
|
|||
export { createServer } from './src/create_server';
|
||||
export { defaultValidationErrorHandler } from './src/default_validation_error_handler';
|
||||
export { getListenerOptions } from './src/get_listener_options';
|
||||
export { getServerOptions } from './src/get_server_options';
|
||||
export { getServerOptions, getServerTLSOptions } from './src/get_server_options';
|
||||
export { getRequestId } from './src/get_request_id';
|
||||
export { setTlsConfig } from './src/set_tls_config';
|
||||
export { sslSchema, SslConfig } from './src/ssl';
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import { RouteOptionsCors, ServerOptions } from '@hapi/hapi';
|
||||
import { ServerOptions as TLSOptions } from 'https';
|
||||
import { defaultValidationErrorHandler } from './default_validation_error_handler';
|
||||
import { IHttpConfig } from './types';
|
||||
import { IHttpConfig, ISslConfig } from './types';
|
||||
|
||||
const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'];
|
||||
|
||||
|
@ -50,26 +50,31 @@ export function getServerOptions(config: IHttpConfig, { configureTLS = true } =
|
|||
},
|
||||
};
|
||||
|
||||
if (configureTLS && config.ssl.enabled) {
|
||||
const ssl = config.ssl;
|
||||
|
||||
// TODO: Hapi types have a typo in `tls` property type definition: `https.RequestOptions` is used instead of
|
||||
// `https.ServerOptions`, and `honorCipherOrder` isn't presented in `https.RequestOptions`.
|
||||
const tlsOptions: TLSOptions = {
|
||||
ca: ssl.certificateAuthorities,
|
||||
cert: ssl.certificate,
|
||||
ciphers: config.ssl.cipherSuites?.join(':'),
|
||||
// We use the server's cipher order rather than the client's to prevent the BEAST attack.
|
||||
honorCipherOrder: true,
|
||||
key: ssl.key,
|
||||
passphrase: ssl.keyPassphrase,
|
||||
secureOptions: ssl.getSecureOptions ? ssl.getSecureOptions() : undefined,
|
||||
requestCert: ssl.requestCert,
|
||||
rejectUnauthorized: ssl.rejectUnauthorized,
|
||||
};
|
||||
|
||||
options.tls = tlsOptions;
|
||||
if (configureTLS) {
|
||||
options.tls = getServerTLSOptions(config.ssl);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Kibana `SslConfig` into `TLSOptions` that are accepted by the Hapi server,
|
||||
* and by https.Server.setSecureContext()
|
||||
*/
|
||||
export function getServerTLSOptions(ssl: ISslConfig): TLSOptions | undefined {
|
||||
if (!ssl.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ca: ssl.certificateAuthorities,
|
||||
cert: ssl.certificate,
|
||||
ciphers: ssl.cipherSuites?.join(':'),
|
||||
// We use the server's cipher order rather than the client's to prevent the BEAST attack.
|
||||
honorCipherOrder: true,
|
||||
key: ssl.key,
|
||||
passphrase: ssl.keyPassphrase,
|
||||
secureOptions: ssl.getSecureOptions ? ssl.getSecureOptions() : undefined,
|
||||
requestCert: ssl.requestCert,
|
||||
rejectUnauthorized: ssl.rejectUnauthorized,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 const getServerTLSOptionsMock = jest.fn();
|
||||
|
||||
jest.doMock('./get_server_options', () => {
|
||||
const actual = jest.requireActual('./get_server_options');
|
||||
return {
|
||||
...actual,
|
||||
getServerTLSOptions: getServerTLSOptionsMock,
|
||||
};
|
||||
});
|
68
packages/kbn-server-http-tools/src/set_tls_config.test.ts
Normal file
68
packages/kbn-server-http-tools/src/set_tls_config.test.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 { getServerTLSOptionsMock } from './set_tls_config.test.mocks';
|
||||
import { Server } from '@hapi/hapi';
|
||||
import type { ISslConfig } from './types';
|
||||
import { setTlsConfig } from './set_tls_config';
|
||||
|
||||
describe('setTlsConfig', () => {
|
||||
beforeEach(() => {
|
||||
getServerTLSOptionsMock.mockReset();
|
||||
getServerTLSOptionsMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('throws when called for a non-TLS server', () => {
|
||||
const server = new Server({});
|
||||
const config: ISslConfig = { enabled: true };
|
||||
expect(() => setTlsConfig(server, config)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"tried to set TLS config on a non-TLS http server"`
|
||||
);
|
||||
});
|
||||
|
||||
it('calls `getServerTLSOptions` with the correct parameters', () => {
|
||||
const server = new Server({});
|
||||
// easiest way to shim a tls.Server
|
||||
(server.listener as any).setSecureContext = jest.fn();
|
||||
const config: ISslConfig = { enabled: true };
|
||||
|
||||
setTlsConfig(server, config);
|
||||
|
||||
expect(getServerTLSOptionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(getServerTLSOptionsMock).toHaveBeenCalledWith(config);
|
||||
});
|
||||
|
||||
it('throws when called for a disabled SSL config', () => {
|
||||
const server = new Server({});
|
||||
// easiest way to shim a tls.Server
|
||||
(server.listener as any).setSecureContext = jest.fn();
|
||||
const config: ISslConfig = { enabled: false };
|
||||
|
||||
getServerTLSOptionsMock.mockReturnValue(undefined);
|
||||
|
||||
expect(() => setTlsConfig(server, config)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"tried to apply a disabled SSL config"`
|
||||
);
|
||||
});
|
||||
|
||||
it('calls `setSecureContext` on the underlying server', () => {
|
||||
const server = new Server({});
|
||||
// easiest way to shim a tls.Server
|
||||
const setSecureContextMock = jest.fn();
|
||||
(server.listener as any).setSecureContext = setSecureContextMock;
|
||||
const config: ISslConfig = { enabled: true };
|
||||
|
||||
const tlsConfig = { someTlsConfig: true };
|
||||
getServerTLSOptionsMock.mockReturnValue(tlsConfig);
|
||||
|
||||
setTlsConfig(server, config);
|
||||
|
||||
expect(setSecureContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(setSecureContextMock).toHaveBeenCalledWith(tlsConfig);
|
||||
});
|
||||
});
|
29
packages/kbn-server-http-tools/src/set_tls_config.ts
Normal file
29
packages/kbn-server-http-tools/src/set_tls_config.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { Server as HapiServer } from '@hapi/hapi';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as TlsServer } from 'https';
|
||||
import type { ISslConfig } from './types';
|
||||
import { getServerTLSOptions } from './get_server_options';
|
||||
|
||||
function isServerTLS(server: HttpServer): server is TlsServer {
|
||||
return 'setSecureContext' in server;
|
||||
}
|
||||
|
||||
export const setTlsConfig = (hapiServer: HapiServer, sslConfig: ISslConfig) => {
|
||||
const server = hapiServer.listener;
|
||||
if (!isServerTLS(server)) {
|
||||
throw new Error('tried to set TLS config on a non-TLS http server');
|
||||
}
|
||||
const tlsOptions = getServerTLSOptions(sslConfig);
|
||||
if (!tlsOptions) {
|
||||
throw new Error('tried to apply a disabled SSL config');
|
||||
}
|
||||
server.setSecureContext(tlsOptions);
|
||||
};
|
|
@ -164,6 +164,79 @@ describe('#SslConfig', () => {
|
|||
expect(configValue.certificate).toEqual('content-of-another-path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEqualTo()', () => {
|
||||
const createEnabledConfig = (obj: any) =>
|
||||
createConfig({
|
||||
enabled: true,
|
||||
key: 'same-key',
|
||||
certificate: 'same-cert',
|
||||
...obj,
|
||||
});
|
||||
|
||||
it('compares `enabled`', () => {
|
||||
const reference = createConfig({ enabled: true, key: 'same-key', certificate: 'same-cert' });
|
||||
const same = createConfig({ enabled: true, key: 'same-key', certificate: 'same-cert' });
|
||||
const different = createConfig({ enabled: false, key: 'same-key', certificate: 'same-cert' });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `key`', () => {
|
||||
const reference = createEnabledConfig({ key: 'keyA', certificate: 'same-cert' });
|
||||
const same = createEnabledConfig({ key: 'keyA', certificate: 'same-cert' });
|
||||
const different = createEnabledConfig({ key: 'keyB', certificate: 'same-cert' });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `keyPassphrase`', () => {
|
||||
const reference = createEnabledConfig({ keyPassphrase: 'passA' });
|
||||
const same = createEnabledConfig({ keyPassphrase: 'passA' });
|
||||
const different = createEnabledConfig({ keyPassphrase: 'passB' });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `certificate`', () => {
|
||||
const reference = createEnabledConfig({ key: 'same-key', certificate: 'cert-a' });
|
||||
const same = createEnabledConfig({ key: 'same-key', certificate: 'cert-a' });
|
||||
const different = createEnabledConfig({ key: 'same-key', certificate: 'cert-b' });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `cipherSuites`', () => {
|
||||
const reference = createEnabledConfig({ cipherSuites: ['A', 'B'] });
|
||||
const same = createEnabledConfig({ cipherSuites: ['A', 'B'] });
|
||||
const different = createEnabledConfig({ cipherSuites: ['A', 'C'] });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `supportedProtocols`', () => {
|
||||
const reference = createEnabledConfig({ supportedProtocols: ['TLSv1.1', 'TLSv1.2'] });
|
||||
const same = createEnabledConfig({ supportedProtocols: ['TLSv1.1', 'TLSv1.2'] });
|
||||
const different = createEnabledConfig({ supportedProtocols: ['TLSv1.1', 'TLSv1.3'] });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares `clientAuthentication`', () => {
|
||||
const reference = createEnabledConfig({ clientAuthentication: 'none' });
|
||||
const same = createEnabledConfig({ clientAuthentication: 'none' });
|
||||
const different = createEnabledConfig({ clientAuthentication: 'required' });
|
||||
|
||||
expect(reference.isEqualTo(same)).toBe(true);
|
||||
expect(reference.isEqualTo(different)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sslSchema', () => {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { readPkcs12Keystore, readPkcs12Truststore } from '@kbn/crypto';
|
||||
import { constants as cryptoConstants } from 'crypto';
|
||||
|
@ -161,6 +162,13 @@ export class SslConfig {
|
|||
: secureOptions | secureOption; // eslint-disable-line no-bitwise
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public isEqualTo(otherConfig: SslConfig) {
|
||||
if (this === otherConfig) {
|
||||
return true;
|
||||
}
|
||||
return isEqual(this, otherConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const readFile = (file: string) => readFileSync(file, 'utf8');
|
||||
|
|
|
@ -52,7 +52,7 @@ describe('Http server', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
shutdownTimeout = config.shutdownTimeout.asMilliseconds();
|
||||
const { registerRouter, server: innerServer } = await server.setup(config);
|
||||
const { registerRouter, server: innerServer } = await server.setup({ config$: of(config) });
|
||||
innerServerListener = innerServer.listener;
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, {
|
||||
|
|
107
src/core/server/integration_tests/http/set_tls_config.test.ts
Normal file
107
src/core/server/integration_tests/http/set_tls_config.test.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 supertest from 'supertest';
|
||||
import { KBN_CERT_PATH, KBN_KEY_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils';
|
||||
import {
|
||||
createServer,
|
||||
getListenerOptions,
|
||||
getServerOptions,
|
||||
setTlsConfig,
|
||||
} from '@kbn/server-http-tools';
|
||||
import {
|
||||
HttpConfig,
|
||||
config as httpConfig,
|
||||
cspConfig,
|
||||
externalUrlConfig,
|
||||
} from '@kbn/core-http-server-internal';
|
||||
import { flattenCertificateChain, fetchPeerCertificate, isServerTLS } from './tls_utils';
|
||||
|
||||
describe('setTlsConfig', () => {
|
||||
const CSP_CONFIG = cspConfig.schema.validate({});
|
||||
const EXTERNAL_URL_CONFIG = externalUrlConfig.schema.validate({});
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
});
|
||||
|
||||
it('replaces the TLS configuration on the HAPI server', async () => {
|
||||
const rawHttpConfig = httpConfig.schema.validate({
|
||||
name: 'kibana',
|
||||
host: '127.0.0.1',
|
||||
port: 10002,
|
||||
ssl: {
|
||||
enabled: true,
|
||||
certificate: KBN_CERT_PATH,
|
||||
key: KBN_KEY_PATH,
|
||||
cipherSuites: ['TLS_AES_256_GCM_SHA384'],
|
||||
redirectHttpFromPort: 10003,
|
||||
},
|
||||
shutdownTimeout: '1s',
|
||||
});
|
||||
const firstConfig = new HttpConfig(rawHttpConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG);
|
||||
|
||||
const serverOptions = getServerOptions(firstConfig);
|
||||
const listenerOptions = getListenerOptions(firstConfig);
|
||||
const server = createServer(serverOptions, listenerOptions);
|
||||
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
handler: (request, toolkit) => {
|
||||
return toolkit.response('ok');
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
|
||||
const listener = server.listener;
|
||||
|
||||
// force TS to understand what we're working with.
|
||||
if (!isServerTLS(listener)) {
|
||||
throw new Error('Server should be a TLS server');
|
||||
}
|
||||
|
||||
const certificate = await fetchPeerCertificate(firstConfig.host, firstConfig.port);
|
||||
const certificateChain = flattenCertificateChain(certificate);
|
||||
|
||||
expect(isServerTLS(listener)).toEqual(true);
|
||||
expect(certificateChain.length).toEqual(1);
|
||||
expect(certificateChain[0].subject.CN).toEqual('kibana');
|
||||
|
||||
await supertest(listener).get('/').expect(200);
|
||||
|
||||
const secondRawConfig = httpConfig.schema.validate({
|
||||
name: 'kibana',
|
||||
host: '127.0.0.1',
|
||||
port: 10002,
|
||||
ssl: {
|
||||
enabled: true,
|
||||
certificate: ES_CERT_PATH,
|
||||
key: ES_KEY_PATH,
|
||||
cipherSuites: ['TLS_AES_256_GCM_SHA384'],
|
||||
redirectHttpFromPort: 10003,
|
||||
},
|
||||
shutdownTimeout: '1s',
|
||||
});
|
||||
|
||||
const secondConfig = new HttpConfig(secondRawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG);
|
||||
|
||||
setTlsConfig(server, secondConfig.ssl);
|
||||
|
||||
const secondCertificate = await fetchPeerCertificate(firstConfig.host, firstConfig.port);
|
||||
const secondCertificateChain = flattenCertificateChain(secondCertificate);
|
||||
|
||||
expect(secondCertificateChain.length).toEqual(1);
|
||||
expect(secondCertificateChain[0].subject.CN).toEqual('elasticsearch');
|
||||
|
||||
await supertest(listener).get('/').expect(200);
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
});
|
122
src/core/server/integration_tests/http/tls_config_reload.test.ts
Normal file
122
src/core/server/integration_tests/http/tls_config_reload.test.ts
Normal file
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* 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 supertest from 'supertest';
|
||||
import { duration } from 'moment';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { KBN_CERT_PATH, KBN_KEY_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { Router } from '@kbn/core-http-router-server-internal';
|
||||
import {
|
||||
HttpServer,
|
||||
HttpConfig,
|
||||
config as httpConfig,
|
||||
cspConfig,
|
||||
externalUrlConfig,
|
||||
} from '@kbn/core-http-server-internal';
|
||||
import { isServerTLS, flattenCertificateChain, fetchPeerCertificate } from './tls_utils';
|
||||
|
||||
const CSP_CONFIG = cspConfig.schema.validate({});
|
||||
const EXTERNAL_URL_CONFIG = externalUrlConfig.schema.validate({});
|
||||
const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {});
|
||||
|
||||
describe('HttpServer - TLS config', () => {
|
||||
let server: HttpServer;
|
||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const loggingService = loggingSystemMock.create();
|
||||
logger = loggingSystemMock.createLogger();
|
||||
server = new HttpServer(loggingService, 'tests', of(duration('1s')));
|
||||
});
|
||||
|
||||
it('supports dynamic reloading of the TLS configuration', async () => {
|
||||
const rawHttpConfig = httpConfig.schema.validate({
|
||||
name: 'kibana',
|
||||
host: '127.0.0.1',
|
||||
port: 10002,
|
||||
ssl: {
|
||||
enabled: true,
|
||||
certificate: KBN_CERT_PATH,
|
||||
key: KBN_KEY_PATH,
|
||||
cipherSuites: ['TLS_AES_256_GCM_SHA384'],
|
||||
redirectHttpFromPort: 10003,
|
||||
},
|
||||
shutdownTimeout: '1s',
|
||||
});
|
||||
const firstConfig = new HttpConfig(rawHttpConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG);
|
||||
|
||||
const config$ = new BehaviorSubject(firstConfig);
|
||||
|
||||
const { server: innerServer, registerRouter } = await server.setup({ config$ });
|
||||
const listener = innerServer.listener;
|
||||
|
||||
const router = new Router('', logger, enhanceWithContext, {
|
||||
isDev: false,
|
||||
versionedRouteResolution: 'oldest',
|
||||
});
|
||||
router.get(
|
||||
{
|
||||
path: '/',
|
||||
validate: false,
|
||||
},
|
||||
async (ctx, req, res) => {
|
||||
return res.ok({
|
||||
body: 'ok',
|
||||
});
|
||||
}
|
||||
);
|
||||
registerRouter(router);
|
||||
|
||||
await server.start();
|
||||
|
||||
// force TS to understand what we're working with.
|
||||
if (!isServerTLS(listener)) {
|
||||
throw new Error('Server should be a TLS server');
|
||||
}
|
||||
|
||||
const certificate = await fetchPeerCertificate(firstConfig.host, firstConfig.port);
|
||||
const certificateChain = flattenCertificateChain(certificate);
|
||||
|
||||
expect(certificateChain.length).toEqual(1);
|
||||
expect(certificateChain[0].subject.CN).toEqual('kibana');
|
||||
|
||||
await supertest(listener).get('/').expect(200);
|
||||
|
||||
const secondRawConfig = httpConfig.schema.validate({
|
||||
name: 'kibana',
|
||||
host: '127.0.0.1',
|
||||
port: 10002,
|
||||
ssl: {
|
||||
enabled: true,
|
||||
certificate: ES_CERT_PATH,
|
||||
key: ES_KEY_PATH,
|
||||
cipherSuites: ['TLS_AES_256_GCM_SHA384'],
|
||||
redirectHttpFromPort: 10003,
|
||||
},
|
||||
shutdownTimeout: '1s',
|
||||
});
|
||||
|
||||
const secondConfig = new HttpConfig(secondRawConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG);
|
||||
config$.next(secondConfig);
|
||||
|
||||
const secondCertificate = await fetchPeerCertificate(firstConfig.host, firstConfig.port);
|
||||
const secondCertificateChain = flattenCertificateChain(secondCertificate);
|
||||
|
||||
expect(secondCertificateChain.length).toEqual(1);
|
||||
expect(secondCertificateChain[0].subject.CN).toEqual('elasticsearch');
|
||||
|
||||
await supertest(listener).get('/').expect(200);
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
});
|
38
src/core/server/integration_tests/http/tls_utils.ts
Normal file
38
src/core/server/integration_tests/http/tls_utils.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { Server as NodeHttpServer } from 'http';
|
||||
import { Server as NodeTlsServer } from 'https';
|
||||
import tls from 'tls';
|
||||
|
||||
export function isServerTLS(server: NodeHttpServer): server is NodeTlsServer {
|
||||
return 'setSecureContext' in server;
|
||||
}
|
||||
|
||||
export const fetchPeerCertificate = (host: string, port: number) => {
|
||||
return new Promise<tls.DetailedPeerCertificate>((resolve, reject) => {
|
||||
const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: false });
|
||||
socket.once('secureConnect', () => {
|
||||
const cert = socket.getPeerCertificate(true);
|
||||
socket.destroy();
|
||||
resolve(cert);
|
||||
});
|
||||
socket.once('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const flattenCertificateChain = (
|
||||
cert: tls.DetailedPeerCertificate,
|
||||
accumulator: tls.DetailedPeerCertificate[] = []
|
||||
) => {
|
||||
accumulator.push(cert);
|
||||
if (cert.issuerCertificate && cert.fingerprint256 !== cert.issuerCertificate.fingerprint256) {
|
||||
flattenCertificateChain(cert.issuerCertificate, accumulator);
|
||||
}
|
||||
return accumulator;
|
||||
};
|
|
@ -155,6 +155,8 @@
|
|||
"@kbn/core-test-helpers-model-versions",
|
||||
"@kbn/core-plugins-contracts-browser",
|
||||
"@kbn/core-plugins-contracts-server",
|
||||
"@kbn/dev-utils",
|
||||
"@kbn/server-http-tools",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue