diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c78373..55724bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Allow to use a non-standard socket (for example, `sslsocket` with TLS support). +### Added + +- Add option to control keepalive connection state (#137). + ## [1.2.0] - 2021-11-10 ### Changed diff --git a/README.md b/README.md index f25be23..7d586e6 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,20 @@ httpd = require('http.server').new(host, port[, { options } ]) By default uses `log.info` function for requests logging. * `log_errors` - same as the `log_requests` option but is used for error messages logging. By default uses `log.error()` function. +* `disable_keepalive` - disables keep-alive connections with misbehaving + clients. Parameter accept a table that contains a user agents names. + By default table is empty. + + Example: + + ```lua + local httpd = http_server.new('127.0.0.1', 8080, { + log_requests = true, + log_errors = true, + disable_keepalive = { 'curl/7.68.0' } + }) + ``` + ## Using routes diff --git a/http/server.lua b/http/server.lua index 1b32d54..d193f45 100644 --- a/http/server.lua +++ b/http/server.lua @@ -23,6 +23,16 @@ local function sprintf(fmt, ...) return string.format(fmt, ...) end +-- Converts a table to a map, values becomes keys with value 'true'. +-- { 'a', 'b', 'c' } -> { 'a' = true, 'b' == 'true', 'c' = true } +local function tomap(tbl) + local map = {} + for _, v in pairs(tbl) do + map[v] = true + end + return map +end + local function valid_cookie_value_byte(byte) -- https://tools.ietf.org/html/rfc6265#section-4.1.1 -- US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, @@ -891,6 +901,11 @@ local function process_client(self, s, peer) end end + local useragent = p.headers['user-agent'] + if self.disable_keepalive[useragent] == true then + hdrs.connection = 'close' + end + local response = { "HTTP/1.1 "; status; @@ -1259,7 +1274,9 @@ local function httpd_start(self) local server = self.tcp_server_f(self.host, self.port, { name = 'http', handler = function(...) + self.internal.preprocess_client_handler() process_client(self, ...) + self.internal.postprocess_client_handler() end, http_server = self, }) @@ -1283,8 +1300,13 @@ local exports = { options = {} end if type(options) ~= 'table' then - errorf("options must be table not '%s'", type(options)) + errorf("options must be table not a '%s'", type(options)) end + options.disable_keepalive = options.disable_keepalive or {} + if type(options.disable_keepalive) ~= 'table' then + error('Option disable_keepalive must be a table.') + end + local default = { max_header_size = 4096, header_timeout = 100, @@ -1297,6 +1319,7 @@ local exports = { log_requests = true, log_errors = true, display_errors = false, + disable_keepalive = {}, } local self = { @@ -1330,6 +1353,13 @@ local exports = { ctx = {}, static = {}, }, + + disable_keepalive = tomap(options.disable_keepalive), + + internal = { + preprocess_client_handler = function() end, + postprocess_client_handler = function() end, + } } return self diff --git a/test/integration/http_server_options_test.lua b/test/integration/http_server_options_test.lua new file mode 100644 index 0000000..d70d535 --- /dev/null +++ b/test/integration/http_server_options_test.lua @@ -0,0 +1,89 @@ +local t = require('luatest') +local http_client = require('http.client') + +local helpers = require('test.helpers') + +local g = t.group() + +g.after_each(function() + helpers.teardown(g.httpd) +end) + +g.before_test('test_keepalive_is_allowed', function() + g.httpd = helpers.cfgserv() + g.httpd:start() +end) + +g.test_keepalive_is_allowed = function() + local conn_is_opened = false + local conn_is_closed = false + g.httpd.internal.preprocess_client_handler = function() conn_is_opened = true end + g.httpd.internal.postprocess_client_handler = function() conn_is_closed = true end + + -- Set HTTP keepalive headers: Connection:Keep-Alive and + -- Keep-Alive:timeout=. Otherwise HTTP client will send + -- "Connection:close". + local opts = { + keepalive_idle = 3600, + keepalive_interval = 3600, + } + local r = http_client.request('GET', helpers.base_uri .. '/test', nil, opts) + t.assert_equals(r.status, 200) + t.assert_equals(r.headers.connection, 'keep-alive') + t.assert_equals(conn_is_opened, true) + t.assert_equals(conn_is_closed, false) -- Connection is alive. +end + +g.before_test('test_keepalive_is_disallowed', function() + g.useragent = 'Mozilla/4.0' + g.httpd = helpers.cfgserv({ + disable_keepalive = { g.useragent }, + }) + g.httpd:start() +end) + +g.test_keepalive_is_disallowed = function() + local conn_is_opened = false + local conn_is_closed = false + g.httpd.internal.preprocess_client_handler = function() conn_is_opened = true end + g.httpd.internal.postprocess_client_handler = function() conn_is_closed = true end + + -- Set HTTP keepalive headers: Connection:Keep-Alive and + -- Keep-Alive:timeout=. Otherwise HTTP client will send + -- "Connection:close". + local opts = { + headers = { + ['user-agent'] = g.useragent, + }, + keepalive_idle = 3600, + keepalive_interval = 3600, + } + local r = http_client.request('GET', helpers.base_uri .. '/test', nil, opts) + t.assert_equals(r.status, 200) + t.assert_equals(r.headers.connection, 'close') + t.assert_equals(conn_is_opened, true) + t.assert_equals(conn_is_closed, true) -- Connection is closed. +end + +g.before_test('test_disable_keepalive_is_set', function() + g.useragent = 'Mozilla/5.0' + g.httpd = helpers.cfgserv({ + disable_keepalive = { g.useragent }, + }) + g.httpd:start() +end) + +g.test_disable_keepalive_is_set = function(g) + local httpd = g.httpd + t.assert_equals(httpd.disable_keepalive[g.useragent], true) +end + +g.before_test('test_disable_keepalive_default', function() + g.httpd = helpers.cfgserv() + g.httpd:start() +end) + +g.test_disable_keepalive_default = function(g) + local httpd = g.httpd + t.assert_equals(table.getn(httpd.options.disable_keepalive), 0) +end