Skip to content

Latest commit

 

History

History
515 lines (480 loc) · 32.9 KB

File metadata and controls

515 lines (480 loc) · 32.9 KB

0039: Lua scripts

Status: accepted. Builds on ADR 0010, ADR 0012, ADR 0017 and ADR 0036.

Context

Operators who move from NGINX bring OpenResty scripts with them: phase handlers written in access_by_lua_block or header_filter_by_lua_file, modules loaded with require, ngx.shared dictionaries and cjson. The gateway runs no scripts; requests pass only the fixed policies of sites and routes. The product specification (§11.3) lets only trusted administrators publish scripts, gives scripts versioned request, response, context, upstream, log and crypto capabilities and nothing of the host by default, bounds every run in work, wall-clock time and memory with an explicit fallback, and asks for embedded Lua to be weighed against WebAssembly first.

OpenResty's model has well-known weak spots. A script stuck in a loop holds its worker and every request on it; a worker's memory is bounded only by the machine; ffi, io and os.execute reach the host; a failing script answers 500 with whatever it changed before failing; a global written by mistake is shared by requests until a tool finds it; and scripts can only be tried against a running NGINX.

Decision

Runtime. Scripts run on Luau, embedded through mlua with Luau's sources built in. Luau keeps the language of Lua 5.1, the dialect OpenResty's LuaJIT speaks, and is made to host code it does not trust (sandboxing):

  • its library has no io, package, loadlib, dofile, loadfile, os.execute or bytecode loading, and there is no FFI;
  • its global table, libraries and string metatable are read-only, and each script gets an environment of its own that falls back to them;
  • it calls the host's interrupt at every function call and loop iteration, native code included, so the host can stop or suspend a running script;
  • its allocator lets the host cap a VM's memory.

Native code generation is used where Luau has it, on x86-64 and AArch64.

OpenResty compatibility. The configuration language takes lua-nginx-module's directives with their names, contexts and NGINX inheritance, where a directive in an inner block replaces the outer one:

Directive Runs Contexts
init_by_lua_block, _file once in each VM when a configuration is activated http
init_worker_by_lua_block, _file once in each VM after init_by_lua http
exit_worker_by_lua_block, _file once in each VM when a configuration replaces it, before its pending timers run http
set_by_lua_block $name [arg ...], set_by_lua_file $name <file> [arg ...] as the request reaches the server or route, before its rewrite handler; $name is what the code returns server, route
server_rewrite_by_lua_block, _file before the route is chosen; may change the URI and host it is chosen by http, server
rewrite_by_lua_block, _file after the route is chosen, before security policies http, server, route
access_by_lua_block, _file after security policies http, server, route
precontent_by_lua_block, _file after access and the HTTP policies, just before the action http, server, route
content_by_lua_block, _file as the action of a server or route, instead of proxying or serving files server, route
balancer_by_lua_block, _file each time an upstream endpoint is chosen, retries included upstream
header_filter_by_lua_block, _file on the response header, before it is sent http, server, route
body_filter_by_lua_block, _file on each chunk of the response body http, server, route
log_by_lua_block, _file after the response is sent http, server, route
ssl_client_hello_by_lua_block, _file as a TLS handshake's hello arrives, for the server its server name selects http, server
ssl_certificate_by_lua_block, _file as a TLS handshake chooses the certificate it presents http, server
ssl_session_fetch_by_lua_block, _file as a TLS handshake offers to resume a session the listener does not hold http
ssl_session_store_by_lua_block, _file as a TLS handshake makes a session http
proxy_ssl_certificate_by_lua_block, _file as a request's TLS connection to its upstream chooses the certificate it presents when asked for one http, server, route
proxy_ssl_verify_by_lua_block, _file as a request's new TLS connection to its upstream is judged by the certificate the upstream presented http, server, route
lua_shared_dict <name> <size> declares a dictionary every VM shares http

