http.lua 10.9 KB
Newer Older
1 2 3
-- Load dependent modules
if not stats then modules.load('stats') end

4 5 6 7 8
-- This is a module that does the heavy lifting to provide an HTTP/2 enabled
-- server that supports TLS by default and provides endpoint for other modules
-- in order to enable them to export restful APIs and websocket streams.
-- One example is statistics module that can stream live metrics on the website,
-- or publish metrics on request for Prometheus scraper.
9 10 11
local http_server = require('http.server')
local http_headers = require('http.headers')
local http_websocket = require('http.websocket')
12
local http_util = require "http.util"
13
local x509, pkey = require('openssl.x509'), require('openssl.pkey')
14
local has_mmdb, mmdb  = pcall(require, 'mmdb')
15 16 17 18 19 20

-- Module declaration
local M = {
	servers = {},
}

21 22 23 24 25 26 27 28 29
-- Map extensions to MIME type
local mime_types = {
	js = 'application/javascript',
	css = 'text/css',
	tpl = 'text/html',
	ico = 'image/x-icon'
}

-- Preload static contents, nothing on runtime will touch the disk
30 31
local function pgload(relpath, modname)
	if not modname then modname = 'http' end
32 33
	local mdir = moduledir()
	local fp, err = io.open(string.format('%s/%s/%s', mdir, modname, relpath), 'r')
Marek Vavrusa's avatar
Marek Vavrusa committed
34
	if not fp then
35
		fp, err = io.open(string.format('%s/%s/static/%s', mdir, modname, relpath), 'r')
Marek Vavrusa's avatar
Marek Vavrusa committed
36
	end
37 38 39 40 41
	if not fp then error(err) end
	local data = fp:read('*all')
	fp:close()
	-- Guess content type
	local ext = relpath:match('[^\\.]+$')
42
	return {mime_types[ext] or 'text', data, nil, 86400}
43
end
44
M.page = pgload
45 46 47

-- Preloaded static assets
local pages = {
48 49
	'favicon.ico',
	'kresd.js',
50
	'kresd.css',
51 52
	'jquery.js',
	'd3.js',
53 54
	'topojson.js',
	'datamaps.world.min.js',
55
	'dygraph-combined.js',
56 57 58 59 60 61
	'selectize.min.js',
	'selectize.min.css',
	'selectize.bootstrap3.min.css',
	'bootstrap.min.js',
	'bootstrap.min.css',
	'bootstrap-theme.min.css',
62
	'glyphicons-halflings-regular.woff2',
63 64 65 66
}

-- Serve preloaded root page
local function serve_root()
67
	local data = pgload('main.tpl')[2]
68
	data = data
69
	        :gsub('{{ title }}', M.title or ('kresd @ ' .. hostname()))
70
	        :gsub('{{ host }}', hostname())
71
	return function (_, stream)
72
		-- Render snippets
73 74
		local rsnippets = {}
		for _,v in pairs(M.snippets) do
75 76
			local sid = string.lower(string.gsub(v[1], ' ', '-'))
			table.insert(rsnippets, string.format('<section id="%s"><h2>%s</h2>\n%s</section>', sid, v[1], v[2]))
77
		end
78 79 80 81
		-- Return index page
		return data
		        :gsub('{{ secure }}', stream:checktls() and 'true' or 'false')
		        :gsub('{{ snippets }}', table.concat(rsnippets, '\n'))
82 83 84 85 86
	end
end

-- Export HTTP service endpoints
M.endpoints = {
87
	['/'] = {'text/html', serve_root()},
88
}
89 90

-- Export static pages
91
for _, pg in ipairs(pages) do
92
	M.endpoints['/'..pg] = pgload(pg)
93 94 95
end

-- Export built-in prometheus interface
96 97
local prometheus = require('prometheus')
for k, v in pairs(prometheus.endpoints) do
98
	M.endpoints[k] = v
99
end
Marek Vavrusa's avatar
Marek Vavrusa committed
100
M.prometheus = prometheus
101

102 103 104 105 106 107 108
-- Export built-in trace interface
local http_trace = require('http_trace')
for k, v in pairs(http_trace.endpoints) do
	M.endpoints[k] = v
end
M.trace = http_trace

109 110
-- Export HTTP service page snippets
M.snippets = {}
111

112 113
-- Serve known requests, for methods other than GET
-- the endpoint must be a closure and not a preloaded string
114
local function serve(endpoints, h, stream)
115
	local hsend = http_headers.new()
116
	local path = h:get(':path')
117
	local entry = endpoints[path]
118
	if not entry then -- Accept top-level path match
119
		entry = endpoints[path:match '^/[^/?]*']
120
	end
121
	-- Unpack MIME and data
122
	local data, mime, ttl, err
123
	if entry then
