calog/examples/httpd.lua

445 lines
20 KiB
Lua

-- httpd.lua -- a polyglot-server-in-a-script for calog. The whole HTTP/1.1 protocol -- request
-- parsing, routing, keep-alive, response shaping, and the WebSocket upgrade + frame codec -- lives
-- here in Lua. The only native surface it stands on is calog's generic TCP transport (tcpListen /
-- tcpAccept / tcpRecv / tcpSend / tcpClose from calogNet) and two crypto helpers (cryptoHashSha1 +
-- cryptoBase64Encode + cryptoHexDecode from calogCrypto) for the WebSocket handshake. There is no C
-- HTTP code behind it; this is the calog thesis -- systems primitives in C, protocol in a script.
--
-- Concurrency follows the actor model: one context running server:serve(port) handles connections
-- serially (fine for control/webhook endpoints). For parallelism, share the listener handle across
-- several contexts, each calling tcpAccept on it -- the OS load-balances. Binary-safe strings are
-- required (WebSocket masks and binary bodies carry NUL bytes), so this runs on any calog engine
-- except my-basic, whose char*/strlen strings truncate at the first NUL.
--
-- Usage:
-- local s = httpd.new()
-- s:route("GET", "/hi", function(req) return "hello " .. req.path end)
-- s:route("GET", "/made", function(req) return { status = 201, body = "created" } end)
-- s:route("POST", "/up", fn, { spool = { dir = "spool", max = 8e9 } }) -- body to a file: req.bodyFile, req.bodySize
-- return { file = { path = p, offset = 0, length = n } } -- a response streamed from a file
-- s:websocket("/ws", function(msg) return "echo: " .. msg.message end) -- returns a text reply or nil
-- s:serve(8080) -- opts: { keep = fn, acceptTimeout = ms }
local httpd = {}
httpd.__index = httpd
local WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
local HEADER_MAX = 64 * 1024
local BODY_MAX = 16 * 1024 * 1024 -- a body held in memory; a spooled route sets its own cap
local RECV_CHUNK = 8192
local SPOOL_CHUNK = 65536 -- read this much at a time into a spooled body
local SPOOL_FLUSH = 1024 * 1024 -- and write it out once this much has gathered
local FILE_CHUNK = 65536 -- a file response is sent this much at a time
local ACCEPT_POLL = 200 -- ms; the accept loop wakes this often to re-check its keep predicate
local REASON = {
[101] = "Switching Protocols", [200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
[206] = "Partial Content", [413] = "Content Too Large", [416] = "Range Not Satisfiable",
[301] = "Moved Permanently", [302] = "Found", [400] = "Bad Request", [401] = "Unauthorized",
[403] = "Forbidden", [404] = "Not Found", [405] = "Method Not Allowed", [500] = "Internal Server Error",
}
function httpd.new()
return setmetatable({ routes = {}, routeOpts = {}, prefixes = {}, wsRoutes = {} }, httpd)
end
-- opts.spool = { dir = folder, max = bytes } writes the request body to a file in `dir` instead of
-- holding it in memory: the handler gets req.bodyFile and req.bodySize, the file is removed after
-- the handler returns unless it moved it, and a body over `max` is answered 413 unread.
-- opts.onRefused(req, status), if given, is told of a request answered that way without its handler,
-- so a server that logs every request can log that one too.
function httpd:route(method, path, handler, opts)
self.routes[method:upper() .. " " .. path] = handler
self.routeOpts[method:upper() .. " " .. path] = opts
end
-- A route for everything under a path. The handler gets req.prefix (what matched) and req.rest (the
-- segments after it), so "/v1/download/" can serve "/v1/download/dragon/3" without every id needing
-- its own registration. An exact route always wins, and between prefixes the longest one does.
function httpd:routePrefix(method, prefix, handler, opts)
self.prefixes[#self.prefixes + 1] = { method = method:upper(), prefix = prefix, handler = handler, opts = opts }
table.sort(self.prefixes, function(a, b) return #a.prefix > #b.prefix end)
end
function httpd:websocket(path, handler)
self.wsRoutes[path] = handler
end
-- Read one request's head off the connection, starting from any bytes `buf` already holds from a
-- previous (pipelined) read. Returns (request, rest) -- request is { method, path, query, version,
-- headers, length } with the body still unread, rest the bytes already received past the head -- or
-- nil on close or a malformed head. The body is read by readBody or spoolBody once the route is
-- known, so a route can take it as a file and a body over its cap is refused before it is read.
local function readHead(conn, buf)
buf = buf or ""
local headerEnd = nil
while true do
headerEnd = buf:find("\r\n\r\n", 1, true)
if headerEnd then break end
if #buf > HEADER_MAX then return nil end
local chunk = tcpRecv(conn, RECV_CHUNK)
if not chunk then return nil end
buf = buf .. chunk
end
local head = buf:sub(1, headerEnd - 1)
local rest = buf:sub(headerEnd + 4)
local method, target, version = head:match("^(%S+)%s+(%S+)%s+(%S+)")
if not method then return nil end
local path, query = target:match("^([^?]*)%??(.*)$")
local headers = {}
for line in head:gmatch("[^\r\n]+") do
local k, v = line:match("^([^:]+):%s*(.-)%s*$")
if k then
k = k:lower()
-- A conflicting duplicate Content-Length is a CL.CL smuggling vector: reject it.
if k == "content-length" and headers[k] and headers[k] ~= v then return nil end
headers[k] = v
end
end
-- We frame bodies by Content-Length only. A Transfer-Encoding (chunked) request would desync with
-- the leftover-buffer keep-alive path (a TE.CL smuggle), so reject it rather than mis-frame it.
if headers["transfer-encoding"] then return nil end
-- Content-Length is DIGIT-only per RFC 7230; reject the hex/scientific/signed/float forms tonumber
-- would otherwise accept (a body-length desync / smuggling risk). The cap is the route's.
local clen = 0
local clRaw = headers["content-length"]
if clRaw then
if not clRaw:match("^%d+$") or #clRaw > 18 then return nil end
clen = math.tointeger(tonumber(clRaw)) or 0
end
return { method = method:upper(), path = path, query = query, version = version, headers = headers, length = clen }, rest
end
-- The body into memory: gathered as pieces and joined once, since growing one string by each piece
-- copies all of it every time. Returns (body, leftover) or nil on close.
local function readBody(conn, rest, clen)
local parts, have = { rest }, #rest
while have < clen do
local chunk = tcpRecv(conn, RECV_CHUNK)
if not chunk then return nil end
parts[#parts + 1] = chunk
have = have + #chunk
end
local all = table.concat(parts)
return all:sub(1, clen), all:sub(clen + 1)
end
-- The body into a new file in `dir`, a piece at a time, written out in larger runs. Returns (path,
-- leftover) or nil on close, with any partial file removed.
local function spoolBody(conn, rest, clen, dir)
fsMkdir(dir)
local path = dir .. "/body-" .. cryptoHexEncode(cryptoRandomBytes(8)) .. ".part"
local parts, pending, have = {}, 0, 0
local leftover = ""
local function flush()
if pending > 0 then
fsAppend(path, table.concat(parts))
parts, pending = {}, 0
end
end
fsWrite(path, "")
local function take(chunk)
local need = clen - have
if #chunk > need then
leftover = chunk:sub(need + 1)
chunk = chunk:sub(1, need)
end
parts[#parts + 1] = chunk
pending = pending + #chunk
have = have + #chunk
if pending >= SPOOL_FLUSH then flush() end
end
-- A dropped connection raises out of tcpRecv: the partial file goes before the error does.
local ok, closed = pcall(function()
take(rest)
while have < clen do
local chunk = tcpRecv(conn, SPOOL_CHUNK)
if not chunk then return true end
take(chunk)
end
flush()
return false
end)
if not ok or closed then
fsRemove(path)
if not ok then error(closed, 0) end
return nil
end
return path, leftover
end
-- HTTP/1.1 keeps the connection alive unless "Connection: close"; HTTP/1.0 closes unless keep-alive.
local function wantsKeepAlive(req)
local conn = (req.headers["connection"] or ""):lower()
if req.version == "HTTP/1.1" then
return not conn:find("close", 1, true)
end
return conn:find("keep%-alive") ~= nil
end
-- Turn a handler's return value into an HTTP response and send it. nil -> 204; a string -> 200 with
-- that body; a table -> { status = 200, headers = {}, body = "" }, or with file = { path, offset,
-- length } in place of a body, that piece of a file sent a chunk at a time rather than held whole.
local function writeResponse(conn, resp, keepAlive)
local status, body, extra = 200, "", nil
if resp == nil then
status = 204
elseif type(resp) == "string" then
body = resp
elseif type(resp) == "table" then
status = resp.status or 200
body = resp.body or ""
extra = resp.headers
end
local file = type(resp) == "table" and resp.file or nil
local length = file and file.length or #body
local out = { string.format("HTTP/1.1 %d %s\r\nContent-Length: %d\r\nConnection: %s\r\n",
status, REASON[status] or "Status", length, keepAlive and "keep-alive" or "close") }
if extra then
for k, v in pairs(extra) do out[#out + 1] = k .. ": " .. v .. "\r\n" end
end
out[#out + 1] = "\r\n"
if not file then
out[#out + 1] = body
tcpSend(conn, table.concat(out)) -- raises on a broken connection; httpd:handle's pcall catches it
return
end
tcpSend(conn, table.concat(out))
local offset, sent = file.offset or 0, 0
while sent < length do
local piece = fsRead(file.path, offset + sent, math.min(FILE_CHUNK, length - sent))
if #piece == 0 then error("httpd: the file ended before its length", 0) end
tcpSend(conn, piece)
sent = sent + #piece
end
end
-- ---- WebSocket (RFC 6455) --------------------------------------------------------------------
-- Sec-WebSocket-Accept = base64(SHA1(clientKey + magic GUID)). cryptoHashSha1 returns hex, so decode
-- it back to the 20 raw bytes before base64 -- all via calog's crypto natives, no C here.
local function wsHandshake(conn, req)
local key = req.headers["sec-websocket-key"]
if not key then return false end
local accept = cryptoBase64Encode(cryptoHexDecode(cryptoHashSha1(key .. WS_MAGIC)))
local resp = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" ..
"Connection: Upgrade\r\nSec-WebSocket-Accept: " .. accept .. "\r\n\r\n"
tcpSend(conn, resp)
return true
end
-- Write one unmasked server frame (FIN set); server-to-client frames are never masked.
local function wsFrame(conn, opcode, payload)
local len = #payload
local header
if len < 126 then
header = string.char(0x80 | opcode, len)
elseif len < 65536 then
header = string.char(0x80 | opcode, 126, (len >> 8) & 0xFF, len & 0xFF)
else
local b = { 0x80 | opcode, 127 }
for i = 7, 0, -1 do b[#b + 1] = (len >> (i * 8)) & 0xFF end
header = string.char(table.unpack(b))
end
tcpSend(conn, header .. payload)
end
-- The message loop: read masked client frames, REASSEMBLE fragmented messages (a data frame with
-- FIN=0 followed by continuation frames, opcode 0x0, until FIN=1), hand each complete text/binary
-- message to the handler as { path, message }, send back any string it returns, answer pings, and
-- stop on close/error. `buf` seeds it with any bytes already read past the upgrade request.
local function wsServe(conn, path, handler, buf)
buf = buf or ""
local function need(n)
while #buf < n do
local chunk = tcpRecv(conn, RECV_CHUNK)
if not chunk then return false end
buf = buf .. chunk
end
return true
end
local msg = nil -- accumulated payload of an in-progress fragmented message
local msgOp = nil -- opcode (0x1 text / 0x2 binary) of that message's first frame
while true do
if not need(2) then return end
local b1, b2 = buf:byte(1), buf:byte(2)
local fin = (b1 & 0x80) ~= 0
local opcode = b1 & 0x0F
local masked = (b2 & 0x80) ~= 0
local len = b2 & 0x7F
local pos = 3
if len == 126 then
if not need(4) then return end
len = (buf:byte(3) << 8) | buf:byte(4)
pos = 5
elseif len == 127 then
if not need(10) then return end
len = 0
for i = 3, 10 do len = (len << 8) | buf:byte(i) end
pos = 11
end
-- len is a signed Lua integer: a 64-bit length with bit 63 set reads NEGATIVE, so guard both
-- ends before trusting it (else it slips past the cap and spins the loop). Client frames MUST
-- be masked.
if not masked or len < 0 or len > BODY_MAX then return end
if not need(pos + 3) then return end
local mask = { buf:byte(pos), buf:byte(pos + 1), buf:byte(pos + 2), buf:byte(pos + 3) }
pos = pos + 4
if not need(pos + len - 1) then return end
local raw = buf:sub(pos, pos + len - 1)
buf = buf:sub(pos + len)
local bytes = {}
for i = 1, len do bytes[i] = string.char(raw:byte(i) ~ mask[((i - 1) & 3) + 1]) end
local payload = table.concat(bytes)
if opcode == 0x8 then
wsFrame(conn, 0x8, "")
return
elseif opcode == 0x9 then
wsFrame(conn, 0xA, payload) -- ping -> pong (control frames may interleave)
elseif opcode == 0xA then
-- pong: ignore
elseif opcode == 0x0 or opcode == 0x1 or opcode == 0x2 then
if opcode == 0x0 then
if not msgOp then return end -- continuation with no start frame: protocol error
msg = msg .. payload
else
if msgOp then return end -- new data frame mid-message: protocol error
msgOp = opcode
msg = payload
end
if #msg > BODY_MAX then return end
if fin then
local ok, reply = pcall(handler, { path = path, message = msg })
if ok and type(reply) == "string" then
wsFrame(conn, 0x1, reply)
end
msg, msgOp = nil, nil
end
else
return -- unknown opcode: fail the connection
end
end
end
-- Serve one connection to completion, then ALWAYS close it. The whole per-connection loop runs under
-- pcall: calogNet raises a Lua error on a socket failure (it does not return nil), so a client that
-- drops mid-request tears down only this connection, never the accept loop. A route handler that
-- errors becomes a 500; a WebSocket handler that errors just drops that reply (see wsServe).
function httpd:handle(conn)
pcall(function()
local buf = ""
while true do
local req
req, buf = readHead(conn, buf)
if not req then break end
local upgrade = (req.headers["upgrade"] or ""):lower():find("websocket", 1, true)
local connhdr = (req.headers["connection"] or ""):lower():find("upgrade", 1, true)
if upgrade and connhdr then
local handler = self.wsRoutes[req.path]
if handler and wsHandshake(conn, req) then
wsServe(conn, req.path, handler, buf) -- hand over any bytes past the upgrade request
end
return -- the connection is now a (closed) WebSocket, not keep-alive HTTP
end
local keepAlive = wantsKeepAlive(req)
local key = req.method .. " " .. req.path
local handler = self.routes[key] or self.routes["* " .. req.path]
local opts = self.routeOpts[key] or self.routeOpts["* " .. req.path]
if not handler then
for _, entry in ipairs(self.prefixes) do
if (entry.method == req.method or entry.method == "*") and req.path:sub(1, #entry.prefix) == entry.prefix then
req.prefix = entry.prefix
req.rest = req.path:sub(#entry.prefix + 1)
handler = entry.handler
opts = entry.opts
break
end
end
end
-- The body, now that the route says how to take it. One over its cap is answered 413
-- unread, and the connection closed, since the unread body is still on the wire.
local spool = opts and opts.spool
local cap = spool and spool.max or BODY_MAX
if req.length > cap then
if opts and opts.onRefused then
pcall(opts.onRefused, req, 413)
end
writeResponse(conn, { status = 413, body = "Content Too Large" }, false)
break
end
if spool then
req.bodyFile, buf = spoolBody(conn, buf, req.length, spool.dir)
if not req.bodyFile then break end
req.bodySize, req.body = req.length, ""
else
req.body, buf = readBody(conn, buf, req.length)
if not req.body then break end
end
if handler then
local ok, resp = pcall(handler, req)
if req.bodyFile and fsExists(req.bodyFile) then
fsRemove(req.bodyFile)
end
if ok then
writeResponse(conn, resp, keepAlive)
else
writeResponse(conn, { status = 500, body = "Internal Server Error" }, keepAlive)
end
else
if req.bodyFile then fsRemove(req.bodyFile) end
writeResponse(conn, { status = 404, body = "Not Found" }, keepAlive)
end
if not keepAlive then break end
end
end)
tcpClose(conn)
end
-- Bind `port` and serve until opts.keep() (if given) returns false. opts.acceptTimeout bounds each
-- accept so the keep predicate is polled even with no traffic. opts.host narrows the bind to one
-- address -- "127.0.0.1" for a server that should be reachable only through a reverse proxy in
-- front of it, since there the proxy is the security boundary and a wildcard bind is a way around
-- it.
function httpd:serve(port, opts)
opts = opts or {}
-- TLS is opt-in via opts.tls + opts.cert + opts.key; pass a clean map to the transport (never the
-- whole opts table, which may carry Lua function fields like keep/onReady that are not map values).
-- An empty Lua table is not a keyed map, and tcpListen refuses one, so the plain case passes no
-- opts at all rather than a { host = nil } that vanishes into {}.
local srv
if opts.tls then
srv = tcpListen(port, { host = opts.host, tls = true, cert = opts.cert, key = opts.key })
elseif opts.host then
srv = tcpListen(port, { host = opts.host })
else
srv = tcpListen(port)
end
if not srv then return false end
if opts.onReady then opts.onReady() end
local keep = opts.keep
local timeout = opts.acceptTimeout or ACCEPT_POLL
while (keep == nil) or keep() do
-- tcpAccept RAISES on a failed/timed-out TLS handshake (a bad ClientHello, a health-check probe,
-- a stalled client). Catch it here so one bad connection cannot terminate the accept loop.
local ok, conn = pcall(tcpAccept, srv, timeout)
if ok and conn then self:handle(conn) end
end
tcpClose(srv)
return true
end
return httpd