lua-resty-clienthello-ratelimit

Three-tier TLS ClientHello rate limiter for OpenResty and Apache APISIX

$ opm get nemethhh/lua-resty-clienthello-ratelimit

lua-resty-clienthello-ratelimit

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 dictionary

  • T1: per-IP leaky-bucket rate limiting

  • T2: per-SNI-domain leaky-bucket rate limiting

The repository includes:

  • a platform-agnostic core module

  • an OpenResty adapter with nginx-lua-prometheus metrics support

  • an APISIX adapter that hooks into ssl_client_hello_phase

  • Dockerized unit, APISIX integration, and OpenResty integration test suites

Repository layout

    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

How it works

For each TLS ClientHello:

  1. The core module extracts the raw client IP address via FFI.

  2. It checks whether that IP is already in the blocklist shared dict.

  3. It applies a per-IP rate limit.

  4. If an SNI is present, it applies a per-domain rate limit.

  5. If the per-IP limiter rejects a client, the IP is automatically added to the blocklist for block_ttl seconds.

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 |

Tested versions

| Component | Version | | --- | --- | | OpenResty | openresty/openresty:jammy (1.25.x) | | Apache APISIX | 3.15.0 |

Requirements

For local development and test execution:

  • Docker with Compose support

  • make

  • openssl on 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.req

  • ngx.ssl.clienthello

  • resty.core

Optional metrics integrations:

  • nginx-lua-prometheus for the OpenResty adapter

  • APISIX Prometheus plugin for the APISIX adapter

Installation

Install via OPM:

    opm get nemethhh/lua-resty-clienthello-ratelimit

Or 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.ratelimit

  • resty.clienthello.ratelimit.config

  • resty.clienthello.ratelimit.metrics

  • resty.clienthello.ratelimit.openresty

  • resty.clienthello.ratelimit.apisix

Core module

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"
    end

Notes:

  • check() must run in ssl_client_hello_by_lua* context.

  • If client IP extraction fails, the limiter currently returns false and 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.

OpenResty usage

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.prometheus so a /metrics location can call collect()

  • rejects a handshake with ngx.exit(ngx.ERROR) when a limit is hit

APISIX usage

Example files:

The APISIX adapter is loaded as a custom plugin shim:

    local adapter = require("resty.clienthello.ratelimit.apisix")
    return adapter

Add 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()

Metrics

Depending on traffic patterns and configuration, the limiter can emit:

  • tls_clienthello_blocked_total

  • tls_clienthello_passed_total

  • tls_clienthello_rejected_total

  • tls_ip_autoblock_total

  • tls_clienthello_no_sni_total

Typical labels include:

  • reason=blocklist

  • layer=per_ip

  • layer=per_domain

Testing

The repository ships with three Docker-based test targets:

    make unit
    make integration
    make openresty-integration

Or run everything:

    make all

What each target does:

  • make unit: builds t/unit/Dockerfile and runs Busted specs for the core module

  • make integration: generates test certificates, starts APISIX plus a test runner, and executes TLS handshake plus metrics tests

  • make openresty-integration: generates test certificates, starts OpenResty plus a test runner, and executes equivalent adapter tests

Generated artifacts:

  • t/integration/certs/server.crt

  • t/integration/certs/server.key

  • t/integration/conf/apisix.yaml

Cleanup:

    make clean

Test endpoints

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

License

MIT. See LICENSE.

Authors

nemethhh

License

mit

Versions