http.lua 9.77 KB
Newer Older
1
2
3
4
5
-- 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.
Marek Vavrusa's avatar
Marek Vavrusa committed
6
7
8
9
local cqueues = require('cqueues')
local server = require('http.server')
local headers = require('http.headers')
local websocket = require('http.websocket')
10
local x509, pkey = require('openssl.x509'), require('openssl.pkey')
11
local has_mmdb, mmdb  = pcall(require, 'mmdb')
Marek Vavrusa's avatar
Marek Vavrusa committed
12
13
14
15
16
17
18

-- Module declaration
local cq = cqueues.new()
local M = {
	servers = {},
}

19
20
21
22
23
24
25
26
27
-- 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
28
29
30
local function pgload(relpath, modname)
	if not modname then modname = 'http' end
	local fp, err = io.open(string.format('%s/%s/%s', moduledir, modname, relpath), 'r')
31
32
33
34
35
	if not fp then error(err) end
	local data = fp:read('*all')
	fp:close()
	-- Guess content type
	local ext = relpath:match('[^\\.]+$')
36
	return {mime_types[ext] or 'text', data, nil, 86400}
37
end
38
M.page = pgload
39
40
41

-- Preloaded static assets
local pages = {
42
43
	'favicon.ico',
	'kresd.js',
44
	'kresd.css',
45
46
	'jquery.js',
	'd3.js',
47
48
49
50
51
52
53
54
55
56
57
58
	'topojson.js',
	'datamaps.world.min.js',
	'rickshaw.min.js',
	'rickshaw.min.css',
	'selectize.min.js',
	'selectize.min.css',
	'selectize.bootstrap3.min.css',
	'bootstrap.min.js',
	'bootstrap.min.css',
	'bootstrap.min.css.map',
	'bootstrap-theme.min.css',
	'bootstrap-theme.min.css.map',
59
60
61
62
}

-- Serve preloaded root page
local function serve_root()
63
	local data = pgload('main.tpl')[2]
64
65
66
	data = data
	        :gsub('{{ title }}', 'kresd @ '..hostname())
	        :gsub('{{ host }}', hostname())
67
	return function (h, stream)
68
		-- Render snippets
69
70
		local rsnippets = {}
		for _,v in pairs(M.snippets) do
71
72
			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]))
73
		end
74
75
76
77
		-- Return index page
		return data
		        :gsub('{{ secure }}', stream:checktls() and 'true' or 'false')
		        :gsub('{{ snippets }}', table.concat(rsnippets, '\n'))
Marek Vavrusa's avatar
Marek Vavrusa committed
78
79
80
81
82
	end
end

-- Export HTTP service endpoints
M.endpoints = {
83
	['/'] = {'text/html', serve_root()},
Marek Vavrusa's avatar
Marek Vavrusa committed
84
}
85
86

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

-- Export built-in prometheus interface
for k, v in pairs(require('prometheus')) do
	M.endpoints[k] = v
94
95
96
97
end

-- Export HTTP service page snippets
M.snippets = {}
Marek Vavrusa's avatar
Marek Vavrusa committed
98

99
100
101
-- Serve known requests, for methods other than GET
-- the endpoint must be a closure and not a preloaded string
local function serve(h, stream)
Marek Vavrusa's avatar
Marek Vavrusa committed
102
103
	local hsend = headers.new()
	local path = h:get(':path')
104
	local entry = M.endpoints[path]
105
106
107
	if not entry then -- Accept top-level path match
		entry = M.endpoints[path:match '^/[^/]*']
	end
108
	-- Unpack MIME and data
109
	local mime, data, err
110
111
	if entry then
		mime, data = unpack(entry)
Marek Vavrusa's avatar
Marek Vavrusa committed
112
113
	end
	-- Get string data out of service endpoint
114
	if type(data) == 'function' then
115
		data, err = data(h, stream)
116
117
		-- Handler doesn't provide any data
		if data == false then return end
118
		if type(data) == 'number' then return tostring(data), err end
119
120
121
	-- Methods other than GET require handler to be closure
	elseif h:get(':method') ~= 'GET' then
		return '501'
122
	end
Marek Vavrusa's avatar
Marek Vavrusa committed
123
	if type(data) == 'table' then data = tojson(data) end
124
	if not mime or type(data) ~= 'string' then
125
		return '404'
Marek Vavrusa's avatar
Marek Vavrusa committed
126
127
128
	else
		-- Serve content type appropriately
		hsend:append(':status', '200')
129
130
131
132
133
		hsend:append('content-type', mime)
		local ttl = entry and entry[4]
		if ttl then
			hsend:append('cache-control', string.format('max-age=%d', ttl))
		end
Marek Vavrusa's avatar
Marek Vavrusa committed
134
135
136
137
138
139
		assert(stream:write_headers(hsend, false))
		assert(stream:write_chunk(data, true))
	end
end

-- Web server service closure
140
local function route(endpoints)
Marek Vavrusa's avatar
Marek Vavrusa committed
141
	return function (stream)
142
143
144
145
146
147
148
149
		-- 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
Marek Vavrusa's avatar
Marek Vavrusa committed
150
151
152
153
154
155
156
157
158
159
160
161
162
		local h = assert(stream:get_headers())
		local m = h:get(':method')
		local path = h:get(':path')
		-- Upgrade connection to WebSocket
		local ws = websocket.new_from_stream(h, stream)
		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
