Skip to content

kraloveckey/nginx-ipset-blocklist

Repository files navigation

JarvIPs

JarvIPs – an nginx module for dynamic IP access control using Linux netfilter ipsets as a blacklists/whitelists.

Unlike nginx's built-in allow/deny directives β€” which require nginx -s reload on every IP list change β€” nginx-ipset-blocklist checks the ipset kernel table on every request.

You can add or remove IPs from the set and the change takes effect instantly, with zero nginx restarts and zero dropped connections.


Overview


Why RPC? Why not query ipset directly from nginx?

^ back to top ^

This is the first question everyone asks.

nginx worker processes run as an unprivileged user (www-data, nobody, etc.) and cannot call libipset or touch netfilter directly β€” that requires root or CAP_NET_ADMIN. Spawning a privileged subprocess on every HTTP request is too slow.

The solution used here is a small root-owned daemon (ipset_test_server) that holds the privileged connection. nginx workers communicate with it over ONC RPC (a lightweight binary IPC mechanism, part of the standard C library) via a local UDP socket. The round-trip is a single UDP packet each way and takes under 1 ms on localhost.

ONC RPC (also called Sun RPC) uses a system service called rpcbind as a "port directory". When ipset_test_server starts, it registers its UDP port with rpcbind. When an nginx worker starts, it asks rpcbind "where is program 0x200000f1?" and gets back the port. After that, all communication is direct, UDP, localhost. rpcbind is a standard Linux service β€” it is already present on any server that runs NFS or NIS, and it is trivially installed with apt install rpcbind everywhere else.


Architecture

^ back to top ^

                    HTTP request arrives
                           β”‚
                           β–Ό
                    nginx master process (root)
                           β”‚ fork
                    β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚              β”‚  ... N worker processes (www-data)
                    β–Ό              β–Ό
             nginx worker    nginx worker
                    β”‚
                    β”‚  on every request that hits a location
                    β”‚  with blacklist/whitelist configured:
                    β”‚
                    β”‚  [ACCESS phase]
                    β”‚  ngx_ipset_blocklist.c   – module logic
                    β”‚  └─ ipset_test.c              – RPC client wrapper
                    β”‚     └─ ipset_test_rpc_clnt.c  – auto-generated RPC stub
                    β”‚
                    β”‚  UDP packet (localhost, ~0.1 ms)
                    β”‚  "Is 1.2.3.4 in set 'myblacklist'?"
                    β”‚
                    β–Ό
             ipset_test_server                     (runs as root)
             ipset_test_server.c
             └─ ipset_test_rpc_xdr.c  β€” serialisation (auto-generated)
             └─ system("ipset test myblacklist 1.2.3.4")
                    β”‚
                    β–Ό
             Linux kernel β€” netfilter ipset table   (in-memory hash)
                    β”‚
                    └─ answer: IN_SET / NOT_IN_SET
                    β”‚
                    β–Ό  UDP reply
             nginx worker
             └─ IPADDR_IN_IPSET    β†’ return HTTP 403
             └─ IPADDR_NOT_IN_SET  β†’ pass request to content handler
             └─ RPC error          β†’ log warning, pass request (fail-open)


             rpcbind                               (standard system service)
             └─ "phone book": maps program IDs to UDP/TCP ports
             └─ ipset_test_server registers here at startup
             └─ nginx worker asks here for the port, once per worker start

Repository structure

^ back to top ^

`nginx-ipset-blocklist`/
β”œβ”€β”€ ngx_ipset_blocklist.c        nginx module: config parsing, request handler
β”œβ”€β”€ ipset_test.c                 RPC client: builds request, calls RPC stub
β”œβ”€β”€ ipset_test.h                 public API: init / test / deinit
β”œβ”€β”€ ipset_test_rpc.h             RPC interface definition (auto-generated by rpcgen)
β”œβ”€β”€ ipset_test_rpc_clnt.c        RPC client stub          (auto-generated by rpcgen)
β”œβ”€β”€ ipset_test_rpc_xdr.c         XDR serialisation        (auto-generated by rpcgen)
β”œβ”€β”€ ipset_test_server.c          RPC daemon: receives queries, calls ipset
β”œβ”€β”€ Makefile                     builds ipset_test_server only (no nginx needed)
β”œβ”€β”€ ipset_test_server.service    systemd unit for the daemon
└── config                       nginx build system integration

