Skip to content

Latest commit

 

History

History
221 lines (145 loc) · 15.8 KB

File metadata and controls

221 lines (145 loc) · 15.8 KB
Relintio

relintio-agent

pypi python license

The Relintio in-process agent for Python ASGI applications.


ASGI middleware that scores every request inside your own process, against a policy it synchronizes from the control plane and caches on disk, and enforces the decision before any of your routes run. No proxy, no DNS change, and no round trip on the hot path: a visitor who has already passed the challenge is recognised from a signed cookie the agent verifies locally.

from fastapi import FastAPI
from relintio_agent.asgi import UltimateProtectorMiddleware

app = FastAPI()

app.add_middleware(
    UltimateProtectorMiddleware,
    license_key="UP_LIVE_...",
    api_url="https://api.relintio.com/v1",
)

Installation

pip install relintio-agent

Python 3.10 or newer. Depends on httpx and cryptography.

This is a server-side SDK. It holds your licence key, which never leaves the server: it is the HMAC key that signs every outbound call and mints every passport, and the browser SDKs deliberately carry a publishable key instead. If a licence key would end up in a bundle, in a template, or in a URL, that is the bug — not a configuration to work around.

Quickstart

FastAPI, Starlette, or any ASGI app

add_middleware wraps the whole application, so it covers every route regardless of where this call sits relative to your route definitions. It does have to run before the app starts serving requests.

import os

from fastapi import FastAPI
from relintio_agent.asgi import UltimateProtectorMiddleware

app = FastAPI()

app.add_middleware(
    UltimateProtectorMiddleware,
    license_key=os.environ["UP_LICENSE_KEY"],
    api_url=os.environ["UP_API_URL"],
    except_paths=["/healthz", "/webhooks/*"],
)

Exclude health checks and inbound webhooks explicitly, as above. Machine callers score as bots, and the alternative — lowering protection globally to stop paging yourself — protects nobody.

From the environment instead

If you can change how the app is created but would rather keep configuration out of code:

from fastapi import FastAPI
from relintio_agent import wrap_asgi_app

app = wrap_asgi_app(FastAPI())
UP_LICENSE_KEY=UP_LIVE_...
UP_API_URL=https://api.relintio.com/v1

wrap_asgi_app returns the app untouched when UP_LICENSE_KEY or UP_API_URL is missing, and when UP_AGENT_DISABLE is set. That is deliberate — an app that will not boot because a key is absent is a worse outage than an app that boots unprotected — but it means a typo in an environment variable name looks exactly like a working install. Verify against the control plane before calling it done.

Without touching the app factory

The Python bundle from Dashboard → Deployment → Python ships a sitecustomize.py. Put its directory on PYTHONPATH, set UP_ASGI_APP="module:app", and it imports that object on interpreter startup, wraps it, and writes it back. Every failure path in it is a silent pass — it is a convenience, not a guarantee.

Configuration

