A module, not a framework integration. It keeps a rule set synchronized from the control plane on a background thread — every ten seconds or so, with jitter and exponential backoff — and answers allow, challenge or block from the cached copy with no network call and no allocation on the decision path. You hand it a client address, a user agent and a path; what you do with the verdict is yours, because Zig has no one HTTP server for this to plug into. It depends on nothing but the standard library.
const std = @import("std");
const relintio = @import("relintio");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var agent = try relintio.Agent.init(allocator, .{
.license_key = std.posix.getenv("UP_LICENSE_KEY") orelse "",
.api_url = "https://api.relintio.com/v1",
.domain = "example.com",
.sync_interval_seconds = 10,
});
defer agent.deinit();
try agent.startSync();
const result = agent.checkRequest("203.0.113.7", "curl/8.4.0", "/api/admin/delete");
if (std.mem.eql(u8, result.action, "block")) {
std.log.info("blocked, score {d}", .{result.score});
} else if (std.mem.eql(u8, result.action, "challenge")) {
std.log.info("challenge, score {d}", .{result.score});
}
}Add it to build.zig.zon:
.dependencies = .{
.relintio = .{
.url = "https://github.com/Relintio/relintio-zig-agent/archive/refs/tags/v0.1.1.tar.gz",
// Run `zig build` once and paste the hash it prints.
.hash = "1220...",
},
},and wire the module in build.zig:
const relintio = b.dependency("relintio", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("relintio", relintio.module("relintio"));The code targets Zig 0.13.x and uses that release's standard library — std.ArrayList(T).init, and std.http.Client.open with a caller-supplied server_header_buffer. Both changed shape afterwards, so a newer toolchain will not build this as it stands.
AgentConfig is a struct literal with defaults for everything but the key.
| Field | Type | Default | Meaning |
|---|---|---|---|
license_key |
[]const u8 |
— | Required. Secret — see below. |
api_url |
[]const u8 |
https://api.relintio.com/v1 |
Trailing slashes are stripped. |
domain |
[]const u8 |
"" |
The licensed domain, sent on every sync. |
sync_interval_seconds |
u32 |
10 |
Target cadence. Floored at 10; jitter and backoff apply on top. |
The config borrows its slices rather than copying them, so they must outlive the agent. String literals are the easy case; a key read into a heap buffer at startup has to stay allocated.
The licence key is a secret. It signs every outbound call and it is the HMAC key a passport is minted under, so anything holding it can forge both. Keep it in the environment, never in a repository, and never anywhere it can reach a browser — that is what publishable keys are for, and they belong to the React and Shopify SDKs, not this one.
checkRequest takes the rules mutex, walks the cached rules, and returns a score and an action. It never touches the network and never allocates, so it is safe to call from whatever thread is holding the connection.
Rules are additive. Each one that matches contributes its score and may escalate the action; the totals then decide on their own:
| Total score | Action |
|---|---|
| 0–49 | allow |
| 50–99 | challenge |
| 100 or more | block |
A rule whose own action is block blocks whatever the total says. A rule marked challenge escalates unless something already said block.
The dashboard assigns 100 to a block rule and 60 to a challenge rule, which has a consequence worth knowing before you write the third rule: two challenge rules matching the same request add to 120 and block it. Nothing warns you, and the dashboard still calls both rules challenges.
type |
Compared against | Condition |
|---|---|---|
ip |
client IP | equals |
path |
request path | contains |
user_agent |
User-Agent header |
contains |
header |
the request headers you pass in | contains |
contains is an ASCII case-insensitive search, and so is equals — which matters for an IPv6 address written 2001:DB8::1 in the dashboard and sent lowercase on the wire, and is the reason the shared contract settles it that way rather than leaving each runtime to choose.
A header rule carries its own small grammar in the pattern. Without a colon it names a header and tests that it is present with a non-empty value; an empty header is not a signal. With one — X-Forwarded-Host: evil.example — the part before names the header and the part after is matched against its value, case-insensitively on both sides, with the whitespace around the colon trimmed. Every value under a repeated name is considered, not just the first.
Headers reach the agent through checkRequestWithHeaders, which is the same call with a []const Header on the end:
const headers = [_]relintio.Header{
.{ .name = "User-Agent", .value = "curl/8.4.0" },
.{ .name = "X-Scanner", .value = "nuclei" },
};
const result = agent.checkRequestWithHeaders("203.0.113.7", "curl/8.4.0", "/api/admin/delete", &headers);checkRequest still exists and still takes three arguments; it is that call with an empty header slice, so a header rule cannot match through it.
regex is a fourth condition the dashboard does not emit today. There is no regular-expression engine in the standard library, so a rule carrying it is skipped — it never matches and never throws. It is not degraded into a substring search, which is what three other SDKs did: a rule that matches something other than what its author wrote is worse than one that does nothing. An unrecognised type or condition fails closed the same way.
contracts/rule-conditions-v1.json in the platform repository is the definition all twelve SDKs are held to, and zig build test asserts every vector in it, printing the regex ones it skips by name.
result.action is a static string — "allow", "challenge" or "block" — so comparing it with std.mem.eql costs nothing and it does not need freeing.
passport.zig is a dependency-free implementation of the v2 token: v2.<base64url payload>.<base64url signature>, HMAC-SHA256 under the licence key, carrying an absolute expiry and a binding hash. Verification is entirely local, because the edge has to keep deciding when the control plane is unreachable. Both the signature and the binding go through a comparison that does not return early.
It is reachable as relintio.passport, re-exported from main.zig. That re-export is load-bearing for more than convenience: because the root module now references it, its ten conformance tests are analysed and run by zig build test. Before, nothing imported the file, so the tests compiled to nothing and passed by never existing.
The binding is sha256(licenceKey|userAgent|acceptLanguage) truncated to 16 hex characters, over the raw header values. Trim them, lowercase them or cap them for logging and this agent computes a different hash from the challenge server, which locks out every visitor who just passed.
It replaced sha256("verified" + licenceKey) — one constant string, the same for every visitor of a site, valid for a week. One leaked cookie bypassed the agent until the key was rotated. Tokens in that shape are no longer parsed at all.
Agent does not use any of this. The only part of passport the agent itself calls is signingHeaders. Verification and minting are primitives for whatever handles your challenge exchange:
const passport = @import("relintio").passport;
/// True when this visitor already passed the challenge. No network call: the
/// licence key is enough to check the signature and the binding.
pub fn holdsPassport(
cookie: []const u8,
license_key: []const u8,
user_agent: []const u8,
accept_language: []const u8,
) bool {
return passport.verify(cookie, license_key, user_agent, accept_language, 0) != null;
}
/// Turn a freshly redeemed `up_token` into the cookie this agent will accept.
pub fn issue(
out: []u8,
ttl_from_token: i64,
license_key: []const u8,
user_agent: []const u8,
accept_language: []const u8,
) ![]const u8 {
return passport.mint(
out,
passport.clampTtl(ttl_from_token),
license_key,
user_agent,
accept_language,
0,
);
}mint writes into a caller-supplied buffer — 512 bytes is comfortable — so nothing here needs an allocator. Set the cookie Path=/, HttpOnly, Secure over HTTPS, with Max-Age equal to the ttl, under the name in passport.cookie_name. Pass the token's ttl through clampTtl first: it arrives signed, but a bug upstream should not be able to mint a ten-year cookie. passport.clock_skew_seconds is the 60 seconds of drift allowed between the challenge server and this process.
verify decodes the payload into a fixed 512-byte buffer and rejects anything larger. Today's payloads are well under a hundred bytes; a future revision that grows them would need that constant raised in step.
Every outbound ingest call carries:
X-Relintio-Timestamp: 1785120000
X-Relintio-Nonce: <16–128 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 for that credential, and the signature in constant time — burning the nonce last, so a forged request cannot consume one the real agent is about to use.
agent_signature_mode on the server has three settings. off checks nothing. optional allows 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 one private postSigned, which stringifies the payload once into an ArrayList and uses that same slice for the body hash, the Content-Length and the socket write. This is the part that is easy to get wrong: signing the payload struct and letting the request re-encode it puts a signature over bytes that never went on the wire, which the server cannot reproduce and the agent cannot see itself doing. An endpoint added around the chokepoint rather than through it does not degrade gracefully; it 401s, and the edge goes blind.
signingHeaders returns a Headers struct with inline buffers rather than allocations, so signing cannot fail for want of memory on the telemetry path, where there is nobody to report the failure to. The accessors hand back slices into the struct: keep it in a named variable for as long as the request is in flight, or the header values point at a dead stack slot.
zig build testFourteen tests: three that stand a listener on loopback and recompute the signature from the bytes the agent actually transmitted, one for the scoring engine, and ten conformance tests in passport.zig checking binding, mint, verify, ttl clamping and the request signature against contracts/passport-v2-vectors.json. The vectors are shared by all twelve SDKs; a failure there means this agent disagrees with the challenge server, which in production means visitors who passed the challenge are blocked by the agent with nothing in any log to explain it.
The expected values are inlined as constants rather than parsed from the JSON at build time. That is a real cost — they have to be regenerated by hand when the vectors change — and it buys not adding a JSON dependency to a module whose selling point is having none.
sendTelemetry blocks the calling thread. It performs the HTTP POST inline, under the same mutex the rules syncer uses, so a call from a request handler holds that request open for a full round trip to the control plane and may also wait behind an in-flight sync. Nothing in the module moves it off the hot path for you. Hand it to a worker thread, or accept the latency knowingly.
Clean traffic is sampled at 1%. 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 agent must not move after startSync. The sync thread is spawned with a *Agent pointing at wherever the value lived at that moment. var agent = try Agent.init(...) and then passing &agent around is fine; copying or returning the struct by value afterwards leaves a thread writing into the old location.
Nothing is protected until the first sync lands. The rule list starts empty and an empty list scores zero, so checkRequest allows everything. startSync does the first sync on the background thread, not inline — call syncRules() once before you serve if you need rules in place from the first request.
A dead control plane is a silent allow, not an outage. syncRules returns an error, the loop discards it, and the previous rules stay 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 to a five-minute ceiling with jitter on every interval, so a fleet restarting together does not synchronize into a thundering herd. There is no inactive-licence handling in this agent: an expired subscription stops new rules arriving, it does not start refusing traffic.
deinit waits. The sync loop checks its stop flag once a second, so shutdown takes up to that long, plus however long a sync already in flight takes to finish. The HTTP client sets no timeout of its own, so "in flight" is bounded by the network rather than by this module.
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 webhooks with dashboard bypass rules — never by lowering protection globally, and never by carving out login, registration, checkout or password reset.
Start in observe mode — call checkRequest, log the verdict, act on nothing — and watch a day of real traffic before you enforce. The dashboard shows what the agent scored and why, so the question to settle first 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.
Security reports go to support@relintio.com, not to a public issue.
MIT. See LICENSE.