Challenge Providers
Provider-agnostic edge actions — the ChallengeProvider trait and the built-in providers
Edge actions and ChallengeProvider
Edge actions (block / challenge / rate-limit at CDN/WAF) are
provider-agnostic via the
ChallengeProvider
trait, mirroring the LlmProvider pattern. This lets you swap Cloudflare
for AWS WAF, Fastly, Bunny… without changing ActionKind, the rules, or
the verdict filter.
/// A provider that can apply an edge action (block / challenge / rate-limit).
#[async_trait]
pub trait ChallengeProvider: Send + Sync {
fn name(&self) -> &'static str;
async fn apply(&self, ip: IpAddr, verdict: Verdict, opts: &EdgeOptions) -> Result<()>;
}The provider receives
EdgeOptions
(ttl + mode, where mode is an
EdgeMode:
block | js_challenge | managed_challenge | rate_limit) and may derive the
effective mode from the verdict. Implementations must be idempotent and
keep their own de-duplication cache — the daemon may replay decisions
after a restart.
Verdict filter — ChallengeAction
The "which verdict becomes an action" logic lives once, in core: the
ChallengeAction
type wraps the provider and its applies_to accepts only Block,
Challenge and RateLimit. Allow (benign) and Quarantine (held for
LLM analysis) are filtered out centrally — the provider never repeats the
check. The provider just executes at the edge.
Built-in providers
The providers known to
daemon::build_challenge_action:
| Provider | Crate | Enforcement | Notes |
|---|---|---|---|
cloudflare | sentry-action-cloudflare | IP Access Rules + IP Lists (IPv6) | API; TTL via notes |
firewall | sentry-action-firewall | nftables / ipset / firewalld | kernel; Linux-only; runs inline |
opnsense | sentry-action-opnsense | alias table via REST alias_util | drop-only |
pfsense | sentry-action-opnsense | pfctl table | drop-only |
nginx | sentry-action-nginx | deny includes + JS challenge geo map | same host as the daemon |
Cloudflare
Applies block/challenge/rate-limit via IP Access Rules
(POST /zones/{zone}/firewall/access_rules/rules; the legacy
/firewall/rules endpoint was deprecated). The API has no per-rule TTL,
so every rule Sentry creates carries the stamp
sentry:<created_unix>:<ttl_secs> in notes — expiry recoverable across
restarts.
[[action]]
type = "challenge"
provider = "cloudflare"
[action.options]
mode = "managed_challenge" # block | js_challenge | managed_challenge | rate_limit
ttl_secs = 86400
# max_failures = 3 # circuit breaker (default 3)
# ipv6_prefix = 64 # block/rate-limit IPv6 via IP List
# list_name = "sentry_blocks"- Env:
SENTRY_CF_TOKEN+SENTRY_CF_ZONErequired (without them the action is skipped with a warning);SENTRY_CF_ACCOUNToptional (the account id is derived from the zone lookup when absent). - Reconcile at startup: verifies the token, re-adopts live edge rules into the local cache (preventing duplicate inserts) and deletes expired ones.
- Reaper: a background task (300 s) deletes rules whose
notes-encoded TTL has lapsed. - Idempotency:
duplicate-rule(CF 10009) is treated as success. - Circuit breaker: after
max_failuresconsecutive failed inserts the provider disables itself until restart (a config failure does not generate an endless stream of doomed requests). - IPv6 /64 (F2.14): IP Access Rules accept exact addresses only; with
ipv6_prefix,Block/RateLimitverdicts for IPv6 clients become prefix items in an account-level IP List (sentry_blocks) + a single custom ruleip.src in $sentry_blocks(block action).
firewall
Kernel bans (same crate described in Actions). The only provider that runs inline in the hot path by default — local containment, zero queueing latency.
[[action]]
type = "challenge"
provider = "firewall"
[action.options]
backend = "auto" # auto | nftables | ipset | firewalld
ttl_secs = 86400opnsense / pfsense
Appliance bans — one crate, two protocols (details in
Actions). Drop-only
platforms: Challenge and RateLimit are logged as unenforced.
[[action]]
type = "challenge"
provider = "opnsense" # or "pfsense"
[action.options]
base_url = "https://192.0.2.10" # required for opnsense
table = "sentry_blocks"nginx
Include generation + reload (details in
Actions). For the JS challenge,
include the generated snippet inside your server {}:
include /etc/nginx/conf.d/sentry/sentry-challenge-if.conf;[[action]]
type = "challenge"
provider = "nginx"
[action.options]
conf_dir = "/etc/nginx/conf.d/sentry"
reload_cmd = "nginx -s reload"
validate = trueNever-ban guard
IPs in [real_ip] trusted_ips (and trusted_lists/whitelist, which
join the same set) are never banned: the pipeline short-circuits to
Allow and tags the IP as ReputationTier::Authorized — so the
ChallengeAction verdict filter does not even fire. The local containment
providers (firewall, opnsense/pfsense, nginx) refuse the IP again
as a final guard, before any command or API call; the inline edge
fast-path honors the set too.
Compatibility alias
type = "cloudflare" without provider is equivalent to the canonical
type = "challenge" + provider = "cloudflare" form and kept for
compatibility. New configs should prefer the canonical form.
Adding a new edge provider (AWS WAF, Fastly, Bunny…)
- Create the crate
sentry-action-<name>implementingChallengeProvider. - Add it to
sentry-cli/Cargo.toml. - Add an arm to the
daemon::build_challenge_actionmatch.
Without changing ActionKind, rules, or the verdict filter.
Provider-free option: PoW challenge at the inline edge
In [deployment] mode = "inline" there is an option that talks to no API
at all: the built-in interactive JavaScript challenge with proof-of-work
(JsChallenge,
opt-in under [edge.challenge]). A Challenge verdict becomes a 503
interstitial with a SHA-256 puzzle; the solution becomes the
sentry_ch=<bucket>:<nonce> cookie, valid for one bucket of grace.
[edge.challenge]
enabled = true
# secret_env = "SENTRY_EDGE_CHALLENGE_SECRET" # required when enabled
# bucket_secs = 3600 # challenge and cookies rotate per bucket
# difficulty = 16 # leading zero bits (clamped 8..=28)
# template_path = "/etc/sentry/challenge.html" # custom page (hot-read)- The secret (
SENTRY_EDGE_CHALLENGE_SECRET, 32+ random bytes) is required whenenabled = true— startup fails without it. - Who executes the
Challengeverdict at the edge is decided bychallenge_backend(ChallengeBackend):sentry(default, built-in PoW) orcloudflare(the verdict becomes a CF rule; requires an action withprovider = "cloudflare"). - The PoW never unlocks a
Block; bots verified via rDNS ([bot_verification]) pass without solving; the cookie getsSecurewhen the edge itself terminates TLS.