124 125 126
		mime = entry[1]
		data = entry[2]
		ttl = entry[4]
127 128
	end
	-- Get string data out of service endpoint
129
	if type(data) == 'function' then
130 131 132 133 134
		local set_mime, set_ttl
		data, err, set_mime, set_ttl = data(h, stream)
		-- Override default endpoint mime/TTL
		if set_mime then mime = set_mime end
		if set_ttl then ttl = set_ttl end
135 136
		-- Handler doesn't provide any data
		if data == false then return end
137
		if type(data) == 'number' then return tostring(data), err end
138 139
	-- Methods other than GET require handler to be closure
	elseif h:get(':method') ~= 'GET' then
140
		return '501', ''
141
	end
142
	if type(data) == 'table' then data = tojson(data) end
143
	if not mime or type(data) ~= 'string' then
144
		return '404', ''
145 146 147
	else
		-- Serve content type appropriately
		hsend:append(':status', '200')
148
		hsend:append('content-type', mime)
149
		hsend:append('content-length', tostring(#data))
150 151 152
		if ttl then
			hsend:append('cache-control', string.format('max-age=%d', ttl))
		end
153 154 155 156 157 158
		assert(stream:write_headers(hsend, false))
		assert(stream:write_chunk(data, true))
	end
end

-- Web server service closure
159
local function route(endpoints)
160
	return function (_, stream)
161 162 163 164 165 166 167 168
		-- HTTP/2: We're only permitted to send in open/half-closed (remote)
		local connection = stream.connection
		if connection.version >= 2 then
			if stream.state ~= 'open' and stream.state ~= 'half closed (remote)' then
				return
			end
		end
		-- Start reading headers
169 170 171 172
		local h = assert(stream:get_headers())
		local m = h:get(':method')
		local path = h:get(':path')
		-- Upgrade connection to WebSocket
173
		local ws = http_websocket.new_from_stream(stream, h)
174 175 176 177 178 179 180 181
		if ws then
			assert(ws:accept { protocols = {'json'} })
			-- Continue streaming results to client
			local ep = endpoints[path]
			local cb = ep[3]
			if cb then
				cb(h, ws)
			end
182
			ws:close()
183 184
			return
		else
185
			local ok, err, reason = http_util.yieldable_pcall(serve, endpoints, h, stream)
186
			if not ok or err then
187
				if err ~= '404' and verbose() then
188 189
					log('[http] %s %s: %s (%s)', m, path, err or '500', reason)
				end
190
				-- Method is not supported
191
				local hsend = http_headers.new()
192
				hsend:append(':status', err or '500')
193 194 195 196 197 198
				if reason then
					assert(stream:write_headers(hsend, false))
					assert(stream:write_chunk(reason, true))
				else
					assert(stream:write_headers(hsend, true))
				end
199
			end
200
		end
201 202 203 204 205 206 207 208
	end
end

-- @function Create self-signed certificate
local function ephemeralcert(host)
	-- Import luaossl directly
	local name = require('openssl.x509.name')
	local altname = require('openssl.x509.altname')
209 210
	local openssl_bignum = require('openssl.bignum')
	local openssl_rand = require('openssl.rand')
211 212 213 214
	-- Create self-signed certificate
	host = host or hostname()
	local crt = x509.new()
	local now = os.time()
215 216 217 218
	crt:setVersion(3)
	-- serial needs to be unique or browsers will show uninformative error messages
	crt:setSerial(openssl_bignum.fromBinary(openssl_rand.bytes(16)))
	-- use the host we're listening on as canonical name
219 220 221
	local dn = name.new()
	dn:add("CN", host)
	crt:setSubject(dn)
222
	crt:setIssuer(dn) -- should match subject for a self-signed
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
	local alt = altname.new()
	alt:add("DNS", host)
	crt:setSubjectAlt(alt)
	-- Valid for 90 days
	crt:setLifetime(now, now + 90*60*60*24)
	-- Can't be used as a CA
	crt:setBasicConstraints{CA=false}
	crt:setBasicConstraintsCritical(true)
	-- Create and set key (default: EC/P-256 as a most "interoperable")
	local key = pkey.new {type = 'EC', curve = 'prime256v1'}
	crt:setPublicKey(key)
	crt:sign(key)
	return crt, key
end

-- @function Prefer HTTP/2 or HTTP/1.1
local function alpnselect(_, protos)
	for _, proto in ipairs(protos) do
		if proto == 'h2' or proto == 'http/1.1' then
			return proto
		end
244
	end
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
	return nil
end

-- @function Create TLS context
local function tlscontext(crt, key)
	local http_tls = require('http.tls')
	local ctx = http_tls.new_server_context()
	if ctx.setAlpnSelect then
		ctx:setAlpnSelect(alpnselect)
	end
	assert(ctx:setPrivateKey(key))
	assert(ctx:setCertificate(crt))
	return ctx
end

-- @function Refresh self-signed certificates
local function updatecert(crtfile, keyfile)
	local f = assert(io.open(crtfile, 'w'), string.format('cannot open "%s" for writing', crtfile))
	local crt, key = ephemeralcert()
	-- Write back to file
	f:write(tostring(crt))
	f:close()
	f = assert(io.open(keyfile, 'w'), string.format('cannot open "%s" for writing', keyfile))
	local pub, priv = key:toPEM('public', 'private')
	assert(f:write(pub..priv))
	f:close()
	return crt, key
272 273 274
end

-- @function Listen on given HTTP(s) host
275
function M.add_interface(conf)
276
	local crt, key, ephemeral
277
	if conf.crtfile ~= false then
278
		-- Check if the cert file exists
279 280 281
		if not conf.crtfile then
			conf.crtfile = 'self.crt'
			conf.keyfile = 'self.key'
282
			ephemeral = true
283
		elseif not conf.keyfile then
284 285
			error('certificate provided, but missing key')
		end
286
		-- Read or create self-signed x509 certificate
287
		local f = io.open(conf.crtfile, 'r')
288 289 290 291 292
		if f then
			crt = assert(x509.new(f:read('*all')))
			f:close()
			-- Continue reading key file
			if crt then
293
				f = io.open(conf.keyfile, 'r')
294 295 296 297
				key = assert(pkey.new(f:read('*all')))
				f:close()
			end
		elseif ephemeral then
298
			crt, key = updatecert(conf.crtfile, conf.keyfile)
299 300 301
		end
		-- Check loaded certificate
		if not crt or not key then
302
			panic('failed to load certificate "%s"', conf.crtfile)
303 304
		end
	end
305
	-- Compose server handler
306 307 308 309 310 311 312
	local routes = route(conf.endpoints or M.endpoints)
	-- Enable SO_REUSEPORT by default (unless explicitly turned off)
	local reuseport = (conf.reuseport ~= nil) and conf.reuseport or true
	if not reuseport and worker.id > 0 then
		warn('[http] the "reuseport" option is disabled and multiple forks are used, ' ..
			 'port binding will fail on some instances')
	end
313
	-- Create TLS context and start listening
314
	local s, err = http_server.listen {
315
		cq = worker.bg_worker.cq,
316 317 318 319 320 321
		host = conf.host or 'localhost',
		port = conf.port or 8053,
		v6only = conf.v6only,
		reuseaddr = conf.reuseaddr,
		reuseport = reuseport,
		client_timeout = conf.client_timeout or 5,
322
		ctx = crt and tlscontext(crt, key),
323
		onstream = routes,
324
	}
325 326 327 328 329
	-- Manually call :listen() so that we are bound before calling :localname()
	if s then
		err = select(2, s:listen())
	end
	if err then
330
		panic('failed to listen on %s@%d: %s', conf.host, conf.port, err)
331 332
	end
	table.insert(M.servers, s)
333 334 335 336
	-- Create certificate renewal timer if ephemeral
	if crt and ephemeral then
		local _, expiry = crt:getLifetime()
		expiry = math.max(0, expiry - (os.time() - 3 * 24 * 3600))
337
		event.after(expiry, function ()
338
			log('[http] refreshed ephemeral certificate')
339
			crt, key = updatecert(conf.crtfile, conf.keyfile)
340 341 342
			s.ctx = tlscontext(crt, key)
		end)
	end
343 344
end

345 346 347 348 349 350 351 352 353 354 355
-- @function Listen on given HTTP(s) host (backwards compatible interface)
function M.interface(host, port, endpoints, crtfile, keyfile)
	return M.add_interface {
		host = host,
		port = port,
		endpoints = endpoints,
		crtfile = crtfile,
		keyfile = keyfile,
	}
end

356 357
-- @function Init module
function M.init()
358
	worker.coroutine(prometheus.init)
359 360
end

361 362
-- @function Cleanup module
function M.deinit()
363 364 365 366
	for i, server in ipairs(M.servers) do
		server:close()
		M.servers[i] = nil
	end
367
	prometheus.deinit()
368
end
369

370 371
-- @function Configure module
function M.config(conf)
372 373 374
	if conf == true then conf = {} end
	assert(type(conf) == 'table', 'config { host = "...", port = 443, cert = "...", key = "..." }')
	-- Configure web interface for resolver
375 376 377 378 379 380 381
	if conf.geoip then
		if has_mmdb then
			M.geoip = mmdb.open(conf.geoip)
		else
			error('[http] mmdblua library not found, please remove GeoIP configuration')
		end
	end
382
	M.add_interface(conf)
383 384 385
end

return M