Inline Edge
Inline deployment: reverse proxy, block fast-path, TLS, PoW challenge, uploads and tuning
Inline Edge
With [deployment] mode = "inline" Sentry stops being an observer and
becomes the application front: an axum reverse proxy
(sentry-edge/src/proxy.rs)
that inspects every request before the upstream and enforces the
verdict immediately. The proxy runs the same Arc<Pipeline> as passive
sources (trackers never double-count) and hands finished events
(Incoming::Processed) to the daemon.
Health check, upstream groups and failover (F14)
Startup requires at least one healthy server per upstream group
(health_path, default /, timeout health_timeout_secs = 5 s) — a down
secondary only warns (failover exists precisely for that), but a group
with no healthy server refuses to start: the edge never comes up
"protecting" a dead backend. The edge→upstream hop is tuned (F12.6):
connect_timeout 5 s, a keepalive pool with upstream_pool_idle = 32
connections per host (idle 90 s, TCP keepalive 60 s) and tcp_nodelay —
zero TCP handshake per request on the internal hop.
Requests are routed by Host header (h1 Host / h2 :authority) to
per-site upstream groups — nginx server_name + proxy_pass. Each group
load-balances (round_robin, least_conn, ip_hash), runs passive
health checks (max_fails/fail_timeout: enough connection failures
inside the window eject the server for the window) and fails over: a
connect error/timeout retries the next healthy server once per request
(5xx responses are not retried) — sentry_edge_upstream_failovers_total {vhost} counts the retries; a fully-down group answers 502. Upstreams may
be hostnames (Docker container names, k8s services) — resolved through the
system resolver at connect time.
h1 upgrade requests (WebSocket) take a raw pass-through: the pipeline still
decides first, then the edge replays the request with an h1 client,
answers 101 and pipes both upgraded connections bidirectionally.
response_buffering = false (or text/event-stream) streams upstream
responses instead of buffering them, skipping compression.
Block fast-path (BlockTable)
The BlockTable
is a shared HashMap<IpAddr, Option<Instant>>. The edge (proxy,
middleware, edge-tcp and the TLS acceptor) consults is_blocked before
any processing: a blocked IP gets an immediate 403, with no event and no
pipeline pass — no webhook spam, O(1) cost.
- Block verdicts from the pipeline are mirrored to
ip_state(Postgres) withNOTIFY sentry_blocks_changed; startup pre-warm and per-node LISTEN make a block applied anywhere deny on every node's edge (daemon.rs). - The never-ban guard (
[real_ip] trusted_ips) applies to the fast-path too. - Metrics:
sentry_edge_block_hits_total,sentry_block_table_size.
Verdict → HTTP
| Verdict | Response |
|---|---|
| Block | 403 (customizable page) |
| RateLimit | 429 with Retry-After |
| Challenge | PoW interstitial (sentry backend) or Cloudflare rule (cloudflare backend) |
| Allow | proxy to the upstream |
Every edge-generated page (403/429/challenge/413/502/301) goes through the
ErrorPages
map (F12.1): [edge.error_pages] dir with <status>.html +
default.html, UTF-8, 256 KiB/file cap, {{SENTRY_STATUS}},
{{SENTRY_TITLE}}, {{SENTRY_MESSAGE}}, {{SENTRY_TRACE_ID}},
{{SENTRY_ICON}} placeholders with HTML escaping. Trace ID always (header
x-sentry-trace-id + log).
JavaScript proof-of-work challenge (F7.11)
[edge.challenge] enabled = true swaps the Challenge response for a
stateless SHA-256 PoW interstitial
(challenge.rs):
challenge_id = SHA-256(secret || ip || bucket); buckets ofbucket_secs(default 3600) with one bucket of grace — thesentry_chcookie survives rotation.- Secret via env (
secret_env, defaultSENTRY_EDGE_CHALLENGE_SECRET) — required when enabled; share it across edge nodes. difficultydefault 16; no-CDN page, optionaltemplate_pathwith hot-read.- PoW never unlocks a Block; responses are 503 with
Retry-After. - Crawlers verified by rDNS (F7.7) pass without solving.
Inline TLS with ClientHello telemetry (F8)
The edge-tls feature (tls.rs):
the acceptor peeks the ClientHello before the rustls handshake,
parses SNI/ciphers/extensions
(clienthello.rs)
and feeds the pipeline one TlsHandshake event (SNI + JA3 + JA4) per
connection. The BlockTable fast-path denies before the handshake.
- ALPN
h2+http/1.1with HTTP/2 served on the same router ([edge] http2, default true); metricsentry_edge_tls_alpn_total{alpn}. tls_allowed_hosts— a missing/unknown SNI (the exposed 443 listener) becomes theTlsSniMismatchsignal (weight 20); a post-handshake Block/Quarantine drops the connection.tls_redirect_httpsanswers 301 on port 80 after the pipeline — HTTP traffic keeps being monitored and blocked.- Gauge
sentry_edge_tls_cert_not_afterwith a warn when the certificate expires within 14 days.
Upload and body inspection (F10)
[uploads] enabled = true (opt-in, requires inline) turns on body
analysis before the proxy: multipart RFC 7578, urlencoded and JSON
(multipart.rs,
uploads.rs).
inspect_kb(default 4096 = 4 MiB) bounds the per-request buffer; the memory ceiling is ~concurrency ×inspect_kb— hence the concurrency cap.- A body larger than the cap follows the
oversizepolicy:reject(413),skip(forward without parsing) orflag(forward +UploadOversizesignal, weight 20 + counts toward the flood window). - Signals:
UploadTypeMismatch(30),UploadPolyglot(60),UploadExecutable(50),UploadFlood(25),UploadDisallowed(30, from theallowed_extensionsallowlist).mode = "shadow"zeroes the weights — detect without blocking. - SQLi/XSS in filenames and form fields re-emit the known HTTP signals, so existing dashboards and rules work for free.
Web security posture (F11)
posture.rs
evaluates the upstream response headers (never edge-generated pages)
and attaches PostureAdvisory findings with a locked weight of 0:
csp, hsts, coop, clickjacking, trusted_types, nosniff,
referrer_policy — each disableable via [posture] checks. Dedupe per
(host, check) with a 1 h TTL, 64-host cap. Metric
sentry_posture_findings_total{check, host}; report via sentry posture.
mode = "enforce" is rejected at load (header injection is roadmap).
Response compression (F12.5)
compression.rs —
one middleware covers upstream responses and edge pages on both listeners.
Bodies are already buffered → synchronous re-encode with an exact
content-length.
Accept-Encodingnegotiation with preference zstd > brotli > gzip (zstd behind theedge-zstdfeature; brotli/gzip are pure Rust and always built). Levels:level_br4,level_gzip6,level_zstd3.- Never compressed:
content-encodingalready present, 206/content-range,no-transform, body smaller thanmin_length(256), content types outside the allowlist (gzip_typessemantics;text/event-streamexcluded) and payloads that would not shrink.Vary: Accept-Encodingalways added for candidates. - Under overload (the shared
OverloadStateflag) compression turns itself off. Metricsentry_edge_compressed_total{algo}.
Forwarded headers and upstream hop (F12.6/7)
[edge] forwarded_headers = true (default): the edge strips the client's
X-Forwarded-*/X-Real-IP and sets its resolved view —
X-Forwarded-For/X-Real-IP = client IP (§8.1 precedence),
X-Forwarded-Proto = real scheme (via the TlsTerminated extension,
never a spoofable header), X-Forwarded-Host = original Host. The backend
nginx keeps real_ip/vhost working without spoofing. Topology advisories
(edge_advisories()): double TLS with an https:// upstream, and an
unencrypted hop to an off-loopback upstream.
Concurrency cap
[edge] max_concurrent_requests (default 1024, 0 = unlimited): every
in-flight request buffers up to the inspection/forward cap in memory, so a
connection flood multiplies that cost. Past the cap, requests get an
immediate 503 (with the customizable error pages) instead of being
buffered — memory stays bounded by design.