236 lines
6.6 KiB
Lua
236 lines
6.6 KiB
Lua
-- SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
-- 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.
|
|
local http_server = require('http.server')
|
|
local http_headers = require('http.headers')
|
|
local http_websocket = require('http.websocket')
|
|
local http_util = require "http.util"
|
|
local x509, pkey = require('openssl.x509'), require('openssl.pkey')
|
|
|
|
-- Module declaration
|
|
local M = {}
|
|
|
|
-- Export HTTP service endpoints
|
|
M.endpoints = {
|
|
['/'] = {'text/html', 'test'},
|
|
}
|
|
|
|
-- Serve known requests, for methods other than GET
|
|
-- the endpoint must be a closure and not a preloaded string
|
|
local function serve(endpoints, h, stream)
|
|
local hsend = http_headers.new()
|
|
local path = h:get(':path')
|
|
local entry = endpoints[path]
|
|
if not entry then -- Accept top-level path match
|
|
entry = endpoints[path:match '^/[^/?]*']
|
|
end
|
|
-- Unpack MIME and data
|
|
local data, mime, ttl, err
|
|
if entry then
|
|
mime = entry[1]
|
|
data = entry[2]
|
|
ttl = entry[4]
|
|
end
|
|
-- Get string data out of service endpoint
|
|
if type(data) == 'function' then
|
|
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
|
|
-- Handler doesn't provide any data
|
|
if data == false then return end
|
|
if type(data) == 'number' then return tostring(data), err end
|
|
-- Methods other than GET require handler to be closure
|
|
elseif h:get(':method') ~= 'GET' then
|
|
return '501', ''
|
|
end
|
|
if not mime or type(data) ~= 'string' then
|
|
return '404', ''
|
|
else
|
|
-- Serve content type appropriately
|
|
hsend:append(':status', '200')
|
|
hsend:append('content-type', mime)
|
|
hsend:append('content-length', tostring(#data))
|
|
if ttl then
|
|
hsend:append('cache-control', string.format('max-age=%d', ttl))
|
|
end
|
|
assert(stream:write_headers(hsend, false))
|
|
assert(stream:write_chunk(data, true))
|
|
end
|
|
end
|
|
|
|
-- Web server service closure
|
|
local function route(endpoints)
|
|
return function (_, stream)
|
|
-- 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
|
|
local h = assert(stream:get_headers())
|
|
local m = h:get(':method')
|
|
local path = h:get(':path')
|
|
-- Upgrade connection to WebSocket
|
|
local ws = http_websocket.new_from_stream(stream, h)
|
|
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
|
|
ws:close()
|
|
return
|
|
else
|
|
local ok, err, reason = http_util.yieldable_pcall(serve, endpoints, h, stream)
|
|
if not ok or err then
|
|
print(string.format('%s err %s %s: %s (%s)', os.date(), m, path, err or '500', reason))
|
|
-- Method is not supported
|
|
local hsend = http_headers.new()
|
|
hsend:append(':status', err or '500')
|
|
if reason then
|
|
assert(stream:write_headers(hsend, false))
|
|
assert(stream:write_chunk(reason, true))
|
|
else
|
|
assert(stream:write_headers(hsend, true))
|
|
end
|
|
else
|
|
print(string.format('%s ok %s %s', os.date(), m, path))
|
|
end
|
|
end
|
|
end
|
|
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
|
|
end
|
|
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 Listen on given HTTP(s) host
|
|
function M.add_interface(conf)
|
|
local crt, key
|
|
if conf.tls ~= false then
|
|
assert(conf.cert, 'cert missing')
|
|
assert(conf.key, 'private key missing')
|
|
-- Check if a cert file was specified
|
|
-- Read x509 certificate
|
|
local f = io.open(conf.cert, 'r')
|
|
if f then
|
|
crt = assert(x509.new(f:read('*all')))
|
|
f:close()
|
|
-- Continue reading key file
|
|
if crt then
|
|
f = io.open(conf.key, 'r')
|
|
key = assert(pkey.new(f:read('*all')))
|
|
f:close()
|
|
end
|
|
end
|
|
-- Check loaded certificate
|
|
assert(crt and key,
|
|
string.format('failed to load certificate "%s"', conf.cert))
|
|
end
|
|
-- Compose server handler
|
|
local routes = route(conf.endpoints or M.endpoints)
|
|
-- Check if UNIX socket path is used
|
|
local addr_str
|
|
if not conf.path then
|
|
conf.host = conf.host or 'localhost'
|
|
conf.port = conf.port or 8453
|
|
addr_str = string.format('%s@%d', conf.host, conf.port)
|
|
else
|
|
if conf.host or conf.port then
|
|
error('either "path", or "host" and "port" must be provided')
|
|
end
|
|
addr_str = conf.path
|
|
end
|
|
-- Create TLS context and start listening
|
|
local s, err = http_server.listen {
|
|
-- cq = worker.bg_worker.cq,
|
|
host = conf.host,
|
|
port = conf.port,
|
|
path = conf.path,
|
|
v6only = conf.v6only,
|
|
unlink = conf.unlink,
|
|
reuseaddr = conf.reuseaddr,
|
|
reuseport = conf.reuseport,
|
|
client_timeout = conf.client_timeout or 5,
|
|
ctx = crt and tlscontext(crt, key),
|
|
tls = conf.tls,
|
|
onstream = routes,
|
|
-- Log errors, but do not throw
|
|
onerror = function(myserver, context, op, err, errno) -- luacheck: ignore 212
|
|
local msg = '[http] ' .. op .. ' on ' .. tostring(context) .. ' failed'
|
|
if err then
|
|
msg = msg .. ': ' .. tostring(err)
|
|
end
|
|
print(msg)
|
|
end,
|
|
}
|
|
-- Manually call :listen() so that we are bound before calling :localname()
|
|
if s then
|
|
err = select(2, s:listen())
|
|
end
|
|
assert(not err, string.format('failed to listen on %s: %s', addr_str, err))
|
|
return s
|
|
end
|
|
|
|
-- init
|
|
local files = {
|
|
'ok0_badtimes.xml',
|
|
'ok1.xml',
|
|
'ok1_expired1.xml',
|
|
'ok1_notyet1.xml',
|
|
'ok2.xml',
|
|
'err_attr_validfrom_missing.xml',
|
|
'err_attr_validfrom_invalid.xml',
|
|
'err_attr_extra_attr.xml',
|
|
'err_elem_missing.xml',
|
|
'err_elem_extra.xml',
|
|
'err_multi_ta.xml',
|
|
'unsupp_nonroot.xml',
|
|
'unsupp_xml_v11.xml'
|
|
}
|
|
|
|
-- Export static pages specified at command line
|
|
for _, name in ipairs(files) do
|
|
local fd = io.open(name)
|
|
assert(fd, string.format('unable to open file "%s"', name))
|
|
M.endpoints['/' .. name] = { 'text/xml', fd:read('*a') }
|
|
fd:close()
|
|
end
|
|
|
|
local server = M.add_interface({
|
|
host = 'localhost',
|
|
port = 8080,
|
|
tls = true,
|
|
cert = 'x509/server.pem',
|
|
key = 'x509/server-key.pem'
|
|
})
|
|
|
|
server:loop()
|