The three *_rpc_*.{c,h} files were generated with rpcgen from a .x interface definition file and should not be edited manually.


Features

^ back to top ^

  • Blacklist – return HTTP 403 if the client IP is in the named ipset.
  • Whitelist β€” return HTTP 403 if the client IP is not in the named ipset.
  • Both can be active simultaneously in the same context; check order is controlled by ipset_priority (blacklist first by default).
  • Configurable at http {}, server {}, and location {} level.
  • Child contexts inherit from parents; any level can override or disable with off.
  • One ipset name for both address families, or separate names for IPv4 / IPv6.
  • Fail-open on RPC errors: logs a warning and passes the request through.
  • Works with nginx 1.9.11+ as a dynamic module, or any modern nginx as static.

Configuration syntax

^ back to top ^

blacklist "setname";                  # one ipset for both IPv4 and IPv6
blacklist "setname4" "setname6";      # separate ipsets per address family
blacklist off;                        # disable blacklist at this level (not inherited down)

whitelist "setname";
whitelist "setname4" "setname6";
whitelist off;

ipset_priority blacklist;             # check blacklist first (default)
ipset_priority whitelist;             # check whitelist first β€” a whitelisted IP
                                      # always passes, regardless of blacklist

Valid in: http {}, server {}, location {}

Important

⚠️ Do not combine blacklist/whitelist with return in the same location {} block.

Nginx processes requests in phases:

  1. REWRITE Phase (return, rewrite)
  2. ACCESS Phase (this module, allow, deny)
  3. CONTENT Phase (try_files, proxy_pass, static files)

Because return executes in the REWRITE phase, it will answer the request and terminate processing before the module's access check is ever reached. Use try_files or a proxy backend instead:

# WRONG β€” return fires before the module
location /api/ {
    blacklist "bad_ips";
    return 200 "ok";       # ← this wins, module is bypassed
}

# CORRECT β€” try_files runs in CONTENT phase, after ACCESS
location /api/ {
    blacklist "bad_ips";
    try_files /index.html =503;   # module runs first, then this
}

Inheritance rules

^ back to top ^

A context that does not mention blacklist or whitelist inherits its parent's setting. Using off at any level disables that list for the current context and prevents it from being inherited by child contexts.

http { blacklist "global"; }          ← set at top level
  server { ... }                      ← inherits "global"
  server {
    blacklist off;                    ← disabled for this server
    location /a { ... }               ← also disabled (inherited "off")
    location /b { blacklist "x"; }    ← re-enabled with a different set
  }

When both blacklist and whitelist are active, the check order is controlled by ipset_priority:

  • ipset_priority blacklist (default) β€” blacklist is checked first. A blacklisted IP is denied even if it is also whitelisted. Use this when "deny overrides allow" β€” for example, to block a compromised corporate server that is still in the whitelist.
  • ipset_priority whitelist β€” whitelist is checked first. A whitelisted IP always passes, regardless of the blacklist. Use this when "allow overrides deny" β€” for example, to guarantee VIP clients are never accidentally blocked.

ipset_priority is inherited from parent contexts the same way blacklist and whitelist are.


Configuration examples

^ back to top ^

Global blacklist for all servers

http {
    blacklist "bad_ips" "bad_ips6";

    server {
        listen 80;
        server_name app.example.com;
        # inherits the global blacklist automatically
    }

    server {
        listen 80;
        server_name internal.example.com;
        blacklist off;   # this server is exempt from the global blacklist
    }
}

Whitelist a restricted admin area

^ back to top ^

server {
    listen 443 ssl;

    location /admin/ {
        whitelist "office_ips";   # only office IPs; everyone else gets 403
        try_files $uri $uri/ =404;
    }

    location / {
        # public β€” no restrictions
    }
}

Simultaneous blacklist + whitelist

^ back to top ^

Both conditions apply: the IP must not be in the blacklist AND must be in the whitelist. Blacklist is checked first.

