diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 6cc0e9785a2d4..be0dc3ec36ea2 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -257,6 +257,9 @@ modify the landing page when opening Kibana. Supported on {ece}. `server.host:`:: *Default: "localhost"* This setting specifies the host of the back end server. +`server.keepaliveTimeout:`:: *Default: "120000"* The number of milliseconds to wait for additional data before restarting +the `server.socketTimeout` counter. + `server.maxPayloadBytes:`:: *Default: 1048576* The maximum payload size in bytes for incoming server requests. @@ -271,6 +274,9 @@ rewrite requests that are prefixed with `server.basePath` or require that they are rewritten by your reverse proxy. This setting was effectively always `false` before Kibana 6.3 and will default to `true` starting in Kibana 7.0. +`server.socketTimeout:`:: *Default: "120000"* The number of milliseconds to wait before closing an +inactive socket. + `server.ssl.certificate:` and `server.ssl.key:`:: Paths to the PEM-format SSL certificate and SSL key files, respectively. diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 1c7d709f1a630..bdb65809d811c 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -14,11 +14,13 @@ Object { "autoListen": true, "cors": false, "host": "localhost", + "keepaliveTimeout": 120000, "maxPayload": ByteSizeValue { "valueInBytes": 1048576, }, "port": 5601, "rewriteBasePath": false, + "socketTimeout": 120000, "ssl": Object { "cipherSuites": Array [ "ECDHE-RSA-AES128-GCM-SHA256", diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index 705445a46c83d..76d8205b9e7fc 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -24,7 +24,7 @@ import { sample } from 'lodash'; import { DevConfig } from '../dev'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; -import { createServer, getServerOptions } from './http_tools'; +import { createServer, getListenerOptions, getServerOptions } from './http_tools'; const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); @@ -62,7 +62,8 @@ export class BasePathProxyServer { this.log.debug('starting basepath proxy server'); const serverOptions = getServerOptions(this.httpConfig); - this.server = createServer(serverOptions); + const listenerOptions = getListenerOptions(this.httpConfig); + this.server = createServer(serverOptions, listenerOptions); // Register hapi plugin that adds proxying functionality. It can be configured // through the route configuration object (see { handler: { proxy: ... } }). diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 35f3db9fb97c6..11eec99a6f014 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -125,6 +125,16 @@ describe('with TLS', () => { expect(configValue.ssl.certificateAuthorities).toBe('/authority/'); }); + test('can specify socket timeouts', () => { + const obj = { + keepaliveTimeout: 1e5, + socketTimeout: 5e5, + }; + const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); + expect(keepaliveTimeout).toBe(1e5); + expect(socketTimeout).toBe(5e5); + }); + test('can specify several `certificateAuthorities`', () => { const obj = { ssl: { diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 4d2279e90abed..f808a4e9032dd 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -63,6 +63,12 @@ export const config = { }), rewriteBasePath: schema.boolean({ defaultValue: false }), ssl: sslSchema, + keepaliveTimeout: schema.number({ + defaultValue: 120000, + }), + socketTimeout: schema.number({ + defaultValue: 120000, + }), }, { validate: rawConfig => { @@ -90,6 +96,8 @@ export type HttpConfigType = TypeOf; export class HttpConfig { public autoListen: boolean; public host: string; + public keepaliveTimeout: number; + public socketTimeout: number; public port: number; public cors: boolean | { origin: string[] }; public maxPayload: ByteSizeValue; @@ -108,6 +116,8 @@ export class HttpConfig { this.cors = rawConfig.cors; this.maxPayload = rawConfig.maxPayload; this.basePath = rawConfig.basePath; + this.keepaliveTimeout = rawConfig.keepaliveTimeout; + this.socketTimeout = rawConfig.socketTimeout; this.rewriteBasePath = rawConfig.rewriteBasePath; this.publicDir = env.staticFilesDir; this.ssl = new SslConfig(rawConfig.ssl || {}); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 6dbae8a14d601..2bc5d265a9135 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -22,7 +22,7 @@ import { Request, Server, ServerOptions } from 'hapi'; import { modifyUrl } from '../../utils'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; -import { createServer, getServerOptions } from './http_tools'; +import { createServer, getListenerOptions, getServerOptions } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnRequestFormat, OnRequestHandler } from './lifecycle/on_request'; import { Router, KibanaRequest } from './router'; @@ -101,7 +101,8 @@ export class HttpServer { public setup(config: HttpConfig): HttpServerSetup { const serverOptions = getServerOptions(config); - this.server = createServer(serverOptions); + const listenerOptions = getListenerOptions(config); + this.server = createServer(serverOptions, listenerOptions); return { options: serverOptions, diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index c062b71f3c521..40344ed2c7034 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -17,9 +17,16 @@ * under the License. */ +import supertest from 'supertest'; import { Request, ResponseToolkit } from 'hapi'; import Joi from 'joi'; + import { defaultValidationErrorHandler, HapiValidationError } from './http_tools'; +import { HttpServer } from './http_server'; +import { HttpConfig } from './http_config'; +import { Router } from './router'; +import { loggingServiceMock } from '../logging/logging_service.mock'; +import { ByteSizeValue } from '@kbn/config-schema'; const emptyOutput = { statusCode: 400, @@ -57,3 +64,38 @@ describe('defaultValidationErrorHandler', () => { } }); }); + +describe('timeouts', () => { + const logger = loggingServiceMock.create(); + const server = new HttpServer(logger, 'foo'); + + test('returns 408 on timeout error', async () => { + const router = new Router(''); + router.get({ path: '/a', validate: false }, async (req, res) => { + await new Promise(resolve => setTimeout(resolve, 2000)); + return res.ok({}); + }); + router.get({ path: '/b', validate: false }, (req, res) => res.ok({})); + + const { registerRouter, server: innerServer } = await server.setup({ + socketTimeout: 1000, + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + ssl: {}, + } as HttpConfig); + registerRouter(router); + + await server.start(); + + await supertest(innerServer.listener) + .get('/a') + .expect(408); + await supertest(innerServer.listener) + .get('/b') + .expect(200); + }); + + afterAll(async () => { + await server.stop(); + }); +}); diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 9cdaf64d78122..d22bc1aafd80f 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -78,11 +78,30 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = { return options; } -export function createServer(options: ServerOptions) { - const server = new Server(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); - // Revert to previous 120 seconds keep-alive timeout in Node < 8. - server.listener.keepAliveTimeout = 120e3; + server.listener.keepAliveTimeout = listenerOptions.keepaliveTimeout; + server.listener.setTimeout(listenerOptions.socketTimeout); + server.listener.on('timeout', socket => { + if (socket.writable) { + socket.end(Buffer.from('HTTP/1.1 408 Request Timeout\r\n\r\n', 'ascii')); + } else { + 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')); diff --git a/src/core/server/http/https_redirect_server.ts b/src/core/server/http/https_redirect_server.ts index 789b3890c0112..7e1086752023f 100644 --- a/src/core/server/http/https_redirect_server.ts +++ b/src/core/server/http/https_redirect_server.ts @@ -22,7 +22,7 @@ import { format as formatUrl } from 'url'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; -import { createServer, getServerOptions } from './http_tools'; +import { createServer, getListenerOptions, getServerOptions } from './http_tools'; export class HttpsRedirectServer { private server?: Server; @@ -42,10 +42,13 @@ export class HttpsRedirectServer { // Redirect server is configured in the same way as any other HTTP server // within the platform with the only exception that it should always be a // plain HTTP server, so we just ignore `tls` part of options. - this.server = createServer({ - ...getServerOptions(config, { configureTLS: false }), - port: config.ssl.redirectHttpFromPort, - }); + this.server = createServer( + { + ...getServerOptions(config, { configureTLS: false }), + port: config.ssl.redirectHttpFromPort, + }, + getListenerOptions(config) + ); this.server.ext('onRequest', (request: Request, responseToolkit: ResponseToolkit) => { return responseToolkit diff --git a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 77cef2b625877..e59312cf8a948 100644 --- a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -6,9 +6,11 @@ Object { "basePath": "/abc", "cors": false, "host": "host", + "keepaliveTimeout": 5000, "maxPayload": 1000, "port": 1234, "rewriteBasePath": false, + "socketTimeout": 2000, "ssl": Object { "enabled": true, "keyPassphrase": "some-phrase", @@ -23,9 +25,11 @@ Object { "basePath": "/abc", "cors": false, "host": "host", + "keepaliveTimeout": 5000, "maxPayload": 1000, "port": 1234, "rewriteBasePath": false, + "socketTimeout": 2000, "ssl": Object { "certificate": "cert", "enabled": false, diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts index 7571ef867b26b..202a1e471af9b 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts @@ -69,6 +69,8 @@ describe('#get', () => { cors: false, host: 'host', maxPayloadBytes: 1000, + keepaliveTimeout: 5000, + socketTimeout: 2000, port: 1234, rewriteBasePath: false, ssl: { enabled: true, keyPassphrase: 'some-phrase', someNewValue: 'new' }, @@ -83,6 +85,8 @@ describe('#get', () => { cors: false, host: 'host', maxPayloadBytes: 1000, + keepaliveTimeout: 5000, + socketTimeout: 2000, port: 1234, rewriteBasePath: false, ssl: { enabled: false, certificate: 'cert', key: 'key' }, diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index ea5c3c972fe5c..6e8d052069589 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -68,6 +68,8 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { port: configValue.port, rewriteBasePath: configValue.rewriteBasePath, ssl: configValue.ssl, + keepaliveTimeout: configValue.keepaliveTimeout, + socketTimeout: configValue.socketTimeout, }; } diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 0fab228481182..09db387b6d6d9 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -81,6 +81,8 @@ export default () => Joi.object({ name: Joi.string().default(os.hostname()), host: Joi.string().hostname().default('localhost'), port: Joi.number().default(5601), + keepaliveTimeout: Joi.number().default(120000), + socketTimeout: Joi.number().default(120000), maxPayloadBytes: Joi.number().default(1048576), autoListen: Joi.boolean().default(true), defaultRoute: Joi.string().default('/app/kibana').regex(/^\//, `start with a slash`),