163
			ws:close()
Marek Vavrusa's avatar
Marek Vavrusa committed
164
165
			return
		else
166
			local ok, err, reason = pcall(serve, h, stream)
167
			if not ok or err then
168
				log('[http] %s %s: %s (%s)', m, path, err or '500', reason)
169
170
171
				-- Method is not supported
				local hsend = headers.new()
				hsend:append(':status', err or '500')
172
173
174
175
176
177
				if reason then
					assert(stream:write_headers(hsend, false))
					assert(stream:write_chunk(reason, true))
				else
					assert(stream:write_headers(hsend, true))
				end
178
			end
Marek Vavrusa's avatar
Marek Vavrusa committed
179
180
		end
		stream:shutdown()
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
	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')
	-- Create self-signed certificate
	host = host or hostname()
	local crt = x509.new()
	local now = os.time()
	crt:setSerial(now)
	local dn = name.new()
	dn:add("CN", host)
	crt:setSubject(dn)
	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
Marek Vavrusa's avatar
Marek Vavrusa committed
218
	end
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
	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
Marek Vavrusa's avatar
Marek Vavrusa committed
246
247
248
end

-- @function Listen on given HTTP(s) host
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
function M.interface(host, port, endpoints, crtfile, keyfile)
	local crt, key, ephemeral
	if crtfile ~= false then
		-- Check if the cert file exists
		if not crtfile then
			crtfile = 'self.crt'
			keyfile = 'self.key'
			ephemeral = true
		else error('certificate provided, but missing key') end
		-- Read or create self-signed x509 certificate
		local f = io.open(crtfile, 'r')
		if f then
			crt = assert(x509.new(f:read('*all')))
			f:close()
			-- Continue reading key file
			if crt then
				f = io.open(keyfile, 'r')
				key = assert(pkey.new(f:read('*all')))
				f:close()
			end
		elseif ephemeral then
			crt, key = updatecert(crtfile, keyfile)
		end
		-- Check loaded certificate
		if not crt or not key then
274
			panic('failed to load certificate "%s" - %s', crtfile, err or 'error')
275
276
277
		end
	end
	-- Create TLS context and start listening
Marek Vavrusa's avatar
Marek Vavrusa committed
278
279
280
	local s, err = server.listen {
		host = host,
		port = port,
281
282
		client_timeout = 5,
		ctx = crt and tlscontext(crt, key),
Marek Vavrusa's avatar
Marek Vavrusa committed
283
284
	}
	if not s then
285
		panic('failed to listen on %s#%d: %s', host, port, err)
Marek Vavrusa's avatar
Marek Vavrusa committed
286
287
	end
	-- Compose server handler
288
	local routes = route(endpoints)
Marek Vavrusa's avatar
Marek Vavrusa committed
289
	cq:wrap(function ()
290
		assert(s:run(routes))
Marek Vavrusa's avatar
Marek Vavrusa committed
291
292
293
		s:close()
	end)
	table.insert(M.servers, s)
294
295
296
297
298
	-- 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))
		event.after(expiry, function (ev)
299
			log('[http] refreshed ephemeral certificate')
300
301
302
303
			crt, key = updatecert(crtfile, keyfile)
			s.ctx = tlscontext(crt, key)
		end)
	end
Marek Vavrusa's avatar
Marek Vavrusa committed
304
305
306
307
308
309
310
end

-- @function Cleanup module
function M.deinit()
	if M.ev then event.cancel(M.ev) end
	M.servers = {}
end
311

Marek Vavrusa's avatar
Marek Vavrusa committed
312
313
-- @function Configure module
function M.config(conf)
314
315
		conf = conf or {}
		assert(type(conf) == 'table', 'config { host = "...", port = 443, cert = "...", key = "..." }')
Marek Vavrusa's avatar
Marek Vavrusa committed
316
		-- Configure web interface for resolver
317
318
		if not conf.port then conf.port = 8053 end
		if not conf.host then conf.host = 'localhost' end
319
		if conf.geoip and has_mmdb then M.geoip = mmdb.open(conf.geoip) end
320
		M.interface(conf.host, conf.port, M.endpoints, conf.cert, conf.key)
Marek Vavrusa's avatar
Marek Vavrusa committed
321
322
		-- TODO: configure DNS/HTTP(s) interface
		if M.ev then return end
323
324
		-- Schedule both I/O activity notification and timeouts
		local poll_step
325
		poll_step = function ()
Marek Vavrusa's avatar
Marek Vavrusa committed
326
			local ok, err, _, co = cq:step(0)
327
			if not ok then warn('[http] error: %s %s', err, debug.traceback(co)) end
328
329
330
			-- Reschedule timeout or create new one
			local timeout = cq:timeout()
			if timeout then
331
332
				-- Throttle web requests
				if timeout == 0 then timeout = 0.001 end
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
				-- Convert from seconds to duration
				timeout = timeout * sec
				if not M.timeout then
					M.timeout = event.after(timeout, poll_step)
				else
					event.reschedule(M.timeout, timeout)
				end
			else -- Cancel running timeout when there is no next deadline
				if M.timeout then
					event.cancel(M.timeout)
					M.timeout = nil
				end
			end
		end
		M.ev = event.socket(cq:pollfd(), poll_step)
Marek Vavrusa's avatar
Marek Vavrusa committed
348
349
350
end

return M