-- 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: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 local RECV_CHUNK = 8192 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", [204] = "No Content", [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 = {}, wsRoutes = {} }, httpd) end function httpd:route(method, path, handler) self.routes[method:upper() .. " " .. path] = handler end function httpd:websocket(path, handler) self.wsRoutes[path] = handler end -- Read one request off the connection, starting from any bytes `buf` already holds from a previous -- (pipelined) read. Returns (request, leftoverBuffer) or nil on close/malformed. request is -- { method, path, query, version, headers, body }; leftoverBuffer feeds the next keep-alive read. local function readRequest(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), and reject anything over the cap. local clen = 0 local clRaw = headers["content-length"] if clRaw then if not clRaw:match("^%d+$") then return nil end local n = tonumber(clRaw) if not n or n > BODY_MAX then return nil end clen = math.tointeger(n) or 0 end while #rest < clen do local chunk = tcpRecv(conn, RECV_CHUNK) if not chunk then return nil end rest = rest .. chunk end local req = { method = method:upper(), path = path, query = query, version = version, headers = headers, body = rest:sub(1, clen), } return req, rest:sub(clen + 1) -- leftover: bytes of the next pipelined request, if any 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 = "" }. 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 out = { string.format("HTTP/1.1 %d %s\r\nContent-Length: %d\r\nConnection: %s\r\n", status, REASON[status] or "Status", #body, 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" out[#out + 1] = body tcpSend(conn, table.concat(out)) -- raises on a broken connection; httpd:handle's pcall catches it 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 = readRequest(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 handler = self.routes[req.method .. " " .. req.path] or self.routes["* " .. req.path] if handler then local ok, resp = pcall(handler, req) if ok then writeResponse(conn, resp, keepAlive) else writeResponse(conn, { status = 500, body = "Internal Server Error" }, keepAlive) end else 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. 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). local srv if opts.tls then srv = tcpListen(port, { tls = true, cert = opts.cert, key = opts.key }) 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