Internal proposal — planning stage. ngx_http_sentinel_module is a first-party nginx/Angie module for making one calm, explainable decision about a client: allow it, observe it, challenge it, tarpit it, or block it. It combines locally computed signals with out-of-band reputation data, then acts once in NGX_HTTP_PREACCESS_PHASE — before access/auth, after request headers are available.
This page is intentionally noindex,nofollow. It describes an unreleased module; directive names and examples are design targets, not a configuration reference.
The short version: one score, one bounded decision
Today, bot protection often feels like a cupboard full of useful but separate tools: an IP reputation decision here, a User-Agent rule there, a 404 counter somewhere else. Sentinel gives those inputs one owner and one verdict. It is deliberately boring in the good security-engineering sense: a weighted integer score, explicit cut points, and a log line that explains what happened. No mystery machine learning. No request waiting on a remote API while the web server holds its breath.
request headers
│
▼
[ sentinel @ PREACCESS ] ──► in-module signals + shared-memory state
│ (no request-path network I/O)
▼
weighted score ──► shadow log | allow | challenge | tarpit | block
The rollout starts in shadow mode: Sentinel calculates and logs the score but changes nothing for the visitor. That gives operators real traffic to tune against before enforcement is even on the table. A small kill switch — sentinel off; plus reload — remains the emergency exit.
What Sentinel owns — and what it deliberately does not
Sentinel is one self-contained module. It may port proven ideas from sibling modules, but it does not read their runtime variables or require them to be installed. That keeps the deployment story pleasantly dull: one module, one configuration surface, one decision point.
| Signal or capability | Delivery point | Boundary |
|---|---|---|
| JA4H HTTP fingerprint | Phase 1 | Computed from method, version and request-header shape/order inside Sentinel. Pure HTTP; no core patch. |
| UA/bot heuristics | Phase 1 | Native score input, ported into Sentinel rather than obtained from a sibling-module variable. |
| Error bursts and scanner paths | Phase 1 | Bounded shared-memory counter plus static checks for paths such as /.env, /.git and /wp-login.php; no hot-path regex. |
| Shared score/ban state | Phase 1 | Locked, bounded, expiring rbtree/slab state. A full zone or malformed input logs and allows by default. |
| Bounded tarpit | Phase 2 | Separate resource-model review: global connection cap, tiny fixed writes, hard lifetime and cleanup on every exit path. |
| CrowdSec decisions | Phase 3 | Loaded out of band into local state. Sentinel never makes a live CrowdSec query while serving a request. |
| JA4 TLS ClientHello fingerprint | Phase 4, only if needed | Not patch-free. It requires an nginx core ClientHello callback patch, so it is intentionally outside the Phase 1–3 pure-HTTP scope. |
That final row is an important correction to the earlier pitch. A pure HTTP module cannot see the TLS ClientHello. JA4H gives us useful HTTP-layer discrimination first; TLS JA4 is deferred until traffic shows that JA4H and other core signals are being evaded, and only then with the required core patch and an in-module parser. TCP-level JA4T is likewise future work and depends on proxy-protocol v2 TLVs.
How JA4, CrowdSec and combined scoring actually help
No individual signal gets to play detective, judge and executioner. An IP address is shared or rotated. A User-Agent is a self-written name tag. A TLS fingerprint can change after a browser update. Reputation data may be old, broad or simply wrong for a particular endpoint. Sentinel is useful because it turns several small, explainable clues into one reviewable decision — and starts by logging that decision instead of enforcing it.
Think of the score as a security guard who writes down why they are uneasy, rather than a magic eight-ball that blurts “bot.” One strange fact is often just the internet being the internet. Several independent facts lining up are a different story. The useful question is not “can this single header be faked?” — of course it can — but “how much effort would it take for a harmful client to keep every relevant layer and its behaviour consistent over time?”
JA4: a shape of the TLS client, not a magic identity card
JA4 describes the shape of a TLS ClientHello: the protocol choices and extensions offered before HTTP exists. A stock browser, a curl build, a headless automation stack and a custom malware client often make different shapes even if all of them claim the same User-Agent. That makes a TLS fingerprint useful for clustering behaviour across changing IP addresses. It does not prove who a client is. Real people can share a TLS stack, browsers can update, and determined clients can imitate familiar fingerprints.
That is why TLS JA4 is planned as a later, evidence-driven addition rather than a Phase 1 headline feature. Reading it correctly requires an nginx core patch that receives the OpenSSL ClientHello callback. Until traffic demonstrates that the extra patch surface is worth maintaining, the pure HTTP module uses JA4H instead. A TLS fingerprint may become a strong score input or a useful correlation key; it should rarely be a solitary block rule.
Its best job is correlation, not prophecy. If a particular TLS shape keeps appearing across many source addresses while issuing the same suspicious HTTP requests, JA4 provides a useful thread through an IP-rotation campaign. If a familiar browser stack visits once, reads a normal page and leaves, it is merely a detail in the record. The module should not punish visitors because their browser upgraded on Tuesday or because a legitimate library was written by someone who had strong opinions about TLS extensions. That way lies an incident ticket with too many screenshots and not enough dignity.
JA4H: what the HTTP client reveals after TLS
JA4H fingerprints the HTTP request shape available to an access-phase module: method, HTTP version, header order, and the presence or form of headers such as Cookie, Referer and Accept-Language. It can distinguish “this claims to be Chrome” from “this request is structurally much closer to a quick script,” without trusting the printed User-Agent alone. It also works when TLS details are unavailable or deliberately randomized.
JA4H has the same humility requirement. Corporate proxies, privacy tools and legitimate SDKs can produce unusual shapes; a neat-looking header order does not make a scraper harmless. Sentinel therefore treats JA4H as one input to a weighted score and records $sentinel_ja4h for tuning and forensic comparison. The value is in the combination: an odd request shape plus scanner paths, a burst of errors and a known reputation is far more meaningful than an odd request shape alone.
In practical terms, JA4H makes cheap costume changes less persuasive. A scraper can rotate an IP, copy a popular browser User-Agent and even vary a few headers. Reproducing a browser’s complete request habits consistently — method choice, HTTP version, header order, cookies, referer behaviour and language preferences — is more work. It is still not impossible, and it is not a reason to block on JA4H alone. It is simply another piece of evidence that makes the flimsy fake moustache easier to spot when the client is also rattling every doorknob on the building.
User-Agent: useful testimony from an unreliable witness
The User-Agent header says what a client wants to be called. Sometimes that is useful: a real browser, a legitimate command-line client, a monitoring probe and a known crawler have different operational expectations. Sometimes it is theatre: hostile clients routinely announce themselves as Chrome, Safari, Googlebot or whatever name they hope will make the receptionist look away. Treating the literal string as an identity is therefore an excellent way to ban a real visitor while welcoming a script wearing a name badge it printed at home.
Phase 1 plans native UA/bot heuristics inside Sentinel rather than a runtime dependency on a separate user-agent module. The port map calls for a configuration-time-loaded trie and version-range matcher, which gives predictable matching without a regex engine doing gymnastics in the request hot path. The parser can recognize broad client families and obviously automation-shaped declarations, but its result is only a score input. “curl” is not evil; plenty of APIs, health checks and competent humans use it. “Chrome” is not innocent either; it is a string, not a character reference.
The interesting signal is coherence. A claimed Chrome UA paired with browser-like JA4H and normal navigation behaviour is ordinary. The same claimed Chrome UA paired with script-like header order, no cookies, a machine-gun sequence of /.env and /.git/config probes, and a fresh reputation match tells a much less charming story. Conversely, a known internal monitoring UA from an allowlisted network should not be dragged into a tarpit merely because it is honest about being a monitor. Future forward-confirmed crawler verification gives search engines a stronger path to an allowlist than trusting the word “Googlebot” printed on a suspicious postcard.
CrowdSec: local intelligence, never a request-path dependency
CrowdSec supplies decisions collected from community and local detections. Sentinel’s job is not to make a synchronous API request for every visitor; that would add latency, introduce a new failure dependency and give an attacker another way to tie up workers. Instead, a bouncer or sidecar refreshes an expiring local decision feed out of band, and Sentinel reads that local state under its shared-memory rules.
In practice, a CrowdSec hit can be a high-confidence contribution for an IP that was recently identified as abusive. It is still a contribution, not an instruction to abandon judgment. A deployment may want a direct block for an unambiguous local decision, a challenge for a softer community signal, or a lower weight when the match is old. TTLs and deletion events matter: reputation should age out rather than becoming a permanent scarlet letter for a recycled address.
The feed lifecycle is deliberately unglamorous: receive a decision outside the request path, validate its shape, insert or refresh a bounded local entry with its expiry, remove it when the feed says it is gone, and log malformed input without making visitors pay for the feeder’s bad day. In the first CrowdSec phase, IP keys are the useful common denominator; later fingerprint keys can add precision when the corresponding signal actually exists. That separation lets operators gain reputation context without teaching nginx workers to become tiny, impatient API clients. Nobody needs a web server doing interpretive dance while a remote service times out.
The value is the intersection of clues
The initial scoring model is intentionally a plain weighted sum. Each available phase contributes bounded evidence, then thresholds map the result to a verdict. Exact weights are deployment policy and must be tuned in shadow mode; the following is a reasoning sketch, not a fixed default:
illustrative score =
60 if an active CrowdSec/local decision matches
+ 35 if the request hits a scanner path or honeypot rule
+ 25 if the recent per-client error window is abnormal
+ 20 if native UA/bot heuristics are suspicious
+ 15 if JA4H is unusual or conflicts with the claimed UA
+ ... future signals only when they prove useful
allow below the challenge threshold
challenge a plausible human with enough risk to verify
tarpit an expensive-looking automated client (Phase 2)
block several strong, corroborating indicators
Those numbers are deliberately illustrative. A WordPress login endpoint, a public documentation site and a machine-to-machine API do not have the same idea of “normal.” A documentation site might tolerate an unusual SDK but score scanner paths heavily; an authentication endpoint may challenge sooner; an internal admin service may choose fail-closed semantics after its own allowlist. Thresholds are policy. The implementation’s responsibility is to make the inputs bounded, the arithmetic predictable and the resulting verdict visible.
Combining information needs discipline as well as ambition. Closely related signals should not be blindly counted three times just because the spreadsheet has room for extra columns: a CrowdSec IP decision and a local IP soft-ban may describe the same event. The score design should cap contributions where appropriate, attach a reason/component record to the verdict, and favour evidence from different layers — reputation, HTTP shape, declared client, and actual behaviour over time. That gives an operator something actionable to inspect in shadow logs instead of a mysterious 97/100 with no clue which goblin supplied the other 96 points.
Consider four very different requests. A normal browser with a coherent JA4H, no error history and no reputation match should glide through. A new command-line API client from a datacentre may look a bit odd but has no harmful behaviour; shadow logs may show it deserves a gentle threshold, not a ban. A client probing /.env, /.git/config and forgotten WordPress paths accumulates behavioural evidence even if it rotates its IP and lies about Chrome. Add a fresh CrowdSec decision or a persistent fingerprint correlation and the score reaches a much more defensible block threshold.
That combination also makes the system safer to tune. An operator can lower the impact of a noisy signal without throwing it away, or raise the challenge threshold without losing obvious scanner detection. Future verified-search-engine and monitoring allowlists can bypass or reduce scoring explicitly. Every decision remains observable through the score, verdict and component logs, so a false positive becomes a threshold/configuration problem to fix — not a shrug and a black box.
This is also why shadow mode is non-negotiable. Before enforcement, operators can ask sensible questions of real traffic: which UA families would have crossed the challenge line, whether a corporate proxy creates a noisy JA4H cluster, whether an error burst is a scanner or a broken link in the site, and whether CrowdSec matches are fresh enough to deserve their weight. Tune, observe again, then enforce gradually. The alternative is deploying a very confident bouncer who has never seen the guest list. BOFH tradition has its place; it is not a substitute for evidence.
Safety constraints are features, not footnotes
- No network I/O in the request path. A request performs local calculation and shared-memory lookups only. Feed refresh happens elsewhere.
- Fail open by default. A missing fingerprint, malformed feed, lock fallback or full zone is logged and allowed.
sentinel_fail closed;is an explicit high-security choice. - Bounded state. Shared-memory entries have a size limit and expiry; the module cannot quietly grow a second database in RAM.
- No hot-path allocation or regex. Request-pool/fixed-stack data only, so a weird client cannot turn string matching into a tiny denial-of-service machine.
- Tarpits are capped before they exist. Phase 2 cannot ship without a global connection budget, timer/lifetime limits, fixed buffers and leak tests for normal completion, aborts, timeouts and worker exit.
- Every behavior is testable. Each function, feature and fixed finding gets a test in the same change; the planned harness covers build/runtime tests, ASan, Valgrind, CodeQL and fuzzing for attacker-controlled input.
The decision pipeline
The score remains intentionally legible. It is a tunable weighted sum of the signals available in that phase. Cross a threshold and the module selects a verdict; in shadow mode it records the same verdict without enforcing it. Operators can expose $sentinel_score, $sentinel_verdict and $sentinel_ja4h in an access log to see precisely how the thresholds behave.
score = UA/bot signal
+ recent error/scanner behaviour
+ locally loaded reputation / policy state
+ JA4H signal
low score → allow
middle → challenge (optional handoff to existing challenge/captcha tooling)
high → tarpit, once Phase 2's resource model exists
maximum → block (403 or 444)
“Challenge” is a soft bridge, not a hard dependency: Sentinel can hand a verdict to installed JS-challenge or captcha tooling where present. The first release does not pretend that every future response mechanism has already been built. Proof-of-work, throttling, cache-only origin shielding, maze mode, metrics exports and feedback to CrowdSec are post-core increments.
Illustrative configuration shape
These directives are proposed design names. The point is the rollout pattern, not copy-and-paste configuration for a module that has not yet shipped.
http {
sentinel_zone client_reputation:32m;
server {
sentinel on;
sentinel_mode shadow; # calculate + log; never enforce
sentinel_fail open; # safety default
sentinel_threshold challenge=40 tarpit=70 block=90;
log_format sentinel '$remote_addr verdict=$sentinel_verdict '
'score=$sentinel_score ja4h=$sentinel_ja4h '
'"$http_user_agent"';
access_log /var/log/nginx/sentinel.log sentinel;
}
}
The operational sequence is equally intentional: enable shadow mode, inspect the decision distribution and false-positive candidates, adjust thresholds, then opt into enforcement route by route. Login endpoints and scanner magnets may earn stricter policy later; a public content route should begin conservatively. There is no prize for blocking a real reader quickly.
What this could look like in real nginx configurations
The snippets below make the intended operations story less abstract. They are design examples, not supported configuration: Sentinel has no released parser yet. Each calls out the phase that would make it possible, and every decision still begins with shadow mode.
1. Protect a login vhost without turning the public site into a minefield
# Phase 1 target: start by observing the public site.
server {
server_name www.example.net;
sentinel on;
sentinel_mode shadow;
sentinel_fail open;
sentinel_threshold challenge=45 tarpit=70 block=90;
}
# A separate, sensitive vhost can use tighter cut points and hand a
# middle score to existing challenge tooling instead of blocking a human.
server {
server_name login.example.net;
sentinel on;
sentinel_mode enforce; # only after shadow tuning
sentinel_threshold challenge=15 tarpit=55 block=85;
sentinel_action_challenge @human_check;
}
location @human_check {
# Existing JS challenge / captcha integration goes here.
# Sentinel hands off; it does not require a sibling module at runtime.
}
That is the intended graduated response: a request that is merely suspicious can prove it is a browser or a person; a repeated scanner-like client can receive a stronger response. The public site gets evidence first, not a surprise outage dressed as security.
2. Make a tarpit expensive for the scraper, not for nginx
# Phase 2 target: these limits are global safety rails.
server {
server_name archive.example.net;
sentinel on;
sentinel_mode enforce;
sentinel_threshold challenge=40 tarpit=70 block=95;
sentinel_tarpit_max_conns 128; # all workers together
sentinel_tarpit_delay 750ms; # tiny, deliberate writes
sentinel_tarpit_bytes 64k; # bounded response budget
}
A tarpit is never “open as many slow sockets as the attacker can send.” The module owns a shared connection budget and a hard lifetime; when the budget is exhausted it must take its safe fallback path. This phase earns its own review precisely because slow responses are dangerously easy to get almost right.
3. Use CrowdSec intelligence without a live lookup
# Phase 3 target: a bouncer/sidecar refreshes local decisions out of band.
http {
sentinel_zone client_reputation:64m;
sentinel_crowdsec_zone crowdsec_decisions;
server {
server_name api.example.net;
sentinel on;
sentinel_mode enforce;
sentinel_fail open;
sentinel_threshold challenge=35 tarpit=65 block=90;
}
}
The important bit is what is not in this configuration: no API URL, token or synchronous HTTP client. The out-of-band feeder updates bounded local state; serving a request remains a local shared-memory operation. A malformed or unavailable feed logs a problem and fails open by default.
4. Be deliberately strict on an isolated admin endpoint
# A high-security endpoint may choose availability trade-offs explicitly.
server {
server_name admin.internal.example.net;
allow 10.0.0.0/8;
deny all;
sentinel on;
sentinel_mode enforce;
sentinel_fail closed; # opt-in; never the default
sentinel_threshold challenge=10 tarpit=45 block=70;
}
sentinel_fail closed; belongs on an endpoint where rejecting a dubious request is safer than preserving availability. It should not quietly become the default for a public blog, shop or API. That distinction is the whole point of making the failure policy obvious in configuration.
5. Roll everything back in one reload
server {
server_name www.example.net;
# Incident switch: disable the decision hook immediately.
sentinel off;
}
No scavenger hunt through rate-limit maps, include files and third-party daemons. A reload removes Sentinel from the request path. That small escape hatch is a first-class production feature, not an afterthought.
Speculative full syntax sketch
This is intentionally a vocabulary sketch, not a promise or a parser contract. Plain directives come from the locked target set; lines labelled SPECULATIVE are ideas for the post-core roadmap. The point is to test whether the module would feel coherent to configure before we fossilise an API in C.
# /etc/nginx/conf.d/sentinel.conf — SPECULATIVE; NOT IMPLEMENTED
http {
# Core target API
sentinel_zone client_reputation:64m;
sentinel_crowdsec_zone crowdsec_decisions; # Phase 3
# TLS JA4 needs the Phase 4 nginx ClientHello patch; do not enable it
# in the pure-HTTP Phase 1–3 build.
# sentinel_ja4_blocklist /etc/nginx/sentinel/ja4-deny.txt;
log_format sentinel_json escape=json
'{"ip":"$remote_addr","verdict":"$sentinel_verdict",'
'"score":"$sentinel_score","ja4h":"$sentinel_ja4h",'
'"request":"$request"}';
server {
listen 443 ssl http2;
server_name www.example.net;
# Core target API
sentinel on;
sentinel_mode shadow; # shadow | enforce
sentinel_fail open; # open | closed
sentinel_threshold challenge=40 tarpit=70 block=90;
sentinel_action_challenge @human_check;
sentinel_tarpit_max_conns 128; # Phase 2
sentinel_tarpit_delay 750ms; # Phase 2
sentinel_tarpit_bytes 64k; # Phase 2
access_log /var/log/nginx/sentinel.json sentinel_json;
# SPECULATIVE post-core API: readable policy rather than regex soup.
# sentinel_allow verified_search_engines;
# sentinel_allow 192.0.2.0/24;
# sentinel_policy /wp-login.php challenge=10 tarpit=45 block=75;
# sentinel_policy /wp-json/ challenge=35 tarpit=70 block=95;
# sentinel_score_weight ua=15 errors=25 scanner_path=35 crowdsec=60;
# sentinel_honeypot /wp-content/uploads/.sentinel-canary;
# sentinel_metrics vts statsd otel;
# sentinel_feedback crowdsec; # only for confirmed verdicts
location @human_check {
# Existing challenge/captcha implementation, if selected.
return 503;
}
}
}
The design preference is visible even in the speculative lines: one obvious policy language, explicit cut points, a small allowlist surface and telemetry that can answer “what did Sentinel do?” without turning every deployment into detective work. The implementation may choose different names, contexts or defaults after Phase 1 learns from real configuration tests — that is exactly why this remains a sketch.
Delivery plan: narrow, reviewable phases
| Phase | Scope | Gate |
|---|---|---|
| 0 — Recon | Port map for the error counter, UA heuristics, shared-memory pattern, optional challenge bridge and test harness. | Source-level reconnaissance completed; it confirms the self-contained path and the TLS-JA4 patch requirement. |
| 1 — Score and decide | Addon skeleton, PREACCESS handler, JA4H, native UA/error/scanner signals, bounded shared state, variables, shadow mode, allow/challenge/block and tests. | Pure HTTP; zero nginx core patch. |
| 2 — Tarpit | Drip response plus connection/timer/lifetime safeguards and abuse/leak testing. | Resource model reviewed before implementation. |
| 3 — CrowdSec feed | Out-of-band decision ingestion, TTL handling, malformed-feed fail-open behavior and tests. | The request path remains local-only. |
| 4 — TLS/TCP fingerprints | Optional TLS JA4 with its nginx patch; optional JA4T through proxy-protocol v2. | Only when observed evasion justifies the extra surface. |
After the core proves itself, smaller additions can layer onto the same score-then-act pipeline: HTTP/2 frame-order fingerprints, UA/fingerprint coherence, ASN/geo weighting, velocity, honeypots, header anomalies, verified-search-engine allowlisting, structured metrics and verdict feedback. They remain a roadmap, not a marketing promise smuggled into Phase 1.
Questions this design has already answered
Why not ask CrowdSec during each request?
Because remote calls on the request path create latency, queueing and an easy way to make a web server wait on its own protection system. Sentinel consumes a refreshed local feed instead; a feed failure is observable, but it does not turn visitors into hostages.
Does Sentinel depend on the existing bot or error-abuse modules?
No. Their useful patterns are ported into Sentinel as native code and inputs. That avoids fragile runtime coupling and gives one module ownership of scoring. The standalone error-abuse module is only considered for later deprecation after Sentinel has proved its equivalent signal in production.
Why is TLS JA4 not in the first release?
JA4 reads the TLS ClientHello, which a pure HTTP module never receives. Capturing it needs a core nginx patch that installs an OpenSSL ClientHello callback. We can gain meaningful value from HTTP-layer JA4H first and add the patch-bearing work only when the traffic evidence says it earns its maintenance cost.
Could a tarpit become a denial-of-service against us?
It could if implemented carelessly, which is precisely why it is a separate phase. Sentinel will globally cap tarpit connections, use fixed-size writes and timers, impose a hard lifetime, and test cleanup across every termination path before operators can enable the action.
How can we test this without blocking anyone?
Start with sentinel_mode shadow;. It computes the would-be verdict and emits it to logs, but it allows every request. That makes threshold tuning a traffic-observation exercise instead of a game of “hope the pager stays quiet.”
Current status
The private project repository contains the module README, locked design and phased TODO. Phase 0 has established the port map and corrected the TLS-JA4 assumption. No production module code exists yet. The next implementation milestone is Phase 1: a safe, testable, pure-HTTP score-and-decide skeleton running in shadow mode.