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

[LUA]EntryCalled

Request

on_request(request)

Per request, for Layer7Module = http. Returns a decision. The ingress router and the API gateway are both this.

Session

on_session(client, session)

Per connection, for Layer7Module = lua. Owns the connection, for a protocol SSLB3 does not parse.

HealthChecker

chunk

Per lua health check. Gets a socket as io; returns SSLB_RESULT_OK to pass.

Balancer

chunk

Per backend choice, for Balancer = lua. Returns a backend id.

Selector

chunk

Per connection, for Selector = lua. Returns a serverfarm name.

Startup

chunk

Once, after backends are initialised. Receives the version as ... . Privileged.

Shutdown

chunk

Once, during shutdown. Privileged.

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 },
  }
end

The 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:

farm

Which serverfarm serves the request.

backend

A specific backend, bypassing the balancer.

set_headers / remove_headers

Headers set on, or stripped from, the request to the backend.

set_response_headers / remove_response_headers

The same on the response.

respond

Answer here instead of proxying: { status, headers, body }.

access_log

Write an access record for this request.

advertise_http3 / http3_port

Withhold Alt-Svc from this response, or say which port it names.

inspect_request_body

Pass the request body through on_request_body before any backend sees it.

inspect_response

Call on_response with the response head before the client sees it.

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 = "{}" }
end

A 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

log(level, message) · log_enabled(level)

Write at DEBUG0–DEBUG5, and ask first on a per-request path so a discarded line costs nothing to build.

get_user_data · set_user_data(key, value, ttl) · remove_user_data

The store shared by every VM in the process — the only way two workers, or a worker and the admin handler, see each other’s state.

user_data_keys() · user_data() · user_data_generation()

Its keys, its contents, and a counter every change bumps, so a script can rebuild what it derived from the store only when something changed.

get_config_var(section, key, default) · get_config_section(section)

The running configuration, so a script is driven by a section of its own rather than by edits to it.

get_lua_settings()

The [LUA] settings as the engine is running with them, defaults included — for a role that must refuse an unsafe one.

get_serverfarms() · get_native_routes()

Every farm by name, and the farms that route natively with MatchHost or MatchPath, read live.

check_basic_auth(header, htpasswd)

Parse and verify an Authorization header against htpasswd contents in one step, so the password never becomes a Lua value. nil, not false, when declined under load.

ip_allowed(address, allow, deny)

CIDR allow and deny lists, most specific rule winning.

vm_id()

A number unique to this VM, for per-worker keys in the shared store.

splice(client, upstream)

Session scripts only: hand both directions to the engine and copy in Rust until either side closes.

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.bignum

Strict — 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.