lua-resty-clienthello-ratelimit is a three-tier TLS ClientHello rate limiter for OpenResty and Apache APISIX.
It is designed to run in ssl_client_hello_by_lua* and reject abusive TLS handshakes before normal HTTP request processing begins. The limiter combines:
T0: IP blocklist in a shared dictionaryT1: per-IP leaky-bucket rate limitingT2: per-SNI-domain leaky-bucket rate limiting
The repository includes:
- a platform-agnostic core module
- an OpenResty adapter with
nginx-lua-prometheusmetrics support - an APISIX adapter that hooks into
ssl_client_hello_phase - Dockerized unit, APISIX integration, and OpenResty integration test suites
lib/resty/clienthello/ratelimit/
init.lua core limiter
config.lua config validation
metrics.lua cached inc_counter builder (shared by both adapters)
openresty.lua OpenResty adapter
apisix.lua APISIX adapter
examples/
nginx.conf example OpenResty config
apisix-config.yaml example APISIX config fragment
apisix-plugin-shim.lua example APISIX plugin shim
t/
unit/ Busted unit tests
integration/ APISIX integration tests
openresty-integration/ OpenResty integration tests
For each TLS ClientHello:
- The core module extracts the raw client IP address via FFI.
- It checks whether that IP is already in the blocklist shared dict.
- It applies a per-IP rate limit.
- If an SNI is present, it applies a per-domain rate limit.
- If the per-IP limiter rejects a client, the IP is automatically added to the blocklist for
block_ttlseconds.
Configuration is required — there are no defaults. You must specify at least one rate-limiting tier:
| Tier | Key | Required fields |
|---|---|---|
| Per-IP (T0+T1) | per_ip |
rate (number > 0), burst (number >= 0), block_ttl (number > 0) |
| Per-domain (T2) | per_domain |
rate (number > 0), burst (number >= 0) |
Shared dictionaries (names are fixed):
| Dict | Purpose |
|---|---|
tls-hello-per-ip |
Per-IP rate limiter state |
tls-hello-per-domain |
Per-SNI rate limiter state |
tls-ip-blocklist |
Auto-blocked IPs with TTL |
| Component | Version |
|---|---|
| OpenResty | openresty/openresty:jammy (1.25.x) |
| Apache APISIX | 3.15.0 |
For local development and test execution:
- Docker with Compose support
makeopensslon the host, for generating the self-signed integration-test certificate
For runtime use:
- Lua 5.1 compatible environment
- OpenResty with
ssl_client_hello_by_lua* resty.limit.reqngx.ssl.clienthelloresty.core
Optional metrics integrations:
nginx-lua-prometheusfor the OpenResty adapter- APISIX Prometheus plugin for the APISIX adapter
Install via OPM:
opm get nemethhh/lua-resty-clienthello-ratelimitOr copy the lib/ tree into your OpenResty/APISIX Lua path manually:
cp -r lib/resty /usr/local/openresty/lualib/This provides the following Lua modules:
resty.clienthello.ratelimitresty.clienthello.ratelimit.configresty.clienthello.ratelimit.metricsresty.clienthello.ratelimit.openrestyresty.clienthello.ratelimit.apisix
The core module is platform-agnostic and exposes new(opts, metrics) plus check().
local limiter = require("resty.clienthello.ratelimit")
local lim, warnings = limiter.new({
per_ip = { rate = 2, burst = 4, block_ttl = 10 },
per_domain = { rate = 5, burst = 10 },
}, my_metrics_adapter)
local rejected, reason = lim:check()
if rejected then
-- reason is one of: "blocklist", "per_ip", "per_domain"
endNotes:
check()must run inssl_client_hello_by_lua*context.- If client IP extraction fails, the limiter currently returns
falseand allows the handshake to continue. - If no SNI is present, only the blocklist and per-IP layers are applied.
The optional metrics adapter is expected to expose:
{
inc_counter = function(name, labels) ... end
}The bundled resty.clienthello.ratelimit.metrics module provides make_cached_inc_counter(prometheus, exptime) which builds this adapter efficiently — prometheus counter objects and label value arrays are cached after the first call per unique name/labels pair. Both adapters use this builder internally.
Important invariant: labels tables passed to inc_counter must be module-level constants (the same table reference on every call). Per-request label tables cause unbounded cache growth.
An example configuration is available in examples/nginx.conf.
Minimal setup:
http {
lua_shared_dict tls-hello-per-ip 1m;
lua_shared_dict tls-hello-per-domain 1m;
lua_shared_dict tls-ip-blocklist 1m;
lua_shared_dict prometheus-metrics 1m;
init_worker_by_lua_block {
require("resty.clienthello.ratelimit.openresty").init({
per_ip = { rate = 2, burst = 4, block_ttl = 10 },
per_domain = { rate = 5, burst = 10 },
prometheus_dict = "prometheus-metrics",
-- metrics_exptime = 300, -- optional: counter TTL in seconds (default 300)
})
}
server {
listen 443 ssl;
ssl_certificate /path/to/server.crt;
ssl_certificate_key /path/to/server.key;
ssl_client_hello_by_lua_block {
require("resty.clienthello.ratelimit.openresty").check()
}
}
}The OpenResty adapter:
- initializes the core limiter once per worker
- optionally initializes
nginx-lua-prometheus - exposes
adapter.prometheusso a/metricslocation can callcollect() - rejects a handshake with
ngx.exit(ngx.ERROR)when a limit is hit
Example files:
The APISIX adapter is loaded as a custom plugin shim:
local adapter = require("resty.clienthello.ratelimit.apisix")
return adapterAdd the shim as apisix/plugins/tls-clienthello-limiter.lua, then update APISIX config:
apisix:
extra_lua_path: "/path/to/custom-plugins/?.lua"
plugins:
- tls-clienthello-limiter
nginx_config:
http:
custom_lua_shared_dict:
tls-hello-per-ip: 1m
tls-hello-per-domain: 1m
tls-ip-blocklist: 1m
plugin_attr:
tls-clienthello-limiter:
per_ip:
rate: 2
burst: 4
block_ttl: 10
per_domain:
rate: 5
burst: 10
# metrics_exptime: 300 # optional: counter TTL in seconds (default: no expiry)The APISIX adapter:
- reads settings from
plugin_attr.tls-clienthello-limiter - builds a metrics adapter on top of APISIX Prometheus, when available
- monkey-patches
apisix.ssl_client_hello_phase - restores the original phase handler in
destroy()
Depending on traffic patterns and configuration, the limiter can emit:
tls_clienthello_blocked_totaltls_clienthello_passed_totaltls_clienthello_rejected_totaltls_ip_autoblock_totaltls_clienthello_no_sni_total
Typical labels include:
reason=blocklistlayer=per_iplayer=per_domain
The repository ships with three Docker-based test targets:
make unit
make integration
make openresty-integrationOr run everything:
make allWhat each target does:
make unit: buildst/unit/Dockerfileand runs Busted specs for the core modulemake integration: generates test certificates, starts APISIX plus a test runner, and executes TLS handshake plus metrics testsmake openresty-integration: generates test certificates, starts OpenResty plus a test runner, and executes equivalent adapter tests
Generated artifacts:
t/integration/certs/server.crtt/integration/certs/server.keyt/integration/conf/apisix.yaml
Cleanup:
make cleanThe integration harness exposes these ports on the host:
| Stack | Port | Purpose |
|---|---|---|
| APISIX | 9443 |
TLS test listener |
| APISIX | 9091 |
Prometheus metrics |
| APISIX | 9092 |
healthz |
| OpenResty | 19443 |
TLS test listener |
| OpenResty | 19092 |
metrics and healthz |
MIT. See LICENSE.