Skip to content

Repository files navigation

Relintio

relintio-golang-agent

pkg.go.dev go license

The Relintio agent for Go.


An in-process agent that scores every request before your handlers see it. It synchronizes the rule set from the control plane on a background goroutine, keeps it behind a read-write mutex so the request path never blocks on the network, and decides — allow, challenge, block — from memory. Telemetry leaves on its own goroutine and is dropped rather than queued when the buffer fills, so a slow control plane costs latency nowhere. No proxy, no DNS change, no sidecar.

package main

import (
	"log"
	"net/http"
	"os"
	"time"

	relintio "github.com/Relintio/relintio-golang-agent"
)

func main() {
	agent := relintio.NewAgent(relintio.Config{
		LicenseKey:   os.Getenv("UP_LICENSE_KEY"),
		ApiUrl:       "https://api.relintio.com/v1",
		Domain:       "shop.example.com",
		SyncInterval: 10 * time.Second,
	})
	agent.StartSync()
	defer agent.StopSync()

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	log.Fatal(http.ListenAndServe(":8080", relintio.Middleware(agent)(mux)))
}

Installation

go get github.com/Relintio/relintio-golang-agent

go.mod declares go 1.25.0, so an older toolchain refuses to build the package rather than degrading. The import path ends in relintio-golang-agent but the package is named relintio; most editors add the alias for you, and the explicit relintio "github.com/..." above is there so a hand-written import does not surprise anyone.

middleware_gin.go carries no build tag, so github.com/gin-gonic/gin is linked into every binary that imports this package — including one that only ever calls relintio.Middleware. It costs binary size, not runtime.

Registration

Register before your own routes. Middleware added after the router runs only once the handler has already answered, and the diff looks identical either way — this is the most common way an install ends up looking finished while protecting nothing.

relintio.Middleware(agent) returns a func(http.Handler) http.Handler, so it composes with chi, gorilla/mux, alice, or a bare ServeMux. For Gin:

r := gin.Default()
r.Use(relintio.GinMiddleware(agent))

Both adapters run the same decision path (Agent.Inspect), so they cannot disagree about a verdict. Neither exempts any path: /_relintio/challenge and /_relintio/verify used to be skipped, and since nothing here ever served either of them the skip was an unprotected path and nothing more.

StartSync is guarded by a sync.Once and StopSync by another, so calling either twice is a no-op rather than a panic on a closed channel. The telemetry goroutine starts inside NewAgent, not inside StartSync.

Configuration

Field Type Default Meaning
LicenseKey string Keys every signature and every passport. Secret — see below.
ApiUrl string https://api.relintio.com/v1 Filled in when empty. A trailing / is trimmed on each call.
Domain string "" Sent with every sync so the control plane can resolve which licence policy applies.
SyncInterval time.Duration 10 * time.Second Target cadence. Backoff and jitter apply on top of it.

NewAgent validates nothing. An empty LicenseKey produces a well-formed HMAC over an empty key, the control plane rejects it, syncRules returns false, and the agent runs with an empty rule set — which allows everything. Nothing in the process says so, so treat a missing key as a startup failure in your own 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.

What happens on a request

CheckRequest takes a read lock and walks the synchronized rules once. A rule matches on the client IP, the user agent, or the path; 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 First entry of X-Forwarded-For, else RemoteAddr, with the port and any [] stripped
user_agent r.UserAgent()
path r.URL.Path
header r.Header. 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 with regexp and cached per pattern. equals and contains are both case-insensitive — 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 regex that does not compile never matches and never panics.

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 rules_test.go.

Then the thresholds:

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 package. StartChallenge 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. ChallengeOutcome has three cases, and the difference between them matters:

Kind Meaning What the middleware does
ChallengeRedirect 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
ChallengeDisabled The licence has the challenge switched off, or the plan does not include it Honours Block, which carries the licence's challenge_fallback; the platform default is to block
ChallengeUnavailable 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 queued to a buffered channel of 1024 events and sent on the background goroutine as /agent/log. When the buffer is full the event is discarded rather than blocking the handler. The response is never read, so a rejected telemetry post is invisible from inside the process.