The body of a *_by_lua_block directive is read with Lua's lexical rules, as ngx_lua reads it, so braces inside strings, long brackets and comments do not end it, and it is printed back unchanged. The forms that take their code as a string, which lua-nginx-module discourages — init_by_lua, init_worker_by_lua, set_by_lua, rewrite_by_lua, access_by_lua, content_by_lua, header_filter_by_lua, body_filter_by_lua and log_by_lua — are read as the block they stand for, with a warning, and printed as it. The NGINX importer carries these directives over.

Variables. set and set_by_lua* give the request variables in nginx's order: those of http and a server as the server's server_rewrite phase begins, a route's as its rewrite phase begins, each before the phase's handler, so a variable is what the last block the request reached gave it. set_by_lua* runs its code in the set_by_lua* context lua-nginx-module documents, with the directive's arguments, templates filled in for the request, as ngx.arg; the variable is the first value the code returns, as text for strings and numbers and empty for anything else. set_by_lua_block takes arguments too, which nginx's does not, so the importer carries set_by_lua over with its arguments. In a configuration with Lua handlers, a constant set gives is such a variable: ngx.var reads it, scripts may change it, and the templates of headers, log fields, redirects and answers read the value it has when they are filled in, which they name as ${lua:name}; $name is written so there, and ${lua:name} also reads what a script gave any other variable. Where a value must be known before requests, a constant stays the text it was set to, and a variable only scripts set cannot be used. ngx.var also reads the variables nginx gives a request — its line, header fields, cookies and arguments, its addresses, $scheme, $https, its times, and on TLS connections $ssl_protocol, $ssl_cipher by OpenSSL's name for the suite and $ssl_server_name.

TLS handshakes. ssl_client_hello_by_lua* and ssl_certificate_by_lua* run before rustls answers the client's hello, through Pingora's hook for what arrives ahead of TLS: the gateway reads the records that hold the hello, gives them back to the handshake as they were, and runs the handlers of the server the hello's server name selects, or of the listener's default server when it names none. nginx runs ssl_client_hello_by_lua* with the default server's configuration, since OpenSSL calls it before the server name is known; here the name is known, so a server's handler applies to its own names. A listener none of whose servers have such handlers reads nothing ahead of rustls. In ssl_client_hello_by_lua*, ngx.ssl.clienthello reads the hello's server name, versions, cipher suites and extensions, GREASE values (RFC 8701) left out as OpenSSL leaves them out. ngx.ssl reads the server name, the addresses, the version and the client's random in any phase, converts and parses PEM and DER certificates and keys, and in ssl_certificate_by_lua* presents the chain and key set with set_der_cert and set_der_priv_key, or set_cert and set_priv_key, in place of the TLS profile's after clear_certs. ngx.exit(ngx.ERROR) ends the handshake, as does a failed handler unless lua_on_error continue, and a certificate without its key or with a key that does not match it. ngx.ocsp gives the OCSP responder the leaf of a chain names, builds the request that asks about it (RFC 6960), checks a response — signed by the issuer or by a responder the issuer certified for OCSP, saying the leaf is good, within its validity give or take five minutes, as OpenSSL allows — and in ssl_certificate_by_lua* staples a response to the certificate presented, the script's or the TLS profile's. The handlers have the functions lua-nginx-module allows there (ngx.exit, ngx.sleep, cosockets, light threads, timers and ngx.semaphore), and neither ngx.ctx nor ngx.var. verify_client asks the client for a certificate and verifies it on the authorities it is given, with at most as many intermediates as its depth (1 unless given), and as in nginx a certificate that does not verify ends no handshake: the connection's requests read $ssl_client_verify (SUCCESS, FAILED: and why, or NONE), $ssl_client_raw_cert, $ssl_client_cert, $ssl_client_s_dn and $ssl_client_i_dn as RFC 2253 writes them, $ssl_client_serial and $ssl_client_fingerprint, and a resumed session's certificate is verified again on the same terms. rustls names acceptable authorities for a whole listener, so none are named to the client. The functions that need OpenSSL return nil and why: clienthello.set_protocols, since rustls offers every connection to a listener the same versions; get_session_master_key, which would give scripts what decrypts the connection; and get_req_ssl_pointer and its kin, which hand out OpenSSL handles for the FFI scripts do not have. ssl_session_fetch_by_lua* and ssl_session_store_by_lua* share sessions beyond one listener. rustls keeps each listener's sessions; a hello that offers to resume one the listener does not hold — by its first TLS 1.3 ticket, which the gateway issues as the session's ID, or by its TLS 1.2 session ID — runs the fetch handler after ssl_client_hello_by_lua*, and a session it gives with ngx.ssl.session.set_serialized_session is resumed without ssl_certificate_by_lua*, as in nginx. Each session a handshake makes runs the store handler, where get_session_id and get_serialized_session give it to keep in ngx.shared or, through a timer, elsewhere; as in nginx it may not wait, and it runs apart from the handshake. The serialized form is rustls', so gateways that share sessions run the same version, and sessions resume only on listeners whose TLS profile resumes them.

proxy_ssl_certificate_by_lua* chooses, with ngx.ssl.proxysslcert, the certificate a request's TLS connection to its upstream presents when the upstream asks for one: it runs as the upstream endpoint is chosen, before Pingora connects, and what it sets — checked to be X.509 with a key that matches — is presented, while clear_certs alone presents none. nginx runs it in the handshake, only when asked; here it may run for a request that reuses a connection, which Pingora keeps apart by the certificate it presented. It may be written in http and servers as well as routes, inherited as the other handlers are, and a failure answers 502 unless lua_on_error says otherwise. proxy_ssl_verify_by_lua* judges each new TLS connection to an upstream, once its handshake is done and before the request goes on it: ngx.ssl.proxysslverify.get_verify_cert gives the chain the upstream presented in DER, as ngx.ocsp takes it, since scripts have no FFI for OpenSSL's certificate objects; get_verify_result gives 0 when the route's upstream TLS terms verified it, and otherwise OpenSSL's code for how it verifies on the pool's trust anchors or the system's; and set_verify_result with anything but 0 refuses the connection, which answers 502 and is not kept. A certificate the route's terms refuse ends the handshake before the script runs, so a script that accepts what they would not, such as a pinned self-signed certificate, runs on a route that does not verify. ngx.proxyssl gives the connection's version.

