Lua interface reference
Every hook SSLB3 calls, what each receives and returns, and every function and library a script can reach. The ingress router and the API gateway are written against exactly this, and nothing else.
SSLB3 embeds LuaJIT — Lua 5.1 with LuaJIT's extensions — one VM per worker thread. Scripts are files in LUA.ScriptDirectory, named in the [LUA] section. Anything not on this page is not available. The long form, with every field of every table, is doc/lua-api.md.
The script kinds
A module script runs once per VM to define its functions, and the engine then calls the entry; a chunk script runs top to bottom per call and returns its answer. Startup and Shutdown are privileged: with LUA.DataDirectory set they get the file library, which nothing on the request or connection path does.
The request hooks
on_request(request) is called once per request by the HTTP module, which has already parsed it — HTTP/1.1, HTTP/2 and HTTP/3 look the same from here. It returns a decision; it never performs IO, because a worker's VM carries every concurrent stream on that worker and a script that blocked would stall them all.
lua
function on_request(request)
if request.path:find("^/admin") and not request.headers["authorization"] then
return { respond = { status = 401, headers = { ["www-authenticate"] = 'Basic realm="admin"' } } }
end
return {
farm = "api",
set_headers = { ["X-Forwarded-For"] = request.forwarded_for },
}
endThe request carries method, path, query, host, version, headers and headers_all, the client as resolved through any trusted proxy, the listener, TLS and SNI. Every decision field is optional, and an empty table means proxy it as it stands:
Seeing bodies and responses
Four more hooks, none required, and none costing anything unless a decision asks: a request whose decision sets neither inspect_ field is proxied exactly as if they did not exist. The table on_request returned comes back as the first argument of each, so it is where a script keeps what it learnt.
lua
on_request_body(decision, chunk, eof) --> nil | "" | bytes | { respond = {...} }
on_response(decision, response) --> nil | { status, set_headers, remove_headers,
-- inspect_body, respond }
on_response_body(decision, chunk, eof) --> as on_request_body
on_request_done(decision)The body hooks are called once per piece as it arrives, then once more with ("", true). Return nothing to hold, bytes to release them, or a respond table to refuse. The engine never holds anything — a script that wants the whole body keeps the pieces itself — and no backend is contacted until the first bytes are released, so a body refused here never reaches one. on_response sees the head before anything is written to the client, and the head waits, unwritten, while the body is inspected. on_request_done is called once however the request ended — including a client that hung up mid-upload, which reaches no other hook.
Each hook call has its own instruction budget. A body that stops arriving is cut off at the farm's idle timeout (408 in, 504 out), one that breaks is 400 or 502, and a hook that raises is a 500.
Endpoints of its own
lua
function on_admin_request(request) -- { method, path, query, body, headers, headers_all }
return { status = 200, headers = { ["content-type"] = "application/json" }, body = "{}" }
endA request script may serve endpoints under /api/v1/script/ on the admin API. The handler runs on a privileged VM of its own, not on a worker. The credential is checked before it is called and removed from the request it sees, so it can neither read it nor decide who may call it. The admin VM and the workers share only the user data store — which is how a change made through an endpoint reaches the traffic.
Global functions
Libraries
Built into every VM, in Rust, and all of them sandbox-safe. Each fails by returning nil and a message rather than raising, so bad input from a client is a value to test, not an error to catch. A call costs about one instruction against the limit whatever it does: parsing a megabyte of JSON is one call.
json
lua
json.decode(text [, { max_depth = n }]) --> value | nil, message
json.encode(value [, { pretty = true }]) --> text | nil, message
json.type(value) --> "null" | "boolean" | "number" | "string" | "array" | "object" | "bignum"
json.null json.array json.object json.bignumStrict — duplicate keys, trailing data, a byte-order mark, invalid UTF-8 and lone surrogates are refused — and lossless where Lua would not be: null survives in a table, [] and {} are told apart by metatable, and an integer past 2^53 is a bignum carrying its exact digits. Object keys are sorted on encode, so the same table always encodes the same way.
yaml
lua
yaml.decode(text) --> value | nil, message
YAML 1.2, into the same values json.decode returns. Refuses duplicate keys, alias expansion past a budget, nesting past 64 levels and documents past 250,000 nodes; yes and on are strings, not true.
regex
lua
local pattern, message = regex.compile(source [, { engine = "backtracking", max_input = n }])
pattern:test(text) --> boolean | nil, message
pattern:engine() --> "linear" | "backtracking"ECMA-262 syntax, the dialect JSON Schema patterns are written in. Linear time by default, so no input can make a match slow — and so lookaround and backreferences are refused. The backtracking engine runs those, and refuses any text longer than max_input (1024 characters by default), which is what makes it safe to offer.
utf8
lua
utf8.len(text) --> characters | nil utf8.valid(text) --> boolean
Characters rather than bytes, and whether a string is UTF-8 at all.
url
lua
url.decode(text [, plus]) --> bytes | nil, message url.encode(text) --> text
Percent-encoding, with plus = true reading + as a space. A malformed escape is an error rather than a guess.
time
lua
time.now() --> seconds since the epoch time.monotonic() --> seconds since start, never backwards
The two clocks a script needs once the sandbox has removed os.
format
lua
format.check(name, value) --> true | false | nil
The JSON Schema formats as assertions — date-time, date, time, duration, email, hostname, ipv4, ipv6, uri, iri, uri-template, uuid, json-pointer, regex, byte and their relatives. nil for a format it does not know. Strict where parsers disagree: an IPv4 octet with a leading zero is refused.
file
lua
file.read(name) --> contents | nil | nil, message file.write(name, data) --> true | nil, message file.remove(name) --> true | false | nil, message file.list() --> names | nil, message file.exists(name) --> boolean
Privileged VMs only — Startup, Shutdown and the admin handler — and only with LUA.DataDirectory set. Confined to that directory by construction: it takes names, not paths, so there is nothing to traverse, and it refuses symbolic links. A write goes to a temporary file renamed into place, so a crash leaves the old content or the new.
Limits
An error ends the request or connection, never the process. Every call has an InstructionLimit budget — ten million by default — which catches a script that has stopped making progress; it turns LuaJIT's JIT off, so a deployment that would rather have the JIT sets it to 0 and gives up the guard. The shared store is bounded by UserDataMaxEntries. All of it is in the configuration reference.