A self-hosted bot-filtering gateway that sits in front of Traefik.
It looks at how a client really talks (TCP, TLS, HTTP/2, HTTP/3, JS) and checks that against what the client says it is.
A scraper can copy Chrome's User-Agent string in one line of code.
Copying Chrome's TLS ClientHello, HTTP/2 settings frame, header order and
TCP SYN all at once is much harder. LarpGuard fingerprints every layer of
the connection and asks one question: does this stack match the browser
it claims to be?
You don't touch your Traefik routers or container labels. The gateway takes over ports 443/80, and Traefik moves to loopback behind it.
- π Fingerprints at every layer. TCP SYN (Linux), JA4, HTTP/2 (Akamai format), header order, QUIC transport parameters. JA4T/JA4H are optional.
- π°οΈ Proxies give themselves away. The kernel's TCP round trip ends
at the nearest TCP stack, a proxy's; the TLS 1.3 handshake reaches the
real client. A big gap means a relay (
relayed_connection), no proxy list needed. - π§ Consistency scoring. Each signal adds a weight and a reason code.
Requests are classed
human,suspicious,botorverified_bot. Traefik receives the verdict inX-Bot-*headers. - π Shadow mode first. Score and log everything, act on nothing. Then
switch to
enforceper host, from YAML or a Docker label. - π§© JS challenge and proof-of-work. The page probes canvas, WebGL,
audio, fonts, screen, Worker vs. window, DevTools-protocol traces and
patched natives. Tokens (
__bs) are stateless and HMAC-signed, bound to the JA4 and subnet, with rotating keys. The cookie never reaches your app. - π§· CAPTCHA when it matters. A built-in puzzle (server-rendered shapes,
encrypted answers, timed rounds, hand-like pointer paths) or Turnstile,
hCaptcha and reCAPTCHA. Suspicious JS challenges escalate to it, and the
captchaaction asks for it. It is bound to the browser that ran the probes, so answers bought from a solving farm don't transfer. - π€ Crawler verification. Search engines are verified by published IP
ranges and by reverse plus forward DNS. GPTBot, PerplexityBot and friends
are checked against their published ranges. Impostors get
fake_crawler. AI crawlers follow their own policy. - βοΈ Web Bot Auth. Signed agents (HTTP Message Signatures, Ed25519) are
verified against their published key directories. Trusted agents become
verified_bot, and forged signatures are penalised. - π Rule language. Write conditions like
path ~ "/admin*" and class != "human". Types are checked when the config loads. - π¦ Rate limiting. GCRA with composite keys: IP, subnet, ASN, fingerprint, API key, cookie or session. Filters by host, path, method and class. Per-IP connection limits apply before TLS.
- π ASN awareness. The iptoasn.com database refreshes in the
background. It provides
asn,asn_org,asn_countryandasn_hostingin rules and events, plus a hosting-network signal. - π‘οΈ XDP pre-filter. A BPF program with its own loader (no libbpf) drops blocklisted sources, per-prefix SYN floods and malformed packets before the kernel's TCP stack. It fails open with the gateway.
- π― Honeypots and behaviour. Trap paths such as
/.envand/.git/*, robotic timing, 404 storms, ID enumeration, API calls without the page. - π’ More than block. Other actions are
challenge,captcha,throttle,tarpit(a response that trickles out) anddecoy(fake data with a watermark you can trace later). - π² Built-in ML signal. Gradient-boosted trees are trained on your own ClickHouse events with weak labels and evaluated in C++ in microseconds. No ONNX runtime.
- β‘ HTTP/3 at the edge. Built on ngtcp2 and nghttp3, with the same verdicts as TCP, Retry tokens and QUIC JA4.
- π gRPC works. Requests go through a pooled HTTP/2 upstream to Traefik, including streaming and trailers-only responses.
- π³ Docker labels.
botshield.mode,botshield.ratelimit.*,botshield.challenge.pathsand more. Changes apply without a restart. - π₯ Hot reload.
SIGHUPorPOST /reloadswaps the fingerprint library, policy, rules, limits and model atomically. A broken config is never applied. - π§― Fail-open. Workers send heartbeats, systemd runs a watchdog, and nftables redirects traffic straight to Traefik if the gateway dies. Adding LarpGuard should never take your sites down.
- π Analytics. Connection and request events go to NATS and then ClickHouse. They never contain full IPs, query strings or cookie values. Anomaly clusters suggest new library entries.
- π₯οΈ Web console. Overview, a requests explorer with the reasons behind each verdict, fingerprints, a rule editor that type-checks with the gateway's own parser and tests drafts on the last hour of traffic, lists, rate limits, host modes, crawlers, attack mode and an audit log with revert. Dark and light, works on a phone.
βββββββββββββββββββββββββ LarpGuard ββββββββββββββββββββββββββ
client ββ :443 βββΆ β ClientHello ββΆ terminate ββΆ fingerprints ββΆ score ββΆ rules β βββΆ Traefik :8443 βββΆ apps
(TCP / QUIC) β β β β (HTTP/2 pool,
β βββΆ passthrough (unknown SNI, acme-tls/1) βΌ β X-Forwarded-For,
β allow Β· challenge Β· throttle Β· β X-Bot-*)
client ββ :80 ββββΆ β passthrough + PROXY v2 tarpit Β· decoy Β· block β βββΆ Traefik :8080
ββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββ
βββΆ NATS ββΆ ClickHouse ββΆ clusters Β· model training
- Thread-per-core. One
asioevent loop per worker, withSO_REUSEPORTon TCP and UDP. The hot path takes no global locks and does no synchronous network I/O. - Certificates come from Traefik's
acme.json. It is polled and reloaded. Hosts without a certificate pass through untouched. - Anything the gateway can't serve correctly goes to Traefik instead of failing.
CI publishes a multi-arch image (linux/amd64, linux/arm64) to GHCR on
every push to main. The image contains bot-shield, bot-shield-watchdog,
nftables and the default config.
docker pull ghcr.io/kewldan/larpguard:latestdocker run --rm ghcr.io/kewldan/larpguard:latest --check-configFor a real deployment, use deploy/docker-compose.yml.
It runs Traefik, the gateway and the watchdog in host network:
cd deploy && docker compose up -d| Tag | Points to |
|---|---|
latest |
The current main, after CI passes |
sha-<commit> |
One exact commit |
1.2.3, 1.2, 1 |
A release, from a v1.2.3 git tag |
The binaries, config file and labels still use the working name
bot-shield (bot-shield, bot-shield-watchdog, botshield.*).
Requirements: CMake β₯ 3.28, a C++20 compiler, and network access on the first configure. Pinned dependencies are fetched automatically: BoringSSL, Asio, nghttp2, ngtcp2, nghttp3, llhttp, zlib, yaml-cpp, nlohmann/json and GoogleTest.
cmake -S . -B build -G Ninjacmake --build buildbuild/src/bot-shield --config deploy/bot-shield.yaml --check-configOr build the container image yourself:
docker build -t larpguard .Everything you need is in deploy/:
| File | What it is |
|---|---|
bot-shield.yaml |
Gateway config with every key and its default |
traefik.yml |
The only Traefik change: entrypoints move to :8443/:8080 and trust PROXY v2 and X-Forwarded-* from the gateway |
docker-compose.yml |
Traefik, LarpGuard and the watchdog in host network, plus optional NATS and ClickHouse |
failopen/ |
nftables table that closes :8443/:8080 from outside (load it at boot), and the fail-open redirect rules |
systemd/ |
Units for the gateway (Type=notify, WatchdogSec) and the watchdog |
clickhouse/schema.sql |
Event tables, aggregates and cluster views |
fingerprints.yaml |
Fingerprint library and signal weights |
Important
Keep HTTP/3 off in Traefik, or QUIC traffic will bypass the gateway.
Let LarpGuard accept it instead: set http3.enabled: true.
Tip
Start with mode: shadow. Watch X-Bot-Score, X-Bot-Class and
X-Bot-Reasons in your logs or ClickHouse for a while, then switch busy
hosts to enforce.
mode: shadow # shadow | enforce
policy:
hosts:
shop.example.com: enforce
rules:
- name: monitoring
when: 'cidr("10.0.0.0/8") and header("x-monitor") == "1"'
action: allow
- name: admin-scrapers
when: 'path ~ "/admin*" and class != "human"'
action: block
- name: mobile-app
when: 'ua_tool == "okhttp" and host == "api.example.com"'
action: score
score: -40
crawlers:
ai: challenge # default | allow | challenge | captcha | block
captcha:
provider: builtin # or turnstile | hcaptcha | recaptcha
web_bot_auth:
trusted: ["chatgpt.com"]
http3:
enabled: trueOr set policy per container with labels:
labels:
botshield.mode: enforce
botshield.ratelimit.login: "rate=5/m key=ip path=/login methods=POST"
botshield.challenge.paths: "/checkout*,/login"
botshield.ai_crawlers: blockUnknown config keys are errors, so typos never pass silently.
The admin API listens on a unix socket (mode 0600). It has no TCP port.
curl --unix-socket /run/bot-shield/admin.sock http://bot-shield/status| Endpoint | Purpose |
|---|---|
GET /status |
Workers, connections, verdict generation |
GET /config |
The verdict settings in effect, without secrets |
GET /policy?host=β¦ |
The effective policy for a host (GET /policy: every host with a mode of its own) |
POST /policy, DELETE /policy/<host> |
Host mode from the console; beats the config until removed |
/lists |
Allow and block lists with TTLs (they win over rules) |
GET /decisions/<id> |
Why a request was decided this way (from X-Bot-Request-Id) |
/attack |
Under-attack mode: challenge everyone without a token |
POST /rules/test |
Compile a rule condition and run it on sample events |
GET /asn?numbers=β¦ |
AS names from the ASN database |
POST /reload |
Hot-reload verdict settings |
Prometheus metrics and /health are served on 127.0.0.1:9900.
/health returns 503 when a worker loop stalls.
π With whoami: true, any host answers /__bs/whoami with what the
gateway thinks of that very request instead of acting on it: who you look
like, the score and reasons with their weights, the fingerprints, and what
would happen to you now and in enforce mode. A browser gets a page, curl
gets text, ?format=json gets JSON:
curl https://shop.example.com/__bs/whoamiIt shows how the scoring works, so it is off by default: turn it on to test clients, not on a site under attack.
ghcr.io/kewldan/larpguard-console runs next to the gateway. It serves the
UI and a small backend that talks to the admin socket and ClickHouse; the
browser never reaches either directly. Sign-in takes a password and a TOTP
code, or a passkey each user adds in Settings; roles are viewer and
operator, every change lands in a hash-chained audit log, and full client
addresses never leave the gateway.
echo 'a long password' | docker run --rm -i ghcr.io/kewldan/larpguard-console hash-passworddocker run --rm ghcr.io/kewldan/larpguard-console totp-secret adminPut the hash and the secret into deploy/console.yaml
and start the console service from
deploy/docker-compose.yml. It listens on
127.0.0.1:9901; publish it through a Traefik router of its own.
ctest --test-dir build --output-on-failureThe end-to-end suite runs against a fake Traefik. It needs python3,
openssl, node (runs the challenge script) and a curl with HTTP/2:
python3 -I tests/e2e/run_e2e.py build/src/bot-shieldcmake --build build --target format-checkcmake --build build --target tidy- Console. In
ui/:npm ci, thennpm run dev:api(backend on an in-memory gateway with synthetic traffic) andnpm run dev:web; sign in asdev(seeui/server/dev/users.ts). Checks:npm run lint,npm run typecheck,npm test,npx playwright test; screenshots are compared on Linux withnpm run e2e:docker. - CI.
ci.ymlruns format, clang-tidy, unit and e2e tests (plain and under ASan/UBSan) in a privilegeddebian:trixiecontainer (so the XDP tests run), and the console's lint, types, unit, browser and screenshot tests, for every push and PR. Onmainandv*tags it then publishes both images. - Style. Google C++ Style, enforced by
clang-formatandclang-tidy.tidymust report 0 warnings. - Sanitizers. Build with
-DBOT_SHIELD_SANITIZE=ON -DCMAKE_BUILD_TYPE=Debug. - XDP builds on Linux when
clang(BPF target) andpython3are installed. Its tests load the program withBPF_PROG_TEST_RUN, which needs root or a privileged container. Elsewhere they are skipped. - Linux-only paths (
TCP_SAVED_SYN, multi-workerSO_REUSEPORT). Test them in adebian:trixiecontainer. - No JA4T/JA4H (FoxIO License): build with
-DBOT_SHIELD_FOXIO=OFF. - Retrain the model from ClickHouse events with
tools/train_model.py. - Find new fingerprint clusters with
tools/cluster_candidates.py.
src/
βββ tls/ ClientHello parsing, certificate store, acme.json watcher
βββ net/ classifier, PROXY v2, sockets, HTTPS client, ASN database
βββ server/ workers, connection handlers, verdict pipeline, /metrics, /health
βββ proxy/ HTTP/1.1 + HTTP/2 server side, HTTP/2 upstream pool to Traefik
βββ quic/ HTTP/3 server (ngtcp2 + nghttp3)
βββ fp/ fingerprints and passive HTTP taps (fp/foxio: JA4T/JA4H)
βββ score/ scorer, rule language, shadow/enforce policy
βββ challenge/ JS challenge, probes, CAPTCHA (puzzle + providers), tokens
βββ crawler/ crawler catalogue, rDNS, IP ranges, Web Bot Auth
βββ behavior/ behaviour signals and honeypot marks
βββ action/ tarpit and decoy responses
βββ ratelimit/ GCRA, sharded store, limit keys
βββ model/ feature extraction and GBDT evaluation
βββ docker/ Docker API client and botshield.* labels
βββ admin/ admin API, lists, decision log, under-attack mode
βββ events/ JSON events and the NATS publisher
βββ watchdog/ bot-shield-watchdog (fail-open)
βββ xdp/ XDP pre-filter: BPF program and loader (Linux)
βββ config/ strict YAML config
ui/
βββ shared/ the console API contract (zod schemas)
βββ server/ the console backend: sessions, admin socket, ClickHouse, audit
βββ src/ the console UI: design tokens, components, screens
βββ tests/e2e/ browser and screenshot tests
The architecture, ADRs, the full signal catalogue and the milestone history
are in docs/architecture.md. That document is in
Russian.
Numbers from a 2 vCPU / 2 GB test box, driven by
oha from ~8 ms away. Not a benchmark β a
sense of scale and of where the limit is.
| Load | Result |
|---|---|
| 1,000 req/s, HTTP/2 | p99 15 ms, gateway ~0.2 of one core |
1,000 req/s of spoofed Chrome on an enforce host |
all blocked (403), gateway 0.4 core, upstream untouched |
| ~4,700 req/s (saturation) | still serving, gateway < 1 core / 65 MiB; latency climbs, nothing dropped |
| a new TLS connection per request, flooded | XDP caps SYNs at the configured rate per /24 and drops the rest in the kernel |
The verdict engine is thread-per-core and cheap: on this box it never was the bottleneck β the shared CPU (chiefly event ingestion into ClickHouse) was. Blocking and challenging cost about the same as proxying, so under-attack mode is a real defence even here: dropping a flood is cheaper than forwarding it.
On 2 GB, size ClickHouse carefully (small thread pools and caches) or put it on its own host β the analytics sink, not the gateway, is what wants RAM.
MIT. The JA4T/JA4H code in src/fp/foxio/ is under the
FoxIO License 1.1 instead. Build with
-DBOT_SHIELD_FOXIO=OFF to leave it out.