location /api/ {
    blacklist "known_bots"    "known_bots6";
    whitelist "api_clients"   "api_clients6";
    proxy_pass http://backend;
}

Per-location overrides

^ back to top ^

http {
    blacklist "global_bl" "global_bl6";   # active by default everywhere

    server {
        location /public/ {
            blacklist off;   # anyone can access this path
            whitelist off;
            try_files $uri =404;
        }

        location /secure/ {
            blacklist "strict_bl";   # tighter list overrides the global one
            whitelist "vpn_users";
            proxy_pass http://secure_backend;
        }
    }
}

Check order with ipset_priority

^ back to top ^

http {
    blacklist "bad_ips";
    whitelist "vip_clients";
    ipset_priority whitelist;   # VIP always pass, even if in blacklist

    location /api/ {
        ipset_priority blacklist;   # stricter: blacklist wins here
    }
}

Dynamic updates β€” no nginx reload required

^ back to top ^

This is the main advantage over nginx's built-in allow/deny:

# Block a new IP β€” takes effect on the very next request
sudo ipset add myblacklist 1.2.3.4

# Unblock
sudo ipset del myblacklist 1.2.3.4

# Block a subnet
sudo ipset add myblacklist 10.0.0.0/8

# View current contents
sudo ipset list myblacklist

# Persist sets across reboots
sudo ipset save > /etc/ipset.rules
# Restore: sudo ipset restore < /etc/ipset.rules
# (add this to a systemd unit or rc.local to auto-restore on boot)

# On Debian/Ubuntu systems, the easiest way to restore ipsets automatically 
# on boot is to install the persistence package:
sudo apt install ipset-persistent
sudo netfilter-persistent save

Startup order

^ back to top ^

All three services must be running, in this order:

1. rpcbind            ← system service; start it with systemctl
2. ipset_test_server  ← our daemon; must be up before nginx workers fork
3. nginx              ← workers connect to the daemon during init_process

Important distinction: If ipset_test_server is not running when nginx starts or reloads, the worker processes will fail to initialize the RPC client and will exit immediately with an [emerg] error. Nginx will refuse to start. However, if the daemon crashes after nginx is already running, the active workers will not crash. They will safely fall back to the "fail-open" state, logging a [warn] and allowing the HTTP requests through until the daemon is restarted.

With make install the provided systemd unit sets Before=nginx.service and After=network.target, so the boot order is handled automatically.


Error log messages

^ back to top ^

# Request denied by blacklist
[notice] ipset_blacklist: access denied by blacklist "myblacklist"

# Request denied by whitelist (IP was not in the set)
[notice] ipset_blacklist: access denied by whitelist "office_ips" (IP not in set)

# RPC daemon unreachable β€” request passed through (fail-open)
[warn]   ipset_blacklist: RPC error querying blacklist "myblacklist" β€” request passed through

# Daemon not running when nginx started β€” worker exits
[emerg]  ipset_blacklist: failed to connect to ipset RPC server on localhost

Caveats

^ back to top ^

  • Fail-open: when ipset_test_server is unreachable, requests are passed through rather than blocked. This is intentional β€” a crashed daemon should not take down your site. Monitor the daemon and restart nginx workers if it crashes for an extended period.
  • Rename or delete an ipset: restart ipset_test_server and reload nginx. Changing ipset contents (add/del) never requires any restart.
  • UDP transport: the default is UDP with a 25-second timeout. For very high request rates with many workers, switching to TCP ("udp" β†’ "tcp" in ipset_test.c) can reduce port exhaustion.
  • return directive: see the warning in the Configuration section above.

Build and install guide

^ back to top ^

The project produces two independent binaries. Both must be built and running for the module to work.

What How to build Needs nginx source?
ipset_test_server plain make No
nginx module (.so or baked in) nginx ./configure + make Yes

Build the daemon first β€” it does not depend on nginx at all. Then build the nginx module.


System requirements

^ back to top ^

Component Minimum version Install
Linux kernel 3.0+ β€”
gcc any modern apt install build-essential
ipset utility 7.x apt install ipset
rpcbind any apt install rpcbind
libtirpc any apt install libtirpc-dev
libpcre3 any apt install libpcre3-dev
zlib any apt install zlib1g-dev
libssl any apt install libssl-dev
nginx source 1.30.0+ for dynamic; any modern for static download below

