diff options
Diffstat (limited to 'daemon/lua/trust_anchors.test/webserv.lua')
-rw-r--r-- | daemon/lua/trust_anchors.test/webserv.lua | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/daemon/lua/trust_anchors.test/webserv.lua b/daemon/lua/trust_anchors.test/webserv.lua new file mode 100644 index 0000000..c108bba --- /dev/null +++ b/daemon/lua/trust_anchors.test/webserv.lua @@ -0,0 +1,236 @@ +-- 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() |