Option Environment Default Meaning
license_key UP_LICENSE_KEY Required.
api_url UP_API_URL Required. https://api.relintio.com/v1.
sync_interval_seconds UP_SYNC_INTERVAL_SECONDS 10 Policy refresh interval. Floored at 10.
only_paths UP_ONLY_PATHS Protect only these. Exact (/checkout) or prefix (/product/*).
except_paths UP_EXCEPT_PATHS Never protect these. Wins over only_paths.
only_regex UP_ONLY_REGEX Protect only paths matching this Python regex.
UP_AGENT_DISABLE wrap_asgi_app returns the app unwrapped.

Environment lists are comma- or newline-separated, and the UP_ prefix is configurable via config_from_env(prefix=...).

Clean traffic is sampled at 1%, and that is not configurable. ALLOW_SAMPLE_RATE in ultimateprotector_agent.client is fixed at 0.01, matching UsageMeterService::ALLOW_SAMPLE_RATE on the platform and AgentPayloadService::LOG_ALLOW_SAMPLE_RATE in the compiled engine. The platform multiplies a reported allow back up by that rate to estimate real traffic, so an install choosing its own rate reports a number the platform then corrects by the wrong constant — silently, on that customer's bill. The allow_sample_rate keyword and the UP_ALLOW_SAMPLE_RATE environment variable are gone for that reason. Blocks, challenges, decoys and slows are never sampled: they are the security record and are counted at face value.

Everything else — rules, blocklists, allowlists, geo policy, custom WAF rules — lives in the dashboard and arrives through the sync described below. Nothing about detection is configured here. The scoring weights and tier thresholds below are compiled into the agent and are not configurable from either side.

What happens on a request

In order, and the order is the point:

  1. Scope filter. Non-HTTP scopes and paths excluded by only_paths / except_paths / only_regex pass straight through.
  2. Challenge return. A request carrying ?up_token= is verified as a v2 pass token. Valid, and the agent mints its own passport cookie and redirects to the same URL with the token stripped. Invalid, and it is a 403 — the only way to hold a valid token is to have just passed the challenge, so a broken one is an attempt, not an accident.
  3. Passport. A valid relintio_passport cookie short-circuits to allow, verified locally with no network call.
  4. Policy. Rules are fetched from /agent/verify, AES-256-CBC encrypted under a key derived from the licence key and authenticated with HMAC before decryption. They are cached in memory, and on disk beside an HMAC sidecar — a cache whose MAC does not verify is deleted and re-fetched rather than trusted, because shared hosting means the file is not yours alone.
  5. Server-side exclusions. bypass_paths and curl_safety_paths from the policy, then TLS fingerprint checks, the IP allowlist, and SEO safety.
  6. Intelligence. Global blocklist, geo firewall, blocked CIDRs, honeypot headers, scanner user agents and bot regexes, VPN and proxy heuristics over reverse DNS, referrer checks, then your custom WAF rules.
  7. Deep scan. The additive score below, and the tier it lands in.
  8. Allow, reported at the sample rate — one in a hundred.

Refresh happens in the background on a jittered interval — sync_interval_seconds multiplied by 0.8–1.2 — and backs off exponentially to five minutes after repeated failures, so a control plane recovering from an outage is not re-hammered by every agent at once.

Risk scoring

Signals are independent and additive, capped at 100:

Signal Points Why
Empty User-Agent +50 No browser omits it.
User-Agent under 10 characters +25 Too short to be a real one.
Scanner keyword in User-Agent +40 Matched against the keyword list from policy.
Rate burst +35 Token bucket exhausted.
No Accept-Language +20 Every browser sends one.
Missing or */* Accept +15 Generic client.
POST without Referer +15 Form submission from nowhere.
Connection: close +10 Not how browsers behave.
Tier Score What happens
Allow 0–39 The request proceeds.
Slow 40–59 Two-second asyncio.sleep, then proceeds.
Challenge 60–74 Redirect to the hosted challenge.
Decoy 75–84 A maintenance page, served 200.
Block 85–100 The configured block response.

The rate limiter is a per-IP token bucket: 8 tokens per second, burst 24, with route multipliers that widen it for /assets/ and tighten it for /login, /auth and /wp-admin. Buckets idle for two minutes are evicted every five.

Deep scan is skipped for paths the policy marks curl-safe, so an API a customer calls with curl is not blocked for looking like curl.

Passport v2 and request signing

Both mechanisms are specified in contracts/agent-protocol-v2.md, and this SDK is checked against the shared vectors in contracts/passport-v2-vectors.json. Neither needs the network at verification time, because the edge has to keep deciding when the control plane is unreachable.

A passport is v2.<payload>.<signature> — HMAC-SHA256 over the encoded payload bytes, keyed on the licence key. It expires on its own, and it is bound to the visitor's raw User-Agent and Accept-Language headers, so a stolen cookie is worth nothing on another client. The binding uses the uncapped header values: the agent truncates the user agent to 1024 characters for logging, and using that truncated value here would make this agent disagree with the challenge server and lock out every visitor with a long user agent. The IP is deliberately not part of the binding, because the challenge and the protected site routinely observe different client addresses. The cookie is HttpOnly, SameSite=Lax, Secure over HTTPS, with its lifetime clamped to between 5 minutes and 7 days.

Signing covers the exact transmitted bytes. Every outbound call — /agent/verify, /agent/log, /agent/heartbeat, /agent/challenge/init, /agent/geo-lookup — goes through signed_json_request(), which serialises the body once and returns it alongside the headers that sign it:

from ultimateprotector_agent.client import signed_json_request

body, headers = signed_json_request({"license_key": "UP_LIVE_...", "ip": "203.0.113.7"}, "UP_LIVE_...")
# httpx: content=body, never json=payload

Returning both from one call is the whole design. The server hashes the bytes it receives, so anything that re-encodes the payload after signing produces a signature it cannot reproduce — and httpx's separators and ensure_ascii differ from ours, so a body carrying a non-ASCII user agent would go out in a form the signature does not cover. Serialise once, hash those bytes, send those bytes. A new endpoint added around this helper rather than through it will not degrade gracefully; it will take a 401 and go quiet.

Signatures are rejected outside ±300 seconds, so keep the server's clock in step with NTP.

Challenge disabled

challenge_enabled is a policy setting and it is also plan-gated: a licence without the bot challenge has it forced off server-side.

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 agent does call that endpoint, and _start_challenge() reads fallback off the answer. allow is the only value that clears it, so a fallback that is missing or that this agent does not recognise blocks — the platform's own default, and the safe reading of a field it could not parse. _respond_challenge() returns False on allow and the request carries on down the middleware to your application; on block it serves the block page and returns True. Every caller branches on that return, so the two cannot diverge.

Do not confuse this with a failed call. challenge_disabled is a policy answer carrying what to do instead; a timeout, a refused connection or an unreadable body is unavailable, which has no fallback to honour and always blocks. _start_challenge() keeps the two apart, and so must anything built on top of it.

Edge cases

An unreachable control plane fails open. A timed-out sync leaves the last known rules in place, or lets the request through when there are none, and schedules a retry.

An answer of expired or outdated does not. Both put the agent into its expired state, and in that state it stops evaluating and serves a 503 notice page that reloads every six seconds — for every visitor, until the licence is restored. That is protection ending loudly rather than silently, but it is a hard failure and worth knowing before you deploy.

Those two are the whole list. Going over an allowance is no longer one of them: quota_exceeded used to be a third state that dropped the ruleset and stood the agent down over a billing number, and it is gone from the platform. Overage warns and bills. Anything else the control plane can answer with — an unknown status, an empty body, a payload that will not decrypt — is a failed sync: the last good policy stays in force and the agent retries with backoff.

A challenge that cannot be initialised becomes a block. /agent/challenge/init is signed like everything else; if it fails, the agent serves the block page rather than putting the licence key in a redirect URL where it would land in access logs and referrer headers. No challenge was issued, so nothing was passed, and the score that sent this visitor here still stands — the whole fleet agrees on this, and an unreachable token service is not the way in. See Challenge disabled above for the case where the customer turned the challenge off on purpose, which is not this one.

Geo is resolved server-side when the CDN did not. With no CF-IPCountry or equivalent, the agent calls /agent/geo-lookup, which reads local MaxMind GeoLite2 data. Results are cached for 24 hours, concurrent lookups for the same address are deduplicated, and a failed lookup yields XX rather than an error.

Geo blocks are soft. A visitor refused by the geo firewall gets a plain regional-policy page and no ban — they are not an attacker, they are in the wrong country.

Logging is fire-and-forget. Log calls are background tasks whose responses are never inspected, so nothing local notices if the ingest endpoint rejects them. A logging outage is invisible from inside the process; check the dashboard, not the application log.

Heartbeat is a background thread, at most one every five minutes, taken off the event loop with a two-second timeout, and swallowing all errors.

Reported version

The distribution is 0.9.8; the agent reports 0.9.6 as agent_version and in X-Agent-Version, which is what the dashboard displays. The two drifted and the reported value is the one that matters when someone asks you which version is deployed.

Import paths

relintio_agent is the documented package. ultimateprotector_agent is the original name and remains a working alias — relintio_agent re-exports from it — so existing imports keep resolving. New code should use relintio_agent; internals not re-exported, such as signed_json_request, still live under the old name.

After install

Start in observe mode. Watch a day of real traffic in the dashboard, confirm the requests you expect are scored the way you expect, and only then enforce. Never carve out login, registration, checkout or password reset to silence a false positive — those are the routes the agent exists for.

An install that has not been verified against the control plane is not an install: restart, open one public route the middleware handles, then enter that URL in Relintio and select Verify target.

Links

License

Proprietary. See LICENSE.