summaryrefslogtreecommitdiffstats
path: root/modules/http/http.lua.in
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/http/http.lua.in427
1 files changed, 427 insertions, 0 deletions
diff --git a/modules/http/http.lua.in b/modules/http/http.lua.in
new file mode 100644
index 0000000..521ddb2
--- /dev/null
+++ b/modules/http/http.lua.in
@@ -0,0 +1,427 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+-- Load dependent modules
+if not stats then modules.load('stats') end
+if not bogus_log then modules.load('bogus_log') end
+
+local ffi = require('ffi')
+local cqueues = require('cqueues')
+cqueues.socket = require('cqueues.socket')
+assert(cqueues.VERSION >= 20150112) -- fdopen changed semantics
+
+-- 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 has_mmdb, mmdb = pcall(require, 'mmdb')
+
+-- A sub-module for certificate management.
+local tls_cert = require('kres_modules.http_tls_cert')
+
+-- Module declaration
+local M = {
+ servers = {},
+ configs = { _builtin = {} } -- configuration templates
+}
+
+-- inherited by all configurations
+M.configs._builtin._all = {
+ cq = worker.bg_worker.cq,
+ cert = 'self.crt',
+ key = 'self.key',
+ ephemeral = true,
+ client_timeout = 5
+}
+-- log errors but do not throw
+M.configs._builtin._all.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
+ if verbose() then
+ log(msg)
+ end
+end
+
+-- M.config() without explicit "kind" modifies this
+M.configs._all = {}
+
+-- DoH
+M.configs._builtin.doh = {}
+
+-- management endpoint
+M.configs._builtin.webmgmt = {}
+
+-- 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
+local function pgload(relpath, modname)
+ if not modname then modname = 'http' end
+ local fp, err = io.open(string.format(
+ '@modules_dir@/%s/%s', modname, relpath), 'r')
+ if not fp then
+ fp, err = io.open(string.format(
+ '@modules_dir@/%s/static/%s', modname, relpath), 'r')
+ end
+ if not fp then error(err) end
+ local data = fp:read('*all')
+ fp:close()
+ -- Guess content type
+ local ext = relpath:match('[^\\.]+$')
+ return {mime_types[ext] or 'text', data, nil, 86400}
+end
+M.page = pgload
+
+-- Preloaded static assets
+local pages = {
+ 'favicon.ico',
+ 'kresd.js',
+ 'kresd.css',
+ 'jquery.js',
+ 'd3.js',
+ 'topojson.js',
+ 'datamaps.world.min.js',
+ 'dygraph.min.js',
+ 'selectize.min.js',
+ 'selectize.bootstrap3.css',
+ 'bootstrap.min.js',
+ 'bootstrap.min.css',
+ 'bootstrap-theme.min.css',
+ 'glyphicons-halflings-regular.woff2',
+}
+
+-- Serve preloaded root page
+local function serve_root()
+ local data = pgload('main.tpl')[2]
+ data = data
+ :gsub('{{ title }}', M.title or ('kresd @ ' .. hostname()))
+ :gsub('{{ host }}', hostname())
+ return function (_, _)
+ -- Render snippets
+ local rsnippets = {}
+ for _,v in pairs(M.snippets) do
+ 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]))
+ end
+ -- Return index page
+ return data
+ :gsub('{{ snippets }}', table.concat(rsnippets, '\n'))
+ end
+end
+
+-- Export HTTP service endpoints
+M.configs._builtin.doh.endpoints = {}
+M.configs._builtin.webmgmt.endpoints = {}
+local mgmt_endpoints = M.configs._builtin.webmgmt.endpoints
+
+mgmt_endpoints['/'] = {'text/html', serve_root()}
+
+-- Export static pages
+for _, pg in ipairs(pages) do
+ mgmt_endpoints['/'..pg] = pgload(pg)
+end
+
+-- Export built-in prometheus interface
+local prometheus = require('kres_modules.prometheus')
+for k, v in pairs(prometheus.endpoints) do
+ mgmt_endpoints[k] = v
+end
+M.prometheus = prometheus
+
+-- Export built-in trace interface
+local http_trace = require('kres_modules.http_trace')
+for k, v in pairs(http_trace.endpoints) do
+ mgmt_endpoints[k] = v
+end
+M.trace = http_trace
+
+M.configs._builtin.doh.endpoints = {}
+local http_doh = require('kres_modules.http_doh')
+for k, v in pairs(http_doh.endpoints) do
+ mgmt_endpoints[k] = v
+ M.configs._builtin.doh.endpoints[k] = v
+end
+M.doh = http_doh
+
+-- Export HTTP service page snippets
+M.snippets = {}
+
+-- 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, any_origin, err
+ if entry then
+ mime = entry[1]
+ data = entry[2]
+ ttl = entry[4]
+ any_origin = entry[5]
+ 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 type(data) == 'table' then data = tojson(data) 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
+ if any_origin then
+ hsend:append('access-control-allow-origin', '*')
+ end
+ assert(stream:write_headers(hsend, false))
+ assert(stream:write_chunk(data, true))
+ end
+end
+
+-- Web server service closure
+local function route(endpoints)
+ assert(type(endpoints) == 'table', 'endpoints are not a table, is it a botched template?')
+ 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
+ if verbose() then
+ log('[http] %s %s HTTP/%d web socket open',
+ m, path, connection.version)
+ end
+ 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()
+ if verbose() then
+ log('[http] %s %s HTTP/%d web socket closed',
+ m, path, connection.version)
+ end
+ return
+ else
+ local ok, err, reason = http_util.yieldable_pcall(serve, endpoints, h, stream)
+ if not ok or err then
+ err = err or '500'
+ if verbose() then
+ log('[http] %s %s HTTTP/%d %s %s',
+ m, path, connection.version, err, reason or '')
+ end
+ -- Method is not supported
+ local hsend = http_headers.new()
+ hsend:append(':status', err)
+ if reason then
+ assert(stream:write_headers(hsend, false))
+ assert(stream:write_chunk(reason, true))
+ else
+ assert(stream:write_headers(hsend, true))
+ end
+ else if verbose() then
+ log('[http] %s %s HTTP/%d 200',
+ m, path, connection.version)
+ end
+ end
+
+ end
+ end
+end
+
+-- @function Merge dictionaries, nil is like empty dict.
+-- Values from right-hand side dictionaries take precedence.
+local function mergeconf(...)
+ local merged = {}
+ local ntables = select('#', ...)
+ local tables = {...}
+ for i = 1, ntables do
+ local intable = tables[i]
+ if intable ~= nil then
+ assert(type(intable) == 'table', 'cannot merge non-tables')
+ for key, val in pairs(intable) do
+ merged[key] = val
+ end
+ end
+ end
+ return merged
+end
+
+-- @function Listen on given socket
+-- using configuration for specific "kind" of HTTP server
+local function add_socket(fd, kind, addr_str)
+ assert(M.servers[fd] == nil, 'socket is already served by an HTTP instance')
+ local conf = mergeconf(M.configs._builtin._all, M.configs._builtin[kind],
+ M.configs._all, M.configs[kind])
+ conf.socket = cqueues.socket.fdopen({ fd = fd, reuseport = true, reuseaddr = true })
+ if conf.tls ~= false then -- Create a TLS context, either from files or new.
+ if conf.ephemeral then
+ if not M.ephem_state then
+ M.ephem_state = { servers = M.servers }
+ tls_cert.ephemeral_state_maintain(M.ephem_state, conf.cert, conf.key)
+ end
+ conf.ctx = M.ephem_state.ctx
+ else
+ local certs, key = tls_cert.load(conf.cert, conf.key)
+ conf.ctx = tls_cert.new_tls_context(certs, key)
+ end
+ assert(conf.ctx)
+ end
+ -- Compose server handler
+ local routes = route(conf.endpoints)
+ conf.onstream = routes
+ -- Create TLS context and start listening
+ local s, err = http_server.new(conf)
+ -- Manually call :listen() so that we are bound before calling :localname()
+ if s then
+ err = select(2, s:listen())
+ end
+ if err then
+ panic('failed to listen on %s: %s', addr_str, err)
+ end
+ M.servers[fd] = {kind = kind, server = s, config = conf}
+end
+
+-- @function Stop listening on given socket
+local function remove_socket(fd)
+ local instance = M.servers[fd]
+ assert(instance, 'HTTP module is not listening on given socket')
+
+ instance.server:close()
+ M.servers[fd] = nil
+end
+
+-- @function Listen for config changes from net.listen()/net.close()
+local function cb_socket(...)
+ local added, endpoint, addr_str = unpack({...})
+ endpoint = ffi.cast('struct endpoint **', endpoint)[0]
+ local kind = ffi.string(endpoint.flags.kind)
+ local socket = endpoint.fd
+ if added then
+ return add_socket(socket, kind, addr_str)
+ else
+ return remove_socket(socket)
+ end
+end
+
+-- @function Init module
+function M.init()
+ net.register_endpoint_kind('doh', cb_socket)
+ net.register_endpoint_kind('webmgmt', cb_socket)
+end
+
+-- @function Cleanup module
+function M.deinit()
+ for fd, _ in pairs(M.servers) do
+ remove_socket(fd)
+ end
+ tls_cert.ephemeral_state_destroy(M.ephem_state)
+ net.register_endpoint_kind('doh')
+ net.register_endpoint_kind('webmgmt')
+end
+
+-- @function Configure module, i.e. store new configuration template
+-- kind = socket type (doh/webmgmt)
+function M.config(conf, kind)
+ if conf == nil and kind == nil then
+ -- default module config, nothing to do
+ return
+ end
+
+ kind = kind or '_all'
+ assert(type(kind) == 'string')
+
+ local operation
+ -- builtins cannot be removed or added
+ if M.configs._builtin[kind] then
+ operation = 'modify'
+ conf = conf or {}
+ elseif M.configs[kind] then -- config on an existing user template
+ if conf then operation = 'modify'
+ else operation = 'delete' end
+ else -- config for not-yet-existing template
+ if conf then operation = 'add'
+ else panic('[http] endpoint kind "%s" does not exist, '
+ .. 'nothing to delete', kind) end
+ end
+
+ if operation == 'modify' or operation == 'add' then
+ assert(type(conf) == 'table', 'config { cert = "...", key = "..." }')
+
+ if conf.cert then
+ conf.ephemeral = false
+ if not conf.key then
+ panic('[http] certificate provided, but missing key')
+ end
+ -- test if it can be loaded or not
+ tls_cert.load(conf.cert, conf.key)
+ end
+ 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
+ end
+
+ for _, instance in pairs(M.servers) do
+ -- modification cannot be implemented as
+ -- remove_socket + add_socket because remove closes the socket
+ if instance.kind == kind or kind == '_all' then
+ panic('unable to modify configration for '
+ .. 'endpoint kind "%s" because it is in '
+ .. 'use, use net.close() first', kind)
+ end
+ end
+
+ if operation == 'add' then
+ net.register_endpoint_kind(kind, cb_socket)
+ elseif operation == 'delete' then
+ net.register_endpoint_kind(kind)
+ end
+ M.configs[kind] = conf
+end
+
+return M