API — sentry-edge
Public API reference for sentry-edge: EdgeRuntime, middleware/reverse proxy, PoW challenge, error pages, TLS, compression and protocol validation
API — sentry-edge
sentry-edge is the inline edge: three building blocks over the same Pipeline — the axum middleware (middleware), the serve reverse proxy and the TCP listener (tcp_listener). The default deployment stays passive; inline requires [deployment] mode = "inline" and a healthy upstream. The crate declares #![forbid(unsafe_code)].
Shared runtime
EdgeRuntime
EdgeRuntime holds pipeline + enrichment + capture limits + mode, and is cloneable (Arc inside). Built through builders:
pub fn new(pipeline: Arc<Pipeline>, enrich: Option<Enricher>, body_cap: usize) -> Self;| Builder | Effect |
|---|---|
with_block_table | fast-path denies IPs from the BlockTable before the pipeline |
with_trust | SharedTrustSet: never-ban passes, blacklist denies on the fast path, header-borne IPs only win behind a trusted proxy |
with_uploads | body inspection (UploadsInspection: inspect_bytes, max_files, oversize) |
with_challenge / with_challenge_backend | local PoW or Cloudflare delegation |
with_bot_verifier | rDNS-verified crawlers bypass the challenge |
with_error_pages | custom HTML pages (error_pages_handle returns the Arc for handlers without the runtime in scope) |
with_protocol | protocol validation engine (F9) + metrics |
with_overload | under pressure, body inspection is skipped; detection never is |
with_posture | security-header advisories (F11, weight 0) |
Execution methods: process (enrich → pipeline, measures process_us), inspect_body, challenge_gate and is_hard_blocked (block table + blacklist, with the never-ban guard — you cannot lock yourself out).
inspect_body — reject / skip / flag
pub fn inspect_body(
&self,
content_type: Option<&str>,
body: &[u8],
) -> (Option<Vec<UploadInfo>>, Option<Vec<u8>>);Bodies up to inspect_bytes become upload metadata + the analysis prefix for the upload heuristics. Beyond the cap, the [uploads] oversize policy decides: reject answers 413 at the caller; skip forwards without parsing or signaling; flag forwards and attaches synthetic metadata that feeds the flood window and raises UploadOversize. Inspection is orthogonal to persistence: HttpData.body falls back to the capture cap after process.
Real client IP resolution
real_client_ip follows the fixed precedence CF-Connecting-IP > True-Client-IP > X-Real-IP > first XFF hop > peer. Prefer real_client_ip_with: headers only win when the peer is a trusted proxy — a direct-to-origin client cannot forge CF-Connecting-IP.
Middleware vs reverse proxy
Axum middleware
middleware::handler embeds Sentry into a Rust app without a proxy hop:
pub async fn handler(State(runtime): State<EdgeRuntime>, req: Request, next: Next) -> Response;Usage: middleware::from_fn_with_state(runtime, sentry_edge::middleware::handler). The mode comes from MiddlewareMode (with_mode on the runtime): Inline enforces the verdict before the handler (Block→403, RateLimit→429, Challenge→challenge); Shadow only observes and attaches the decision under the DECISION extension key.
Reverse proxy
pub async fn serve(
runtime: EdgeRuntime,
cfg: EdgeProxyConfig,
decided: mpsc::Sender<ProcessedEvent>,
) -> Result<()>;serve requires an upstream health check at startup. EdgeProxyConfig covers the listen address ("" disables the plain listener — HTTPS-only edge), upstream, hop tuning (connect timeout, keepalive pool) and forwarded_headers (F12: the edge strips the client's X-Forwarded-* and sets the resolved view). With a TlsEdgeConfig, ports 80 and 443 serve through the same router; tls_redirect_https answers 301 after the pipeline, so port-80 traffic stays monitored.
JS challenge (PoW) — module challenge
JsChallenge is a stateless, multi-node-safe SHA-256 proof-of-work: challenge_id = SHA-256(secret || ip || bucket) — no server-side session, only the shared secret (SENTRY_EDGE_CHALLENGE_SECRET, required when enabled).
pub fn new(secret: Vec<u8>, bucket_secs: u64, difficulty: u8, title: String, template_path: Option<PathBuf>) -> Self;
pub fn challenge_id(&self, ip: IpAddr, bucket: u64) -> String;
pub fn classify_cookie(&self, ip: IpAddr, value: &str, now: SystemTime) -> CookieCheck;
pub fn page_response(&self, ip: IpAddr, headers: &HeaderMap, now: SystemTime) -> Response;The browser must find a nonce such that SHA-256(challenge_id || ":" || nonce) has difficulty leading zero bits (leading_zero_bits) and reload with the sentry_ch cookie ("<bucket>:<nonce>"). Difficulty is clamped to 8..=28; the bucket rotates and the previous bucket is honored as boundary grace. CookieCheck classifies Cloudflare-style: no/expired cookie → fresh interstitial; a present cookie that fails verification → terminal 403 (a client that tried and forged does not loop). The full gate lives in EdgeRuntime::challenge_gate, returning a ChallengeGate (Pass/Page/Blocked/Disabled) — PoW never unlocks a Block; verified crawlers pass without JS. The template (template_path) is re-read on every render and must carry the {{CHALLENGE}}, {{DIFFICULTY}}, {{BUCKET}}, {{MAX_AGE}} and {{SECURE}} placeholders.
Error pages — module pages
Every edge-generated response (403/429/challenge/413/502) is HTML with a Trace ID. ErrorPages loads overrides from [edge.error_pages] dir once at startup: <status>.html (e.g. 403.html) covers that status, default.html covers the rest; a broken or >256 KiB file is warn + skip, never takes the edge down.
pub fn from_dir(dir: &Path) -> Self;
pub fn render_response(&self, status: StatusCode, title: &str, message: &str, trace: Option<Uuid>) -> Option<Response>;Placeholders are substituted with HTML-escaping: {{SENTRY_STATUS}}, {{SENTRY_TITLE}}, {{SENTRY_MESSAGE}}, {{SENTRY_TRACE_ID}} (empty when absent) and {{SENTRY_ICON}} (embedded logo as a data URI). The trace id also lands in the x-sentry-trace-id header. Helpers that consult the map before the built-in page: block_page, rate_limit_page, challenge_required_page, challenge_failed_page, delegated_challenge_page, payload_too_large_page and bad_gateway_page.
TLS telemetry — modules clienthello and tls
clienthello
parse extracts telemetry from the ClientHello bytes before the rustls handshake (the bytes are replayed through a prefixed stream — the handshake sees identical octets):
pub fn parse(buf: &[u8]) -> Probe;
pub struct ClientHello {
pub sni: Option<String>,
pub ja3: Option<String>,
pub ja4: Option<String>,
pub alpn: Option<String>,
}Probe returns Hello/NeedMore/Invalid — hellos fragmented across multiple records are reassembled (64 KiB cap). JA3 is the md5 of the canonical wire-order string; JA4 follows the FoxIO scheme with a truncated sha256 over the sorted cipher/extension lists.
tls (feature edge-tls)
serve_tls accepts TLS connections: the block table denies before any byte flows, the handshake has a 5 s budget (slowloris drops), a TlsHandshake event enters the same pipeline (missing/unknown SNI → TlsSniMismatch) and a post-handshake Block tears the connection down. cert_not_after feeds the certificate-expiry gauge. Metrics in TlsMetrics: handshakes by version, failures, SNI mismatch and negotiated ALPN.
Compression — module compression
A response middleware (compress_layer) on by default via [edge.compress]; covers upstream responses and edge pages on both listeners. Since bodies are already buffered, the re-encode is synchronous over Vec<u8> — exact content-length, no streaming.
pub fn negotiate(accept: Option<&HeaderValue>) -> Option<Encoding>;
pub struct Compressor { /* ... */ }
impl Compressor {
pub async fn compress_response(&self, accept: Option<&HeaderValue>, resp: Response) -> Response;
}Negotiation (negotiate): the highest q-value of Accept-Encoding wins; ties prefer zstd > brotli > gzip (zstd behind the edge-zstd feature). Never compressed: responses already carrying content-encoding, 206/content-range, no-transform, bodies below the minimum, content-types outside the allowlist (nginx gzip_types semantics; text/event-stream excluded) and payloads that do not shrink. Vary: Accept-Encoding is always set on candidates; under OverloadState pressure it turns itself off.
Edge TCP + protocol validation
tcp_listener
pub struct TcpEdgeConfig {
pub listen: String,
pub upstream: String,
pub connect_timeout_secs: u64,
}
pub async fn serve_tcp(runtime: EdgeRuntime, cfg: TcpEdgeConfig, decided: mpsc::Sender<ProcessedEvent>) -> Result<()>;serve_tcp is the inline front for non-HTTP services: verdict at connect time (block closes the connection), then a byte pipe to the real backend.
protocol
When a sentry-protocol schema guards the local port and declares framing, the pump validates the client→server direction frame by frame. ProtocolConnection::bind attaches the engine to the port's schema (None = plain passthrough):
pub fn bind(engine: &ProtocolEngine, local_port: u16, metrics: Option<ProtocolMetrics>) -> Option<Self>;
pub fn ingest(&mut self, chunk: &[u8]) -> Ingest;Ingest carries forward (frames the upstream may receive), violations, disconnect (set in enforce — shadow forwards and signals) and framing_broken. Violations become one event per connection with a ProtocolViolation signal weighted by the cited policy (on_repeat escalation doubles it), via signals. Metrics in ProtocolMetrics: sentry_protocol_violations_total{schema, policy} and sentry_protocol_frames_total{schema}.