Install all build dependencies at once:

sudo apt update
sudo apt install build-essential ipset rpcbind libtirpc-dev \
                 libpcre3-dev zlib1g-dev libssl-dev

Note

libtirpc vs libnsl: Modern distros (Debian 11+, Ubuntu 22.04+, Kali 2022+) use libtirpc.

The project's config script and Makefile detect which one is present automatically β€” you do not need to change anything manually.


Part 1 β€” Build and install ipset_test_server

^ back to top ^

1.1 Build

^ back to top ^

cd /path/to/nginx-ipset-blocklist

make
# Output: ./ipset_test_server

If the build succeeds you will see:

Built: ipset_test_server
Run:   sudo ./ipset_test_server
       (must run before nginx starts)

1.2 Verify rpcbind is running

^ back to top ^

rpcbind must be active before the daemon can register itself:

sudo systemctl enable rpcbind
sudo systemctl start rpcbind
sudo systemctl status rpcbind --no-pager  # must show: Active: active (running)

1.3 Install as a systemd service (recommended for production)

^ back to top ^

sudo make install
# This does:
#   cp ipset_test_server /usr/local/sbin/
#   cp ipset_test_server.service /etc/systemd/system/
#   systemctl daemon-reload
#   systemctl enable ipset_test_server
#   systemctl start  ipset_test_server

Verify the daemon is registered with rpcbind:

rpcinfo -p localhost | grep 536871153
# Expected output β€” two lines, one for UDP and one for TCP:
# 536871153    1   udp  XXXXX
# 536871153    1   tcp  XXXXX

If you see those two lines, the daemon is running correctly and nginx workers will be able to connect to it.

1.4 Manual start (for development / testing)

^ back to top ^

# Start in foreground so you can see output
sudo ./ipset_test_server

# Or in background:
sudo ./ipset_test_server &

# Check registration:
rpcinfo -p localhost | grep 536871153

Part 2 β€” Build the nginx module

^ back to top ^

Both options (dynamic .so and static) require the nginx source tree.

The source is a build-time dependency only β€” the resulting binary or .so has no source dependency and can be deployed anywhere.

The nginx source version must match the nginx binary you will run.

# Check the version of your currently installed nginx:
nginx -v
# Example: nginx version: nginx/1.30.1

# Download the matching source:
export NGX_VER=1.30.1
wget https://nginx.org/download/nginx-${NGX_VER}.tar.gz
tar xf nginx-${NGX_VER}.tar.gz
cd nginx-${NGX_VER}

Note

If you are building a static module and will replace the system nginx binary anyway, the version just needs to be a recent stable release β€” it does not have to match anything.


Option A β€” Dynamic module (recommended)

^ back to top ^

A dynamic module is a .so shared library that nginx loads at startup.

Your existing nginx binary is not modified or replaced. To update the module, replace the .so and run nginx -s reload β€” no downtime.

Step 1 β€” Configure

^ back to top ^

Run this inside the nginx source directory:

cd nginx-${NGX_VER}

./configure \
    --add-dynamic-module=/path/to/nginx-ipset-blocklist \
    --with-compat

--with-compat makes the .so ABI-compatible with any nginx built with the same flag, including distro-packaged nginx binaries. Always include it for dynamic modules.

You should see near the end of the output:

adding module in /path/to/nginx-ipset-blocklist
 + ngx_ipset_blocklist was configured

Step 2 β€” Build only the module

^ back to top ^

make modules
# Takes about 10-30 seconds β€” compiles only the module, not nginx itself
# Output: objs/ngx_ipset_blocklist.so

Step 3 β€” Find where nginx expects modules

^ back to top ^

nginx -V 2>&1 | grep modules-path
# Example output:
# --modules-path=/usr/lib/nginx/modules
# or
# --modules-path=/usr/share/nginx/modules

Step 4 β€” Install the module

^ back to top ^

# Replace the path with what you found in step 3:
sudo cp objs/ngx_ipset_blocklist.so /usr/lib/nginx/modules/

# Verify it landed there:
ls -la /usr/lib/nginx/modules/ | grep ipset

Step 5 β€” Enable in nginx.conf

^ back to top ^

Open /etc/nginx/nginx.conf and add the following as the very first line, before events {} and http {}:

load_module modules/ngx_ipset_blocklist.so;

events {
    ...
}

http {
    ...
}

Step 6 β€” Validate and reload

^ back to top ^

sudo nginx -t
# Must print: configuration file /etc/nginx/nginx.conf test is successful

sudo nginx -s reload
# Graceful reload β€” no dropped connections, no downtime

Updating the module later (no downtime)

^ back to top ^

cd nginx-${NGX_VER}
make modules
sudo cp objs/ngx_ipset_blocklist.so /usr/lib/nginx/modules/
sudo nginx -s reload

Option B β€” Static module (baked into the nginx binary)

^ back to top ^

The module is compiled directly into the nginx binary. No .so file, no load_module directive needed. To update the module you must recompile nginx and replace the binary.

Use this option when:

  • You are building nginx from scratch anyway.
  • You want the simplest possible deployment (single binary, no external files).
  • You are building a container image or custom package.

Step 1 β€” Check your current nginx compile flags

^ back to top ^

If you are replacing an existing nginx installation, copy its compile flags so you preserve all currently active modules:

nginx -V 2>&1
# Look for the long list of --with-* flags in "configure arguments"

Step 2 β€” Configure with the module

^ back to top ^

cd nginx-${NGX_VER}

./configure \
    --add-module=/path/to/nginx-ipset-blocklist \
    --prefix=/etc/nginx \
    --sbin-path=/usr/sbin/nginx \
    --modules-path=/usr/lib/nginx/modules \
    --conf-path=/etc/nginx/nginx.conf \
    --error-log-path=/var/log/nginx/error.log \
    --http-log-path=/var/log/nginx/access.log \
    --pid-path=/run/nginx.pid \
    --with-http_ssl_module \
    --with-http_v2_module \
    --with-http_realip_module
    # paste any other --with-* flags from nginx -V here

Step 3 β€” Build

^ back to top ^

make -j$(nproc)
# Takes 2-5 minutes depending on your CPU
# Output: objs/nginx

Step 4 β€” Install

^ back to top ^

# Stop nginx, back up the old binary, replace it
sudo systemctl stop nginx
sudo cp /usr/sbin/nginx /usr/sbin/nginx.bak
sudo cp objs/nginx /usr/sbin/nginx

# Verify the new binary works:
sudo nginx -t

# Start nginx
sudo systemctl start nginx
nginx -v

No load_module line is needed in nginx.conf β€” the module is always present in this binary.

Updating the module later

^ back to top ^

make -j$(nproc)
sudo cp objs/nginx /usr/sbin/nginx
sudo nginx -t
sudo systemctl restart nginx   # full restart required when replacing the binary

Part 3 β€” Create ipsets

^ back to top ^

ipsets must exist before nginx starts. If nginx starts and an ipset named in the config does not exist, the first RPC query to that set will fail (logged as a warning) and the request will be passed through.

# Create sets for IPv4
sudo ipset create myblacklist hash:ip family inet
sudo ipset create mywhitelist hash:ip family inet

# Create sets for IPv6
sudo ipset create myblacklist6 hash:ip family inet6
sudo ipset create mywhitelist6 hash:ip family inet6

# Add some IPs
sudo ipset add myblacklist 1.2.3.4
sudo ipset add myblacklist 10.0.0.0/8   # subnets work too

# Persist across reboots
sudo ipset save > /etc/ipset.rules

# To restore on boot, add this to a systemd unit or /etc/rc.local:
# ipset restore < /etc/ipset.rules

Part 4 β€” Verify everything works

^ back to top ^

4.1 Minimal test config

^ back to top ^

Create /etc/nginx/conf.d/ngx_test.conf:

server {
    listen 8080;
    server_name _;

    root /var/www/html;
    default_type text/plain;

    # Sanity check β€” no module involved, always 200
    location = /ping {
        return 200 "pong\n";
    }

    # Blacklist test β€” use try_files, NOT return
    # (return runs before the access check and would bypass the module)
    location = /test {
        blacklist "myblacklist" "myblacklist6";
        try_files /index.html =503;
    }
}
sudo mkdir -p /var/www/html
sudo touch /var/www/html/index.html
echo "it works" | sudo tee /var/www/html/index.html

sudo nginx -t && sudo nginx -s reload

4.2 Run the test sequence

^ back to top ^

# Add a second loopback address to simulate a "bad" IP
sudo ip addr add 127.0.0.2/8 dev lo

# Add it to the blacklist
sudo ipset add myblacklist 127.0.0.2

# 1. Sanity: nginx itself works
curl -s -o /dev/null -w "ping:          %{http_code}\n" http://127.0.0.1:8080/ping
# Expected: 200

# 2. Clean IP is allowed
curl -s -o /dev/null -w "clean IP:      %{http_code}\n" http://127.0.0.1:8080/test
# Expected: 200

# 3. Blacklisted IP is blocked
curl -s -o /dev/null -w "blacklisted:   %{http_code}\n" \
    --interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 403

# 4. Dynamic update β€” remove from blacklist, NO nginx reload
sudo ipset del myblacklist 127.0.0.2
curl -s -o /dev/null -w "after del:     %{http_code}\n" \
    --interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 200  ← instant, no reload needed

# 5. Dynamic update β€” add back
sudo ipset add myblacklist 127.0.0.2
curl -s -o /dev/null -w "after add:     %{http_code}\n" \
    --interface 127.0.0.2 http://127.0.0.1:8080/test
# Expected: 403  ← instant again

4.3 Check the logs

^ back to top ^

sudo tail -f /var/log/nginx/error.log | grep -i ipset
# [notice] ipset_blacklist: access denied by blacklist "myblacklist"

4.4 Cleanup after testing

^ back to top ^

sudo ip addr del 127.0.0.2/8 dev lo
sudo ipset del myblacklist 127.0.0.2
sudo rm /etc/nginx/conf.d/ngx_test.conf
sudo nginx -s reload

Startup order summary

^ back to top ^

boot
 β”‚
 β”œβ”€β–Ί rpcbind starts            (system service, usually automatic)
 β”‚
 β”œβ”€β–Ί ipset_test_server starts  (Before=nginx.service in the .service file)
 β”‚       └─ registers with rpcbind: "I am 0x200000f1, UDP port XXXXX"
 β”‚
 └─► nginx starts
         └─ each worker calls init_ipset_test_clnt()
                └─ asks rpcbind: "where is 0x200000f1?"
                └─ gets back the UDP port
                └─ worker is ready to handle requests

Troubleshooting

^ back to top ^

  1. Workers crash on startup: worker process exited with fatal code 2.

ipset_test_server is not running or rpcbind is not running.

sudo systemctl status rpcbind
sudo systemctl status ipset_test_server

# Fix:
sudo systemctl start rpcbind
sudo systemctl start ipset_test_server
sudo systemctl restart nginx
  1. Module has no effect β€” all requests return 200 regardless of ipset contents.

You are using return in the same location {} block as blacklist/whitelist.

The return directive runs in the REWRITE phase, before the ACCESS phase where the module runs. Replace return 200 "..." with try_files:

# Wrong:
location /x { blacklist "bl"; return 200 "ok"; }

# Correct:
location /x { blacklist "bl"; try_files $uri =503; }
  1. Build fails: rpc/rpc.h: No such file or directory.
sudo apt install libtirpc-dev
# The project config script will detect it automatically β€” no other changes needed
  1. Build fails: ngx_ipset_blocklist.so ABI error when loading.

The nginx source version used to compile the .so does not match the running nginx binary. Recompile using the matching source version (check nginx -v).

  1. rpcinfo -p localhost shows nothing for program 536871153.

The daemon is not running or failed to register. Check:

sudo systemctl status ipset_test_server
sudo journalctl -t ipset_test_server -n 30
  1. Daemon is running but nginx workers still cannot connect.

Check that rpcbind is listening:

ss -ulnp | grep rpcbind   # UDP
ss -tlnp | grep rpcbind   # TCP

Also verify the daemon's UDP port is reachable from localhost:

rpcinfo -u localhost 536871153 1
# Expected: program 536871153 version 1 ready and waiting