The API gateway

SSLB3 as an API gateway: every request checked against an OpenAPI contract before any backend sees it, and every response checked before the client does. OpenAPI 3.0 and 3.1, JSON or YAML, uploaded at runtime and proved in full before they are enforced.

One engine, three roles

SSLB3 is a platform first. The engine — listeners, TLS, HTTP/1.1, HTTP/2 and HTTP/3, the streaming proxy, health checks, the intrusion detector — is the same in every deployment, and what it does with a request is decided by one thing: the script it loads as LUA.Request, or none at all. That is what I mean by a role.

Native routing

no script

MatchHost and MatchPath on a serverfarm, matched in Rust. The fastest way to route HTTP, and a complete balancer from one configuration file.

Ingress controller

ingress_router.lua

Configured from Kubernetes by a sidecar, with a routing script for everything an annotation can ask for that configuration cannot express.

API gateway

api_gateway.lua

OpenAPI contracts uploaded at runtime and enforced on every request and response, before a backend or a client sees what failed.

A process plays one role. The ingress router and the gateway are never loaded together, and neither needed the engine to change: the gateway is built on hooks any request script can use, and on libraries any script can call. That is the extensibility the project has been built around since 2011 — the Lua interface reference is the whole of it.

How it works

A request is parsed by the engine as any request is. The gateway finds the contract that describes it, by host and base path, and the operation within it, and checks everything the head carries: the path and method, every parameter, the media type. If the operation has a body to check, the body is held — passed to the script a piece at a time as it arrives, kept by the script, and validated against its schema once it is complete.

Only then is a backend contacted. A refused request never opens a connection to anything. One that passes goes on byte for byte as the client sent it: the gateway validates a parse of the body but never forwards a re-encoding, so the backend reads exactly what was checked. The response comes back through the same script, its head and its body checked on the way to the client.

How the API gateway checks a requestA client's request is checked first from its head — the operation, the method, every parameter and the media type — and then, with the body held by the gateway script, against the body's schema. Only a request that passes both is released to a backend, byte for byte. One that fails either check is answered with a problem document and no backend connection is ever opened. The response comes back through the same script, checked on its way to the client.clientSSLB3 running api_gateway.luaresponse · response bodyrequestoperation · methodparameters · media typerequest bodyheld by the scriptchecked against its schemareleasedas sentbackendbackendproblem+jsonrefused here — no backend connection is opened

Running one

The repository has a complete configuration, etc/sslb-api-gateway.conf, commented throughout. This is what the role depends on:

sslb-api-gateway.conf

[Listener-https]
Protocol = tls
ListenPort = 8443
CertificateFile = sslb.crt
PrivateKeyFile = sslb.key
Layer7Module = http

[AdminAPI]
Enabled = yes
AuthType = apikey
ApiKey =                 ; from SSLB3_ADMINAPI_APIKEY

[Serverfarm]
Layer7Module = http

[Serverfarm-orders]

[LUA]
Request = api_gateway.lua
Startup = api_gateway_startup.lua
Sandbox = yes
DataDirectory = /var/lib/sslb3/gateway
UserDataMaxEntries = 0

Three of those are requirements, and the gateway refuses to start without them rather than run where a request could slip past unchecked:

  • No serverfarm sets MatchHost or MatchPath. A request the engine routes natively never reaches a script, so it would never be validated. In this role the contracts decide where requests go.
  • UserDataMaxEntries = 0. Contracts live in the shared store, and a store that evicts would drop one — an API that silently stopped being validated.
  • An UnmatchedServerfarm, if set, names a farm that exists.

Uploading a contract

A contract is an OpenAPI document under a name of your choosing, bound to the serverfarm that serves its API, uploaded through the admin API:

shell

curl -k -X PUT -H "X-API-Key: $KEY" -H 'Content-Type: application/yaml' \
     --data-binary @orders.yaml \
     'https://127.0.0.1:6443/api/v1/script/contracts/orders?farm=orders'

json

{
  "base_path": "/v1",
  "farm": "orders",
  "modes": { "request": "enforce", "request_body": "enforce",
             "response": "detect", "response_body": "detect" },
  "name": "orders",
  "openapi": "3.1.0",
  "operations": 12,
  "warnings": []
}

A contract is proved before it is accepted. Every schema is compiled, every reference resolved, every pattern shown to be runnable, every operation built — unused component schemas included. One that cannot be enforced exactly as written is refused with the list of what is wrong, and nothing changes; the version already in force stays in force. A contract is never half applied. ?dry_run=true proves one without storing it, which is what a CI pipeline wants.

Which requests a contract describes comes from the path of its first servers entry, server variables filled in with their defaults, and optionally a host. Within a contract, concrete paths win over templated ones as OpenAPI says they must — /orders/latest before /orders/{id}. A request no contract describes is answered 404, or with UnmatchedServerfarm set, passed through unchecked — which is how a gateway goes in front of an existing estate one API at a time.

Contracts in several files

References to other documents resolve against documents uploaded alongside the contract. Nothing is ever fetched. Each contract has a base URI of its own, so $ref: schemas/common.yaml#/Order names a document uploaded to /contracts/orders/documents/schemas/common.yaml. A contract referring to a document that is missing is refused, a document that would break the contract using it is refused, and one still needed cannot be deleted.

What is checked

CheckDefaultWhat it covers

Request

enforce

The operation exists (404) and allows the method (405, with Allow); every path, query, header and cookie parameter; a body present when required and absent when the operation takes none; a Content-Type the operation accepts (415).

Request body

enforce

The body against its schema.

Response

detect

A status the operation declares — exactly, as a range like 4XX, or by default; a Content-Type declared for it; declared headers present and valid.