Clean traffic is sampled at 1%. AllowSampleRate 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.

Passport v2

The protocol-v2 primitives are here, exported, and checked against contracts/passport-v2-vectors.json by passport_test.go:

const PassportCookie = "relintio_passport"
const ClockSkewSeconds = 60

func PassportBinding(licenseKey, userAgent, acceptLanguage string) string
func VerifyPassport(value, licenseKey, userAgent, acceptLanguage string, now int64) *PassportPayload
func MintPassport(ttl int, licenseKey, userAgent, acceptLanguage string, now int64) string
func ClampTTL(ttl int) int

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 lowercasing them here makes this agent disagree with the challenge server, which locks out every visitor rather than a few. Verification is offline: passport.go imports no HTTP package at all, because the edge has to keep deciding when the control plane does not. Both the signature and the binding are compared in constant time, 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. VerifyPassport rejects anything not beginning v2., so tokens in that form no longer pass.

Both adapters wire this up, and neither 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. Agent.Inspect now runs, in order:

  1. VerifyPassportCookie — a valid relintio_passport short-circuits to allow, before any scoring.
  2. ExchangePassportToken — a ?up_token= handed back by the hosted page is verified, ClampTTL'd off the payload's ttl, minted into a relintio_passport cookie (Path=/, HttpOnly, SameSite=Lax, Secure on HTTPS or behind X-Forwarded-Proto: https), and answered with a 302 to the same path with the query stripped, so the token does not linger in referrer headers. An up_token that does not verify is a block, not a pass: the only way to hold one is to have just solved the challenge.
  3. Scoring, and the tiers above.

All three are exported, so a hand-rolled adapter can compose the same path.

Request signing

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.

Every call goes through the unexported postSigned, which marshals the payload once and hands that same []byte to both SigningHeaders and the request body. This is the part that is easy to get wrong. Go's map[string]interface{} marshals in sorted key order, which looks deterministic enough to invite signing a map and letting something else encode it again; the moment a field type or an encoder changes, the signature covers bytes that were never transmitted, the server — which hashes what it received — rejects everything, and nothing in any log explains it. An endpoint added around postSigned rather than through it does not degrade gracefully; it 401s, and the edge goes blind. signing_test.go catches exactly that by running a real request against an httptest server and recomputing the signature from the bytes that arrived.

SigningHeaders draws its nonce from crypto/rand. If that read fails it falls back to the nanosecond clock rather than failing the call, on the grounds that the server treats a nonce as single-use anyway and a dropped telemetry event is worse than a slightly less unpredictable one.

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. 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 agent does call that endpoint, and StartChallenge reads fallback off the answer into ChallengeOutcome.Block. 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. Agent.Inspect turns that into VerdictAllow (on to your handler) or VerdictBlock (the block page), and both adapters render the verdict from there, so neither can disagree about it.

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 ChallengeUnavailable, which has no fallback to honour and always blocks. The table above keeps the two apart, and so must anything built on top of them.

Edge cases

Sync failure fails open. syncRules returns false on a transport error, a non-200, a body over 1 MiB, or unparseable JSON, and leaves the previous rules in place. 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 SyncInterval by powers of two to a five-minute ceiling, with ±20% jitter on every interval so a fleet restarting together does not synchronize into a thundering herd. 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 it does mean a cold start is unprotected for a moment, and unlike the Rust agent this one 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.

Domain is not derived from the request. It comes from Config alone. A binary serving several hostnames reports one of them to the control plane, and the policy it receives is that one's.

Rules are replaced, never merged. A successful sync swaps the whole slice under the write lock. There is no partial update and no revision check, so a truncated rule set from the control plane silently becomes the whole policy until the next sync.

In production

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 StopSync on shutdown. It closes the stop channel, which ends both the sync loop and the telemetry loop; without it there is nothing to end them and they run for the life of the process, against a server nobody is calling.

Links

Security reports go to support@relintio.com, not to a public issue.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages