A single nginx PREACCESS module that scores every client and acts on the verdict β tarpit bots, block scanners, PoW-challenge grey-area requests, feed decisions back to CrowdSec. No network in the request path, no external process, no added latency.
Ships as libnginx-mod-http-sentinel in the
deb.myguard.nl nginx / angie build.
Only ci.yml has a pull_request trigger. Its reusable workflow members keep
the fast PR gates under one entry point; long discovery runs stay scheduled.
| Workflow | Trigger | Gates |
|---|---|---|
build-test.yml |
PR via ci.yml |
validation, strict module build, production-seam unit tests, .so artifact, live runtime/Redis suite, Test::Nginx |
security-scanners.yml |
PR via ci.yml |
flawfinder β₯4, clang-tidy, semgrep |
fuzzing.yml |
PR via ci.yml |
all three production-linked fuzz surfaces |
valgrind.yml |
PR via ci.yml |
60-second memcheck soak |
codeql.yml |
PR via ci.yml + monthly |
CodeQL over src/ |
asan.yml |
PR via ci.yml |
static ASan/UBSan runtime suite and request-storm soak |
lint.yml |
PR via ci.yml after the PR2 linter adoption |
the shared ci/linter/ gate on a hosted runner |
ci-deep.yml |
monthly + dispatch | long fuzz, memcheck, helgrind, scanners, coverage, nginx mainline/stable and Angie compatibility |
bump.yml runs weekly or by dispatch to refresh verified nginx/Angie source
pins, action/tool pins, and ci/vendor/nginx-tests through a reviewable PR.
Build caches are separated by toolchain mode (debug, asan, module,
coverage) so incompatible objects cannot mix. On a local identical rerun the
debug build reused its configured tree and completed in 0.1 seconds, versus 28
seconds for the initial build; cumulative ccache statistics are deliberately not
presented as an isolated CI speedup. The scheduled deep workflow publishes a
module-only gcov report (currently 38.9% line, 64.9% function, and 31.1% branch
coverage); it is visibility, not a coverage-percentage gate.
Every request is scored by combining weighted signals β bot UA, error burst, scanner paths, velocity, datacenter ASN, TLS/TCP fingerprints, CrowdSec decisions. The score maps to a verdict: allow β challenge β tarpit β block.
score β₯ block_threshold β 403 / 444 drop (+ optional TTL soft-ban)
score β₯ tarpit_threshold β bounded garbage drip, throttle, or cache-only
score β₯ challenge_threshold β proof-of-work page
otherwise β allow
In shadow mode (sentinel_mode shadow) the module scores and logs without
acting. Run shadow first, watch $sentinel_verdict in your access log, then flip
to enforce once you're happy with the weights.
The module has no runtime sibling deps: it absorbs its own error-rate counter, bot-UA heuristics, JA4H fingerprinter, and scanner-path table. CrowdSec and Redis state are loaded out-of-band by a worker timer; the request path only does shared-memory lookups.
http {
sentinel_zone main:10m; # shared-memory identity store
server {
listen 80;
server_name example.com;
sentinel on;
sentinel_mode shadow; # observe first, switch to enforce later
sentinel_zone main:10m;
log_format sentinel_log '$remote_addr $sentinel_score/$sentinel_verdict '
'bot=$sentinel_bot scanner=$sentinel_scanner '
'errrate=$sentinel_errrate';
access_log /var/log/nginx/sentinel.log sentinel_log;
location / {
root /var/www/html;
}
}
}Watch the log. When scores look right, change shadow β enforce.
Bots constantly probe for /.env, /.git, /wp-login.php, /wp-admin/,
/.aws, /phpinfo and similar. The module has a built-in scanner-path table
(case-insensitive prefix match, no configuration needed) that scores those URIs
at +50 immediately. A bot that keeps hitting 404s also accumulates error-rate
score (1 point per recorded error Γ sentinel_weight_errrate). Add honeypot
paths for anything extra you want to catch.
With default thresholds (challenge=30 tarpit=60 block=80) a scanner-path hit
already lands in the tarpit band on the first request (score 50). After 80+
errors the identity crosses into block.
http {
sentinel_zone reps:10m;
server {
listen 80;
server_name example.com;
sentinel on;
sentinel_mode enforce;
sentinel_zone reps:10m;
# Built-in scanner paths (/.env /.git /wp-login /wp-admin /.aws /phpinfo)
# are matched automatically at weight 50. No config needed.
# Add your own decoy paths via sentinel_honeypot:
sentinel_honeypot /xmlrpc.php /setup.php /config.php /.well-known/evil;
# A bot that hits a honeypot scores 90 β straight to block band.
sentinel_weight_honeypot 90;
# Each 404 / 4xx error adds 1 point. 30 errors β challenge,
# 60 β tarpit, 80 β block. Weight can be raised if you see fast scans.
sentinel_weight_errrate 1;
# Once blocked, ban for 1 hour so retries don't make it past preaccess.
sentinel_block_ttl 3600;
location / {
root /var/www/html;
}
}
}What happens on the first hit to /wp-login.php:
| Signal | Score contribution |
|---|---|
scanner_path (/wp-login) |
+50 |
| (no prior errors, no bot UA) | +0 |
| Total | 50 β tarpit band |
The bot gets a slow garbage drip instead of a fast 404. If it keeps probing, the
error counter climbs and it eventually crosses into block, at which point
sentinel_block_ttl writes a 1-hour soft-ban so every subsequent request from
that IP is short-circuited without re-evaluation.
LLM crawlers and aggressive content scrapers usually send a recognisable bot UA (weight 30 by default) and often hit pages rapidly (velocity signal). Combining both puts them in the tarpit band without affecting real browsers.
http {
sentinel_zone reps:10m;
sentinel_velocity_zone vzone:5m rate=60 window=10 block=1800;
server {
listen 80;
server_name example.com;
sentinel on;
sentinel_mode enforce;
sentinel_zone reps:10m;
sentinel_velocity vzone;
# Raise bot weight so bot-UA alone β tarpit (score 60).
sentinel_weight_bot 60;
# Velocity: >60 req in 10s β +30 on top.
sentinel_weight_velocity 30;
# Tarpit: slow drip of decoy links β the crawler follows them forever.
sentinel_tarpit_maze on;
sentinel_tarpit_delay 8000; # 8 s between drip ticks
sentinel_tarpit_bytes 2048;
sentinel_tarpit_max_lifetime 120000;
location / {
root /var/www/html;
}
}
}abuse.ch SSLBL publishes a curated list of
JA3 TLS fingerprints belonging to known malware / botnet C2 clients. These are
high-confidence: a real browser never matches one. The ssl-fingerprint module
exposes the client's JA3 MD5 as $ssl_fingerprint_ja3_hash; sentinel_ja3
scores a deny-list match at +80 (default) β above the block threshold, so a
C2 client is blocked on the first request.
tools/sslbl-ja3-fetch.sh downloads the feed and generates an nginx include:
# Daily cron β only reload nginx when the feed actually changed (exit 10 = no change)
0 4 * * * /opt/.../tools/sslbl-ja3-fetch.sh -o /etc/nginx/sentinel-ja3-deny.conf \
&& systemctl reload nginxThe generated /etc/nginx/sentinel-ja3-deny.conf is a single
sentinel_ja3_deny <hash> <hash> ...; statement. Wire it up:
http {
server {
listen 443 ssl;
ssl_fingerprint on; # libnginx-mod-ssl-fingerprint
sentinel on;
sentinel_mode enforce;
sentinel_zone main:10m;
sentinel_ja3 $ssl_fingerprint_ja3_hash; # client's JA3 MD5
include /etc/nginx/sentinel-ja3-deny.conf; # SSLBL deny list (generated)
sentinel_weight_ja3 80; # SSLBL = malware/C2 β block band
location / {
root /var/www/html;
}
}
}A C2 client whose JA3 is on the SSLBL list scores 80 β block on the first hit. Everything else is unaffected (empty/missing JA3 fails open, contributes 0).
JA3 (MD5) is the older fingerprint; JA4 is its modern successor. They catch different populations β JA3's value here is specifically the SSLBL malware feed. Run both (
sentinel_ja3+sentinel_ja4) for layered coverage.
abuse.ch Feodo Tracker publishes a
curated list of IPv4 addresses of confirmed botnet command-and-control (C2)
servers (Dridex, Emotet, TrickBot, QakBot, β¦). An inbound client whose IP is
known C2 infrastructure is high-confidence malicious. Unlike the JA3 feed this
matches on the client IP directly β no TLS, no ssl-fingerprint, works on
plain HTTP too.
The deny list is static (a daily feed), so it is compiled into a radix tree
at config load and matched by longest prefix per request β no per-request
allocation, no DB, no network. CIDRs are accepted (sentinel_c2ip_deny 1.2.3.0/24 2001:db8::/32), so it is distinct from a dynamic CrowdSec ban table.
tools/feodo-c2ip-fetch.sh downloads the feed and generates an nginx include:
# Daily cron β only reload nginx when the feed actually changed (exit 10 = no change)
0 4 * * * /opt/.../tools/feodo-c2ip-fetch.sh -o /etc/nginx/sentinel-c2ip-deny.conf \
&& systemctl reload nginxThe generated file is a single sentinel_c2ip_deny <ip> <ip> ...; statement.
Wire it up (no TLS required):
http {
server {
listen 80;
sentinel on;
sentinel_mode enforce;
sentinel_zone main:10m;
include /etc/nginx/sentinel-c2ip-deny.conf; # Feodo C2 IP list (generated)
sentinel_weight_c2ip 80; # C2 infra β block band
location / {
root /var/www/html;
}
}
}A client whose IP is on the Feodo list scores 80 β block on the first request.
Everything else is unaffected: a non-IP connection, an empty list, or an IP not
in the tree all fail open and contribute 0. $sentinel_c2ip exposes the verdict
(0/1) for logging.
http {
resolver 1.1.1.1 8.8.8.8 valid=300s; # needed for FCrDNS
# Shared-memory zones
sentinel_zone main:20m;
sentinel_velocity_zone vzone:5m rate=100 window=10 block=3600;
sentinel_crowdsec_zone cs:4m;
sentinel_fcrdns_zone fcdns:1m;
# GeoIP2 ASN (from libnginx-mod-http-geoip2)
geoip2 /usr/share/GeoIP/GeoLite2-ASN.mmdb {
$geoip2_asn autonomous_system_number;
}
server {
listen 443 ssl;
ssl_fingerprint on; # libnginx-mod-ssl-fingerprint
sentinel on;
sentinel_mode enforce;
sentinel_zone main:20m;
sentinel_threshold challenge=30 tarpit=60 block=80;
# Signals
sentinel_velocity vzone;
sentinel_honeypot /wp-login.php /xmlrpc.php /.env /.git /.aws /actuator;
sentinel_asn $geoip2_asn;
sentinel_datacenter_asn 16509 14618 15169 14061 16276; # AWS/GCP/DO/OVH
sentinel_ja4 $ssl_fingerprint_ja4;
sentinel_ja4_deny t13d1516h2_8daaf6152771_b186095e22b6;
sentinel_fcrdns fcdns;
sentinel_fcrdns_verify_suffix .googlebot.com .google.com .search.msn.com;
# CrowdSec feed (loaded out-of-band by worker timer)
sentinel_crowdsec_zone cs:4m;
sentinel_crowdsec_feed /var/lib/crowdsec/sentinel-feed.txt;
sentinel_crowdsec_interval 10;
# Actions
sentinel_block_status 403;
sentinel_block_ttl 3600;
sentinel_tarpit_maze on;
sentinel_pow on;
sentinel_pow_secret "generate-with-openssl-rand-hex-32";
sentinel_pow_difficulty 18;
location / {
root /var/www/html;
}
# Stricter admin path
location /admin/ {
sentinel_threshold challenge=10 tarpit=40 block=60;
sentinel_weight_bot 100;
root /var/www/html;
}
# Prometheus scrape endpoint
location = /sentinel-status {
sentinel_status;
allow 127.0.0.1;
deny all;
}
}
}One block with every directive, grouped by function. Defaults shown in comments.
http {
# ββ Shared-memory zones ββββββββββββββββββββββββββββββββββββββββββββββββ
# Identity store (error-rate, soft-ban, CrowdSec table)
sentinel_zone NAME:SIZE; # e.g. main:20m (required for errrate/softban)
# Velocity (request-rate) counter β separate zone
sentinel_velocity_zone NAME:SIZE
[rate=100] # max requests per window before flagged
[window=10] # sliding window, seconds
[block=3600]; # how long the flag sticks, seconds
# CrowdSec ban table (also used by Redis pull)
sentinel_crowdsec_zone NAME:SIZE; # e.g. cs:4m
# FCrDNS verdict cache
sentinel_fcrdns_zone NAME:SIZE; # e.g. fcdns:1m
# ββ Core βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
sentinel on|off; # default: off
sentinel_mode enforce|shadow; # default: enforce
sentinel_fail open|closed; # on zone/lookup error; default: open
sentinel_zone NAME:SIZE; # bind the errrate zone (also http context)
sentinel_threshold
challenge=30 # score β₯ this β PoW page (if enabled)
tarpit=60 # score β₯ this β tarpit / throttle / shield
block=80; # score β₯ this β 403 / 444
# ββ Score weights ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Set 0 to disable a signal.
sentinel_weight_errrate 1; # per error event in the sliding window
sentinel_weight_blocked 100; # identity has an active soft-ban
sentinel_weight_scanner 50; # built-in scanner-path hit
sentinel_weight_bot 30; # heuristic bot User-Agent
sentinel_weight_header 25; # malformed / anomalous request headers
sentinel_weight_honeypot 90; # operator-defined decoy path hit
sentinel_weight_velocity 30; # per-identity request rate exceeded
sentinel_weight_asn 35; # client ASN in the flagged list
sentinel_weight_c2ip 80; # client IP on C2 deny list (Feodo C2 infra)
sentinel_weight_coherence 40; # browser UA but bare HTTP client shape
sentinel_weight_ja3 80; # JA3 (TLS) fp on deny list (SSLBL malware/C2)
sentinel_weight_ja4 50; # JA4 (TLS) fp on deny list
sentinel_weight_ja4t 45; # JA4T (TCP) fp on deny list
sentinel_weight_crowdsec 100; # CrowdSec ban table hit (action-tiered)
# ββ Signals ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Velocity (bind a declared zone to this location/server/http)
sentinel_velocity ZONE_NAME;
# Honeypot decoy paths (prefix match, case-sensitive, no regex)
sentinel_honeypot /wp-login.php /.env /.git /actuator [/more ...];
# Trusted CIDR allowlist (score short-circuits to 0; CrowdSec ban still wins)
sentinel_allowlist 192.0.2.0/24 10.0.0.0/8 203.0.113.42;
# Datacenter / abuse ASN (reads a geoip2 module variable β no DB in module)
sentinel_asn $geoip2_asn;
sentinel_datacenter_asn 16509 14618 15169 14061 16276;
# C2 IP deny-list β static radix tree, matches client IP (CIDR ok, no DB).
# Pairs with the abuse.ch Feodo Tracker C2 IP feed (see above).
sentinel_c2ip_deny 1.2.3.4 5.6.7.0/24;
# ... or generated from the Feodo feed:
# include /etc/nginx/sentinel-c2ip-deny.conf;
# JA3 TLS fingerprint deny-list (reads ssl-fingerprint module variable)
# Pairs with the abuse.ch SSLBL malware/C2 JA3 feed (see below).
sentinel_ja3 $ssl_fingerprint_ja3_hash;
sentinel_ja3_deny e7d705a3286e19ea42f587b344ee6865;
# ... or generated from the SSLBL feed:
# include /etc/nginx/sentinel-ja3-deny.conf;
# JA4 TLS fingerprint deny-list (reads ssl-fingerprint module variable)
sentinel_ja4 $ssl_fingerprint_ja4;
sentinel_ja4_deny t13d1516h2_8daaf6152771_b186095e22b6
t13d1715h2_5b57614c22b0_3d5424432f57;
# JA4T TCP fingerprint deny-list (reads PROXY-protocol TLV from edge LB)
sentinel_ja4t $proxy_protocol_tlv_0xe0;
sentinel_ja4t_deny t00nn0_0000_00000000;
# FCrDNS β async forward-confirmed rDNS for self-declaring crawlers
# (prevents UA spoofing bypassing known_good_ua short-circuit)
resolver 1.1.1.1 8.8.8.8 valid=300s; # required for async lookups
sentinel_fcrdns ZONE_NAME; # bind + enable
sentinel_fcrdns_ttl 1h; # verdict cache lifetime
sentinel_fcrdns_verify_suffix .googlebot.com .google.com .search.msn.com;
# ββ Actions β Block ββββββββββββββββββββββββββββββββββββββββββββββββββββ
sentinel_block_status 403; # 400β599, or 444 (drop connection)
sentinel_block_ttl 3600; # seconds; 0 = off (no soft-ban)
# ββ Actions β Tarpit βββββββββββββββββββββββββββββββββββββββββββββββββββ
sentinel_tarpit_max_conns 256; # global concurrent tarpit cap; 0 = disable
sentinel_tarpit_delay 5000; # ms between drip ticks (100β60000)
sentinel_tarpit_bytes 1024; # total bytes dripped (1β65536)
sentinel_tarpit_max_lifetime 30000; # hard ceiling ms (1000β600000)
sentinel_tarpit_maze on|off; # drip decoy crawl links instead of whitespace
# ββ Actions β Throttle (instead of tarpit) βββββββββββββββββββββββββββββ
# On tarpit-band verdict: serve normally but cap egress rate.
# Mutually exclusive with tarpit (throttle wins).
sentinel_throttle_rate 32k; # e.g. 32k, 0 = off
# ββ Actions β Origin shield (instead of tarpit) ββββββββββββββββββββββββ
# On tarpit-band verdict: let request through, raise $sentinel_shield=1.
# Operator wires proxy_no_cache / proxy_cache_use_stale in the proxy block.
sentinel_shield on|off; # default: off
# ββ Actions β Proof-of-work challenge ββββββββββββββββββββββββββββββββββ
sentinel_pow on|off; # default: off
sentinel_pow_secret "long-random-string"; # required; generate with:
# openssl rand -hex 32
sentinel_pow_difficulty 16; # leading zero bits (1β32)
sentinel_pow_ttl 3600; # challenge + cookie lifetime (s)
# ββ CrowdSec feed (out-of-band, worker timer) ββββββββββββββββββββββββββ
sentinel_crowdsec_zone cs:4m; # bind the ban table
sentinel_crowdsec_feed /path/to/feed.txt;
sentinel_crowdsec_interval 10; # reload tick seconds (1β3600)
sentinel_crowdsec_default_ttl 3600; # TTL for entries with expiry=0
sentinel_crowdsec_stale_after 600; # warn if feed is this old (s)
sentinel_crowdsec_max_bytes 16m; # feed size cap
# ββ Redis multi-box shared ban state (out-of-band, worker timer) βββββββ
# Requires sentinel_crowdsec_zone. Links libhiredis.
sentinel_redis 10.0.0.9:6379; # host[:port]; enables the feature
sentinel_redis_password "${REDIS_PW}"; # optional AUTH
sentinel_redis_prefix sentinel; # key namespace (<prefix>:ban:<ip>)
sentinel_redis_interval 10; # pull + flush tick (1β3600 s)
sentinel_redis_ttl 3600; # TTL on pushed ban keys (60β86400 s)
# ββ CrowdSec decision feedback sink (out-of-band, worker timer) ββββββββ
sentinel_cs_sink_path /var/lib/crowdsec/sentinel-decisions.json;
sentinel_cs_sink_interval 10; # drain/rewrite tick (1β3600 s)
sentinel_cs_sink_ttl 3600; # duration field in decision (60β86400 s)
sentinel_cs_sink_scenario sentinel/http-abuse;
# ββ Prometheus metrics βββββββββββββββββββββββββββββββββββββββββββββββββ
# Put sentinel_status; in a location{} block. Protect with allow/deny.
# (shown in server{} block below for clarity)
server {
listen 443 ssl proxy_protocol; # proxy_protocol only if using JA4T
location / {
sentinel on;
sentinel_mode enforce;
# ... directives above are inherited from http{}/server{} ...
}
location = /sentinel-status {
sentinel_status; # content handler β no sentinel eval here
allow 127.0.0.1;
deny all;
}
# Origin-shield wiring (when sentinel_shield on)
location /proxied/ {
sentinel on;
sentinel_mode enforce;
sentinel_shield on;
proxy_pass http://backend;
proxy_cache mycache;
proxy_cache_use_stale updating error timeout;
proxy_cache_background_update on;
proxy_no_cache $sentinel_shield; # don't refresh cache for shielded clients
proxy_cache_bypass "";
}
}
}score = (weight_errrate Γ errrate_count)
+ (weight_blocked Γ 1_if_softbanned)
+ (weight_scanner Γ 1_if_scanner_path)
+ (weight_bot Γ 1_if_bot_ua)
+ (weight_header Γ 1_if_header_anomaly)
+ (weight_honeypot Γ 1_if_honeypot)
+ (weight_velocity Γ 1_if_rate_exceeded)
+ (weight_asn Γ 1_if_datacenter_asn)
+ (weight_coherence Γ 1_if_ua_incoherent)
+ (weight_ja3 Γ 1_if_ja3_denied)
+ (weight_ja4 Γ 1_if_ja4_denied)
+ (weight_ja4t Γ 1_if_ja4t_denied)
+ (weight_crowdsec Γ tier_factor Γ 1_if_crowdsec_hit)
CrowdSec action tiers: ban β full weight (1.0); captcha β block band; throttle β tarpit band. The tier maps the CrowdSec decision to the equivalent verdict without separate weights.
Short-circuit: a UA matching the known-good crawler list (Googlebot, Bingbotβ¦) forces score β 0 unless there is a CrowdSec hit. FCrDNS spoofed verdict suppresses this short-circuit.
Allowlist: a client IP matching sentinel_allowlist forces score β 0, also unless there is a CrowdSec hit.
Score cap: 100 000 (prevents integer overflow from extreme errrate Γ weight products).
The following URI prefixes are matched automatically (case-insensitive, no config needed). Hits add sentinel_weight_scanner (default 50).
| Prefix | What it catches |
|---|---|
/.env |
Laravel / generic env files |
/.git |
Exposed git repositories |
/wp-login |
WordPress login probing |
/wp-admin |
WordPress admin probing |
/.aws |
AWS credential files |
/phpinfo |
PHP info page probing |
Use sentinel_honeypot for anything else (custom decoys, /xmlrpc.php, /actuator, etc.).
| Signal | Variable | Default weight | Triggered by |
|---|---|---|---|
| Error burst | $sentinel_errrate |
Γ1/error | HTTP 4xx/5xx burst in sliding window |
| Soft-ban | β | 100 flat | sentinel_block_ttl wrote a ban for this IP |
| Scanner path | $sentinel_scanner |
50 | Built-in prefix table (see above) |
| Bot UA | $sentinel_bot |
30 | Heuristic bot User-Agent match |
| Header anomaly | $sentinel_header_anomaly |
25 | HTTP/1.1 without Host; CL+TE; dup Host; no Accept+UA |
| Honeypot | $sentinel_honeypot |
90 | sentinel_honeypot prefix match |
| Velocity | $sentinel_velocity |
30 | Per-identity rate exceeded in velocity zone |
| ASN | $sentinel_asn |
35 | Client ASN in sentinel_datacenter_asn list |
| C2 IP | $sentinel_c2ip |
80 | Client IP on sentinel_c2ip_deny radix list (abuse.ch Feodo C2) |
| UA coherence | $sentinel_coherence |
40 | Browser UA + bare HTTP client headers |
| JA3 (TLS) | $sentinel_ja3 |
80 | TLS fingerprint on sentinel_ja3_deny list (abuse.ch SSLBL malware/C2) |
| JA4 (TLS) | $sentinel_ja4 |
50 | TLS fingerprint on sentinel_ja4_deny list |
| JA4T (TCP) | $sentinel_ja4t |
45 | TCP fingerprint on sentinel_ja4t_deny list |
| CrowdSec | $sentinel_crowdsec |
100 | IP in CrowdSec ban table |
| FCrDNS | $sentinel_fcrdns |
β | spoofed suppresses known_good_ua only |
| Allowlist | $sentinel_allowlist |
β | Score β 0 (not a weight) |
All variables are NOCACHEABLE β they resolve to the value computed in PREACCESS.
| Variable | Values | Meaning |
|---|---|---|
$sentinel_score |
integer | Final weighted score |
$sentinel_verdict |
allow/challenge/tarpit/block |
Verdict |
$sentinel_ja4h |
24 hex chars | JA4H request fingerprint (in-module) |
$sentinel_errrate |
integer | Error events in the sliding window |
$sentinel_scanner |
0/1 |
Scanner-path hit |
$sentinel_bot |
0/1 |
Bot UA |
$sentinel_header_anomaly |
0/1 |
Header anomaly |
$sentinel_honeypot |
0/1 |
Honeypot path hit |
$sentinel_velocity |
0/1 |
Velocity exceeded |
$sentinel_asn |
0/1 |
Flagged ASN |
$sentinel_c2ip |
0/1 |
Client IP on C2 deny list (Feodo C2) |
$sentinel_coherence |
0/1 |
UA/header incoherence |
$sentinel_ja3 |
0/1 |
JA3 deny-list hit (SSLBL malware/C2) |
$sentinel_ja4 |
0/1 |
JA4 deny-list hit |
$sentinel_ja4t |
0/1 |
JA4T deny-list hit |
$sentinel_fcrdns |
verified/spoofed/pending |
FCrDNS verdict |
$sentinel_allowlist |
0/1 |
IP in trusted CIDR |
$sentinel_crowdsec |
0/1 |
CrowdSec ban table hit |
$sentinel_crowdsec_action |
none/ban/captcha/throttle |
CrowdSec decision type |
$sentinel_throttled |
0/1 |
Throttle action applied |
$sentinel_shield |
0/1 |
Origin-shield action applied |
$sentinel_pow |
off/challenge/verified |
PoW status |
log_format sentinel_json escape=json
'{"ts":"$time_iso8601","ip":"$remote_addr","uri":"$request_uri",'
'"score":$sentinel_score,"verdict":"$sentinel_verdict",'
'"ja4h":"$sentinel_ja4h","errrate":$sentinel_errrate,'
'"scanner":$sentinel_scanner,"bot":$sentinel_bot,'
'"header":$sentinel_header_anomaly,"honeypot":$sentinel_honeypot,'
'"velocity":$sentinel_velocity,"asn":$sentinel_asn,'
'"c2ip":$sentinel_c2ip,"coherence":$sentinel_coherence,'
'"ja4":$sentinel_ja4,"ja4t":$sentinel_ja4t,'
'"fcrdns":"$sentinel_fcrdns","allowlist":$sentinel_allowlist,'
'"crowdsec":$sentinel_crowdsec,"crowdsec_action":"$sentinel_crowdsec_action",'
'"throttled":$sentinel_throttled}';
server {
sentinel on;
sentinel_mode shadow; # observe without blocking
access_log /var/log/nginx/sentinel.jsonl sentinel_json;
}All sentinel_* directives are valid in http, server, and location context
and follow standard nginx inheritance β a location overrides what it sets,
inherits the rest. Zone declarations (sentinel_zone, sentinel_velocity_zone)
are http-context only.
server {
sentinel on;
sentinel_mode enforce;
sentinel_threshold challenge=30 tarpit=60 block=80;
location /admin/ {
# Strict: bot alone triggers block
sentinel_threshold challenge=10 tarpit=40 block=60;
sentinel_weight_bot 100;
}
location = /healthz {
# Health probe: effectively disabled (thresholds out of reach)
sentinel_threshold challenge=900 tarpit=950 block=990;
sentinel_weight_bot 0;
}
location /api/ {
sentinel_mode shadow; # score but never act
}
location /internal/cb {
sentinel off;
}
}sentinel_status; in a location block emits Prometheus text exposition
(text/plain; version=0.0.4). Counters are lock-free atomics in the same shm
as the tarpit; they are never blocking, never failing. Protect the endpoint:
location = /sentinel-status {
sentinel_status;
allow 127.0.0.1;
deny all;
}| Metric | Type | Labels |
|---|---|---|
sentinel_requests_total |
counter | β |
sentinel_verdict_total |
counter | v=allow|challenge|tarpit|block |
sentinel_signal_total |
counter | s=errrate|blocked|scanner|bot|header|honeypot|velocity|asn|coherence|crowdsec|ja3|ja4|ja4t |
sentinel_shadow_total |
counter | β |
sentinel_tarpit_active |
gauge | β |
Example PromQL: rate(sentinel_verdict_total{v="block"}[5m])
- No network in the request path. Per-request work is shared-memory lookups only. CrowdSec and Redis I/O happen on worker timers.
- Fail-open by default. A zone error, missing variable, or absent module logs and allows.
sentinel_fail closedflips it. - Bounded tarpit. Global concurrent-connection cap, fixed drip buffers, hard max lifetime. Cannot become a self-DoS.
- Self-contained. Every signal is computed in-module. No runtime dependency on sibling modules (geoip2, ssl-fingerprint, proxy-protocol are opt-in sources, not hard deps).
- Shadow-first. The module is designed to be run in
shadowmode first, with the full JSON log, before anyenforceis switched on.
- Sibling modules:
- nginx-error-abuse-module β predecessor; sentinel absorbs its error-rate logic
- nginx-autocert-module
- nginx-strip-filter-module
- nginx-cache-turbo-module
- nginx-label-autoconf-module
- Pitch / design overview
- Package: nginx-modules stack Β· angie-modules stack
Feature branch off master β PR to master β remote CI green β squash-merge.
Build/test standalone: bash ci/tools/ci-build.sh. Every PR/push runs build-test,
security-scanners, fuzzing (120s/target regression) and valgrind (memcheck lite);
the exhaustive fuzz + memcheck/helgrind soak (ci-deep) runs monthly (remote cron) and weekly (local cron).
MIT β see LICENSE.