Response body

detect

The body against its schema.

Parameters are read in every style OpenAPI defines — simple, label and matrix in paths; form, spaceDelimited, pipeDelimited and deepObject in queries — exploded or not, and converted to the type their schema expects, so ?limit=10 is checked as the integer 10. Bodies are validated for application/json, any +json type, and application/x-www-form-urlencoded.

Detect, then enforce

Each check is off, detect — counted, logged and let through — or enforce. Requests are enforced by default and responses only detected, and that is deliberate: enforcing on responses turns a backend drifting from its contract into an outage, which is a decision to take once the counts show it does not drift. Putting a contract in front of an API that already has clients works the same way — upload it in detect, watch /api/v1/script/stats, then enforce. A contract can set its own modes when it is uploaded, and an operation can set its own with an x-sslb3-validation extension.

What a refusal looks like

An RFC 9457 problem document, saying where and which rule — never the value that failed:

json

{
  "detail": "the request body does not match the API contract",
  "errors": [
    { "keyword": "minimum", "location": "body",
      "message": "must be at least 1", "pointer": "/quantity" }
  ],
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

A response that fails an enforced check is replaced with a 502 that says only that the upstream response did not match. What the backend sent is the backend's data, and the client is the one party that must not be shown it — so there are no details at all, and the log records where it failed and which rule, never the value.

OpenAPI, in full

OpenAPI 3.0 and 3.1, from JSON or YAML. 3.1 schemas are JSON Schema 2020-12 and are validated as such — $ref beside other keywords, $dynamicRef, unevaluatedProperties, prefixItems, if/then/else and the rest. 3.0 schemas follow 3.0's own rules: nullable, boolean exclusive bounds, a $ref that replaces its siblings. Both get OpenAPI's additions: readOnly and writeOnly by direction, and discriminator choosing the one schema to validate against.

format is an assertion here, not the annotation JSON Schema makes it by default — a format: uuid that admitted anything would be no check. Integers are exact through 64 bits, so an int64 above 253 is compared as the digits the client sent rather than as a double that might round into range.

Hard to talk past

A gateway that can be talked past is worse than none, because it is trusted. The usual way past one is a request it reads one way and the backend another, so most of this is about leaving no room for the two to disagree.

Strict JSON, original bytes

Duplicate keys, trailing data, a byte-order mark, invalid UTF-8 and lone surrogates are refused — each is a document two parsers can read differently. What is forwarded is what the client sent, never a re-encoding.

No ambiguous paths or parameters

An encoded slash or backslash, a dot segment, an empty segment, an encoded NUL: refused whenever the request check is on, detect included. A scalar parameter or form field sent twice is refused too, since each side may read a different copy.

Nothing it cannot read

A compressed body that would be validated is a 415 rather than bytes passed on unchecked, and so is a charset other than UTF-8. A body sent to an operation that takes none is refused, and an upgrade is never let through.

No regular-expression denial of service

Patterns run on a linear-time engine. One needing backtracking is refused at upload unless explicitly allowed, and then only ever runs on short inputs. YAML is parsed with budgets on aliases, nesting and size.

Bounded holding

Bodies are capped per request and per worker, every hook call is held to the instruction limit, and a client that stops sending is cut off at the farm’s idle timeout whatever the gateway is doing.

Sandboxed, and nothing fetched

The script has no io, no os and no file access on the request path at all. A $ref resolves to an uploaded document or to nothing, and the admin side’s disk access is confined to one directory, by name.

Validation is a filter, not authentication. A contract's securitySchemes are not checked — authorisation stays wherever it is today.

What it costs

Validation runs in Lua under the instruction limit, which keeps a runaway script from wedging a worker and turns LuaJIT's JIT off — so what a body costs is counted in interpreted instructions, and a test keeps the defaults honest: a 1 MiB JSON body of 15,600 objects validates in 4.2 million instructions, about 54 per value and 42% of the default budget. Parsing it is one call into Rust. That is why MaxBodyBytes defaults to 1 MiB, and why raising it much past 2 MiB wants the instruction limit raised too.

Getting there took a compiler shaped for the interpreter: a schema of only scalar keywords — the shape most properties have — becomes a single closure with every test inline, and the loops over properties and items push their location once rather than per element. The first version spent 12.6 million instructions on the same body.

The admin API

The gateway's endpoints are under /api/v1/script/, behind the admin API's own credential. The script serves them but never sees the credential, and never decides who may call it.

GET /contracts

Every contract, summarised.

PUT /contracts/<name>

Upload or replace one. ?farm= is required the first time; ?host=, ?base_path=, the four modes and ?dry_run=true are optional.

GET · DELETE /contracts/<name>

Read one, with its documents, or remove it.

PUT · GET · DELETE /contracts/<name>/documents/<path>

A document the contract refers to; ?uri= for an absolute one.

POST /contracts/<name>/check

Judge a request, and optionally a response, without sending either — for a pipeline that wants to know which side has drifted.

GET /stats

Pass and fail counts per contract, operation and check, summed across workers.

Where contracts live

In memory, in the shared store that is how the admin handler reaches the workers. Each worker compiles what it needs lazily, an operation at a time, and picks up a change on its next request. With a data directory set, every change is written to disk before it is applied — so a write that fails leaves the old contract in force — and api_gateway_startup.lua loads them all back when the process starts. There is no shutdown step: nothing is ever waiting to be saved, so a process that is killed loses nothing.

Each process has its own store, so several replicas each need each contract — uploaded to every one, which is what a deployment pipeline does anyway.

The full guide, with every endpoint, query parameter and setting, is doc/api-gateway.md, and the settings are also in the configuration reference.