A Rack middleware that scores every request before your application sees it. Rules are synchronized from the control plane on a background thread and read from memory behind a mutex, so the request path never waits on the network. Telemetry leaves on a second thread through a bounded queue and is dropped rather than queued when that queue is full. One use line, and it works under anything that speaks Rack — Rails, Sinatra, Hanami, Roda, a bare config.ru. No proxy, no DNS change, no sidecar.
# config.ru
require 'relintio-agent'
require_relative 'app'
agent = Relintio::Agent.new(
license_key: ENV.fetch('UP_LICENSE_KEY'),
api_url: 'https://api.relintio.com/v1',
domain: 'shop.example.com',
sync_interval: 10
)
agent.start_sync
use Relintio::Middleware, agent
run Appgem 'relintio-agent'bundle installRuby 3.0 or newer. The one runtime dependency is rack, at >= 2.0, < 4.0.
Register before your application and before anything else that can answer. In config.ru that means the use line comes before run; under Rails it means position zero:
# config/initializers/relintio.rb
RELINTIO_AGENT = Relintio::Agent.new(
license_key: ENV.fetch('UP_LICENSE_KEY'),
api_url: ENV.fetch('UP_API_URL', 'https://api.relintio.com/v1'),
domain: ENV.fetch('UP_DOMAIN', 'shop.example.com')
)
RELINTIO_AGENT.start_sync
Rails.application.config.middleware.insert_before 0, Relintio::Middleware, RELINTIO_AGENTconfig.middleware.use appends to the end of the stack, which puts the agent after Rack::Attack, after the session, and after anything that already served the response from cache. insert_before 0 is the only position that sees every request. Middleware placed later protects nothing, and the diff looks identical either way.
Pass the agent object. The middleware stores its second argument and calls check_request on it directly; a lambda, a proc, or a symbol is accepted at boot and raises NoMethodError on the first request.
| Key | Type | Default | Meaning |
|---|---|---|---|
:license_key |
String |
— | Keys every signature and every passport. Secret — see below. |
:api_url |
String |
https://api.relintio.com/v1 |
A trailing / is trimmed per call. |
:domain |
String |
'' |
Sent with every sync so the control plane can resolve which licence policy applies. |
:sync_interval |
Integer |
10 |
Target cadence in seconds, floored at 10. Backoff and jitter apply on top. |
Options are read by symbol key only. 'license_key' => … is not a typo the constructor catches; it silently produces an agent with a nil key, which signs with an empty string, gets rejected by the control plane, and runs forever with an empty rule set — allowing everything, quietly. Nothing raises. Treat a missing key as a startup failure in your own boot code.
The licence key is a secret. It signs outbound requests and mints passports, so anything holding it can forge both. Keep it in the environment, never in a repository, and never in code that reaches a browser — that is what publishable keys are for, and those belong to the React and Shopify SDKs, not this one.
Relintio::Middleware is a thin adapter: it builds a Rack::Request and hands it to Agent#inspect_request, which owns the whole decision path — passport, pass-token exchange, scoring and the challenge round trip — and answers with a verdict the middleware renders. No path is exempted. /_relintio/challenge and /_relintio/verify used to pass through untouched, and since nothing in this gem ever served either of them the skip was an unprotected path and nothing more.
Scoring itself is check_request, which takes the rules mutex and walks the synchronized rules once. The score is additive, and a matching rule's own action can escalate to challenge or block regardless of the total.
Rule type |
Compared against |
|---|---|
ip |
Rack::Request#ip |
user_agent |
Rack::Request#user_agent, or "" |
path |
Rack::Request#path_info |
header |
The request headers, recovered from the Rack env's HTTP_* keys. A pattern with no colon tests that the header is present with a non-empty value; Name: value tests the named header against the value. Whitespace around the colon is not significant. |
condition is equals for an exact match, contains for a substring, and regex for a real regular expression, compiled once per pattern and cached. equals and contains both fold case — an IPv6 address carries hex letters that runtimes render in different cases, and the same dashboard rule has to mean the same thing in all of them. A pattern the engine refuses never matches and never raises, and neither does one that exceeds its match deadline.
An unrecognised type or condition never matches. That is deliberate: a rule that quietly means something other than what its author wrote is worse than one that does nothing. regex used to fall through to a substring test and header had no branch at all, which is why the semantics now live in contracts/rule-conditions-v1.json and are asserted vector by vector in test/rules_test.rb.
| Score | Action |
|---|---|
| 0–49 | Pass through |
| 50–99 | Challenge |
| 100+ | Block |
A block is 403 with a static HTML page. A challenge is a 302 to the hosted security check, plus X-Relintio-Action: challenge and X-Relintio-Challenge-URL for a client that inspects the redirect rather than following it — a React front end reads them to raise its own overlay.
The challenge is issued by the control plane, not by this gem. The agent posts the request's own URL to POST /agent/challenge/init (signed, with a two-second deadline, because a visitor is waiting) and gets back an opaque token. The answer has three shapes, and the difference between them matters:
| Outcome | Meaning | What the middleware does |
|---|---|---|
:redirect |
A token or an absolute challenge_url came back |
302 to it — challenge_url if absolute, otherwise /security-check?token=… on the API host with a leading api. stripped |
:disabled |
The licence has the challenge switched off, or the plan does not include it | Honours the licence's challenge_fallback; the platform default is to block |
:unavailable |
The call failed or answered with something unusable | Block. No challenge was issued, so nothing was passed, and failing open here would make an unreachable challenge service the way through |
Collapsing the middle case into the last is a bug another SDK shipped: the whole challenge band silently allowed, on a setting the customer had switched off deliberately.
Telemetry is pushed to a SizedQueue of 1000 and sent from the background thread as /agent/log. The push is non-blocking: when the queue is full the ThreadError is rescued and the event is discarded rather than stalling the request. The response is never read, so a rejected telemetry post is invisible from inside the process.
Clean traffic is sampled at 1%. Relintio::Agent::ALLOW_SAMPLE_RATE is fixed at 0.01 and must match UsageMeterService::ALLOW_SAMPLE_RATE on the platform, which multiplies a reported allow back up by it to estimate real traffic. Reporting every allowed request — which this SDK used to do — inflates the customer's meter a hundredfold against an install of the compiled engine on the same plan. It is a constant rather than a setting for that reason. Blocks, challenges, decoys and slows are never sampled: they are the security record, and the platform counts them at face value.
The protocol-v2 primitives are here in Relintio::Passport, and checked against contracts/passport-v2-vectors.json by test/passport_test.rb:
Relintio::Passport::COOKIE # => "relintio_passport"
Relintio::Passport::CLOCK_SKEW_SECONDS # => 60
Relintio::Passport.binding(license_key, user_agent, accept_language)
Relintio::Passport.verify(value, license_key, user_agent, accept_language, now = nil)
Relintio::Passport.mint(ttl, license_key, user_agent, accept_language, now = nil)
Relintio::Passport.clamp_ttl(ttl)A token is v2.<payload>.<signature>: base64url JSON carrying an absolute expiry and a binding hash, signed with HMAC-SHA256 under the licence key. The binding is sha256(licenceKey|userAgent|acceptLanguage) truncated to 16 hex characters, over the raw header values — trimming or downcasing them here makes this agent disagree with the challenge server, which locks out every visitor rather than a few. Verification is offline: passport.rb requires no HTTP library at all, because the edge has to keep deciding when the control plane does not. Both the signature and the binding go through OpenSSL.secure_compare, and exp is allowed 60 seconds of skew.
The predecessor was sha256("verified" + licenceKey) — one constant string, identical for every visitor of a site. One leaked cookie bypassed the agent entirely until the key was rotated. verify rejects anything not beginning v2., so tokens in that form no longer pass.
Agent#inspect_request wires this up, and nothing used to — a visitor who solved a challenge was re-scored on the very next request and could be challenged again, a loop with no exit. In order:
- A valid
relintio_passportcookie short-circuits to allow, before any scoring. - A
?up_token=handed back by the hosted page is verified,clamp_ttl'd off the payload's'ttl', minted into arelintio_passportcookie (Path=/,HttpOnly,SameSite=Lax,Secureon HTTPS or behindX-Forwarded-Proto: https), and answered with a302to the same path with the query stripped, so the token does not linger in referrer headers. Anup_tokenthat does not verify is a block, not a pass: the only way to hold one is to have just solved the challenge. - Scoring, and the tiers above.
Every outbound call carries:
X-Relintio-Timestamp: 1785120000
X-Relintio-Nonce: <24 chars of [A-Za-z0-9_-]>
X-Relintio-Signature: v1=<64 hex>
The signature is HMAC-SHA256("v1:" + timestamp + ":" + nonce + ":" + sha256(body), licenceKey). The server checks the timestamp within ±300 seconds, the nonce unused within 600 seconds per credential, and the signature in constant time — and burns the nonce last, so a forged request cannot consume one the real agent is about to use.
The server's agent_signature_mode has three settings. off checks nothing. optional accepts an absent signature but still rejects a bad one, so corrupting the header cannot be used as a downgrade. required rejects unsigned ingest with 401, and is both the default and the steady state.
All three outbound calls — /agent/verify, /agent/log and /agent/challenge/init — go through the private signed_post, which runs JSON.generate once and assigns that same string to both signing_headers and req.body. This is the part that is easy to get wrong. A hash re-rendered after signing still parses as the intended JSON, so the payload looks perfect in every log while the bytes differ: a non-ASCII user agent emitted raw by one generator and \u-escaped by another is enough. The server hashes what it received, rejects everything, and nothing explains it. An endpoint added around signed_post rather than through it does not degrade gracefully; it 401s, and the edge goes blind. test/signed_requests_test.rb catches exactly that: it captures the Net::HTTP request the agent built, recomputes the signature from req.body rather than from the hash it was given, and uses a deliberately non-ASCII user agent so a re-encode cannot hide.
The nonce is SecureRandom.urlsafe_base64(18) truncated to 24 characters, fresh on every call, which is what keeps two telemetry posts a millisecond apart from colliding on the server's replay window.
challenge_enabled is a policy setting and it is also plan-gated: a licence without the bot challenge has it forced off server-side. Being over a monthly allowance used to do the same, and no longer does — overage warns and bills, and never takes a defence away.
Rather than have each agent read the flag, /agent/challenge/init refuses to issue a token when it is off and answers 200 with {"status": "challenge_disabled", "fallback": "allow"|"block"}. The 200 is deliberate — this is a policy answer, not an outage, and an agent that treated it as a failure would fail closed on a setting the customer turned off on purpose.
This gem never calls that endpoint, so nothing here reads fallback. If you build the challenge route described above, handle both values: allow continues to the application, block serves the block page.
Threads do not survive fork. The telemetry thread is created in Relintio::Agent.new and the sync thread in start_sync. Under a forking server with preload_app! on — clustered Puma, Unicorn, Passenger — both are created in the parent and neither exists in the worker. start_sync is written to notice (@sync_thread&.alive? is false in the child) so it can be re-armed:
# config/puma.rb — only needed when preload_app! is on
on_worker_boot { RELINTIO_AGENT.start_sync }There is no equivalent for the telemetry thread. In a preloaded worker it never restarts, the queue fills to 1000, and every event after that is dropped — decisions are still enforced, but the dashboard goes quiet. Leaving preload_app! off avoids the whole problem, because each worker then boots the agent itself.
Sync failure fails open. Every error in sync_rules is rescued and swallowed, leaving the previous rules in place. So does a non-200, a body over 1 MiB, and unparseable JSON. A 200 carrying no rules array counts as a failure for the same reason: an unknown status, an error envelope or an empty object is not a policy, and applying it as one would empty the cache and leave the site unprotected. That is what quota_exceeded used to do deliberately — a billing state switching off a defence — and what any unrecognised answer did by accident. Failures back off from :sync_interval by powers of two to a five-minute ceiling, with 80–120% jitter so a fleet restarting together does not synchronize into a thundering herd, and never sleep less than 10 seconds. Until the first successful sync the rule set is empty and everything is allowed — a control-plane outage must never take a site down, but a cold start is unprotected for a moment, and this agent keeps no on-disk cache to shorten that window.
Machine callers are only as risky as your rules make them. This agent scores nothing on its own: with no rules synchronized, curl and a browser are indistinguishable to it. That cuts the other way too — a broad user_agent contains rule will catch your uptime monitor and your webhook sender. Exclude health checks and inbound webhooks with explicit path rules, never by lowering protection globally, and never by carving out login, registration, checkout or password reset.
Scoring holds a process-wide mutex. check_request takes @rules_mutex for the whole walk, and so does a sync when it swaps the rule set. On MRI the GVL makes that nearly free; on JRuby or TruffleRuby, with a large rule set and real parallelism, it is a serialization point on the request path.
Response headers are capitalised. The block and challenge responses return 'Content-Type', 'X-Relintio-Action' and 'X-Relintio-Challenge-URL' in mixed case. The Rack 3 SPEC requires lowercase header keys and the gemspec allows Rack 3 (< 4.0), so under Rack 3 those two responses violate the spec and Rack::Lint — which many test suites wrap around the app — will say so. Rack 2 is unaffected.
Start in observe mode, watch a day of real traffic, and only then enforce. The dashboard shows what the agent scored and why, so the question to settle before enforcement is whether the traffic you expect is scored the way you expect.
Run at least one deploy with agent_signature_mode at optional and the adoption page open. It records which credentials are signing and which are not, and that is the only way to know whether flipping to required will take part of the fleet dark.
Call agent.stop on shutdown. It clears the run flag, wakes the sync thread, pushes the sentinel that ends the telemetry loop, and joins both with a two-second timeout, so a hung socket delays a deploy by two seconds rather than hanging it.
Security reports go to support@relintio.com, not to a public issue.
MIT. See LICENSE.