ngx is lua-nginx-module's API with its documented semantics, in the phases where lua-nginx-module allows each function: ngx.var, ngx.ctx, ngx.req, ngx.resp, ngx.header, ngx.status, ngx.exit, ngx.redirect, ngx.say, ngx.print, ngx.log, the ngx.HTTP_* and log level constants, ngx.re on PCRE2 (the engine NGINX uses), ngx.shared, the time, escaping, argument, base64, digest and quoting functions, ngx.sleep, ngx.get_phase, ngx.worker, ngx.config, ngx.balancer and the TCP cosockets of ngx.socket.tcp and ngx.socket.connect (bind, connect, setclientcert, sslhandshake, send, receive, receiveany, receiveuntil, settimeout, settimeouts, setkeepalive, getreusedtimes, getfd and close), whose idle connections each VM keeps for its later requests, whose handshakes with a certificate of setclientcert resume no session another identity made, and whose defaults the lua_socket_* directives set for http, a server or a route, and the light threads of ngx.thread.spawn, wait and kill, which run on the budget of the run that spawned them; a run ends once its entry thread and its light threads have ended, or as soon as one of them exits. ngx.timer.at and ngx.timer.every run a callback later on the VM that created it, detached from the request, under the limits and permissions of the run that created it, with lua-nginx-module's default caps of 1024 pending and 256 running timers per VM; when a configuration replaces the one that created them, pending timers run at once with premature true, as on a worker's exit. ngx.socket.udp sends and receives datagrams of at most 8192 bytes under the same network permission, ngx.socket.stream is the TCP cosocket, and ngx.req.init_body, append_body and finish_body build a new request body in memory, and ngx.req.socket() reads the request body as it arrives with receive, receiveany and receiveuntil, chunked bodies too, after which the body counts as read, as in lua-nginx-module: a second request socket is refused and ngx.req.set_body_data or ngx.req.init_body give the request a new body. ngx.exec redirects internally: the request is handled again from server_rewrite with its new URI and arguments, and as in nginx a request that changes its URI more than ten times, by jumps and redirects together, is answered 500; $request_uri keeps the client's through both. ngx.run_worker_thread runs a module's function on a VM of its own on a thread of its own, at most lua_worker_thread_vm_pool_size of them at once (10 by default), and copies nil, booleans, numbers, strings and tables of them to it and its results back; there the function has the ngx functions lua-nginx-module allows in that context and no request. Modules OpenResty scripts commonly load are built in: cjson and cjson.safe, bit with LuaJIT BitOp semantics, table.new, table.clear, table.nkeys, resty.core, resty.string, resty.md5, resty.sha1, resty.sha256, resty.random, resty.aes with OpenSSL's AES modes from ECB to GCM, its padding and its EVP_BytesToKey derivation, on RustCrypto's ciphers since lua-resty-string reaches OpenSSL through the FFI, ngx.re, ngx.balancer, ngx.semaphore, whose semaphores the threads, timers and requests of one VM share, ngx.resp and ngx.req with add_header, ngx.process, which refuses enable_privileged_agent because a privileged agent would run scripts with the gateway's own privileges outside the sandbox and signal_graceful_exit because scripts do not stop workers, and resty.lrucache (with resty.lrucache.pureffi), each VM holding its own caches, so the library scripts vendor for its FFI needs no FFI, resty.websocket.server, resty.websocket.client and resty.websocket.protocol, lua-resty-websocket's API over the raw request socket and cosockets, written in Luau since its own needs the FFI, where the server answers with one of the subprotocols the client offers (the first its protocols option names, when given) and the client checks the server's Sec-WebSocket-Accept, as RFC 6455 requires and the library does not, resty.lock, whose locks keep a token of their own in their key, so that releasing or extending a lock that expired leaves alone the lock another took since, and which are released when their object is collected, as lua-resty-lock's are, lua-resty-limit-traffic's resty.limit.req, resty.limit.conn, resty.limit.count and resty.limit.traffic, the leaky bucket's state of each key packed into its dictionary value rather than written through the FFI, lua-tablepool's tablepool, resty.redis, lua-resty-redis's client with its replies, pipelines, transactions, Pub/Sub, module prefixes and connect options (db, password and username, with pools named after the database and user), whose ssl handshakes verify the server unless told not to, as the cosockets under it do, resty.dns.resolver, lua-resty-dns's resolver under the network permission, whose messages hickory-proto makes and reads, retrying the next nameserver after a timeout and over TCP when an answer is truncated, and whose compress_ipv6_addr follows RFC 5952, resty.upload, lua-resty-upload's reader of multipart bodies (RFC 2046, RFC 7578) over the request socket, whose preserve_body gives the request back the body as it arrived, resty.memcached, lua-resty-memcached's client of the memcached text protocol with its key escaping and pipelines, resty.mysql, lua-resty-mysql's client of the MySQL and MariaDB text protocol with TLS, result sets and multiple results, authenticating with mysql_native_password, and with caching_sha2_password and sha256_password by their scramble or, over TLS, the password itself (encrypting it with the server's RSA key, MariaDB's client_ed25519 and the pre-4.1 mysql_old_password are refused saying so), ngx.upstream, lua-upstream-nginx-module's view of the configuration's upstream pools, whose set_peer_down, under the upstream permission, takes a peer out of rotation for every request of the configuration until a script puts it back, next to the gateway's own health checks, drains and passive ejection, resty.upstream.healthcheck, lua-resty-upstream-healthcheck's active checks over it, one VM checking each round, with its status pages in text and for Prometheus, resty.http with resty.http_headers, lua-resty-http's HTTP/1.1 client, which loads no FFI module when it connects as the library does, with its pools, TLS and client certificates, HTTP proxies and CONNECT tunnels, chunked, sized and closing bodies, 100-continue, trailers and request and response proxying (request_pipeline reads each response's body as it returns, since Luau cannot wait inside the metamethod the library reads them in), and ngx.ssl with ngx.ssl.clienthello, ngx.ssl.session, ngx.ssl.proxysslcert and ngx.ssl.proxysslverify, ngx.proxyssl and ngx.ocsp. resty.core.base gives libraries its table helpers, status codes, subsystem check and table references, and the other resty.core modules load and do nothing, since the ngx they would replace with FFI functions is native here. require refuses ngx.pipe, which would start processes on the gateway's host, and resty.shell built on it, resty.signal, which would signal its processes, and LuaJIT's ffi, whose native calls would leave the sandbox, saying so; the configuration check reports them where they are required. lua_capture_error_log keeps, for each VM, what its scripts log up to the size given, oldest messages dropped first, for ngx.errlog.get_logs; ngx.errlog also has raw_log, set_filter_level in init_by_lua and get_sys_filter_level. Errors the runtime's functions raise reach pcall as strings, as a C function's do. Every ngx function either follows its documentation or raises an error naming it, and checking the configuration lists each place a script uses a function this gateway does not provide.

What runs differently: LuaJIT's ffi and jit modules, goto (Luau has continue), string.dump and bytecode, setfenv, getfenv and module are not available; lua_package_path and lua_package_cpath are refused, and lua_code_cache off reads with a warning, since a changed script takes effect as its configuration is activated. lua_transform_underscores_in_response_headers, lua_use_default_type and lua_need_request_body apply as in lua-nginx-module. Directives with nothing to tune here — lua_load_resty_core, lua_malloc_trim, lua_sa_restart, lua_thread_cache_max_entries, lua_http10_buffering (answers to HTTP/1.0 requests are buffered and carry a Content-Length, as on has them), lua_socket_send_lowat (Linux sets no send low-water mark for TCP), rewrite_by_lua_no_postpone, precontent_by_lua_no_postpone, lua_upstream_skip_openssl_default_verify and balancer_keepalive — are read with a warning saying why, so configurations written for OpenResty still read. sslhandshake verifies the server's certificate unless the script passes ssl_verify false, where lua-nginx-module verifies nothing by default: against the system's trusted roots, or the authorities of lua_ssl_trusted_certificate with the revocation lists of lua_ssl_crl. lua_ssl_certificate and lua_ssl_certificate_key give the certificate cosockets present when a server asks for one, lua_ssl_verify_depth the most intermediate certificates a chain may have, not counting a root the server sends along (not limited unless written, where lua-nginx-module allows 1), lua_ssl_protocols the versions among TLSv1.2 and TLSv1.3, and lua_ssl_ciphers the TLS 1.2 suites by their OpenSSL names, TLS 1.3's being offered always, as OpenSSL offers them. These terms are inherited as the lua_socket_* ones are, and certificates, keys and lists are secrets of the inventory; the importer turns a system bundle such as /etc/ssl/certs/ca-certificates.crt into system. lua_ssl_key_log, which would write session keys out, and lua_ssl_conf_command, which takes OpenSSL commands, are refused. With lua_check_client_abort on, rewrite, access and content handlers watch the connection once the request body is in: when the client closes it, the function ngx.on_abort registered runs as a light thread of the run and may end the request with ngx.exit, and without one the run stops and the request is logged as nginx's 499; ngx.on_abort returns nil, "lua_check_client_abort is off" otherwise, as lua-nginx-module's does. ngx.location.capture and capture_multi make subrequests with Pingora's own: each goes through the gateway as a request to the same site does, with the method, args, body, vars, copy_all_vars, share_all_vars and always_forward_body options lua-nginx-module takes, and with ctx its scripts run on the VM of the script that made it with that table as their ngx.ctx. As in nginx, subrequests skip the access phase, are not logged, see ngx.is_subrequest true and may nest fifty deep; capture_multi runs its subrequests at once. A named location (location @name, or match named <name> in a route) takes no request path: ngx.exec("@name") sends the request there with its URI and arguments as they are, starting at the route's rewrite phase as nginx's named locations do, and a name no route has is answered 500. ngx.req.socket(true) gives the client's connection as a full-duplex cosocket (receive, receiveany, receiveuntil, send, the timeouts and close) once the response header went out with ngx.send_headers() and ngx.flush(true), as lua-resty-websocket's server sends its 101: what it sends passes no filter, and it receives what the client sends after the request head, the body included. Pingora writes every response header itself, so a script cannot write its own; before the header went out, or with output still kept, it returns nil and why. Request bodies are held in memory, never in files: ngx.req.get_body_file returns nil, as lua-nginx-module does for a body in memory, and ngx.req.set_body_file is refused, since scripts do not reach the file system.

Native API. Next to ngx, require("panel.v1") returns the capabilities the specification names — req, resp, ctx, upstream, log and crypto — with json, re, time and random, as typed functions without OpenResty's historical quirks. A later version is a new module, so scripts written against panel.v1 keep working.

Scripts and modules. Scripts are part of the configuration: inline blocks, and .lua files under the configuration's lua/ directory. They are checked, versioned, compared and rolled back with every other file, so a revision fixes the exact scripts it ran. *_by_lua_file names a file under lua/; require("a.b") loads a built-in module or lua/a/b.lua and nothing else. A module runs once per VM in an environment of its own, and its result is cached and shared by the requests that VM serves, as OpenResty shares modules within a worker. Changing a script or a Lua directive takes the config.lua permission, which only the Administrator role holds by default; approval policies can cover Lua changes as a resource of their own.

Execution. Each gateway generation starts one VM per data-plane worker thread, loads the configuration's modules and handlers into it and runs init_by_lua and init_worker_by_lua. A request takes one VM at its first handler and keeps it until its log phase. Each handler runs in a coroutine of that VM with a global environment of the request's own: writes stay with the request and reads fall through to the read-only globals, which is the request isolation OpenResty documents, enforced. ngx.ctx is a table of the request shared by its phases. ngx.shared dictionaries live outside the VMs, so every VM sees the same data, and a dictionary keeps its contents across activations while its name and size stay.

What a handler changes — the request line and header, the response status and header, a response it starts — is held until the handler returns. A handler that fails leaves the request as it was before it ran. A response a handler makes goes to the client as nginx sends it, before the handler ends: ngx.flush, ngx.send_headers, ngx.eof and output past 64 KiB send the header, through the header filter, and then what was printed, each piece through the body filter; both filters run on the handler's VM with its ngx.ctx, and over HTTP/1.1 the body is chunked. ngx.eof ends the response but not the handler, which goes on: what it prints after returns nil, "seen eof", as lua-nginx-module's output functions do. A handler that fails once its header went out cannot take it back: the connection closes, unless ngx.eof had ended the response. Where a response is taken by a script, from a subrequest, or tried with ppanel lua test, output is kept until the handler ends, up to 16 MiB.

Limits. Each run of a handler is bounded:

  • in wall-clock time, waits included, by lua_time_limit (100 ms by default);
  • in work, counted in the VM's interrupt checks — function calls and loop iterations — by lua_work_limit (ten million by default);
  • in memory, by lua_memory_limit per VM (64 MiB by default); an allocation over the limit fails the run that made it.

lua_max_pending_timers and lua_max_running_timers cap each VM's timers, lua_regex_cache_max_entries the compiled expressions it keeps (none at 0) and lua_regex_match_limit PCRE2's match limit, with lua-nginx-module's defaults.

A run that keeps the CPU for more than a millisecond is suspended at its next interrupt and resumed after other tasks have run, so a busy script slows its own request and not the others on its thread. The host functions scripts call bound their own work: regular expressions with PCRE2's match limit, JSON and bodies with size limits. Handlers that can only run to completion, such as body_filter_by_lua, are not suspended.

Access handlers run after the security policies of their site and route, as access modules run before access_by_lua in NGINX; access_by_lua_no_postpone on runs them first.

Permissions. Reading and changing the request and response, ngx.ctx, logs, time, JSON, regular expressions, digests and random values are always available. lua_allow grants a site's or route's scripts more: body to read and replace request and response bodies, upstream to choose upstream endpoints, and network to open sockets, which is off unless granted. Declaring a shared dictionary grants its use. A function the scripts have not been granted raises an error.

Failures. lua_on_error chooses what a handler's failure does: fail answers 500, or 502 in the balancer, as OpenResty does; continue goes on as if the handler had not run; a status code answers with that status. Errors, timeouts and exhausted limits are counted and logged with the script, line, phase and request ID.

Observability. ngx.log and print write the gateway's error log with the level, site, route, phase, script and request ID; levels below lua_log_level are dropped. The gateway counts runs by site, route, phase and outcome in pingora_panel_gateway_lua_runs, measures them in pingora_panel_gateway_lua_run_duration_seconds, and reports each VM's memory in pingora_panel_gateway_lua_memory_bytes. Runs longer than lua_slow_threshold (10 ms by default) are logged with their duration and counted as slow, and lua_debug on logs every handler's start, end, duration and outcome.

Checking and testing. The control plane compiles every script with the same Luau compiler when a draft is checked. Syntax errors, missing files, modules require cannot load, functions the gateway does not provide or a phase does not allow, and globals a module writes are diagnostics located in the script. Testing runs one handler against a request described in the call, in the control plane with the gateway's runtime and limits, and reports what it did: the changes to the request and response, the response it sent, its logs, duration and errors. A test reaches nothing outside: it opens no connections, and the timers it creates do not run. GET /api/v1/config/lua/modules, ppanel lua modules and the console list the built-in modules by the library each stands in for, with the draft's scripts that load them, and the refused modules with why; the console marks each module a script loads as built in, a file under lua/, refused or not found.

Switch. lua off; in http keeps the scripts in the configuration but runs none of them.

Contracts. The model, the configuration language, the IR and the gateway's contract carry scripts as source text with their SHA-256, handlers by phase, limits, permissions and fallbacks; a snapshot with any of them requires the lua.scripts capability. The gateway compiles scripts itself and refuses a snapshot whose scripts do not compile. The runtime is a crate of its own that knows neither Pingora nor the control plane; the gateway gives it requests through a port, and the control plane checks and tests scripts with it.

Alternatives

  • LuaJIT, OpenResty's own VM: fastest on numeric code and runs ffi and goto code unchanged. But compiled traces never call debug hooks, so neither work nor time can be bounded while the JIT compiler is on; mlua cannot cap its memory, since 64-bit LuaJIT refuses a host allocator; its globals cannot be made read-only; and FFI has to be removed by hand. The limits of §11.3 would hold only with the JIT off.
  • Lua 5.4: bounded by count hooks and its allocator, but its language differs from Lua 5.1 in setfenv, unpack, loadstring and integer arithmetic and printing, so more OpenResty code breaks than on Luau, and it has no read-only tables.
  • WebAssembly on Wasmtime with the proxy-wasm ABI: fuel metering or epoch interruption bound work and time, each instance's linear memory is capped, isolation is stronger, and proxy-wasm is a settled ABI that Envoy, Istio, Apache APISIX and NGINX's ngx_wasm_module host. But it runs no OpenResty script: Lua would run in an interpreter compiled to WebAssembly, slower than Luau and without ngx, and Pingora has no proxy-wasm host, which would be written from nothing. It stays open as a later extension runtime at the same phases.
  • Scripts in an external plugin process (§14.2): isolated by the operating system, but every phase of every request would wait for a round trip.
  • A script library beside the configuration: a second history next to revisions, and scripts could change what runs without a revision.

Consequences

  • A gateway without the lua.scripts capability refuses snapshots with scripts instead of ignoring them.
  • OpenResty code that uses ffi, jit or goto needs changes; checking points to each place.
  • Scripts are trusted code: the sandbox and limits defend in depth and do not separate tenants.
  • Each VM keeps its own copy of modules and their state, as each OpenResty worker does, while shared dictionaries are one for the process.
  • A request's handlers run on one VM, so a VM serves its requests one handler run at a time; one VM per worker thread keeps that from limiting throughput.