diff --git a/.eslintrc.js b/.eslintrc.js index ab868c29b7bed8..a7b45534391c0a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -93,6 +93,7 @@ const SAFER_LODASH_SET_DEFINITELYTYPED_HEADER = ` const DEV_PACKAGES = [ 'kbn-babel-code-parser', 'kbn-dev-utils', + 'kbn-cli-dev-mode', 'kbn-docs-utils', 'kbn-es*', 'kbn-eslint*', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 33b3e4a7dede62..3d44f46aca4ff7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -158,6 +158,7 @@ /packages/kbn-ui-shared-deps/ @elastic/kibana-operations /packages/kbn-es-archiver/ @elastic/kibana-operations /packages/kbn-utils/ @elastic/kibana-operations +/packages/kbn-cli-dev-mode/ @elastic/kibana-operations /src/cli/keystore/ @elastic/kibana-operations /src/legacy/server/warnings/ @elastic/kibana-operations /.ci/es-snapshots/ @elastic/kibana-operations @@ -194,6 +195,8 @@ /packages/kbn-config/ @elastic/kibana-core /packages/kbn-logging/ @elastic/kibana-core /packages/kbn-legacy-logging/ @elastic/kibana-core +/packages/kbn-crypto/ @elastic/kibana-core +/packages/kbn-http-tools/ @elastic/kibana-core /src/legacy/server/config/ @elastic/kibana-core /src/legacy/server/http/ @elastic/kibana-core /src/legacy/server/logging/ @elastic/kibana-core diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaresponsefactory.md b/docs/development/core/server/kibana-plugin-core-server.kibanaresponsefactory.md index 551cbe3c937504..395c26a6e4bf65 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanaresponsefactory.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaresponsefactory.md @@ -10,10 +10,10 @@ Set of helpers used to create `KibanaResponse` to form HTTP response on an incom ```typescript kibanaResponseFactory: { - custom: | Buffer | Error | Stream | { + custom: | Error | Buffer | { message: string | Error; attributes?: Record | undefined; - } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; + } | Stream | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; badRequest: (options?: ErrorHttpResponseOptions) => KibanaResponse; unauthorized: (options?: ErrorHttpResponseOptions) => KibanaResponse; forbidden: (options?: ErrorHttpResponseOptions) => KibanaResponse; diff --git a/package.json b/package.json index 99591fdc1ea405..2654c433ac5fa8 100644 --- a/package.json +++ b/package.json @@ -127,11 +127,13 @@ "@kbn/apm-utils": "link:packages/kbn-apm-utils", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", + "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", + "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/std": "link:packages/kbn-std", "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", @@ -451,6 +453,7 @@ "@jest/reporters": "^26.5.2", "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", "@kbn/babel-preset": "link:packages/kbn-babel-preset", + "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:packages/kbn-dev-utils", "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/es": "link:packages/kbn-es", diff --git a/src/dev/cli_dev_mode/README.md b/packages/kbn-cli-dev-mode/README.md similarity index 72% rename from src/dev/cli_dev_mode/README.md rename to packages/kbn-cli-dev-mode/README.md index 397017027a52f1..6ce41249674ce2 100644 --- a/src/dev/cli_dev_mode/README.md +++ b/packages/kbn-cli-dev-mode/README.md @@ -26,8 +26,12 @@ The `DevServer` object is responsible for everything related to running and rest The `Optimizer` object manages a `@kbn/optimizer` instance, adapting its configuration and logging to the data available to the CLI. -## `BasePathProxyServer` (currently passed from core) +## `BasePathProxyServer` -The `BasePathProxyServer` is passed to the `CliDevMode` from core when the dev mode is trigged by the `--dev` flag. This proxy injects a random three character base path in the URL that Kibana is served from to help ensure that Kibana features are written to adapt to custom base path configurations from users. +This proxy injects a random three character base path in the URL that Kibana is served from to help ensure that Kibana features +are written to adapt to custom base path configurations from users. -The basePathProxy also has another important job, ensuring that requests don't fail because the server is restarting and that the browser receives front-end assets containing all saved changes. We accomplish this by observing the ready state of the `Optimizer` and `DevServer` objects and pausing all requests through the proxy until both objects report that they aren't building/restarting based on recently saved changes. \ No newline at end of file +The basePathProxy also has another important job, ensuring that requests don't fail because the server is restarting and +that the browser receives front-end assets containing all saved changes. We accomplish this by observing the ready state of +the `Optimizer` and `DevServer` objects and pausing all requests through the proxy until both objects report that +they aren't building/restarting based on recently saved changes. \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/jest.config.js b/packages/kbn-cli-dev-mode/jest.config.js new file mode 100644 index 00000000000000..d04dc571ef2a0b --- /dev/null +++ b/packages/kbn-cli-dev-mode/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-cli-dev-mode'], +}; diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json new file mode 100644 index 00000000000000..2ee9831e960842 --- /dev/null +++ b/packages/kbn-cli-dev-mode/package.json @@ -0,0 +1,26 @@ +{ + "name": "@kbn/cli-dev-mode", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "kibana": { + "devOnly": true + }, + "dependencies": { + "@kbn/config": "link:../kbn-config", + "@kbn/config-schema": "link:../kbn-config-schema", + "@kbn/logging": "link:../kbn-logging", + "@kbn/server-http-tools": "link:../kbn-server-http-tools", + "@kbn/optimizer": "link:../kbn-optimizer", + "@kbn/std": "link:../kbn-std", + "@kbn/dev-utils": "link:../kbn-dev-utils", + "@kbn/utils": "link:../kbn-utils" + } +} \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts new file mode 100644 index 00000000000000..c99485c2733645 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.test.ts @@ -0,0 +1,358 @@ +/* + * 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 } from '@hapi/hapi'; +import { EMPTY } from 'rxjs'; +import supertest from 'supertest'; +import { + getServerOptions, + getListenerOptions, + createServer, + IHttpConfig, +} from '@kbn/server-http-tools'; +import { ByteSizeValue } from '@kbn/config-schema'; + +import { BasePathProxyServer, BasePathProxyServerOptions } from './base_path_proxy_server'; +import { DevConfig } from './config/dev_config'; +import { TestLog } from './log'; + +describe('BasePathProxyServer', () => { + let server: Server; + let proxyServer: BasePathProxyServer; + let logger: TestLog; + let config: IHttpConfig; + let basePath: string; + let proxySupertest: supertest.SuperTest; + + beforeEach(async () => { + logger = new TestLog(); + + config = { + host: '127.0.0.1', + port: 10012, + keepaliveTimeout: 1000, + socketTimeout: 1000, + cors: { + enabled: false, + allowCredentials: false, + allowOrigin: [], + }, + ssl: { enabled: false }, + maxPayload: new ByteSizeValue(1024), + }; + + const serverOptions = getServerOptions(config); + const listenerOptions = getListenerOptions(config); + server = createServer(serverOptions, listenerOptions); + + // setup and start the proxy server + const proxyConfig: IHttpConfig = { ...config, port: 10013 }; + const devConfig = new DevConfig({ basePathProxyTarget: config.port }); + proxyServer = new BasePathProxyServer(logger, proxyConfig, devConfig); + const options: BasePathProxyServerOptions = { + shouldRedirectFromOldBasePath: () => true, + delayUntil: () => EMPTY, + }; + await proxyServer.start(options); + + // set the base path or throw if for some unknown reason it is not setup + if (proxyServer.basePath == null) { + throw new Error('Invalid null base path, all tests will fail'); + } else { + basePath = proxyServer.basePath; + } + proxySupertest = supertest(`http://127.0.0.1:${proxyConfig.port}`); + }); + + afterEach(async () => { + await server.stop(); + await proxyServer.stop(); + jest.clearAllMocks(); + }); + + test('root URL will return a 302 redirect', async () => { + await proxySupertest.get('/').expect(302); + }); + + test('root URL will return a redirect location with exactly 3 characters that are a-z', async () => { + const res = await proxySupertest.get('/'); + const location = res.header.location; + expect(location).toMatch(/[a-z]{3}/); + }); + + test('forwards request with the correct path', async () => { + server.route({ + method: 'GET', + path: `${basePath}/foo/{test}`, + handler: (request, h) => { + return h.response(request.params.test); + }, + }); + await server.start(); + + await proxySupertest + .get(`${basePath}/foo/some-string`) + .expect(200) + .then((res) => { + expect(res.text).toBe('some-string'); + }); + }); + + test('forwards request with the correct query params', async () => { + server.route({ + method: 'GET', + path: `${basePath}/foo/`, + handler: (request, h) => { + return h.response(request.query); + }, + }); + + await server.start(); + + await proxySupertest + .get(`${basePath}/foo/?bar=test&quux=123`) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ bar: 'test', quux: '123' }); + }); + }); + + test('forwards the request body', async () => { + server.route({ + method: 'POST', + path: `${basePath}/foo/`, + handler: (request, h) => { + return h.response(request.payload); + }, + }); + + await server.start(); + + await proxySupertest + .post(`${basePath}/foo/`) + .send({ + bar: 'test', + baz: 123, + }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ bar: 'test', baz: 123 }); + }); + }); + + test('returns the correct status code', async () => { + server.route({ + method: 'GET', + path: `${basePath}/foo/`, + handler: (request, h) => { + return h.response({ foo: 'bar' }).code(417); + }, + }); + + await server.start(); + + await proxySupertest + .get(`${basePath}/foo/`) + .expect(417) + .then((res) => { + expect(res.body).toEqual({ foo: 'bar' }); + }); + }); + + test('returns the response headers', async () => { + server.route({ + method: 'GET', + path: `${basePath}/foo/`, + handler: (request, h) => { + return h.response({ foo: 'bar' }).header('foo', 'bar'); + }, + }); + + await server.start(); + + await proxySupertest + .get(`${basePath}/foo/`) + .expect(200) + .then((res) => { + expect(res.get('foo')).toEqual('bar'); + }); + }); + + test('handles putting', async () => { + server.route({ + method: 'PUT', + path: `${basePath}/foo/`, + handler: (request, h) => { + return h.response(request.payload); + }, + }); + + await server.start(); + + await proxySupertest + .put(`${basePath}/foo/`) + .send({ + bar: 'test', + baz: 123, + }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ bar: 'test', baz: 123 }); + }); + }); + + test('handles deleting', async () => { + server.route({ + method: 'DELETE', + path: `${basePath}/foo/{test}`, + handler: (request, h) => { + return h.response(request.params.test); + }, + }); + await server.start(); + + await proxySupertest + .delete(`${basePath}/foo/some-string`) + .expect(200) + .then((res) => { + expect(res.text).toBe('some-string'); + }); + }); + + describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { + beforeEach(async () => { + const configWithBasePath: IHttpConfig = { + ...config, + basePath: '/bar', + rewriteBasePath: false, + } as IHttpConfig; + + const serverOptions = getServerOptions(configWithBasePath); + const listenerOptions = getListenerOptions(configWithBasePath); + server = createServer(serverOptions, listenerOptions); + + server.route({ + method: 'GET', + path: `${basePath}/`, + handler: (request, h) => { + return h.response('value:/'); + }, + }); + server.route({ + method: 'GET', + path: `${basePath}/foo`, + handler: (request, h) => { + return h.response('value:/foo'); + }, + }); + + await server.start(); + }); + + test('/bar => 404', async () => { + await proxySupertest.get(`${basePath}/bar`).expect(404); + }); + + test('/bar/ => 404', async () => { + await proxySupertest.get(`${basePath}/bar/`).expect(404); + }); + + test('/bar/foo => 404', async () => { + await proxySupertest.get(`${basePath}/bar/foo`).expect(404); + }); + + test('/ => /', async () => { + await proxySupertest + .get(`${basePath}/`) + .expect(200) + .then((res) => { + expect(res.text).toBe('value:/'); + }); + }); + + test('/foo => /foo', async () => { + await proxySupertest + .get(`${basePath}/foo`) + .expect(200) + .then((res) => { + expect(res.text).toBe('value:/foo'); + }); + }); + }); + + describe('shouldRedirect', () => { + let proxyServerWithoutShouldRedirect: BasePathProxyServer; + let proxyWithoutShouldRedirectSupertest: supertest.SuperTest; + + beforeEach(async () => { + // setup and start a proxy server which does not use "shouldRedirectFromOldBasePath" + const proxyConfig: IHttpConfig = { ...config, port: 10004 }; + const devConfig = new DevConfig({ basePathProxyTarget: config.port }); + proxyServerWithoutShouldRedirect = new BasePathProxyServer(logger, proxyConfig, devConfig); + const options: Readonly = { + shouldRedirectFromOldBasePath: () => false, // Return false to not redirect + delayUntil: () => EMPTY, + }; + await proxyServerWithoutShouldRedirect.start(options); + proxyWithoutShouldRedirectSupertest = supertest(`http://127.0.0.1:${proxyConfig.port}`); + }); + + afterEach(async () => { + await proxyServerWithoutShouldRedirect.stop(); + }); + + test('it will do a redirect if it detects what looks like a stale or previously used base path', async () => { + const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; + const res = await proxySupertest.get(`/${fakeBasePath}`).expect(302); + const location = res.header.location; + expect(location).toEqual(`${basePath}/`); + }); + + test('it will NOT do a redirect if it detects what looks like a stale or previously used base path if we intentionally turn it off', async () => { + const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; + await proxyWithoutShouldRedirectSupertest.get(`/${fakeBasePath}`).expect(404); + }); + + test('it will NOT redirect if it detects a larger path than 3 characters', async () => { + await proxySupertest.get('/abcde').expect(404); + }); + + test('it will NOT redirect if it is not a GET verb', async () => { + const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; + await proxySupertest.put(`/${fakeBasePath}`).expect(404); + }); + }); + + describe('constructor option for sending in a custom basePath', () => { + let proxyServerWithFooBasePath: BasePathProxyServer; + let proxyWithFooBasePath: supertest.SuperTest; + + beforeEach(async () => { + // setup and start a proxy server which uses a basePath of "foo" + const proxyConfig = { ...config, port: 10004, basePath: '/foo' }; // <-- "foo" here in basePath + const devConfig = new DevConfig({ basePathProxyTarget: config.port }); + proxyServerWithFooBasePath = new BasePathProxyServer(logger, proxyConfig, devConfig); + const options: Readonly = { + shouldRedirectFromOldBasePath: () => true, + delayUntil: () => EMPTY, + }; + await proxyServerWithFooBasePath.start(options); + proxyWithFooBasePath = supertest(`http://127.0.0.1:${proxyConfig.port}`); + }); + + afterEach(async () => { + await proxyServerWithFooBasePath.stop(); + }); + + test('it will do a redirect to foo which is our passed in value for the configuration', async () => { + const res = await proxyWithFooBasePath.get('/bar').expect(302); + const location = res.header.location; + expect(location).toEqual('/foo/'); + }); + }); +}); diff --git a/src/core/server/http/base_path_proxy_server.ts b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts similarity index 90% rename from src/core/server/http/base_path_proxy_server.ts rename to packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts index a5ed0271893937..40841c8327cc29 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts @@ -8,21 +8,21 @@ import Url from 'url'; import { Agent as HttpsAgent, ServerOptions as TlsOptions } from 'https'; - import apm from 'elastic-apm-node'; -import { ByteSizeValue } from '@kbn/config-schema'; import { Server, Request } from '@hapi/hapi'; import HapiProxy from '@hapi/h2o2'; import { sampleSize } from 'lodash'; import * as Rx from 'rxjs'; import { take } from 'rxjs/operators'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { createServer, getListenerOptions, getServerOptions } from '@kbn/server-http-tools'; -import { DevConfig } from '../dev'; -import { Logger } from '../logging'; -import { HttpConfig } from './http_config'; -import { createServer, getListenerOptions, getServerOptions } from './http_tools'; +import { DevConfig, HttpConfig } from './config'; +import { Log } from './log'; +const ONE_GIGABYTE = 1024 * 1024 * 1024; const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); +const getRandomBasePath = () => sampleSize(alphabet, 3).join(''); export interface BasePathProxyServerOptions { shouldRedirectFromOldBasePath: (path: string) => boolean; @@ -30,9 +30,22 @@ export interface BasePathProxyServerOptions { } export class BasePathProxyServer { + private readonly httpConfig: HttpConfig; private server?: Server; private httpsAgent?: HttpsAgent; + constructor( + private readonly log: Log, + httpConfig: HttpConfig, + private readonly devConfig: DevConfig + ) { + this.httpConfig = { + ...httpConfig, + maxPayload: new ByteSizeValue(ONE_GIGABYTE), + basePath: httpConfig.basePath ?? `/${getRandomBasePath()}`, + }; + } + public get basePath() { return this.httpConfig.basePath; } @@ -49,21 +62,8 @@ export class BasePathProxyServer { return this.httpConfig.port; } - constructor( - private readonly log: Logger, - private readonly httpConfig: HttpConfig, - private readonly devConfig: DevConfig - ) { - const ONE_GIGABYTE = 1024 * 1024 * 1024; - httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); - - if (!httpConfig.basePath) { - httpConfig.basePath = `/${sampleSize(alphabet, 3).join('')}`; - } - } - - public async start(options: Readonly) { - this.log.debug('starting basepath proxy server'); + public async start(options: BasePathProxyServerOptions) { + this.log.write('starting basepath proxy server'); const serverOptions = getServerOptions(this.httpConfig); const listenerOptions = getListenerOptions(this.httpConfig); @@ -88,7 +88,7 @@ export class BasePathProxyServer { await this.server.start(); - this.log.info( + this.log.write( `basepath proxy server running at ${Url.format({ host: this.server.info.uri, pathname: this.httpConfig.basePath, @@ -101,7 +101,7 @@ export class BasePathProxyServer { return; } - this.log.debug('stopping basepath proxy server'); + this.log.write('stopping basepath proxy server'); await this.server.stop(); this.server = undefined; diff --git a/packages/kbn-cli-dev-mode/src/bootstrap.ts b/packages/kbn-cli-dev-mode/src/bootstrap.ts new file mode 100644 index 00000000000000..86a276c64f1f55 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/bootstrap.ts @@ -0,0 +1,43 @@ +/* + * 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 { REPO_ROOT } from '@kbn/utils'; +import { CliArgs, Env, RawConfigAdapter } from '@kbn/config'; +import { CliDevMode } from './cli_dev_mode'; +import { CliLog } from './log'; +import { convertToLogger } from './log_adapter'; +import { loadConfig } from './config'; + +interface BootstrapArgs { + configs: string[]; + cliArgs: CliArgs; + applyConfigOverrides: RawConfigAdapter; +} + +export async function bootstrapDevMode({ configs, cliArgs, applyConfigOverrides }: BootstrapArgs) { + const log = new CliLog(!!cliArgs.quiet, !!cliArgs.silent); + + const env = Env.createDefault(REPO_ROOT, { + configs, + cliArgs, + }); + + const config = await loadConfig({ + env, + logger: convertToLogger(log), + rawConfigAdapter: applyConfigOverrides, + }); + + const cliDevMode = new CliDevMode({ + cliArgs, + config, + log, + }); + + await cliDevMode.start(); +} diff --git a/src/dev/cli_dev_mode/cli_dev_mode.test.ts b/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts similarity index 79% rename from src/dev/cli_dev_mode/cli_dev_mode.test.ts rename to packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts index 9ace543a8929bf..d5bafe7280bd92 100644 --- a/src/dev/cli_dev_mode/cli_dev_mode.test.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.test.ts @@ -7,16 +7,16 @@ */ import Path from 'path'; - +import * as Rx from 'rxjs'; import { REPO_ROOT, createAbsolutePathSerializer, createAnyInstanceSerializer, } from '@kbn/dev-utils'; -import * as Rx from 'rxjs'; import { TestLog } from './log'; -import { CliDevMode } from './cli_dev_mode'; +import { CliDevMode, SomeCliArgs } from './cli_dev_mode'; +import type { CliDevConfig } from './config'; expect.addSnapshotSerializer(createAbsolutePathSerializer()); expect.addSnapshotSerializer(createAnyInstanceSerializer(Rx.Observable, 'Rx.Observable')); @@ -31,6 +31,9 @@ const { Optimizer } = jest.requireMock('./optimizer'); jest.mock('./dev_server'); const { DevServer } = jest.requireMock('./dev_server'); +jest.mock('./base_path_proxy_server'); +const { BasePathProxyServer } = jest.requireMock('./base_path_proxy_server'); + jest.mock('@kbn/dev-utils/target/ci_stats_reporter'); const { CiStatsReporter } = jest.requireMock('@kbn/dev-utils/target/ci_stats_reporter'); @@ -41,13 +44,6 @@ jest.mock('./get_server_watch_paths', () => ({ })), })); -beforeEach(() => { - process.argv = ['node', './script', 'foo', 'bar', 'baz']; - jest.clearAllMocks(); -}); - -const log = new TestLog(); - const mockBasePathProxy = { targetPort: 9999, basePath: '/foo/bar', @@ -55,26 +51,53 @@ const mockBasePathProxy = { stop: jest.fn(), }; -const defaultOptions = { +let log: TestLog; + +beforeEach(() => { + process.argv = ['node', './script', 'foo', 'bar', 'baz']; + log = new TestLog(); + BasePathProxyServer.mockImplementation(() => mockBasePathProxy); +}); + +afterEach(() => { + jest.clearAllMocks(); + mockBasePathProxy.start.mockReset(); + mockBasePathProxy.stop.mockReset(); +}); + +const createCliArgs = (parts: Partial = {}): SomeCliArgs => ({ + basePath: false, cache: true, disableOptimizer: false, dist: true, oss: true, - pluginPaths: [], - pluginScanDirs: [Path.resolve(REPO_ROOT, 'src/plugins')], - quiet: false, - silent: false, runExamples: false, watch: true, - log, -}; + silent: false, + quiet: false, + ...parts, +}); -afterEach(() => { - log.messages.length = 0; +const createDevConfig = (parts: Partial = {}): CliDevConfig => ({ + plugins: { + pluginSearchPaths: [Path.resolve(REPO_ROOT, 'src/plugins')], + additionalPluginPaths: [], + }, + dev: { + basePathProxyTargetPort: 9000, + }, + http: {} as any, + ...parts, +}); + +const createOptions = ({ cliArgs = {} }: { cliArgs?: Partial } = {}) => ({ + cliArgs: createCliArgs(cliArgs), + config: createDevConfig(), + log, }); it('passes correct args to sub-classes', () => { - new CliDevMode(defaultOptions); + new CliDevMode(createOptions()); expect(DevServer.mock.calls).toMatchInlineSnapshot(` Array [ @@ -105,6 +128,9 @@ it('passes correct args to sub-classes', () => { "enabled": true, "oss": true, "pluginPaths": Array [], + "pluginScanDirs": Array [ + /src/plugins, + ], "quiet": false, "repoRoot": , "runExamples": false, @@ -131,33 +157,38 @@ it('passes correct args to sub-classes', () => { ], ] `); + + expect(BasePathProxyServer).not.toHaveBeenCalled(); + expect(log.messages).toMatchInlineSnapshot(`Array []`); }); it('disables the optimizer', () => { - new CliDevMode({ - ...defaultOptions, - disableOptimizer: true, - }); + new CliDevMode(createOptions({ cliArgs: { disableOptimizer: true } })); expect(Optimizer.mock.calls[0][0]).toHaveProperty('enabled', false); }); it('disables the watcher', () => { - new CliDevMode({ - ...defaultOptions, - watch: false, - }); + new CliDevMode(createOptions({ cliArgs: { watch: false } })); expect(Optimizer.mock.calls[0][0]).toHaveProperty('watch', false); expect(Watcher.mock.calls[0][0]).toHaveProperty('enabled', false); }); -it('overrides the basePath of the server when basePathProxy is defined', () => { - new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }); +it('enables the basePath proxy', () => { + new CliDevMode(createOptions({ cliArgs: { basePath: true } })); + + expect(BasePathProxyServer).toHaveBeenCalledTimes(1); + expect(BasePathProxyServer.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + , + Object {}, + Object { + "basePathProxyTargetPort": 9000, + }, + ] + `); expect(DevServer.mock.calls[0][0].argv).toMatchInlineSnapshot(` Array [ @@ -229,9 +260,7 @@ describe('#start()/#stop()', () => { }); it('logs a warning if basePathProxy is not passed', () => { - new CliDevMode({ - ...defaultOptions, - }).start(); + new CliDevMode(createOptions()).start(); expect(log.messages).toMatchInlineSnapshot(` Array [ @@ -261,16 +290,9 @@ describe('#start()/#stop()', () => { }); it('calls start on BasePathProxy if enabled', () => { - const basePathProxy: any = { - start: jest.fn(), - }; + new CliDevMode(createOptions({ cliArgs: { basePath: true } })).start(); - new CliDevMode({ - ...defaultOptions, - basePathProxy, - }).start(); - - expect(basePathProxy.start.mock.calls).toMatchInlineSnapshot(` + expect(mockBasePathProxy.start.mock.calls).toMatchInlineSnapshot(` Array [ Array [ Object { @@ -283,7 +305,7 @@ describe('#start()/#stop()', () => { }); it('subscribes to Optimizer#run$, Watcher#run$, and DevServer#run$', () => { - new CliDevMode(defaultOptions).start(); + new CliDevMode(createOptions()).start(); expect(optimizerRun$.observers).toHaveLength(1); expect(watcherRun$.observers).toHaveLength(1); @@ -291,10 +313,7 @@ describe('#start()/#stop()', () => { }); it('logs an error and exits the process if Optimizer#run$ errors', () => { - new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }).start(); + new CliDevMode(createOptions({ cliArgs: { basePath: true } })).start(); expect(processExitMock).not.toHaveBeenCalled(); optimizerRun$.error({ stack: 'Error: foo bar' }); @@ -319,10 +338,7 @@ describe('#start()/#stop()', () => { }); it('logs an error and exits the process if Watcher#run$ errors', () => { - new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }).start(); + new CliDevMode(createOptions({ cliArgs: { basePath: true } })).start(); expect(processExitMock).not.toHaveBeenCalled(); watcherRun$.error({ stack: 'Error: foo bar' }); @@ -347,10 +363,7 @@ describe('#start()/#stop()', () => { }); it('logs an error and exits the process if DevServer#run$ errors', () => { - new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }).start(); + new CliDevMode(createOptions({ cliArgs: { basePath: true } })).start(); expect(processExitMock).not.toHaveBeenCalled(); devServerRun$.error({ stack: 'Error: foo bar' }); @@ -376,10 +389,7 @@ describe('#start()/#stop()', () => { it('throws if start() has already been called', () => { expect(() => { - const devMode = new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }); + const devMode = new CliDevMode(createOptions({ cliArgs: { basePath: true } })); devMode.start(); devMode.start(); @@ -387,10 +397,7 @@ describe('#start()/#stop()', () => { }); it('unsubscribes from all observables and stops basePathProxy when stopped', () => { - const devMode = new CliDevMode({ - ...defaultOptions, - basePathProxy: mockBasePathProxy as any, - }); + const devMode = new CliDevMode(createOptions({ cliArgs: { basePath: true } })); devMode.start(); devMode.stop(); diff --git a/src/dev/cli_dev_mode/cli_dev_mode.ts b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts similarity index 83% rename from src/dev/cli_dev_mode/cli_dev_mode.ts rename to packages/kbn-cli-dev-mode/src/cli_dev_mode.ts index f4f95f20daeef3..94dbcb9654e8ae 100644 --- a/src/dev/cli_dev_mode/cli_dev_mode.ts +++ b/packages/kbn-cli-dev-mode/src/cli_dev_mode.ts @@ -7,8 +7,6 @@ */ import Path from 'path'; - -import { REPO_ROOT, CiStatsReporter } from '@kbn/dev-utils'; import * as Rx from 'rxjs'; import { map, @@ -20,24 +18,32 @@ import { switchMap, concatMap, } from 'rxjs/operators'; - -import { CliArgs } from '../../core/server/config'; -import { LegacyConfig } from '../../core/server/legacy'; -import { BasePathProxyServer } from '../../core/server/http'; +import { CliArgs } from '@kbn/config'; +import { REPO_ROOT, CiStatsReporter } from '@kbn/dev-utils'; import { Log, CliLog } from './log'; import { Optimizer } from './optimizer'; import { DevServer } from './dev_server'; import { Watcher } from './watcher'; +import { BasePathProxyServer } from './base_path_proxy_server'; import { shouldRedirectFromOldBasePath } from './should_redirect_from_old_base_path'; import { getServerWatchPaths } from './get_server_watch_paths'; +import { CliDevConfig } from './config'; // timeout where the server is allowed to exit gracefully const GRACEFUL_TIMEOUT = 5000; export type SomeCliArgs = Pick< CliArgs, - 'quiet' | 'silent' | 'disableOptimizer' | 'watch' | 'oss' | 'runExamples' | 'cache' | 'dist' + | 'quiet' + | 'silent' + | 'disableOptimizer' + | 'watch' + | 'oss' + | 'runExamples' + | 'cache' + | 'dist' + | 'basePath' >; export interface CliDevModeOptions { @@ -76,49 +82,28 @@ const firstAllTrue = (...sources: Array>) => * */ export class CliDevMode { - static fromCoreServices( - cliArgs: SomeCliArgs, - config: LegacyConfig, - basePathProxy?: BasePathProxyServer - ) { - new CliDevMode({ - quiet: !!cliArgs.quiet, - silent: !!cliArgs.silent, - cache: !!cliArgs.cache, - disableOptimizer: !!cliArgs.disableOptimizer, - dist: !!cliArgs.dist, - oss: !!cliArgs.oss, - runExamples: !!cliArgs.runExamples, - pluginPaths: config.get('plugins.paths'), - pluginScanDirs: config.get('plugins.scanDirs'), - watch: !!cliArgs.watch, - basePathProxy, - }).start(); - } private readonly log: Log; private readonly basePathProxy?: BasePathProxyServer; private readonly watcher: Watcher; private readonly devServer: DevServer; private readonly optimizer: Optimizer; private startTime?: number; - private subscription?: Rx.Subscription; - constructor(options: CliDevModeOptions) { - this.basePathProxy = options.basePathProxy; - this.log = options.log || new CliLog(!!options.quiet, !!options.silent); + constructor({ cliArgs, config, log }: { cliArgs: SomeCliArgs; config: CliDevConfig; log?: Log }) { + this.log = log || new CliLog(!!cliArgs.quiet, !!cliArgs.silent); + + if (cliArgs.basePath) { + this.basePathProxy = new BasePathProxyServer(this.log, config.http, config.dev); + } const { watchPaths, ignorePaths } = getServerWatchPaths({ - pluginPaths: options.pluginPaths ?? [], - pluginScanDirs: [ - ...(options.pluginScanDirs ?? []), - Path.resolve(REPO_ROOT, 'src/plugins'), - Path.resolve(REPO_ROOT, 'x-pack/plugins'), - ], + pluginPaths: config.plugins.additionalPluginPaths, + pluginScanDirs: config.plugins.pluginSearchPaths, }); this.watcher = new Watcher({ - enabled: !!options.watch, + enabled: !!cliArgs.watch, log: this.log, cwd: REPO_ROOT, paths: watchPaths, @@ -133,10 +118,10 @@ export class CliDevMode { script: Path.resolve(REPO_ROOT, 'scripts/kibana'), argv: [ ...process.argv.slice(2).filter((v) => v !== '--no-watch'), - ...(options.basePathProxy + ...(this.basePathProxy ? [ - `--server.port=${options.basePathProxy.targetPort}`, - `--server.basePath=${options.basePathProxy.basePath}`, + `--server.port=${this.basePathProxy.targetPort}`, + `--server.basePath=${this.basePathProxy.basePath}`, '--server.rewriteBasePath=true', ] : []), @@ -153,16 +138,17 @@ export class CliDevMode { }); this.optimizer = new Optimizer({ - enabled: !options.disableOptimizer, + enabled: !cliArgs.disableOptimizer, repoRoot: REPO_ROOT, - oss: options.oss, - pluginPaths: options.pluginPaths, - runExamples: options.runExamples, - cache: options.cache, - dist: options.dist, - quiet: options.quiet, - silent: options.silent, - watch: options.watch, + oss: cliArgs.oss, + pluginPaths: config.plugins.additionalPluginPaths, + pluginScanDirs: config.plugins.pluginSearchPaths, + runExamples: cliArgs.runExamples, + cache: cliArgs.cache, + dist: cliArgs.dist, + quiet: !!cliArgs.quiet, + silent: !!cliArgs.silent, + watch: cliArgs.watch, }); } diff --git a/packages/kbn-cli-dev-mode/src/config/dev_config.ts b/packages/kbn-cli-dev-mode/src/config/dev_config.ts new file mode 100644 index 00000000000000..ddb54bb8f3f7c9 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/dev_config.ts @@ -0,0 +1,28 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const devConfigSchema = schema.object( + { + basePathProxyTarget: schema.number({ + defaultValue: 5603, + }), + }, + { unknowns: 'ignore' } +); + +export type DevConfigType = TypeOf; + +export class DevConfig { + public basePathProxyTargetPort: number; + + constructor(rawConfig: DevConfigType) { + this.basePathProxyTargetPort = rawConfig.basePathProxyTarget; + } +} diff --git a/packages/kbn-cli-dev-mode/src/config/http_config.ts b/packages/kbn-cli-dev-mode/src/config/http_config.ts new file mode 100644 index 00000000000000..34f208c28df680 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/http_config.ts @@ -0,0 +1,65 @@ +/* + * 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 { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; +import { ICorsConfig, IHttpConfig, ISslConfig, SslConfig, sslSchema } from '@kbn/server-http-tools'; + +export const httpConfigSchema = schema.object( + { + host: schema.string({ + defaultValue: 'localhost', + hostname: true, + }), + basePath: schema.maybe(schema.string()), + port: schema.number({ + defaultValue: 5601, + }), + maxPayload: schema.byteSize({ + defaultValue: '1048576b', + }), + keepaliveTimeout: schema.number({ + defaultValue: 120000, + }), + socketTimeout: schema.number({ + defaultValue: 120000, + }), + cors: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + allowCredentials: schema.boolean({ defaultValue: false }), + allowOrigin: schema.arrayOf(schema.string(), { + defaultValue: ['*'], + }), + }), + ssl: sslSchema, + }, + { unknowns: 'ignore' } +); + +export type HttpConfigType = TypeOf; + +export class HttpConfig implements IHttpConfig { + basePath?: string; + host: string; + port: number; + maxPayload: ByteSizeValue; + keepaliveTimeout: number; + socketTimeout: number; + cors: ICorsConfig; + ssl: ISslConfig; + + constructor(rawConfig: HttpConfigType) { + this.basePath = rawConfig.basePath; + this.host = rawConfig.host; + this.port = rawConfig.port; + this.maxPayload = rawConfig.maxPayload; + this.keepaliveTimeout = rawConfig.keepaliveTimeout; + this.socketTimeout = rawConfig.socketTimeout; + this.cors = rawConfig.cors; + this.ssl = new SslConfig(rawConfig.ssl); + } +} diff --git a/packages/kbn-cli-dev-mode/src/config/index.ts b/packages/kbn-cli-dev-mode/src/config/index.ts new file mode 100644 index 00000000000000..89f6d647ef4f51 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { DevConfig } from './dev_config'; +export type { PluginsConfig } from './plugins_config'; +export type { HttpConfig } from './http_config'; +export type { CliDevConfig } from './types'; +export { loadConfig } from './load_config'; diff --git a/packages/kbn-cli-dev-mode/src/config/load_config.ts b/packages/kbn-cli-dev-mode/src/config/load_config.ts new file mode 100644 index 00000000000000..46129834ca2d9e --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/load_config.ts @@ -0,0 +1,44 @@ +/* + * 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 { Env, RawConfigService, ConfigService, RawConfigAdapter } from '@kbn/config'; +import { Logger } from '@kbn/logging'; +import { devConfigSchema, DevConfig, DevConfigType } from './dev_config'; +import { httpConfigSchema, HttpConfig, HttpConfigType } from './http_config'; +import { pluginsConfigSchema, PluginsConfig, PluginsConfigType } from './plugins_config'; +import { CliDevConfig } from './types'; + +export const loadConfig = async ({ + env, + logger, + rawConfigAdapter, +}: { + env: Env; + logger: Logger; + rawConfigAdapter: RawConfigAdapter; +}): Promise => { + const rawConfigService = new RawConfigService(env.configs, rawConfigAdapter); + rawConfigService.loadConfig(); + + const configService = new ConfigService(rawConfigService, env, logger); + configService.setSchema('dev', devConfigSchema); + configService.setSchema('plugins', pluginsConfigSchema); + configService.setSchema('http', httpConfigSchema); + + await configService.validate(); + + const devConfig = configService.atPathSync('dev'); + const pluginsConfig = configService.atPathSync('plugins'); + const httpConfig = configService.atPathSync('http'); + + return { + dev: new DevConfig(devConfig), + plugins: new PluginsConfig(pluginsConfig, env), + http: new HttpConfig(httpConfig), + }; +}; diff --git a/packages/kbn-cli-dev-mode/src/config/plugins_config.ts b/packages/kbn-cli-dev-mode/src/config/plugins_config.ts new file mode 100644 index 00000000000000..7c7fa8902edb3b --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/plugins_config.ts @@ -0,0 +1,37 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { Env } from '@kbn/config'; + +export const pluginsConfigSchema = schema.object( + { + paths: schema.arrayOf(schema.string(), { defaultValue: [] }), + }, + { unknowns: 'ignore' } +); + +export type PluginsConfigType = TypeOf; + +/** @internal */ +export class PluginsConfig { + /** + * Defines directories that we should scan for the plugin subdirectories. + */ + public readonly pluginSearchPaths: string[]; + + /** + * Defines directories where an additional plugin exists. + */ + public readonly additionalPluginPaths: string[]; + + constructor(rawConfig: PluginsConfigType, env: Env) { + this.pluginSearchPaths = [...env.pluginSearchPaths]; + this.additionalPluginPaths = rawConfig.paths; + } +} diff --git a/packages/kbn-cli-dev-mode/src/config/types.ts b/packages/kbn-cli-dev-mode/src/config/types.ts new file mode 100644 index 00000000000000..017442e09bd0df --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/config/types.ts @@ -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. + */ + +import type { DevConfig } from './dev_config'; +import type { HttpConfig } from './http_config'; +import type { PluginsConfig } from './plugins_config'; + +export interface CliDevConfig { + dev: DevConfig; + http: HttpConfig; + plugins: PluginsConfig; +} diff --git a/src/dev/cli_dev_mode/dev_server.test.ts b/packages/kbn-cli-dev-mode/src/dev_server.test.ts similarity index 100% rename from src/dev/cli_dev_mode/dev_server.test.ts rename to packages/kbn-cli-dev-mode/src/dev_server.test.ts diff --git a/src/dev/cli_dev_mode/dev_server.ts b/packages/kbn-cli-dev-mode/src/dev_server.ts similarity index 100% rename from src/dev/cli_dev_mode/dev_server.ts rename to packages/kbn-cli-dev-mode/src/dev_server.ts diff --git a/src/dev/cli_dev_mode/get_active_inspect_flag.ts b/packages/kbn-cli-dev-mode/src/get_active_inspect_flag.ts similarity index 100% rename from src/dev/cli_dev_mode/get_active_inspect_flag.ts rename to packages/kbn-cli-dev-mode/src/get_active_inspect_flag.ts diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts similarity index 100% rename from src/dev/cli_dev_mode/get_server_watch_paths.test.ts rename to packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts similarity index 87% rename from src/dev/cli_dev_mode/get_server_watch_paths.ts rename to packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index 46aa15659a5139..53aa53b5aa63a5 100644 --- a/src/dev/cli_dev_mode/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -47,15 +47,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { ...pluginScanDirs, ].map((path) => Path.resolve(path)) ) - ); - - for (const watchPath of watchPaths) { - if (!Fs.existsSync(fromRoot(watchPath))) { - throw new Error( - `A watch directory [${watchPath}] does not exist, which will cause chokidar to fail. Either make sure the directory exists or remove it as a watch source in the ClusterManger` - ); - } - } + ).filter((path) => Fs.existsSync(fromRoot(path))); const ignorePaths = [ /[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/, diff --git a/src/dev/cli_dev_mode/index.ts b/packages/kbn-cli-dev-mode/src/index.ts similarity index 86% rename from src/dev/cli_dev_mode/index.ts rename to packages/kbn-cli-dev-mode/src/index.ts index db46957504b112..98b52087f231a3 100644 --- a/src/dev/cli_dev_mode/index.ts +++ b/packages/kbn-cli-dev-mode/src/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export * from './cli_dev_mode'; -export * from './log'; +export { bootstrapDevMode } from './bootstrap'; diff --git a/src/dev/cli_dev_mode/log.ts b/packages/kbn-cli-dev-mode/src/log.ts similarity index 100% rename from src/dev/cli_dev_mode/log.ts rename to packages/kbn-cli-dev-mode/src/log.ts diff --git a/packages/kbn-cli-dev-mode/src/log_adapter.ts b/packages/kbn-cli-dev-mode/src/log_adapter.ts new file mode 100644 index 00000000000000..65161fcc56e0e6 --- /dev/null +++ b/packages/kbn-cli-dev-mode/src/log_adapter.ts @@ -0,0 +1,28 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { Log } from './log'; + +export const convertToLogger = (cliLog: Log): Logger => { + const getErrorMessage = (msgOrError: string | Error): string => { + return typeof msgOrError === 'string' ? msgOrError : msgOrError.message; + }; + + const adapter: Logger = { + trace: (message) => cliLog.write(message), + debug: (message) => cliLog.write(message), + info: (message) => cliLog.write(message), + warn: (msgOrError) => cliLog.warn('warning', getErrorMessage(msgOrError)), + error: (msgOrError) => cliLog.bad('error', getErrorMessage(msgOrError)), + fatal: (msgOrError) => cliLog.bad('fatal', getErrorMessage(msgOrError)), + log: (record) => cliLog.write(record.message), + get: () => adapter, + }; + return adapter; +}; diff --git a/src/dev/cli_dev_mode/optimizer.test.ts b/packages/kbn-cli-dev-mode/src/optimizer.test.ts similarity index 96% rename from src/dev/cli_dev_mode/optimizer.test.ts rename to packages/kbn-cli-dev-mode/src/optimizer.test.ts index 409ad1a455a57b..c270a00329897a 100644 --- a/src/dev/cli_dev_mode/optimizer.test.ts +++ b/packages/kbn-cli-dev-mode/src/optimizer.test.ts @@ -43,6 +43,7 @@ const defaultOptions: Options = { dist: true, oss: true, pluginPaths: ['/some/dir'], + pluginScanDirs: ['/some-scan-path'], quiet: true, silent: true, repoRoot: '/app', @@ -83,6 +84,7 @@ it('uses options to create valid OptimizerConfig', () => { runExamples: false, oss: false, pluginPaths: [], + pluginScanDirs: [], repoRoot: '/foo/bar', watch: false, }); @@ -99,6 +101,9 @@ it('uses options to create valid OptimizerConfig', () => { "pluginPaths": Array [ "/some/dir", ], + "pluginScanDirs": Array [ + "/some-scan-path", + ], "repoRoot": "/app", "watch": true, }, @@ -111,6 +116,7 @@ it('uses options to create valid OptimizerConfig', () => { "includeCoreBundle": true, "oss": false, "pluginPaths": Array [], + "pluginScanDirs": Array [], "repoRoot": "/foo/bar", "watch": false, }, diff --git a/src/dev/cli_dev_mode/optimizer.ts b/packages/kbn-cli-dev-mode/src/optimizer.ts similarity index 97% rename from src/dev/cli_dev_mode/optimizer.ts rename to packages/kbn-cli-dev-mode/src/optimizer.ts index 771da21e6151b8..5e2f16fcf7daa8 100644 --- a/src/dev/cli_dev_mode/optimizer.ts +++ b/packages/kbn-cli-dev-mode/src/optimizer.ts @@ -31,6 +31,7 @@ export interface Options { oss: boolean; runExamples: boolean; pluginPaths: string[]; + pluginScanDirs: string[]; writeLogTo?: Writable; } @@ -56,6 +57,7 @@ export class Optimizer { oss: options.oss, examples: options.runExamples, pluginPaths: options.pluginPaths, + pluginScanDirs: options.pluginScanDirs, }); const dim = Chalk.dim('np bld'); diff --git a/src/dev/cli_dev_mode/should_redirect_from_old_base_path.test.ts b/packages/kbn-cli-dev-mode/src/should_redirect_from_old_base_path.test.ts similarity index 100% rename from src/dev/cli_dev_mode/should_redirect_from_old_base_path.test.ts rename to packages/kbn-cli-dev-mode/src/should_redirect_from_old_base_path.test.ts diff --git a/src/dev/cli_dev_mode/should_redirect_from_old_base_path.ts b/packages/kbn-cli-dev-mode/src/should_redirect_from_old_base_path.ts similarity index 100% rename from src/dev/cli_dev_mode/should_redirect_from_old_base_path.ts rename to packages/kbn-cli-dev-mode/src/should_redirect_from_old_base_path.ts diff --git a/src/dev/cli_dev_mode/test_helpers.ts b/packages/kbn-cli-dev-mode/src/test_helpers.ts similarity index 100% rename from src/dev/cli_dev_mode/test_helpers.ts rename to packages/kbn-cli-dev-mode/src/test_helpers.ts diff --git a/src/dev/cli_dev_mode/using_server_process.ts b/packages/kbn-cli-dev-mode/src/using_server_process.ts similarity index 100% rename from src/dev/cli_dev_mode/using_server_process.ts rename to packages/kbn-cli-dev-mode/src/using_server_process.ts diff --git a/src/dev/cli_dev_mode/watcher.test.ts b/packages/kbn-cli-dev-mode/src/watcher.test.ts similarity index 100% rename from src/dev/cli_dev_mode/watcher.test.ts rename to packages/kbn-cli-dev-mode/src/watcher.test.ts diff --git a/src/dev/cli_dev_mode/watcher.ts b/packages/kbn-cli-dev-mode/src/watcher.ts similarity index 100% rename from src/dev/cli_dev_mode/watcher.ts rename to packages/kbn-cli-dev-mode/src/watcher.ts diff --git a/packages/kbn-cli-dev-mode/tsconfig.json b/packages/kbn-cli-dev-mode/tsconfig.json new file mode 100644 index 00000000000000..b2bdaf8ceea36e --- /dev/null +++ b/packages/kbn-cli-dev-mode/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./target", + "declarationMap": true, + "types": ["jest", "node"] + }, + "include": ["./src/**/*.ts"], + "exclude": ["target"] +} diff --git a/packages/kbn-config/src/__mocks__/env.ts b/packages/kbn-config/src/__mocks__/env.ts index e3b3106933f1e9..6f05f8f1f5a45a 100644 --- a/packages/kbn-config/src/__mocks__/env.ts +++ b/packages/kbn-config/src/__mocks__/env.ts @@ -30,6 +30,5 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions runExamples: false, ...(options.cliArgs || {}), }, - isDevCliParent: options.isDevCliParent !== undefined ? options.isDevCliParent : false, }; } diff --git a/packages/kbn-config/src/__snapshots__/env.test.ts.snap b/packages/kbn-config/src/__snapshots__/env.test.ts.snap index fae14529a4af3d..570ed948774cc1 100644 --- a/packages/kbn-config/src/__snapshots__/env.test.ts.snap +++ b/packages/kbn-config/src/__snapshots__/env.test.ts.snap @@ -21,7 +21,6 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -65,7 +64,6 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -108,7 +106,6 @@ Env { "/test/cwd/config/kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevCliParent": true, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -151,7 +148,6 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -194,7 +190,6 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -237,7 +232,6 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/some/home/dir", - "isDevCliParent": false, "logDir": "/some/home/dir/log", "mode": Object { "dev": false, diff --git a/packages/kbn-config/src/env.test.ts b/packages/kbn-config/src/env.test.ts index 09d44f31cf8d55..b9e97514c2dffb 100644 --- a/packages/kbn-config/src/env.test.ts +++ b/packages/kbn-config/src/env.test.ts @@ -36,7 +36,6 @@ test('correctly creates default environment in dev mode.', () => { REPO_ROOT, getEnvOptions({ configs: ['/test/cwd/config/kibana.yml'], - isDevCliParent: true, }) ); diff --git a/packages/kbn-config/src/env.ts b/packages/kbn-config/src/env.ts index b6ff5e3b5aab22..c4845ab429c573 100644 --- a/packages/kbn-config/src/env.ts +++ b/packages/kbn-config/src/env.ts @@ -15,7 +15,6 @@ import { PackageInfo, EnvironmentMode } from './types'; export interface EnvOptions { configs: string[]; cliArgs: CliArgs; - isDevCliParent: boolean; } /** @internal */ @@ -89,12 +88,6 @@ export class Env { */ public readonly configs: readonly string[]; - /** - * Indicates that this Kibana instance is running in the parent process of the dev cli. - * @internal - */ - public readonly isDevCliParent: boolean; - /** * @internal */ @@ -111,7 +104,6 @@ export class Env { this.cliArgs = Object.freeze(options.cliArgs); this.configs = Object.freeze(options.configs); - this.isDevCliParent = options.isDevCliParent; const isDevMode = this.cliArgs.dev || this.cliArgs.envName === 'development'; this.mode = Object.freeze({ diff --git a/packages/kbn-config/src/index.ts b/packages/kbn-config/src/index.ts index 24f271c979f321..8b0bdb0befbfdb 100644 --- a/packages/kbn-config/src/index.ts +++ b/packages/kbn-config/src/index.ts @@ -16,7 +16,12 @@ export { ConfigDeprecationWithContext, } from './deprecation'; -export { RawConfigurationProvider, RawConfigService, getConfigFromFiles } from './raw'; +export { + RawConfigurationProvider, + RawConfigService, + RawConfigAdapter, + getConfigFromFiles, +} from './raw'; export { ConfigService, IConfigService } from './config_service'; export { Config, ConfigPath, isConfigPath, hasConfigPathIntersection } from './config'; diff --git a/packages/kbn-config/src/raw/index.ts b/packages/kbn-config/src/raw/index.ts index 8f65e7877ba56f..01ad83728aa085 100644 --- a/packages/kbn-config/src/raw/index.ts +++ b/packages/kbn-config/src/raw/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export { RawConfigService, RawConfigurationProvider } from './raw_config_service'; +export { RawConfigService, RawConfigurationProvider, RawConfigAdapter } from './raw_config_service'; export { getConfigFromFiles } from './read_config'; diff --git a/packages/kbn-config/src/raw/raw_config_service.ts b/packages/kbn-config/src/raw/raw_config_service.ts index af901f2b3d28e3..cce1132bebdb0e 100644 --- a/packages/kbn-config/src/raw/raw_config_service.ts +++ b/packages/kbn-config/src/raw/raw_config_service.ts @@ -13,7 +13,7 @@ import typeDetect from 'type-detect'; import { getConfigFromFiles } from './read_config'; -type RawConfigAdapter = (rawConfig: Record) => Record; +export type RawConfigAdapter = (rawConfig: Record) => Record; export type RawConfigurationProvider = Pick; diff --git a/packages/kbn-crypto/README.md b/packages/kbn-crypto/README.md new file mode 100644 index 00000000000000..4404c22eba37c5 --- /dev/null +++ b/packages/kbn-crypto/README.md @@ -0,0 +1,3 @@ +# @kbn/crypto + +Crypto tools and utilities for Kibana \ No newline at end of file diff --git a/packages/kbn-crypto/jest.config.js b/packages/kbn-crypto/jest.config.js new file mode 100644 index 00000000000000..811b87e5ed0f6e --- /dev/null +++ b/packages/kbn-crypto/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-crypto'], +}; diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json new file mode 100644 index 00000000000000..6c7b3f3b0c719b --- /dev/null +++ b/packages/kbn-crypto/package.json @@ -0,0 +1,16 @@ +{ + "name": "@kbn/crypto", + "version": "1.0.0", + "private": true, + "license": "SSPL-1.0 OR Elastic License 2.0", + "main": "./target/index.js", + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": {}, + "devDependencies": { + "@kbn/dev-utils": "link:../kbn-dev-utils" + } +} \ No newline at end of file diff --git a/src/core/server/utils/crypto/__fixtures__/README.md b/packages/kbn-crypto/src/__fixtures__/README.md similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/README.md rename to packages/kbn-crypto/src/__fixtures__/README.md diff --git a/src/core/server/utils/crypto/__fixtures__/index.ts b/packages/kbn-crypto/src/__fixtures__/index.ts similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/index.ts rename to packages/kbn-crypto/src/__fixtures__/index.ts diff --git a/src/core/server/utils/crypto/__fixtures__/no_ca.p12 b/packages/kbn-crypto/src/__fixtures__/no_ca.p12 similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/no_ca.p12 rename to packages/kbn-crypto/src/__fixtures__/no_ca.p12 diff --git a/src/core/server/utils/crypto/__fixtures__/no_cert.p12 b/packages/kbn-crypto/src/__fixtures__/no_cert.p12 similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/no_cert.p12 rename to packages/kbn-crypto/src/__fixtures__/no_cert.p12 diff --git a/src/core/server/utils/crypto/__fixtures__/no_key.p12 b/packages/kbn-crypto/src/__fixtures__/no_key.p12 similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/no_key.p12 rename to packages/kbn-crypto/src/__fixtures__/no_key.p12 diff --git a/src/core/server/utils/crypto/__fixtures__/two_cas.p12 b/packages/kbn-crypto/src/__fixtures__/two_cas.p12 similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/two_cas.p12 rename to packages/kbn-crypto/src/__fixtures__/two_cas.p12 diff --git a/src/core/server/utils/crypto/__fixtures__/two_keys.p12 b/packages/kbn-crypto/src/__fixtures__/two_keys.p12 similarity index 100% rename from src/core/server/utils/crypto/__fixtures__/two_keys.p12 rename to packages/kbn-crypto/src/__fixtures__/two_keys.p12 diff --git a/src/core/server/utils/crypto/index.ts b/packages/kbn-crypto/src/index.ts similarity index 100% rename from src/core/server/utils/crypto/index.ts rename to packages/kbn-crypto/src/index.ts diff --git a/src/core/server/utils/crypto/pkcs12.test.ts b/packages/kbn-crypto/src/pkcs12.test.ts similarity index 99% rename from src/core/server/utils/crypto/pkcs12.test.ts rename to packages/kbn-crypto/src/pkcs12.test.ts index 8c6e5bae3b9c1e..ba8eb6554f7b8b 100644 --- a/src/core/server/utils/crypto/pkcs12.test.ts +++ b/packages/kbn-crypto/src/pkcs12.test.ts @@ -18,7 +18,7 @@ import { import { NO_CA_PATH, NO_CERT_PATH, NO_KEY_PATH, TWO_CAS_PATH, TWO_KEYS_PATH } from './__fixtures__'; import { readFileSync } from 'fs'; -import { readPkcs12Keystore, Pkcs12ReadResult, readPkcs12Truststore } from './index'; +import { readPkcs12Keystore, Pkcs12ReadResult, readPkcs12Truststore } from './pkcs12'; const reformatPem = (pem: string) => { // ensure consistency in line endings when comparing two PEM files diff --git a/src/core/server/utils/crypto/pkcs12.ts b/packages/kbn-crypto/src/pkcs12.ts similarity index 100% rename from src/core/server/utils/crypto/pkcs12.ts rename to packages/kbn-crypto/src/pkcs12.ts diff --git a/src/core/server/utils/crypto/sha256.test.ts b/packages/kbn-crypto/src/sha256.test.ts similarity index 100% rename from src/core/server/utils/crypto/sha256.test.ts rename to packages/kbn-crypto/src/sha256.test.ts diff --git a/src/core/server/utils/crypto/sha256.ts b/packages/kbn-crypto/src/sha256.ts similarity index 100% rename from src/core/server/utils/crypto/sha256.ts rename to packages/kbn-crypto/src/sha256.ts diff --git a/packages/kbn-crypto/tsconfig.json b/packages/kbn-crypto/tsconfig.json new file mode 100644 index 00000000000000..e9dd6313e6f79e --- /dev/null +++ b/packages/kbn-crypto/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target", + "declaration": true, + "declarationMap": true + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-server-http-tools/README.md b/packages/kbn-server-http-tools/README.md new file mode 100644 index 00000000000000..c53b7b85354383 --- /dev/null +++ b/packages/kbn-server-http-tools/README.md @@ -0,0 +1,3 @@ +# @kbn/http-tools + +Http utilities for core and the basepath server \ No newline at end of file diff --git a/packages/kbn-server-http-tools/jest.config.js b/packages/kbn-server-http-tools/jest.config.js new file mode 100644 index 00000000000000..e409c235546227 --- /dev/null +++ b/packages/kbn-server-http-tools/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-server-http-tools'], +}; diff --git a/packages/kbn-server-http-tools/package.json b/packages/kbn-server-http-tools/package.json new file mode 100644 index 00000000000000..a8f99689f33354 --- /dev/null +++ b/packages/kbn-server-http-tools/package.json @@ -0,0 +1,20 @@ +{ + "name": "@kbn/server-http-tools", + "main": "./target/index.js", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "rm -rf target && ../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": { + "@kbn/config-schema": "link:../kbn-config-schema", + "@kbn/crypto": "link:../kbn-crypto", + "@kbn/std": "link:../kbn-std" + }, + "devDependencies": { + "@kbn/utility-types": "link:../kbn-utility-types" + } +} \ No newline at end of file diff --git a/packages/kbn-server-http-tools/src/create_server.ts b/packages/kbn-server-http-tools/src/create_server.ts new file mode 100644 index 00000000000000..4752e342d5d3e3 --- /dev/null +++ b/packages/kbn-server-http-tools/src/create_server.ts @@ -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 { Server, ServerOptions } from '@hapi/hapi'; +import { ListenerOptions } from './get_listener_options'; + +export function createServer(serverOptions: ServerOptions, listenerOptions: ListenerOptions) { + const server = new Server(serverOptions); + + server.listener.keepAliveTimeout = listenerOptions.keepaliveTimeout; + server.listener.setTimeout(listenerOptions.socketTimeout); + server.listener.on('timeout', (socket) => { + socket.destroy(); + }); + server.listener.on('clientError', (err, socket) => { + if (socket.writable) { + socket.end(Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')); + } else { + socket.destroy(err); + } + }); + + return server; +} diff --git a/packages/kbn-server-http-tools/src/default_validation_error_handler.test.ts b/packages/kbn-server-http-tools/src/default_validation_error_handler.test.ts new file mode 100644 index 00000000000000..93b09ef13e0307 --- /dev/null +++ b/packages/kbn-server-http-tools/src/default_validation_error_handler.test.ts @@ -0,0 +1,51 @@ +/* + * 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 Joi from 'joi'; +import { Request, ResponseToolkit } from '@hapi/hapi'; +import { + defaultValidationErrorHandler, + HapiValidationError, +} from './default_validation_error_handler'; + +const emptyOutput = { + statusCode: 400, + headers: {}, + payload: { + statusCode: 400, + error: '', + validation: { + source: '', + keys: [], + }, + }, +}; + +describe('defaultValidationErrorHandler', () => { + it('formats value validation errors correctly', () => { + expect.assertions(1); + const schema = Joi.array().items( + Joi.object({ + type: Joi.string().required(), + }).required() + ); + + const error = schema.validate([{}], { abortEarly: false }).error as HapiValidationError; + + // Emulate what Hapi v17 does by default + error.output = { ...emptyOutput }; + error.output.payload.validation.keys = ['0.type', '']; + + try { + defaultValidationErrorHandler({} as Request, {} as ResponseToolkit, error); + } catch (err) { + // Verify the empty string gets corrected to 'value' + expect(err.output.payload.validation.keys).toEqual(['0.type', 'value']); + } + }); +}); diff --git a/packages/kbn-server-http-tools/src/default_validation_error_handler.ts b/packages/kbn-server-http-tools/src/default_validation_error_handler.ts new file mode 100644 index 00000000000000..d2f4e993f3e4b2 --- /dev/null +++ b/packages/kbn-server-http-tools/src/default_validation_error_handler.ts @@ -0,0 +1,63 @@ +/* + * 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 { Lifecycle, Request, ResponseToolkit, Util } from '@hapi/hapi'; +import { ValidationError } from 'joi'; +import Hoek from '@hapi/hoek'; + +/** + * Hapi extends the ValidationError interface to add this output key with more data. + */ +export interface HapiValidationError extends ValidationError { + output: { + statusCode: number; + headers: Util.Dictionary; + payload: { + statusCode: number; + error: string; + message?: string; + validation: { + source: string; + keys: string[]; + }; + }; + }; +} + +/** + * Used to replicate Hapi v16 and below's validation responses. Should be used in the routes.validate.failAction key. + */ +export function defaultValidationErrorHandler( + request: Request, + h: ResponseToolkit, + err?: Error +): Lifecycle.ReturnValue { + // Newer versions of Joi don't format the key for missing params the same way. This shim + // provides backwards compatibility. Unfortunately, Joi doesn't export it's own Error class + // in JS so we have to rely on the `name` key before we can cast it. + // + // The Hapi code we're 'overwriting' can be found here: + // https://github.com/hapijs/hapi/blob/master/lib/validation.js#L102 + if (err && err.name === 'ValidationError' && err.hasOwnProperty('output')) { + const validationError: HapiValidationError = err as HapiValidationError; + const validationKeys: string[] = []; + + validationError.details.forEach((detail) => { + if (detail.path.length > 0) { + validationKeys.push(Hoek.escapeHtml(detail.path.join('.'))); + } else { + // If no path, use the value sigil to signal the entire value had an issue. + validationKeys.push('value'); + } + }); + + validationError.output.payload.validation.keys = validationKeys; + } + + throw err; +} diff --git a/packages/kbn-server-http-tools/src/get_listener_options.ts b/packages/kbn-server-http-tools/src/get_listener_options.ts new file mode 100644 index 00000000000000..00884312b599fb --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_listener_options.ts @@ -0,0 +1,21 @@ +/* + * 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 { IHttpConfig } from './types'; + +export interface ListenerOptions { + keepaliveTimeout: number; + socketTimeout: number; +} + +export function getListenerOptions(config: IHttpConfig): ListenerOptions { + return { + keepaliveTimeout: config.keepaliveTimeout, + socketTimeout: config.socketTimeout, + }; +} diff --git a/packages/kbn-server-http-tools/src/get_request_id.test.ts b/packages/kbn-server-http-tools/src/get_request_id.test.ts new file mode 100644 index 00000000000000..1b098ed4842d30 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_request_id.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { getRequestId } from './get_request_id'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'), +})); + +describe('getRequestId', () => { + describe('when allowFromAnyIp is true', () => { + it('generates a UUID if no x-opaque-id header is present', () => { + const request = { + headers: {}, + raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual( + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ); + }); + + it('uses x-opaque-id header value if present', () => { + const request = { + headers: { + 'x-opaque-id': 'id from header', + }, + raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual( + 'id from header' + ); + }); + }); + + describe('when allowFromAnyIp is false', () => { + describe('and ipAllowlist is empty', () => { + it('generates a UUID even if x-opaque-id header is present', () => { + const request = { + headers: { 'x-opaque-id': 'id from header' }, + raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: [] })).toEqual( + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ); + }); + }); + + describe('and ipAllowlist is not empty', () => { + it('uses x-opaque-id header if request comes from trusted IP address', () => { + const request = { + headers: { 'x-opaque-id': 'id from header' }, + raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( + 'id from header' + ); + }); + + it('generates a UUID if request comes from untrusted IP address', () => { + const request = { + headers: { 'x-opaque-id': 'id from header' }, + raw: { req: { socket: { remoteAddress: '5.5.5.5' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ); + }); + + it('generates UUID if request comes from trusted IP address but no x-opaque-id header is present', () => { + const request = { + headers: {}, + raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, + } as any; + expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ); + }); + }); + }); +}); diff --git a/packages/kbn-server-http-tools/src/get_request_id.ts b/packages/kbn-server-http-tools/src/get_request_id.ts new file mode 100644 index 00000000000000..3af70ecc3a7a32 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_request_id.ts @@ -0,0 +1,22 @@ +/* + * 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 { Request } from '@hapi/hapi'; +import uuid from 'uuid'; + +export function getRequestId( + request: Request, + { allowFromAnyIp, ipAllowlist }: { allowFromAnyIp: boolean; ipAllowlist: string[] } +): string { + const remoteAddress = request.raw.req.socket?.remoteAddress; + return allowFromAnyIp || + // socket may be undefined in integration tests that connect via the http listener directly + (remoteAddress && ipAllowlist.includes(remoteAddress)) + ? request.headers['x-opaque-id'] ?? uuid.v4() + : uuid.v4(); +} diff --git a/packages/kbn-server-http-tools/src/get_server_options.test.ts b/packages/kbn-server-http-tools/src/get_server_options.test.ts new file mode 100644 index 00000000000000..fdcc749f4ae9a1 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_server_options.test.ts @@ -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 { ByteSizeValue } from '@kbn/config-schema'; +import { getServerOptions } from './get_server_options'; +import { IHttpConfig } from './types'; + +jest.mock('fs', () => { + const original = jest.requireActual('fs'); + return { + // Hapi Inert patches native methods + ...original, + readFileSync: jest.fn(), + }; +}); + +const createConfig = (parts: Partial): IHttpConfig => ({ + host: 'localhost', + port: 5601, + socketTimeout: 120000, + keepaliveTimeout: 120000, + maxPayload: ByteSizeValue.parse('1048576b'), + ...parts, + cors: { + enabled: false, + allowCredentials: false, + allowOrigin: ['*'], + ...parts.cors, + }, + ssl: { + enabled: false, + ...parts.ssl, + }, +}); + +describe('getServerOptions', () => { + beforeEach(() => + jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`) + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('properly configures TLS with default options', () => { + const httpConfig = createConfig({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + }, + }); + + expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "cert": "some-certificate-path", + "ciphers": undefined, + "honorCipherOrder": true, + "key": "some-key-path", + "passphrase": undefined, + "rejectUnauthorized": undefined, + "requestCert": undefined, + "secureOptions": undefined, + } + `); + }); + + it('properly configures TLS with client authentication', () => { + const httpConfig = createConfig({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + certificateAuthorities: ['ca-1', 'ca-2'], + cipherSuites: ['suite-a', 'suite-b'], + keyPassphrase: 'passPhrase', + rejectUnauthorized: true, + requestCert: true, + getSecureOptions: () => 42, + }, + }); + + expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` + Object { + "ca": Array [ + "ca-1", + "ca-2", + ], + "cert": "some-certificate-path", + "ciphers": "suite-a:suite-b", + "honorCipherOrder": true, + "key": "some-key-path", + "passphrase": "passPhrase", + "rejectUnauthorized": true, + "requestCert": true, + "secureOptions": 42, + } + `); + }); + + it('properly configures CORS when cors enabled', () => { + const httpConfig = createConfig({ + cors: { + enabled: true, + allowCredentials: false, + allowOrigin: ['*'], + }, + }); + + expect(getServerOptions(httpConfig).routes?.cors).toEqual({ + credentials: false, + origin: ['*'], + headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'], + }); + }); +}); diff --git a/packages/kbn-server-http-tools/src/get_server_options.ts b/packages/kbn-server-http-tools/src/get_server_options.ts new file mode 100644 index 00000000000000..ade90a0e0d3f5d --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_server_options.ts @@ -0,0 +1,75 @@ +/* + * 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 { RouteOptionsCors, ServerOptions } from '@hapi/hapi'; +import { ServerOptions as TLSOptions } from 'https'; +import { defaultValidationErrorHandler } from './default_validation_error_handler'; +import { IHttpConfig } from './types'; + +const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf']; + +/** + * Converts Kibana `HttpConfig` into `ServerOptions` that are accepted by the Hapi server. + */ +export function getServerOptions(config: IHttpConfig, { configureTLS = true } = {}) { + const cors: RouteOptionsCors | false = config.cors.enabled + ? { + credentials: config.cors.allowCredentials, + origin: config.cors.allowOrigin, + headers: corsAllowedHeaders, + } + : false; + const options: ServerOptions = { + host: config.host, + port: config.port, + routes: { + cache: { + privacy: 'private', + otherwise: 'private, no-cache, no-store, must-revalidate', + }, + cors, + payload: { + maxBytes: config.maxPayload.getValueInBytes(), + }, + validate: { + failAction: defaultValidationErrorHandler, + options: { + abortEarly: false, + }, + }, + }, + state: { + strictHeader: false, + isHttpOnly: true, + isSameSite: false, // necessary to allow using Kibana inside an iframe + }, + }; + + 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; + } + + return options; +} diff --git a/packages/kbn-server-http-tools/src/index.ts b/packages/kbn-server-http-tools/src/index.ts new file mode 100644 index 00000000000000..bd1dffa0bb0cab --- /dev/null +++ b/packages/kbn-server-http-tools/src/index.ts @@ -0,0 +1,15 @@ +/* + * 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 type { IHttpConfig, ISslConfig, ICorsConfig } from './types'; +export { createServer } from './create_server'; +export { defaultValidationErrorHandler } from './default_validation_error_handler'; +export { getListenerOptions } from './get_listener_options'; +export { getServerOptions } from './get_server_options'; +export { getRequestId } from './get_request_id'; +export { sslSchema, SslConfig } from './ssl'; diff --git a/src/core/server/legacy/cli_dev_mode.js b/packages/kbn-server-http-tools/src/ssl/index.ts similarity index 86% rename from src/core/server/legacy/cli_dev_mode.js rename to packages/kbn-server-http-tools/src/ssl/index.ts index 3c4bdb4149780a..cbc3f17f915efd 100644 --- a/src/core/server/legacy/cli_dev_mode.js +++ b/packages/kbn-server-http-tools/src/ssl/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { CliDevMode } from '../../../dev/cli_dev_mode'; +export { SslConfig, sslSchema } from './ssl_config'; diff --git a/src/core/server/http/ssl_config.test.mocks.ts b/packages/kbn-server-http-tools/src/ssl/ssl_config.test.mocks.ts similarity index 95% rename from src/core/server/http/ssl_config.test.mocks.ts rename to packages/kbn-server-http-tools/src/ssl/ssl_config.test.mocks.ts index 81dbcf55100f87..adc4adb76f8047 100644 --- a/src/core/server/http/ssl_config.test.mocks.ts +++ b/packages/kbn-server-http-tools/src/ssl/ssl_config.test.mocks.ts @@ -13,7 +13,7 @@ jest.mock('fs', () => { export const mockReadPkcs12Keystore = jest.fn(); export const mockReadPkcs12Truststore = jest.fn(); -jest.mock('../utils', () => ({ +jest.mock('@kbn/crypto', () => ({ readPkcs12Keystore: mockReadPkcs12Keystore, readPkcs12Truststore: mockReadPkcs12Truststore, })); diff --git a/src/core/server/http/ssl_config.test.ts b/packages/kbn-server-http-tools/src/ssl/ssl_config.test.ts similarity index 99% rename from src/core/server/http/ssl_config.test.ts rename to packages/kbn-server-http-tools/src/ssl/ssl_config.test.ts index bb6b1c7ff29f3e..112fcd8a449f75 100644 --- a/src/core/server/http/ssl_config.test.ts +++ b/packages/kbn-server-http-tools/src/ssl/ssl_config.test.ts @@ -34,7 +34,7 @@ describe('#SslConfig', () => { beforeEach(() => { const realFs = jest.requireActual('fs'); mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); - const utils = jest.requireActual('../utils'); + const utils = jest.requireActual('@kbn/crypto'); mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => utils.readPkcs12Keystore(path, password) ); diff --git a/src/core/server/http/ssl_config.ts b/packages/kbn-server-http-tools/src/ssl/ssl_config.ts similarity index 93% rename from src/core/server/http/ssl_config.ts rename to packages/kbn-server-http-tools/src/ssl/ssl_config.ts index 917d416a775639..53d3616a09a75e 100644 --- a/src/core/server/http/ssl_config.ts +++ b/packages/kbn-server-http-tools/src/ssl/ssl_config.ts @@ -7,9 +7,9 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { readPkcs12Keystore, readPkcs12Truststore } from '@kbn/crypto'; import { constants as cryptoConstants } from 'crypto'; import { readFileSync } from 'fs'; -import { readPkcs12Keystore, readPkcs12Truststore } from '../utils'; const protocolMap = new Map([ ['TLSv1', cryptoConstants.SSL_OP_NO_TLSv1], @@ -81,14 +81,13 @@ type SslConfigType = TypeOf; export class SslConfig { public enabled: boolean; - public redirectHttpFromPort: number | undefined; - public key: string | undefined; - public certificate: string | undefined; - public certificateAuthorities: string[] | undefined; - public keyPassphrase: string | undefined; + public redirectHttpFromPort?: number; + public key?: string; + public certificate?: string; + public certificateAuthorities?: string[]; + public keyPassphrase?: string; public requestCert: boolean; public rejectUnauthorized: boolean; - public cipherSuites: string[]; public supportedProtocols: string[]; @@ -164,6 +163,4 @@ export class SslConfig { } } -const readFile = (file: string) => { - return readFileSync(file, 'utf8'); -}; +const readFile = (file: string) => readFileSync(file, 'utf8'); diff --git a/packages/kbn-server-http-tools/src/types.ts b/packages/kbn-server-http-tools/src/types.ts new file mode 100644 index 00000000000000..3cc117d542eeec --- /dev/null +++ b/packages/kbn-server-http-tools/src/types.ts @@ -0,0 +1,37 @@ +/* + * 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 { ByteSizeValue } from '@kbn/config-schema'; + +export interface IHttpConfig { + host: string; + port: number; + maxPayload: ByteSizeValue; + keepaliveTimeout: number; + socketTimeout: number; + cors: ICorsConfig; + ssl: ISslConfig; +} + +export interface ICorsConfig { + enabled: boolean; + allowCredentials: boolean; + allowOrigin: string[]; +} + +export interface ISslConfig { + enabled: boolean; + key?: string; + certificate?: string; + certificateAuthorities?: string[]; + cipherSuites?: string[]; + keyPassphrase?: string; + requestCert?: boolean; + rejectUnauthorized?: boolean; + getSecureOptions?: () => number; +} diff --git a/packages/kbn-server-http-tools/tsconfig.json b/packages/kbn-server-http-tools/tsconfig.json new file mode 100644 index 00000000000000..ec84b963aed700 --- /dev/null +++ b/packages/kbn-server-http-tools/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target", + "declaration": true, + "declarationMap": true + }, + "include": [ + "src/**/*" + ], + "dependencies": { + "@kbn/std": "link:../kbn-std" + } +} diff --git a/packages/kbn-utils/src/repo_root.ts b/packages/kbn-utils/src/repo_root.ts index 20a25023f41660..2c1617098fe20f 100644 --- a/packages/kbn-utils/src/repo_root.ts +++ b/packages/kbn-utils/src/repo_root.ts @@ -57,3 +57,5 @@ const { kibanaDir, kibanaPkgJson } = findKibanaPackageJson(); export const REPO_ROOT = kibanaDir; export const UPSTREAM_BRANCH = kibanaPkgJson.branch; + +export const fromRoot = (...paths: string[]) => Path.resolve(REPO_ROOT, ...paths); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 34b78bbd7e51e8..86b4ac53841f71 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -12,10 +12,8 @@ import { statSync } from 'fs'; import { resolve } from 'path'; import url from 'url'; -import { getConfigPath } from '@kbn/utils'; +import { getConfigPath, fromRoot } from '@kbn/utils'; import { IS_KIBANA_DISTRIBUTABLE } from '../../legacy/utils'; -import { fromRoot } from '../../core/server/utils'; -import { bootstrap } from '../../core/server'; import { readKeystore } from '../keystore/read_keystore'; function canRequire(path) { @@ -31,9 +29,21 @@ function canRequire(path) { } } -const DEV_MODE_PATH = resolve(__dirname, '../../dev/cli_dev_mode'); +const DEV_MODE_PATH = '@kbn/cli-dev-mode'; const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH); +const getBootstrapScript = (isDev) => { + if (DEV_MODE_SUPPORTED && isDev && process.env.isDevCliChild !== 'true') { + // need dynamic require to exclude it from production build + // eslint-disable-next-line import/no-dynamic-require + const { bootstrapDevMode } = require(DEV_MODE_PATH); + return bootstrapDevMode; + } else { + const { bootstrap } = require('../../core/server'); + return bootstrap; + } +}; + const pathCollector = function () { const paths = []; return function (path) { @@ -79,6 +89,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { throw new Error(`Can't use --ssl when "${path}" configuration is already defined.`); } } + ensureNotDefined('server.ssl.certificate'); ensureNotDefined('server.ssl.key'); ensureNotDefined('server.ssl.keystore.path'); @@ -210,31 +221,40 @@ export default function (program) { } const unknownOptions = this.getUnknownOptions(); - await bootstrap({ - configs: [].concat(opts.config || []), - cliArgs: { - dev: !!opts.dev, - envName: unknownOptions.env ? unknownOptions.env.name : undefined, - // no longer supported - quiet: !!opts.quiet, - silent: !!opts.silent, - watch: !!opts.watch, - runExamples: !!opts.runExamples, - // We want to run without base path when the `--run-examples` flag is given so that we can use local - // links in other documentation sources, like "View this tutorial [here](http://localhost:5601/app/tutorial/xyz)". - // We can tell users they only have to run with `yarn start --run-examples` to get those - // local links to work. Similar to what we do for "View in Console" links in our - // elastic.co links. - basePath: opts.runExamples ? false : !!opts.basePath, - optimize: !!opts.optimize, - disableOptimizer: !opts.optimizer, - oss: !!opts.oss, - cache: !!opts.cache, - dist: !!opts.dist, - }, - features: { - isCliDevModeSupported: DEV_MODE_SUPPORTED, - }, + const configs = [].concat(opts.config || []); + const cliArgs = { + dev: !!opts.dev, + envName: unknownOptions.env ? unknownOptions.env.name : undefined, + // no longer supported + quiet: !!opts.quiet, + silent: !!opts.silent, + watch: !!opts.watch, + runExamples: !!opts.runExamples, + // We want to run without base path when the `--run-examples` flag is given so that we can use local + // links in other documentation sources, like "View this tutorial [here](http://localhost:5601/app/tutorial/xyz)". + // We can tell users they only have to run with `yarn start --run-examples` to get those + // local links to work. Similar to what we do for "View in Console" links in our + // elastic.co links. + basePath: opts.runExamples ? false : !!opts.basePath, + optimize: !!opts.optimize, + disableOptimizer: !opts.optimizer, + oss: !!opts.oss, + cache: !!opts.cache, + dist: !!opts.dist, + }; + + // In development mode, the main process uses the @kbn/dev-cli-mode + // bootstrap script instead of core's. The DevCliMode instance + // is in charge of starting up the optimizer, and spawning another + // `/script/kibana` process with the `isDevCliChild` varenv set to true. + // This variable is then used to identify that we're the 'real' + // Kibana server process, and will be using core's bootstrap script + // to effectively start Kibana. + const bootstrapScript = getBootstrapScript(cliArgs.dev); + + await bootstrapScript({ + configs, + cliArgs, applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions), }); }); diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index 42f6d9aedf1d69..4a07e0c010685a 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -11,18 +11,10 @@ import { CliArgs, Env, RawConfigService } from './config'; import { Root } from './root'; import { CriticalError } from './errors'; -interface KibanaFeatures { - // Indicates whether we can run Kibana in dev mode in which Kibana is run as - // a child process together with optimizer "worker" processes that are - // orchestrated by a parent process (dev mode only feature). - isCliDevModeSupported: boolean; -} - interface BootstrapArgs { configs: string[]; cliArgs: CliArgs; applyConfigOverrides: (config: Record) => Record; - features: KibanaFeatures; } /** @@ -30,12 +22,7 @@ interface BootstrapArgs { * @internal * @param param0 - options */ -export async function bootstrap({ - configs, - cliArgs, - applyConfigOverrides, - features, -}: BootstrapArgs) { +export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: BootstrapArgs) { if (cliArgs.optimize) { // --optimize is deprecated and does nothing now, avoid starting up and just shutdown return; @@ -52,7 +39,6 @@ export async function bootstrap({ const env = Env.createDefault(REPO_ROOT, { configs, cliArgs, - isDevCliParent: cliArgs.dev && features.isCliDevModeSupported && !process.env.isDevCliChild, }); const rawConfigService = new RawConfigService(env.configs, applyConfigOverrides); diff --git a/src/core/server/dev/dev_config.ts b/src/core/server/dev/dev_config.ts index 3a303a61c85637..2fec778d857137 100644 --- a/src/core/server/dev/dev_config.ts +++ b/src/core/server/dev/dev_config.ts @@ -6,26 +6,11 @@ * Side Public License, v 1. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; export const config = { path: 'dev', - schema: schema.object({ - basePathProxyTarget: schema.number({ - defaultValue: 5603, - }), - }), + // dev configuration is validated by the dev cli. + // we only need to register the `dev` schema to avoid failing core's config validation + schema: schema.object({}, { unknowns: 'ignore' }), }; - -export type DevConfigType = TypeOf; - -export class DevConfig { - public basePathProxyTargetPort: number; - - /** - * @internal - */ - constructor(rawConfig: DevConfigType) { - this.basePathProxyTargetPort = rawConfig.basePathProxyTarget; - } -} diff --git a/src/core/server/dev/index.ts b/src/core/server/dev/index.ts index 6e0fd343d2ec84..70257d2a5e6c51 100644 --- a/src/core/server/dev/index.ts +++ b/src/core/server/dev/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { config, DevConfig } from './dev_config'; -export type { DevConfigType } from './dev_config'; +export { config } from './dev_config'; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts index 32602849d2e450..63b2233b06a967 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts @@ -11,7 +11,7 @@ jest.mock('fs', () => ({ readFileSync: mockReadFileSync })); export const mockReadPkcs12Keystore = jest.fn(); export const mockReadPkcs12Truststore = jest.fn(); -jest.mock('../utils', () => ({ +jest.mock('@kbn/crypto', () => ({ readPkcs12Keystore: mockReadPkcs12Keystore, readPkcs12Truststore: mockReadPkcs12Truststore, })); diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index d3f9693bab229d..4b6cf220ccd525 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -244,12 +244,12 @@ describe('throws when config is invalid', () => { beforeAll(() => { const realFs = jest.requireActual('fs'); mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); - const utils = jest.requireActual('../utils'); + const crypto = jest.requireActual('@kbn/crypto'); mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => - utils.readPkcs12Keystore(path, password) + crypto.readPkcs12Keystore(path, password) ); mockReadPkcs12Truststore.mockImplementation((path: string, password?: string) => - utils.readPkcs12Truststore(path, password) + crypto.readPkcs12Truststore(path, password) ); }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 879002a6ece51e..d3432344f5a739 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -7,10 +7,10 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { readPkcs12Keystore, readPkcs12Truststore } from '@kbn/crypto'; import { Duration } from 'moment'; import { readFileSync } from 'fs'; import { ConfigDeprecationProvider } from 'src/core/server'; -import { readPkcs12Keystore, readPkcs12Truststore } from '../utils'; import { ServiceConfigDescriptor } from '../internal_types'; import { getReservedHeaders } from './default_headers'; diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index 7e4afbfbfea05a..da4e8199dc6230 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { createSHA256Hash } from '../utils'; +import { createSHA256Hash } from '@kbn/crypto'; import { config } from './config'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); diff --git a/src/core/server/http/base_path_proxy_server.test.ts b/src/core/server/http/base_path_proxy_server.test.ts deleted file mode 100644 index 80c03a2af9031b..00000000000000 --- a/src/core/server/http/base_path_proxy_server.test.ts +++ /dev/null @@ -1,1021 +0,0 @@ -/* - * 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 { BasePathProxyServer, BasePathProxyServerOptions } from './base_path_proxy_server'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import { DevConfig } from '../dev/dev_config'; -import { EMPTY } from 'rxjs'; -import { HttpConfig } from './http_config'; -import { ByteSizeValue, schema } from '@kbn/config-schema'; -import { - KibanaRequest, - KibanaResponseFactory, - Router, - RouteValidationFunction, - RouteValidationResultFactory, -} from './router'; -import { HttpServer } from './http_server'; -import supertest from 'supertest'; -import { RequestHandlerContext } from 'kibana/server'; -import { readFileSync } from 'fs'; -import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; -import { omit } from 'lodash'; -import { Readable } from 'stream'; - -/** - * Most of these tests are inspired by: - * src/core/server/http/http_server.test.ts - * and copied for completeness from that file. The modifications are that these tests use the developer proxy. - */ -describe('BasePathProxyServer', () => { - let server: HttpServer; - let proxyServer: BasePathProxyServer; - let config: HttpConfig; - let configWithSSL: HttpConfig; - let basePath: string; - let certificate: string; - let key: string; - let proxySupertest: supertest.SuperTest; - const logger = loggingSystemMock.createLogger(); - const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); - - beforeAll(() => { - certificate = readFileSync(KBN_CERT_PATH, 'utf8'); - key = readFileSync(KBN_KEY_PATH, 'utf8'); - }); - - beforeEach(async () => { - // setup the server but don't start it until each individual test so that routes can be dynamically configured per unit test. - server = new HttpServer(logger, 'tests'); - config = ({ - name: 'kibana', - host: '127.0.0.1', - port: 10012, - compression: { enabled: true }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - autoListen: true, - keepaliveTimeout: 1000, - socketTimeout: 1000, - cors: { - enabled: false, - allowCredentials: false, - allowOrigin: [], - }, - ssl: { enabled: false }, - customResponseHeaders: {}, - maxPayload: new ByteSizeValue(1024), - rewriteBasePath: true, - } as unknown) as HttpConfig; - - configWithSSL = { - ...config, - ssl: { - enabled: true, - certificate, - cipherSuites: ['TLS_AES_256_GCM_SHA384'], - getSecureOptions: () => 0, - key, - redirectHttpFromPort: config.port + 1, - }, - } as HttpConfig; - - // setup and start the proxy server - const proxyConfig: HttpConfig = { ...config, port: 10013 }; - const devConfig = new DevConfig({ basePathProxyTarget: config.port }); - proxyServer = new BasePathProxyServer(logger, proxyConfig, devConfig); - const options: Readonly = { - shouldRedirectFromOldBasePath: () => true, - delayUntil: () => EMPTY, - }; - await proxyServer.start(options); - - // set the base path or throw if for some unknown reason it is not setup - if (proxyServer.basePath == null) { - throw new Error('Invalid null base path, all tests will fail'); - } else { - basePath = proxyServer.basePath; - } - proxySupertest = supertest(`http://127.0.0.1:${proxyConfig.port}`); - }); - - afterEach(async () => { - await server.stop(); - await proxyServer.stop(); - jest.clearAllMocks(); - }); - - test('root URL will return a 302 redirect', async () => { - await proxySupertest.get('/').expect(302); - }); - - test('root URL will return a redirect location with exactly 3 characters that are a-z', async () => { - const res = await proxySupertest.get('/'); - const location = res.header.location; - expect(location).toMatch(/[a-z]{3}/); - }); - - test('valid params', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - router.get( - { - path: '/{test}', - validate: { - params: schema.object({ - test: schema.string(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.params.test }); - } - ); - const { registerRouter } = await server.setup(config); - registerRouter(router); - await server.start(); - - await proxySupertest - .get(`${basePath}/foo/some-string`) - .expect(200) - .then((res) => { - expect(res.text).toBe('some-string'); - }); - }); - - test('invalid params', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.get( - { - path: '/{test}', - validate: { - params: schema.object({ - test: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: String(req.params.test) }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .get(`${basePath}/foo/some-string`) - .expect(400) - .then((res) => { - expect(res.body).toEqual({ - error: 'Bad Request', - statusCode: 400, - message: '[request params.test]: expected value of type [number] but got [string]', - }); - }); - }); - - test('valid query', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.get( - { - path: '/', - validate: { - query: schema.object({ - bar: schema.string(), - quux: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.query }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .get(`${basePath}/foo/?bar=test&quux=123`) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'test', quux: 123 }); - }); - }); - - test('invalid query', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.get( - { - path: '/', - validate: { - query: schema.object({ - bar: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.query }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .get(`${basePath}/foo/?bar=test`) - .expect(400) - .then((res) => { - expect(res.body).toEqual({ - error: 'Bad Request', - statusCode: 400, - message: '[request query.bar]: expected value of type [number] but got [string]', - }); - }); - }); - - test('valid body', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.post( - { - path: '/', - validate: { - body: schema.object({ - bar: schema.string(), - baz: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ - bar: 'test', - baz: 123, - }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'test', baz: 123 }); - }); - }); - - test('valid body with validate function', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.post( - { - path: '/', - validate: { - body: ({ bar, baz } = {}, { ok, badRequest }) => { - if (typeof bar === 'string' && typeof baz === 'number') { - return ok({ bar, baz }); - } else { - return badRequest('Wrong payload', ['body']); - } - }, - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ - bar: 'test', - baz: 123, - }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'test', baz: 123 }); - }); - }); - - test('not inline validation - specifying params', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - const bodyValidation = ( - { bar, baz }: any = {}, - { ok, badRequest }: RouteValidationResultFactory - ) => { - if (typeof bar === 'string' && typeof baz === 'number') { - return ok({ bar, baz }); - } else { - return badRequest('Wrong payload', ['body']); - } - }; - - router.post( - { - path: '/', - validate: { - body: bodyValidation, - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ - bar: 'test', - baz: 123, - }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'test', baz: 123 }); - }); - }); - - test('not inline validation - specifying validation handler', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - const bodyValidation: RouteValidationFunction<{ bar: string; baz: number }> = ( - { bar, baz } = {}, - { ok, badRequest } - ) => { - if (typeof bar === 'string' && typeof baz === 'number') { - return ok({ bar, baz }); - } else { - return badRequest('Wrong payload', ['body']); - } - }; - - router.post( - { - path: '/', - validate: { - body: bodyValidation, - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ - bar: 'test', - baz: 123, - }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'test', baz: 123 }); - }); - }); - - test('not inline handler - KibanaRequest', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - const handler = ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ) => { - const body = { - bar: req.body.bar.toUpperCase(), - baz: req.body.baz.toString(), - }; - - return res.ok({ body }); - }; - - router.post( - { - path: '/', - validate: { - body: ({ bar, baz } = {}, { ok, badRequest }) => { - if (typeof bar === 'string' && typeof baz === 'number') { - return ok({ bar, baz }); - } else { - return badRequest('Wrong payload', ['body']); - } - }, - }, - }, - handler - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ - bar: 'test', - baz: 123, - }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ bar: 'TEST', baz: '123' }); - }); - }); - - test('invalid body', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.post( - { - path: '/', - validate: { - body: schema.object({ - bar: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .post(`${basePath}/foo/`) - .send({ bar: 'test' }) - .expect(400) - .then((res) => { - expect(res.body).toEqual({ - error: 'Bad Request', - statusCode: 400, - message: '[request body.bar]: expected value of type [number] but got [string]', - }); - }); - }); - - test('handles putting', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.put( - { - path: '/', - validate: { - body: schema.object({ - key: schema.string(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: req.body }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .put(`${basePath}/foo/`) - .send({ key: 'new value' }) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ key: 'new value' }); - }); - }); - - test('handles deleting', async () => { - const router = new Router(`${basePath}/foo`, logger, enhanceWithContext); - - router.delete( - { - path: '/{id}', - validate: { - params: schema.object({ - id: schema.number(), - }), - }, - }, - (_, req, res) => { - return res.ok({ body: { key: req.params.id } }); - } - ); - - const { registerRouter } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await proxySupertest - .delete(`${basePath}/foo/3`) - .expect(200) - .then((res) => { - expect(res.body).toEqual({ key: 3 }); - }); - }); - - describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { - let configWithBasePath: HttpConfig; - - beforeEach(async () => { - configWithBasePath = { - ...config, - basePath: '/bar', - rewriteBasePath: false, - } as HttpConfig; - - const router = new Router(`${basePath}/`, logger, enhanceWithContext); - router.get({ path: '/', validate: false }, (_, __, res) => res.ok({ body: 'value:/' })); - router.get({ path: '/foo', validate: false }, (_, __, res) => res.ok({ body: 'value:/foo' })); - - const { registerRouter } = await server.setup(configWithBasePath); - registerRouter(router); - - await server.start(); - }); - - test('/bar => 404', async () => { - await proxySupertest.get(`${basePath}/bar`).expect(404); - }); - - test('/bar/ => 404', async () => { - await proxySupertest.get(`${basePath}/bar/`).expect(404); - }); - - test('/bar/foo => 404', async () => { - await proxySupertest.get(`${basePath}/bar/foo`).expect(404); - }); - - test('/ => /', async () => { - await proxySupertest - .get(`${basePath}/`) - .expect(200) - .then((res) => { - expect(res.text).toBe('value:/'); - }); - }); - - test('/foo => /foo', async () => { - await proxySupertest - .get(`${basePath}/foo`) - .expect(200) - .then((res) => { - expect(res.text).toBe('value:/foo'); - }); - }); - }); - - test('with defined `redirectHttpFromPort`', async () => { - const router = new Router(`${basePath}/`, logger, enhanceWithContext); - router.get({ path: '/', validate: false }, (_, __, res) => res.ok({ body: 'value:/' })); - - const { registerRouter } = await server.setup(configWithSSL); - registerRouter(router); - - await server.start(); - }); - - test('allows attaching metadata to attach meta-data tag strings to a route', async () => { - const tags = ['my:tag']; - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.get({ path: '/with-tags', validate: false, options: { tags } }, (_, req, res) => - res.ok({ body: { tags: req.route.options.tags } }) - ); - router.get({ path: '/without-tags', validate: false }, (_, req, res) => - res.ok({ body: { tags: req.route.options.tags } }) - ); - registerRouter(router); - - await server.start(); - await proxySupertest.get(`${basePath}/with-tags`).expect(200, { tags }); - - await proxySupertest.get(`${basePath}/without-tags`).expect(200, { tags: [] }); - }); - - describe('response headers', () => { - test('default headers', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.get({ path: '/', validate: false }, (_, req, res) => res.ok({ body: req.route })); - registerRouter(router); - - await server.start(); - const response = await proxySupertest.get(`${basePath}/`).expect(200); - - const restHeaders = omit(response.header, ['date', 'content-length']); - expect(restHeaders).toMatchInlineSnapshot(` - Object { - "accept-ranges": "bytes", - "cache-control": "private, no-cache, no-store, must-revalidate", - "connection": "close", - "content-type": "application/json; charset=utf-8", - } - `); - }); - }); - - test('exposes route details of incoming request to a route handler (POST + payload options)', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.post( - { - path: '/', - validate: { body: schema.object({ test: schema.number() }) }, - options: { body: { accepts: 'application/json' } }, - }, - (_, req, res) => res.ok({ body: req.route }) - ); - registerRouter(router); - - await server.start(); - await proxySupertest - .post(`${basePath}/`) - .send({ test: 1 }) - .expect(200, { - method: 'post', - path: `${basePath}/`, - options: { - authRequired: true, - xsrfRequired: true, - tags: [], - timeout: { - payload: 10000, - idleSocket: 1000, - }, - body: { - parse: true, // hapi populates the default - maxBytes: 1024, // hapi populates the default - accepts: ['application/json'], - output: 'data', - }, - }, - }); - }); - - test('should return a stream in the body', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.put( - { - path: '/', - validate: { body: schema.stream() }, - options: { body: { output: 'stream' } }, - }, - (_, req, res) => { - expect(req.body).toBeInstanceOf(Readable); - return res.ok({ body: req.route.options.body }); - } - ); - registerRouter(router); - - await server.start(); - await proxySupertest.put(`${basePath}/`).send({ test: 1 }).expect(200, { - parse: true, - maxBytes: 1024, // hapi populates the default - output: 'stream', - }); - }); - - describe('timeout options', () => { - describe('payload timeout', () => { - test('POST routes set the payload timeout', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.post( - { - path: '/', - validate: false, - options: { - timeout: { - payload: 300000, - }, - }, - }, - (_, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - await server.start(); - await proxySupertest - .post(`${basePath}/`) - .send({ test: 1 }) - .expect(200, { - timeout: { - payload: 300000, - idleSocket: 1000, // This is an extra option added by the proxy - }, - }); - }); - - test('DELETE routes set the payload timeout', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.delete( - { - path: '/', - validate: false, - options: { - timeout: { - payload: 300000, - }, - }, - }, - (context, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - await server.start(); - await proxySupertest.delete(`${basePath}/`).expect(200, { - timeout: { - payload: 300000, - idleSocket: 1000, // This is an extra option added by the proxy - }, - }); - }); - - test('PUT routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.put( - { - path: '/', - validate: false, - options: { - timeout: { - payload: 300000, - }, - }, - }, - (_, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - await server.start(); - await proxySupertest.put(`${basePath}/`).expect(200, { - timeout: { - payload: 300000, - idleSocket: 1000, // This is an extra option added by the proxy - }, - }); - }); - - test('PATCH routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.patch( - { - path: '/', - validate: false, - options: { - timeout: { - payload: 300000, - }, - }, - }, - (_, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - await server.start(); - await proxySupertest.patch(`${basePath}/`).expect(200, { - timeout: { - payload: 300000, - idleSocket: 1000, // This is an extra option added by the proxy - }, - }); - }); - }); - - describe('idleSocket timeout', () => { - test('uses server socket timeout when not specified in the route', async () => { - const { registerRouter } = await server.setup({ - ...config, - socketTimeout: 11000, - }); - - const router = new Router(basePath, logger, enhanceWithContext); - router.get( - { - path: '/', - validate: { body: schema.maybe(schema.any()) }, - }, - (_, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - - await server.start(); - await proxySupertest - .get(`${basePath}/`) - .send() - .expect(200, { - timeout: { - idleSocket: 11000, - }, - }); - }); - - test('sets the socket timeout when specified in the route', async () => { - const { registerRouter } = await server.setup({ - ...config, - socketTimeout: 11000, - }); - - const router = new Router(basePath, logger, enhanceWithContext); - router.get( - { - path: '/', - validate: { body: schema.maybe(schema.any()) }, - options: { timeout: { idleSocket: 12000 } }, - }, - (context, req, res) => { - return res.ok({ - body: { - timeout: req.route.options.timeout, - }, - }); - } - ); - registerRouter(router); - - await server.start(); - await proxySupertest - .get(`${basePath}/`) - .send() - .expect(200, { - timeout: { - idleSocket: 12000, - }, - }); - }); - - test('idleSocket timeout can be smaller than the payload timeout', async () => { - const { registerRouter } = await server.setup(config); - - const router = new Router(basePath, logger, enhanceWithContext); - router.post( - { - path: `${basePath}/`, - validate: { body: schema.any() }, - options: { - timeout: { - payload: 1000, - idleSocket: 10, - }, - }, - }, - (_, req, res) => { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } - ); - - registerRouter(router); - - await server.start(); - }); - }); - }); - - describe('shouldRedirect', () => { - let proxyServerWithoutShouldRedirect: BasePathProxyServer; - let proxyWithoutShouldRedirectSupertest: supertest.SuperTest; - - beforeEach(async () => { - // setup and start a proxy server which does not use "shouldRedirectFromOldBasePath" - const proxyConfig: HttpConfig = { ...config, port: 10004 }; - const devConfig = new DevConfig({ basePathProxyTarget: config.port }); - proxyServerWithoutShouldRedirect = new BasePathProxyServer(logger, proxyConfig, devConfig); - const options: Readonly = { - shouldRedirectFromOldBasePath: () => false, // Return false to not redirect - delayUntil: () => EMPTY, - }; - await proxyServerWithoutShouldRedirect.start(options); - proxyWithoutShouldRedirectSupertest = supertest(`http://127.0.0.1:${proxyConfig.port}`); - }); - - afterEach(async () => { - await proxyServerWithoutShouldRedirect.stop(); - }); - - test('it will do a redirect if it detects what looks like a stale or previously used base path', async () => { - const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; - const res = await proxySupertest.get(`/${fakeBasePath}`).expect(302); - const location = res.header.location; - expect(location).toEqual(`${basePath}/`); - }); - - test('it will NOT do a redirect if it detects what looks like a stale or previously used base path if we intentionally turn it off', async () => { - const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; - await proxyWithoutShouldRedirectSupertest.get(`/${fakeBasePath}`).expect(404); - }); - - test('it will NOT redirect if it detects a larger path than 3 characters', async () => { - await proxySupertest.get('/abcde').expect(404); - }); - - test('it will NOT redirect if it is not a GET verb', async () => { - const fakeBasePath = basePath !== 'abc' ? 'abc' : 'efg'; - await proxySupertest.put(`/${fakeBasePath}`).expect(404); - }); - }); - - describe('constructor option for sending in a custom basePath', () => { - let proxyServerWithFooBasePath: BasePathProxyServer; - let proxyWithFooBasePath: supertest.SuperTest; - - beforeEach(async () => { - // setup and start a proxy server which uses a basePath of "foo" - const proxyConfig: HttpConfig = { ...config, port: 10004, basePath: '/foo' }; // <-- "foo" here in basePath - const devConfig = new DevConfig({ basePathProxyTarget: config.port }); - proxyServerWithFooBasePath = new BasePathProxyServer(logger, proxyConfig, devConfig); - const options: Readonly = { - shouldRedirectFromOldBasePath: () => true, - delayUntil: () => EMPTY, - }; - await proxyServerWithFooBasePath.start(options); - proxyWithFooBasePath = supertest(`http://127.0.0.1:${proxyConfig.port}`); - }); - - afterEach(async () => { - await proxyServerWithFooBasePath.stop(); - }); - - test('it will do a redirect to foo which is our passed in value for the configuration', async () => { - const res = await proxyWithFooBasePath.get('/bar').expect(302); - const location = res.header.location; - expect(location).toEqual('/foo/'); - }); - }); -}); diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 2bbe9f3f96a559..356dad201ce95d 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -7,12 +7,12 @@ */ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; +import { IHttpConfig, SslConfig, sslSchema } from '@kbn/server-http-tools'; import { hostname } from 'os'; import url from 'url'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url'; -import { SslConfig, sslSchema } from './ssl_config'; const validBasePathRegex = /^\/.*[^\/]$/; const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -156,7 +156,7 @@ export const config = { }; export type HttpConfigType = TypeOf; -export class HttpConfig { +export class HttpConfig implements IHttpConfig { public name: string; public autoListen: boolean; public host: string; diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 54be7b35f68ad4..ccd14d4b99e112 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1288,6 +1288,30 @@ 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, + }); + const router = new Router('', logger, enhanceWithContext); + + router.get({ path: '/a', validate: false }, async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + }); + router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + + registerRouter(router); + + registerRouter(router); + + await server.start(); + + expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); + + await supertest(innerServer.listener).get('/b').expect(200); +}); + describe('setup contract', () => { describe('#createSessionStorage', () => { test('creates session storage factory', async () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index b0510bc414bf83..cd7d7ccc5aeffa 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -10,10 +10,15 @@ import { Server, Request } from '@hapi/hapi'; import HapiStaticFiles from '@hapi/inert'; import url from 'url'; import uuid from 'uuid'; +import { + createServer, + getListenerOptions, + getServerOptions, + getRequestId, +} from '@kbn/server-http-tools'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; -import { createServer, getListenerOptions, getServerOptions, getRequestId } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 9354c89b632929..83279e99bc4761 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -242,29 +242,6 @@ test('returns http server contract on setup', async () => { }); }); -test('does not start http server if process is dev cluster master', async () => { - const configService = createConfigService(); - const httpServer = { - isListening: () => false, - setup: jest.fn().mockReturnValue({}), - start: jest.fn(), - stop: noop, - }; - mockHttpServer.mockImplementation(() => httpServer); - - const service = new HttpService({ - coreId, - configService, - env: Env.createDefault(REPO_ROOT, getEnvOptions({ isDevCliParent: true })), - logger, - }); - - await service.setup(setupDeps); - await service.start(); - - expect(httpServer.start).not.toHaveBeenCalled(); -}); - test('does not start http server if configured with `autoListen:false`', async () => { const configService = createConfigService({ autoListen: false, diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 87143e1160c6c3..5b90440f6ad701 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -153,15 +153,13 @@ export class HttpService } /** - * Indicates if http server has configured to start listening on a configured port. - * We shouldn't start http service in two cases: - * 1. If `server.autoListen` is explicitly set to `false`. - * 2. When the process is run as dev cluster master in which case cluster manager - * will fork a dedicated process where http service will be set up instead. + * Indicates if http server is configured to start listening on a configured port. + * (if `server.autoListen` is not explicitly set to `false`.) + * * @internal * */ private shouldListen(config: HttpConfig) { - return !this.coreContext.env.isDevCliParent && config.autoListen; + return config.autoListen; } public async stop() { diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts deleted file mode 100644 index c2fa3816324fc4..00000000000000 --- a/src/core/server/http/http_tools.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -/* - * 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. - */ - -jest.mock('fs', () => { - const original = jest.requireActual('fs'); - return { - // Hapi Inert patches native methods - ...original, - readFileSync: jest.fn(), - }; -}); - -jest.mock('uuid', () => ({ - v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'), -})); - -import supertest from 'supertest'; -import { Request, ResponseToolkit } from '@hapi/hapi'; -import Joi from 'joi'; - -import { - defaultValidationErrorHandler, - HapiValidationError, - getServerOptions, - getRequestId, -} from './http_tools'; -import { HttpServer } from './http_server'; -import { HttpConfig, config } from './http_config'; -import { Router } from './router'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import { ByteSizeValue } from '@kbn/config-schema'; - -const emptyOutput = { - statusCode: 400, - headers: {}, - payload: { - statusCode: 400, - error: '', - validation: { - source: '', - keys: [], - }, - }, -}; - -afterEach(() => jest.clearAllMocks()); - -describe('defaultValidationErrorHandler', () => { - it('formats value validation errors correctly', () => { - expect.assertions(1); - const schema = Joi.array().items( - Joi.object({ - type: Joi.string().required(), - }).required() - ); - - const error = schema.validate([{}], { abortEarly: false }).error as HapiValidationError; - - // Emulate what Hapi v17 does by default - error.output = { ...emptyOutput }; - error.output.payload.validation.keys = ['0.type', '']; - - try { - defaultValidationErrorHandler({} as Request, {} as ResponseToolkit, error); - } catch (err) { - // Verify the empty string gets corrected to 'value' - expect(err.output.payload.validation.keys).toEqual(['0.type', 'value']); - } - }); -}); - -describe('timeouts', () => { - const logger = loggingSystemMock.create(); - const server = new HttpServer(logger, 'foo'); - const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); - - test('closes sockets on timeout', async () => { - const router = new Router('', logger.get(), enhanceWithContext); - router.get({ path: '/a', validate: false }, async (context, req, res) => { - await new Promise((resolve) => setTimeout(resolve, 2000)); - return res.ok({}); - }); - router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); - const { registerRouter, server: innerServer } = await server.setup({ - socketTimeout: 1000, - host: '127.0.0.1', - maxPayload: new ByteSizeValue(1024), - ssl: {}, - cors: { - enabled: false, - }, - compression: { enabled: true }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any); - registerRouter(router); - - await server.start(); - - expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); - - await supertest(innerServer.listener).get('/b').expect(200); - }); - - afterAll(async () => { - await server.stop(); - }); -}); - -describe('getServerOptions', () => { - beforeEach(() => - jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`) - ); - - it('properly configures TLS with default options', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - }, - }), - {} as any, - {} as any - ); - - expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` - Object { - "ca": undefined, - "cert": "content-some-certificate-path", - "ciphers": "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA", - "honorCipherOrder": true, - "key": "content-some-key-path", - "passphrase": undefined, - "rejectUnauthorized": false, - "requestCert": false, - "secureOptions": 67108864, - } - `); - }); - - it('properly configures TLS with client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - certificateAuthorities: ['ca-1', 'ca-2'], - clientAuthentication: 'required', - }, - }), - {} as any, - {} as any - ); - - expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` - Object { - "ca": Array [ - "content-ca-1", - "content-ca-2", - ], - "cert": "content-some-certificate-path", - "ciphers": "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA", - "honorCipherOrder": true, - "key": "content-some-key-path", - "passphrase": undefined, - "rejectUnauthorized": true, - "requestCert": true, - "secureOptions": 67108864, - } - `); - }); - - it('properly configures CORS when cors enabled', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - cors: { - enabled: true, - allowCredentials: false, - allowOrigin: ['*'], - }, - }), - {} as any, - {} as any - ); - - expect(getServerOptions(httpConfig).routes?.cors).toEqual({ - credentials: false, - origin: ['*'], - headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'], - }); - }); -}); - -describe('getRequestId', () => { - describe('when allowFromAnyIp is true', () => { - it('generates a UUID if no x-opaque-id header is present', () => { - const request = { - headers: {}, - raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual( - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ); - }); - - it('uses x-opaque-id header value if present', () => { - const request = { - headers: { - 'x-opaque-id': 'id from header', - raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, - }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual( - 'id from header' - ); - }); - }); - - describe('when allowFromAnyIp is false', () => { - describe('and ipAllowlist is empty', () => { - it('generates a UUID even if x-opaque-id header is present', () => { - const request = { - headers: { 'x-opaque-id': 'id from header' }, - raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: [] })).toEqual( - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ); - }); - }); - - describe('and ipAllowlist is not empty', () => { - it('uses x-opaque-id header if request comes from trusted IP address', () => { - const request = { - headers: { 'x-opaque-id': 'id from header' }, - raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( - 'id from header' - ); - }); - - it('generates a UUID if request comes from untrusted IP address', () => { - const request = { - headers: { 'x-opaque-id': 'id from header' }, - raw: { req: { socket: { remoteAddress: '5.5.5.5' } } }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ); - }); - - it('generates UUID if request comes from trusted IP address but no x-opaque-id header is present', () => { - const request = { - headers: {}, - raw: { req: { socket: { remoteAddress: '1.1.1.1' } } }, - } as any; - expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual( - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ); - }); - }); - }); -}); diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts deleted file mode 100644 index e909b454feae2c..00000000000000 --- a/src/core/server/http/http_tools.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * 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 } from '@hapi/hapi'; -import type { - Lifecycle, - Request, - ResponseToolkit, - RouteOptionsCors, - ServerOptions, - Util, -} from '@hapi/hapi'; -import Hoek from '@hapi/hoek'; -import type { ServerOptions as TLSOptions } from 'https'; -import type { ValidationError } from 'joi'; -import uuid from 'uuid'; -import { ensureNoUnsafeProperties } from '@kbn/std'; -import { HttpConfig } from './http_config'; - -const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf']; -/** - * Converts Kibana `HttpConfig` into `ServerOptions` that are accepted by the Hapi server. - */ -export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) { - const cors: RouteOptionsCors | false = config.cors.enabled - ? { - credentials: config.cors.allowCredentials, - origin: config.cors.allowOrigin, - headers: corsAllowedHeaders, - } - : false; - // Note that all connection options configured here should be exactly the same - // as in the legacy platform server (see `src/legacy/server/http/index`). Any change - // SHOULD BE applied in both places. The only exception is TLS-specific options, - // that are configured only here. - const options: ServerOptions = { - host: config.host, - port: config.port, - routes: { - cache: { - privacy: 'private', - otherwise: 'private, no-cache, no-store, must-revalidate', - }, - cors, - payload: { - maxBytes: config.maxPayload.getValueInBytes(), - }, - validate: { - failAction: defaultValidationErrorHandler, - options: { - abortEarly: false, - }, - // TODO: This payload validation can be removed once the legacy platform is completely removed. - // This is a default payload validation which applies to all LP routes which do not specify their own - // `validate.payload` handler, in order to reduce the likelyhood of prototype pollution vulnerabilities. - // (All NP routes are already required to specify their own validation in order to access the payload) - payload: (value) => Promise.resolve(ensureNoUnsafeProperties(value)), - }, - }, - state: { - strictHeader: false, - isHttpOnly: true, - isSameSite: false, // necessary to allow using Kibana inside an iframe - }, - }; - - 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(), - requestCert: ssl.requestCert, - rejectUnauthorized: ssl.rejectUnauthorized, - }; - - options.tls = tlsOptions; - } - - return options; -} - -export function getListenerOptions(config: HttpConfig) { - return { - keepaliveTimeout: config.keepaliveTimeout, - socketTimeout: config.socketTimeout, - }; -} - -interface ListenerOptions { - keepaliveTimeout: number; - socketTimeout: number; -} - -export function createServer(serverOptions: ServerOptions, listenerOptions: ListenerOptions) { - const server = new Server(serverOptions); - - server.listener.keepAliveTimeout = listenerOptions.keepaliveTimeout; - server.listener.setTimeout(listenerOptions.socketTimeout); - server.listener.on('timeout', (socket) => { - socket.destroy(); - }); - server.listener.on('clientError', (err, socket) => { - if (socket.writable) { - socket.end(Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')); - } else { - socket.destroy(err); - } - }); - - return server; -} - -/** - * Hapi extends the ValidationError interface to add this output key with more data. - */ -export interface HapiValidationError extends ValidationError { - output: { - statusCode: number; - headers: Util.Dictionary; - payload: { - statusCode: number; - error: string; - message?: string; - validation: { - source: string; - keys: string[]; - }; - }; - }; -} - -/** - * Used to replicate Hapi v16 and below's validation responses. Should be used in the routes.validate.failAction key. - */ -export function defaultValidationErrorHandler( - request: Request, - h: ResponseToolkit, - err?: Error -): Lifecycle.ReturnValue { - // Newer versions of Joi don't format the key for missing params the same way. This shim - // provides backwards compatibility. Unfortunately, Joi doesn't export it's own Error class - // in JS so we have to rely on the `name` key before we can cast it. - // - // The Hapi code we're 'overwriting' can be found here: - // https://github.com/hapijs/hapi/blob/master/lib/validation.js#L102 - if (err && err.name === 'ValidationError' && err.hasOwnProperty('output')) { - const validationError: HapiValidationError = err as HapiValidationError; - const validationKeys: string[] = []; - - validationError.details.forEach((detail) => { - if (detail.path.length > 0) { - validationKeys.push(Hoek.escapeHtml(detail.path.join('.'))); - } else { - // If no path, use the value sigil to signal the entire value had an issue. - validationKeys.push('value'); - } - }); - - validationError.output.payload.validation.keys = validationKeys; - } - - throw err; -} - -export function getRequestId(request: Request, options: HttpConfig['requestId']): string { - return options.allowFromAnyIp || - // socket may be undefined in integration tests that connect via the http listener directly - (request.raw.req.socket?.remoteAddress && - options.ipAllowlist.includes(request.raw.req.socket.remoteAddress)) - ? request.headers['x-opaque-id'] ?? uuid.v4() - : uuid.v4(); -} diff --git a/src/core/server/http/https_redirect_server.ts b/src/core/server/http/https_redirect_server.ts index dd29a46d728e72..28909c0308c223 100644 --- a/src/core/server/http/https_redirect_server.ts +++ b/src/core/server/http/https_redirect_server.ts @@ -8,10 +8,10 @@ import { Request, ResponseToolkit, Server } from '@hapi/hapi'; import { format as formatUrl } from 'url'; +import { createServer, getListenerOptions, getServerOptions } from '@kbn/server-http-tools'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; -import { createServer, getListenerOptions, getServerOptions } from './http_tools'; export class HttpsRedirectServer { private server?: Server; diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index c35b7e2fcd0429..84fe5149c89c66 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -56,7 +56,6 @@ export type { DestructiveRouteMethod, SafeRouteMethod, } from './router'; -export { BasePathProxyServer } from './base_path_proxy_server'; export type { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; export type { AuthenticationHandler, diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 03324dc6c722ff..5b297ab44f8bbe 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -11,14 +11,12 @@ import Boom from '@hapi/boom'; import supertest from 'supertest'; import { schema } from '@kbn/config-schema'; -import { HttpService } from '../http_service'; - import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { HttpService } from '../http_service'; let server: HttpService; - let logger: ReturnType; const contextSetup = contextServiceMock.createSetupContract(); @@ -28,7 +26,6 @@ const setupDeps = { beforeEach(() => { logger = loggingSystemMock.create(); - server = createHttpServer({ logger }); }); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index da6b521bfde9a7..db36bd73602c47 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -7,16 +7,12 @@ */ jest.mock('../../../legacy/server/kbn_server'); -jest.mock('./cli_dev_mode'); import { BehaviorSubject, throwError } from 'rxjs'; import { REPO_ROOT } from '@kbn/dev-utils'; -// @ts-expect-error js file to remove TS dependency on cli -import { CliDevMode as MockCliDevMode } from './cli_dev_mode'; import KbnServer from '../../../legacy/server/kbn_server'; import { Config, Env, ObjectToConfigAdapter } from '../config'; -import { BasePathProxyServer } from '../http'; import { DiscoveredPlugin } from '../plugins'; import { getEnvOptions, configServiceMock } from '../config/mocks'; @@ -228,7 +224,6 @@ describe('once LegacyService is set up with connection info', () => { ); expect(MockKbnServer).not.toHaveBeenCalled(); - expect(MockCliDevMode).not.toHaveBeenCalled(); }); test('reconfigures logging configuration if new config is received.', async () => { @@ -335,74 +330,6 @@ describe('once LegacyService is set up without connection info', () => { }); }); -describe('once LegacyService is set up in `devClusterMaster` mode', () => { - beforeEach(() => { - configService.atPath.mockImplementation((path) => { - return new BehaviorSubject( - path === 'dev' ? { basePathProxyTargetPort: 100500 } : { basePath: '/abc' } - ); - }); - }); - - test('creates CliDevMode without base path proxy.', async () => { - const devClusterLegacyService = new LegacyService({ - coreId, - env: Env.createDefault( - REPO_ROOT, - getEnvOptions({ - cliArgs: { silent: true, basePath: false }, - isDevCliParent: true, - }) - ), - logger, - configService: configService as any, - }); - - await devClusterLegacyService.setupLegacyConfig(); - await devClusterLegacyService.setup(setupDeps); - await devClusterLegacyService.start(startDeps); - - expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1); - expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith( - expect.objectContaining({ silent: true, basePath: false }), - expect.objectContaining({ - get: expect.any(Function), - set: expect.any(Function), - }), - undefined - ); - }); - - test('creates CliDevMode with base path proxy.', async () => { - const devClusterLegacyService = new LegacyService({ - coreId, - env: Env.createDefault( - REPO_ROOT, - getEnvOptions({ - cliArgs: { quiet: true, basePath: true }, - isDevCliParent: true, - }) - ), - logger, - configService: configService as any, - }); - - await devClusterLegacyService.setupLegacyConfig(); - await devClusterLegacyService.setup(setupDeps); - await devClusterLegacyService.start(startDeps); - - expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1); - expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith( - expect.objectContaining({ quiet: true, basePath: true }), - expect.objectContaining({ - get: expect.any(Function), - set: expect.any(Function), - }), - expect.any(BasePathProxyServer) - ); - }); -}); - describe('start', () => { test('Cannot start without setup phase', async () => { const legacyService = new LegacyService({ diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 63b84e2461e71b..f7abe942d0009d 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { combineLatest, ConnectableObservable, EMPTY, Observable, Subscription } from 'rxjs'; +import { combineLatest, ConnectableObservable, Observable, Subscription } from 'rxjs'; import { first, map, publishReplay, tap } from 'rxjs/operators'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { PathConfigType } from '@kbn/utils'; @@ -18,9 +18,7 @@ import { CoreService } from '../../types'; import { Config } from '../config'; import { CoreContext } from '../core_context'; import { CspConfigType, config as cspConfig } from '../csp'; -import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { - BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig, @@ -64,7 +62,6 @@ export class LegacyService implements CoreService { /** Symbol to represent the legacy platform as a fake "plugin". Used by the ContextService */ public readonly legacyId = Symbol(); private readonly log: Logger; - private readonly devConfig$: Observable; private readonly httpConfig$: Observable; private kbnServer?: LegacyKbnServer; private configSubscription?: Subscription; @@ -77,9 +74,6 @@ export class LegacyService implements CoreService { const { logger, configService } = coreContext; this.log = logger.get('legacy-service'); - this.devConfig$ = configService - .atPath(devConfig.path) - .pipe(map((rawConfig) => new DevConfig(rawConfig))); this.httpConfig$ = combineLatest( configService.atPath(httpConfig.path), configService.atPath(cspConfig.path), @@ -142,17 +136,12 @@ export class LegacyService implements CoreService { this.log.debug('starting legacy service'); - // Receive initial config and create kbnServer/ClusterManager. - if (this.coreContext.env.isDevCliParent) { - await this.setupCliDevMode(this.legacyRawConfig!); - } else { - this.kbnServer = await this.createKbnServer( - this.settings!, - this.legacyRawConfig!, - setupDeps, - startDeps - ); - } + this.kbnServer = await this.createKbnServer( + this.settings!, + this.legacyRawConfig!, + setupDeps, + startDeps + ); } public async stop() { @@ -169,26 +158,6 @@ export class LegacyService implements CoreService { } } - private async setupCliDevMode(config: LegacyConfig) { - const basePathProxy$ = this.coreContext.env.cliArgs.basePath - ? combineLatest([this.devConfig$, this.httpConfig$]).pipe( - first(), - map( - ([dev, http]) => - new BasePathProxyServer(this.coreContext.logger.get('server'), http, dev) - ) - ) - : EMPTY; - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { CliDevMode } = require('./cli_dev_mode'); - CliDevMode.fromCoreServices( - this.coreContext.env.cliArgs, - config, - await basePathProxy$.toPromise() - ); - } - private async createKbnServer( settings: LegacyVars, config: LegacyConfig, diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 6a49dd963b4e8f..2d54648d229502 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -91,7 +91,7 @@ const createPlugin = ( }); }; -async function testSetup(options: { isDevCliParent?: boolean } = {}) { +async function testSetup() { mockPackage.raw = { branch: 'feature-v1', version: 'v1', @@ -103,10 +103,7 @@ async function testSetup(options: { isDevCliParent?: boolean } = {}) { }; coreId = Symbol('core'); - env = Env.createDefault(REPO_ROOT, { - ...getEnvOptions(), - isDevCliParent: options.isDevCliParent ?? false, - }); + env = Env.createDefault(REPO_ROOT, getEnvOptions()); config$ = new BehaviorSubject>({ plugins: { initialize: true } }); const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ }); @@ -626,30 +623,3 @@ describe('PluginsService', () => { }); }); }); - -describe('PluginService when isDevCliParent is true', () => { - beforeEach(async () => { - await testSetup({ - isDevCliParent: true, - }); - }); - - describe('#discover()', () => { - it('does not try to run discovery', async () => { - await expect(pluginsService.discover({ environment: environmentSetup })).resolves - .toMatchInlineSnapshot(` - Object { - "pluginPaths": Array [], - "pluginTree": undefined, - "uiPlugins": Object { - "browserConfigs": Map {}, - "internal": Map {}, - "public": Map {}, - }, - } - `); - - expect(mockDiscover).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 92b06d7b6a09b7..8b33e2cf4cc6be 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -7,7 +7,7 @@ */ import Path from 'path'; -import { Observable, EMPTY } from 'rxjs'; +import { Observable } from 'rxjs'; import { filter, first, map, mergeMap, tap, toArray } from 'rxjs/operators'; import { pick } from '@kbn/std'; @@ -75,11 +75,9 @@ export class PluginsService implements CoreService; private readonly pluginConfigDescriptors = new Map(); private readonly uiPluginInternalInfo = new Map(); - private readonly discoveryDisabled: boolean; constructor(private readonly coreContext: CoreContext) { this.log = coreContext.logger.get('plugins-service'); - this.discoveryDisabled = coreContext.env.isDevCliParent; this.pluginsSystem = new PluginsSystem(coreContext); this.configService = coreContext.configService; this.config$ = coreContext.configService @@ -90,14 +88,9 @@ export class PluginsService implements CoreService(); - const initialize = config.initialize && !this.coreContext.env.isDevCliParent; - if (initialize) { + if (config.initialize) { contracts = await this.pluginsSystem.setupPlugins(deps); this.registerPluginStaticDirs(deps); } else { @@ -131,7 +123,7 @@ export class PluginsService implements CoreService void ) { this.loggingSystem = new LoggingSystem(); @@ -87,10 +87,7 @@ export class Root { // Stream that maps config updates to logger updates, including update failures. const update$ = configService.getConfig$().pipe( // always read the logging config when the underlying config object is re-read - // except for the CLI process where we only apply the default logging config once - switchMap(() => - this.env.isDevCliParent ? of(undefined) : configService.atPath('logging') - ), + switchMap(() => configService.atPath('logging')), concatMap((config) => this.loggingSystem.upgrade(config)), // This specifically console.logs because we were not able to configure the logger. // eslint-disable-next-line no-console diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index cf1647ef5cec36..551471d3d3ba81 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -297,7 +297,7 @@ export class BasePath { // Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts // // @internal (undocumented) -export function bootstrap({ configs, cliArgs, applyConfigOverrides, features, }: BootstrapArgs): Promise; +export function bootstrap({ configs, cliArgs, applyConfigOverrides }: BootstrapArgs): Promise; // @public export interface Capabilities { @@ -343,7 +343,7 @@ export const config: { pingTimeout: Type; logQueries: Type; ssl: import("@kbn/config-schema").ObjectType<{ - verificationMode: Type<"none" | "certificate" | "full">; + verificationMode: Type<"certificate" | "none" | "full">; certificateAuthorities: Type; certificate: Type; key: Type; @@ -1265,10 +1265,10 @@ export type KibanaResponseFactory = typeof kibanaResponseFactory; // @public export const kibanaResponseFactory: { - custom: | Buffer | Error | Stream | { + custom: | Error | Buffer | { message: string | Error; attributes?: Record | undefined; - } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; + } | Stream | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; badRequest: (options?: ErrorHttpResponseOptions) => KibanaResponse; unauthorized: (options?: ErrorHttpResponseOptions) => KibanaResponse; forbidden: (options?: ErrorHttpResponseOptions) => KibanaResponse; diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index f2a2b10fdbfde5..fcf09b0295bcbd 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -205,19 +205,3 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockLoggingService.setup).not.toHaveBeenCalled(); expect(mockI18nService.setup).not.toHaveBeenCalled(); }); - -test(`doesn't validate config if env.isDevCliParent is true`, async () => { - const devParentEnv = Env.createDefault(REPO_ROOT, { - ...getEnvOptions(), - isDevCliParent: true, - }); - - const server = new Server(rawConfigService, devParentEnv, logger); - await server.setup(); - - expect(mockEnsureValidConfiguration).not.toHaveBeenCalled(); - expect(mockContextService.setup).toHaveBeenCalled(); - expect(mockHttpService.setup).toHaveBeenCalled(); - expect(mockElasticsearchService.setup).toHaveBeenCalled(); - expect(mockSavedObjectsService.setup).toHaveBeenCalled(); -}); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index ef5164a8c48e18..8905bcd28fe17c 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -120,13 +120,10 @@ export class Server { }); const legacyConfigSetup = await this.legacy.setupLegacyConfig(); - // rely on dev server to validate config, don't validate in the parent process - if (!this.env.isDevCliParent) { - // Immediately terminate in case of invalid configuration - // This needs to be done after plugin discovery - await this.configService.validate(); - await ensureValidConfiguration(this.configService, legacyConfigSetup); - } + // Immediately terminate in case of invalid configuration + // This needs to be done after plugin discovery + await this.configService.validate(); + await ensureValidConfiguration(this.configService, legacyConfigSetup); const contextServiceSetup = this.context.setup({ // We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins: diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index e2dc2c7d99a93e..b0776c48f3bed2 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -6,6 +6,5 @@ * Side Public License, v 1. */ -export * from './crypto'; export * from './from_root'; export * from './package_json'; diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 5e274712ad3a78..d702fed73778f1 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -70,7 +70,6 @@ export function createRootWithSettings( dist: false, ...cliArgs, }, - isDevCliParent: false, }); return new Root( diff --git a/yarn.lock b/yarn.lock index ce504fd9e96862..0bbfe98f5d1d86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2618,6 +2618,10 @@ version "0.0.0" uid "" +"@kbn/cli-dev-mode@link:packages/kbn-cli-dev-mode": + version "0.0.0" + uid "" + "@kbn/config-schema@link:packages/kbn-config-schema": version "0.0.0" uid "" @@ -2626,6 +2630,10 @@ version "0.0.0" uid "" +"@kbn/crypto@link:packages/kbn-crypto": + version "0.0.0" + uid "" + "@kbn/dev-utils@link:packages/kbn-dev-utils": version "0.0.0" uid "" @@ -2690,6 +2698,10 @@ version "0.0.0" uid "" +"@kbn/server-http-tools@link:packages/kbn-server-http-tools": + version "0.0.0" + uid "" + "@kbn/std@link:packages/kbn-std": version "0.0.0" uid ""