camada
camada for Ruby: enforces the tenant snapshot inline (your ordered custom rules, then allow,
block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
outcomes your handlers know (Camada.track), and ships wire events in batches off the request
path. One gem with a Rack middleware that any Rack app mounts (Sinatra, Hanami, plain Rack) and
a Railtie that mounts it for Rails — the sentry-ruby model. Fails open by design: a camada
outage or bug never 5xxes your app.
Not yet on RubyGems — install it from a sibling checkout: gem "camada", path: "../camada-ruby"
in your Gemfile (as camada-ruby-example does); publishing is one
decision with the npm packages (SDK-G01). Ruby 3.1 or newer, no runtime dependencies (stdlib
only: net/http, openssl, digest, json, securerandom, zlib, stringio).
Quickstart
# config.ru — Sinatra, Hanami, or any Rack app
require "camada"
use Camada::Rack # first, so camada answers before routing
run App
# Rails — nothing to add: the Railtie inserts Camada::Rack at the top of the middleware stackEnv (the key is printed once when you create a project in the app):
CAMADA_KEY=<ingest_token>.<snap_token>
CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
The middleware shares one lazy engine built from the environment on the first request. That
build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
answered cold: it passes (fail open), and so does anything else that arrives before that first
poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
To enforce from request 1, warm the engine at boot (an initializer, or config.ru before run)
by waiting for the boot poll:
engine = Camada.default # builds the engine; the boot poll is already running on its thread
if engine.snap # nil when CAMADA_KEY is unset or CAMADA_DISABLED=1
probe = Camada::Snapshot::MatchInput.new(ip: "0.0.0.0")
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
while engine.snap.verdict(probe).reason == "cold" && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
sleep 0.01 # bounded: an unreachable analyst leaves it cold, and the app still fails open
end
endsnap.refresh is not the warm-up: the boot poll holds the single-in-flight lock, so a
synchronous refresh called right after Camada.default returns at once and the engine is
still cold.
Without CAMADA_KEY the engine is inert (one log line, no requests, no enforcement). An app that
reads its own config builds the engine itself and hands it in:
engine = Camada::Engine.new(env: { "CAMADA_KEY" => my_key, "CAMADA_INGEST_URL" => my_ingest })
use Camada::Rack, engineWhat it does per request
- Keeps the snapshot fresh. A Ruby server is a long-lived process, so the default is a poll
thread at the cadence your tenant config sets (
poll_seconds), with ETag/304 and gzip on the wire.CAMADA_SERVERLESS=1switches to a per-request staleness check with no poll thread. Every poll and event batch carriesx-camada-sdk: @camada/ruby/<version>, and polls ask for snapshot v5 (x-camada-snapshot: 5) — the container that carries your ordered custom rules. - Resolves the client from the socket peer (
REMOTE_ADDR), combined withX-Forwarded-Foronly under your tenant's trusted-proxy config (orCAMADA_TRUSTED_PROXYlocally). A forwarded header on its own is never the ip: any caller can set it. Neverrequest.ip:Rack::Request#iptrustsX-Forwarded-Forfrom anyone, which is exactly the spoof camada refuses; the middleware readsenv["REMOTE_ADDR"]and applies your trusted-proxy rules itself. - Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
A block answers
403 Forbiddenwithx-block-reason,x-block-versionand, when a rule decided,x-block-rule; its event ships withblk(andrl). Awarnrule passes and stampswrn; askiprule passes with nothing stamped. Cold (no snapshot yet) passes: fail open. - Challenge: a
challengeverdict gets the self-contained proof-of-work page (or 403 JSON for a non-HTML request);POST /__camada/challengeverifies the solution, sets_cch(bound to the ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged. - Serves the beacon:
GET /_cam/b.js(the@camada/browserbuild, vendored) andPOST /_cam/fp(≤ 32 KB, relayed onto the event batch as asig: 1row with the ip camada resolved). Both fall through to your app when the tenant switched the beacon off. - Runs your app with
x-rid(the rid of the request's event row; never on a 101 handshake) and the_sfpsession cookie on its response, and when the server closes the response body ships one redacted event: method, host, path, scrubbed query, status, latency, header names/sizes/order, the auth scheme (never the credential), cookie count (never values), the matched route pattern when the framework names it (sinatra.route, Rails'route_uri_pattern). An exception in your app ships asst: 500and propagates unchanged.
Options
Camada::Engine.new(...) keyword arguments (also accepted by Camada.default(...) and
Camada::Rack.new(app, nil, ...)); everything credential-shaped comes from the environment.
| option | default | meaning |
|---|---|---|
env |
ENV |
where CAMADA_* are read from (a Hash works too) |
transport |
Net::HTTP |
anything responding to #call(HttpRequest) -> HttpResponse (tests inject a fake) |
refresh_s |
server-steered | poll cadence; set, it is pinned |
challenge |
true |
serve the proof-of-work page for challenge verdicts (CAMADA_CHALLENGE=0 too) |
challenge_path |
/__camada/challenge |
where the page posts its solution |
snapshot_version |
5 |
4 drops your custom rules; 3 the allow/challenge sides too |
script_path / fp_path
|
/_cam/b.js / /_cam/fp
|
the beacon endpoints; keep them in one directory |
Env: CAMADA_KEY (or CAMADA_TOKEN + CAMADA_SNAPSHOT_TOKEN), CAMADA_INGEST_URL,
CAMADA_SNAPSHOT_URL, CAMADA_TRUSTED_PROXY (none | vercel | hops:N | cidrs:a,b),
CAMADA_SERVERLESS=1, CAMADA_CHALLENGE=0, and the kill switch CAMADA_DISABLED=1 (checked per
request; set at boot, no threads start at all).
The first-party beacon
# Sinatra: `env` is the Rack env; Rails: request.env
"<html><head>#{Camada.script_tag(env)}</head>…"The tag is <script src="/_cam/b.js?r=<rid>" async>, so the beacon joins the page view that
served it. Move both paths with script_path / fp_path when /_cam/ is not yours; the script
derives the post path from its own URL, so the two must share a directory.
App-context events
Camada.track(env, "login_failed", user: email)The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
queue. Camada.track never raises and is a no-op on a request the middleware did not run for. The
event name is free-form; the analyst's app-context rules read this vocabulary:
| event | when |
|---|---|
login_failed / login_succeeded
|
a login attempt settled; pass user: so attempts per account can be counted |
signup |
an account was created |
password_reset |
a reset was requested |
mfa_failed |
a second factor was rejected |
payment_failed / payment_succeeded
|
a payment authorisation settled |
coupon_failed |
a promo/voucher code was rejected |
A route you gate yourself: Camada.serve_challenge(env) returns a Rack triple
[status, headers, [body]] to answer with (Sinatra: halt(*answer)) until the browser holds a
valid _cch, then nil.
What this tap can see
sdk-ruby is an in-app tap: status, latency, session, the beacon's browser signals and your
outcomes. Puma hands the env to Rack as binary strings; the middleware reads them as UTF-8 with
invalid bytes replaced (U+FFFD) before matching and shipping, so a stray high byte in a header
neither escapes a rule nor costs the batch it rides in. The Rack env carries no wire header order (hord is the env's order), so the analyst
reads no HEADER_ORDER signal from this tap, and it never scores the absence of header order, ASN,
country or a TLS fingerprint against a request; ASN and country it resolves itself. Enforcement
at this position covers ip, path, user-agent and header conditions — ASN, country and TLS entries
fail open in-app. matches patterns are JS regexes read by Ruby's Onigmo: named groups are
native, [^] and \cX are translated, ^/$ become \A/\z (Ruby's are line anchors,
JS's are not), \d/\w/\b are ASCII as in JS; a spelling Ruby still rejects never matches
here (and never raises), while it does at the edge.
Deploying it
- Every worker process polls its own snapshot (about 5 MB resident, read a word at a time — never
expanded into Ruby Integers) and flushes its own batches; the tenant's
poll_secondskeeps the cadence honest across a fleet. - Threads do not survive a fork. Ruby has no fork hook, so the SDK compares
Process.pidon every call and starts over in a forked worker: new locks, an empty queue, its poll and flush threads restarted. Puma cluster mode withpreload_app!, Unicorn and Passenger need nothing added. - Pending events drain at interpreter exit within half a second (
at_exit). No signal handlers are installed — an app owns its own shutdown — so a worker killed by SIGKILL, or by SIGTERM without a handler, may drop its last batch. - Serverless:
CAMADA_SERVERLESS=1. A cold invocation fails open and catches up on the next one. - The middleware writes lower-case header names; it appends
set-cookieas an Array under Rack 3 and joins cookies with"\n"under Rack 2. It never requiresrackitself.
Fail open
Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
most once a minute, one line, no backtrace), a corrupt snapshot keeps the previous one, a bug in
the gem costs the request its join, never its response. A failed snapshot poll (401, 5xx, no
answer) keeps the rules in place and paces the next one: Retry-After in seconds is honoured,
floored at 5 s and capped at the refresh interval. CAMADA_DISABLED=1 bypasses everything.
Development
bundle install && bundle exec rubocop && bundle exec rspec
No type checker: the gem has no runtime dependencies and a Sorbet or Steep setup would add a toolchain (and RBS signatures for every stdlib seam) for a 2.5k-line port whose behaviour is pinned by the golden fixtures instead; it is deferred, not refused. A Rails example app is deferred the same way — the Railtie ships in the gem, the example repo is Sinatra.
The suite reads the golden snapshot fixtures from the camada-core sibling checkout and pins the
vendored beacon to camada-browser/dist/auto.global.js (npm run build there first, then
ruby scripts/sync_beacon.rb after a beacon release). Both fail by name when the checkout is
missing rather than skipping (CAMADA_FIXTURES_DIR, CAMADA_BROWSER_DIST override the paths).
camada-ruby-example is the hand-test bench (Sinatra under Puma on
:3004), and node scripts/e2e-sdk-ruby.mjs in camada/edge-analyst drives it against a seeded
local analyst over real HTTP, cold first request included.