445 lines
20 KiB
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
|