A Traefik middleware plugin that detects HTTP vulnerability scanning, bans the source, and ships its own web console.
One artifact. No sidecar, no database, no agent. MIT.
Every public host gets the same background noise: /wp-login.php, /.env,
/phpmyadmin, /.git/config, endless 404s from someone else's wordlist.
scanguard watches for it, blocks the source before your backend is touched, and
gives you a page where you can see who is banned and let them back in.
# The whole installation.
experimental:
plugins:
scanguard:
moduleName: github.com/phoen-ix/scanguard
version: v0.1.0| Signature probes | 61 curated paths that only a scanner asks for. Blocked before the request reaches your backend. |
| Distinct-404 floods | A leaky bucket over distinct failing paths — so a scanner walking a wordlist is caught, and a real user hammering one broken link never is. |
| Honeypots | Paths you invent that no legitimate client could know. One hit, instant ban, effectively zero false positives. |
| Scanner user-agents | nikto, sqlmap, nuclei, masscan, zgrab and friends. Generic clients like curl and python-requests are deliberately not in the defaults. |
| SEO crawlers | MJ12bot, AhrefsBot, SemrushBot and friends, as a separate opt-in: detectors.userAgent.crawlers is ignore (default), ban or exempt. They are not scanners, so they get their own switch rather than being smuggled into another rule. Search engines are never on the list. |
| Brute force | Repeated 401/403 from your real login endpoints. |
| Rate abuse | Token bucket per source, independent of status code. |
| Injection payloads | SQLi, traversal, template injection and XSS probes in the query string and optionally the body. Checked in both raw and decoded form. |
Every detector is individually switchable, and every threshold is configurable.
Bans escalate (1h → 24h → 7d → permanent), decay when a source behaves, survive
a Traefik restart, and can be pushed to a webhook, Discord/Slack/ntfy/Gotify, or
AbuseIPDB.
Active bans with the rule that caught them, one-click unblock, manual ban by IP or CIDR, a live activity feed, most-probed paths, top sources, and a full rule editor. It is one self-contained page: no build step, no npm, no CDN.
Every detector, threshold, allowlist and enforcement setting can be changed in the console and takes effect on the next request — no Traefik restart, no reload, no file to edit.
Traefik's configuration is one-way: it is decoded once when the middleware is
built, and nothing can write back to it. Writing to the file provider instead
would be worse — every write trips fsnotify, rebuilds the whole router tree and
re-runs New() for every plugin middleware on every router. So scanguard keeps
its own override store, saved beside the ban list and restored at startup.
- Only what you change is taken over. Edit a honeypot and the console owns
detectors;enforcementkeeps tracking your YAML. The editor marks changed fields and lists exactly which sections it manages. - Edits are validated by the same parser as the file. A broken regex comes back as a 422 with the reason and the running rules are untouched — it cannot half-apply and leave you undefended.
- Reverting restores the file configuration, section by section.
- A Traefik reload re-layers your edits onto the new file config rather than discarding them.
clientIP,store,adminandnotifyare not editable. They hold secrets, or must agree with Traefik's static configuration that nothing at runtime can change — see the comment at the top ofoverrides.go.
With the Redis backend, a rule change on one replica reaches the others on their next refresh, exactly like a ban.
Traefik plugins cannot extend the Traefik dashboard. The dashboard is a compiled-in single-page app with no extension point; traefik#8684 has been open since January 2022, and the dashboard's own CSP blocks embedding a plugin page in an iframe. So scanguard serves its console on a router you define. Put it on a private entrypoint and chain your own auth in front of it.
If your actual problem is "my server is getting hammered and I want it to
stop", deploy CrowdSec instead. CrowdSec plus
crowdsec-bouncer-traefik-plugin plus the community web UI covers most of what
this does, with community-sourced threat intelligence, a real decisions database,
and proper multi-node support. It is free, self-hostable, and more battle-tested
than anything one person writes in a month.
scanguard exists for the case CrowdSec does not serve: one lightweight artifact you drop into Traefik, with a console built in. No agent container, no access log plumbing, no hub. If that is what you want, this is for you.
| scanguard | CrowdSec + bouncer | tomMoulard/fail2ban | |
|---|---|---|---|
| Artifacts to deploy | 1 | 3+ | 1 |
| Built-in web console | ✅ | separate UI | ❌ (#238) |
| Survives restart | ✅ | ✅ | ❌ |
| Multiple Traefik replicas | Redis backend | ✅ | ❌ |
| Community threat intel | ❌ | ✅ | ❌ |
| Bounded memory | ✅ | ✅ | ❌ (no eviction) |
| Trusted-proxy validation | ✅ | ✅ | ❌ (spoofable) |
# traefik static configuration
experimental:
localPlugins:
scanguard:
moduleName: github.com/phoen-ix/scanguard# docker-compose.yml
volumes:
- ./scanguard:/plugins-local/src/github.com/phoen-ix/scanguard:ro
- scanguard-state:/var/lib/scanguardLocal plugins need no network access at startup. That matters more than it
sounds: a remote plugin is fetched from plugins.traefik.io on every Traefik
start, and one transient failure disables every declared plugin with a
misleading error (traefik#13005).
For something whose job is security, prefer a local plugin or a baked image.
Three options, and they compose:
# 1. EntryPoint-wide — every router, automatically. Static config, needs a restart to change.
entryPoints:
websecure:
http:
middlewares:
- scanguard@file
# 2. Per-router — gradual rollout, per-route tuning. Easy to forget a router.
labels:
- "traefik.http.routers.myapp.middlewares=scanguard@file"
# 3. Catch-all — the ONLY way to see probes aimed at hostnames you do not serve.
# Traefik answers those 404 from the router itself; no middleware ever runs.
http:
routers:
catchall:
rule: HostRegexp(`^.+$`)
priority: 1
service: your-dummy-service
middlewares: [scanguard]http:
routers:
scanguard-console:
rule: PathPrefix(`/`)
entryPoints: [admin] # bind this entrypoint to 127.0.0.1 in production
service: your-dummy-service # never reached; the middleware terminates
middlewares: [scanguard-ui, your-basic-auth]
middlewares:
scanguard-ui:
plugin:
scanguard:
instanceName: default # must match your detector instance
uiMode: true
admin:
enabled: true
token: "a-long-random-secret"A uiMode instance is a view onto the shared runtime, not a second
configuration of it — it contributes only the admin surface and cannot overwrite
your detectors.
If Traefik sits behind Cloudflare, a load balancer, or a Kubernetes Service,
RemoteAddr is the proxy, not the client. Ban it and every legitimate user is
locked out at once. Both halves of the configuration are required:
# 1. Traefik must be told to keep the headers. Without this it DELETES every
# X-Forwarded-* header before any middleware runs, and scanguard silently
# bans nobody at all.
entryPoints:
websecure:
forwardedHeaders:
trustedIPs: ["173.245.48.0/20", "..."] # cloudflare.com/ips-v4
# 2. scanguard must be told which peers may be believed.
clientIP:
trustedProxies: ["173.245.48.0/20", "..."] # the same list
header: Cf-Connecting-IptrustedProxies has no default, on purpose. A configurable client-IP header
without a trusted-proxy check is not a hardening gap, it is a remote "ban anybody
you name" primitive — an attacker sets the header per request to evade their own
ban and to poison your list with third parties. scanguard refuses to start if you
set clientIP.header without clientIP.trustedProxies, never bans an address
inside trustedProxies, and refuses to attribute a request it cannot identify.
If your load balancer speaks PROXY protocol, use it instead — it actually
rewrites RemoteAddr and the whole problem disappears.
Roll out with dryRun: true first. It runs every detector, logs and displays
everything it would have banned, and blocks nothing.
Seeing response codes means wrapping http.ResponseWriter. A wrapper that drops
http.Flusher and http.Hijacker silently breaks streaming on every route it
touches — traefik#10269 — and
it surfaces weeks later as "the chat stopped working", never as an error.
scanguard forwards Flush, Hijack, ReadFrom and Push, skips wrapping
protocol-upgrade requests entirely, and does not wrap at all when no
response-status detector is enabled. examples/streaming-check.sh is the release
gate, and it passes against Traefik v3.7.10. Re-run it after every Traefik
upgrade — and if you ever see a flusher-unavailable warning in the log, set
observeResponse: false on that route.
scanguard cannot touch nftables itself: os/exec does not exist under the Yaegi
interpreter, and Traefik has no privileges to do it with. Instead it emits a ban
event and you supply the helper — a small privileged container that consumes
these webhooks and applies nftables rules. No such helper ships here; what ships
is the payload contract below, verified end to end by examples/notify-check.sh
against a real receiver:
notify:
webhooks:
- url: http://scanguard-nft:8080/ban
events: [ban, unban]{ "source": "scanguard", "instance": "default",
"event": { "kind": "ban", "key": "203.0.113.5/32", "client": "203.0.113.5",
"detector": "signature", "rule": "/wp-login\\.php", "banFor": "1h",
"method": "GET", "path": "/wp-login.php", "time": "2026-08-16T10:00:00Z" } }Only ban and unban are delivered — per-request rejections are not, so a
blocked scanner hammering your site cannot turn your webhook endpoint into a
firehose. Delivery is bounded and fire-and-forget: a dead endpoint costs nothing
but a log line, and cannot stall a request or take Traefik down with it.
Full reference
enabled: true
instanceName: default # keys the shared state; instances sharing a name share bans
dryRun: false # detect and log, never block
observeResponse: true # wrap ResponseWriter; needed by badPaths and bruteForce
store:
backend: memory # memory | file | redis
path: /var/lib/scanguard/state.json
snapshotInterval: 30s
failOpen: true
maxEntries: 50000 # hard cap on tracked sources; LRU-evicted
redis:
address: redis:6379
password: ""
db: 0
keyPrefix: "scanguard:"
timeout: 2s
clientIP:
trustedProxies: [] # NO DEFAULT — see the warning above
header: "" # e.g. Cf-Connecting-Ip; only honoured from a trusted peer
ipv4Prefix: 32 # ban-key width
ipv6Prefix: 64 # /128 bans are useless; a host owns a whole /64
allowlist:
cidrs: [] # office egress, uptime monitors
userAgents: [] # verified crawlers
paths: [] # e.g. ^/health$
detectors:
signatures: { enabled: true, useDefaults: true, patterns: [], exclude: [] }
honeypots: { enabled: true, paths: [] }
userAgent: { enabled: true, useDefaults: true, patterns: [], banEmptyUA: false }
badPaths: { enabled: true, capacity: 10, leak: 10s, statuses: [400,401,403,404,405,501] }
bruteForce: { enabled: false, capacity: 5, window: 60s, statuses: [401,403], paths: [] }
rateAbuse: { enabled: false, rps: 50, burst: 100 }
payload: { enabled: false, useDefaults: true, scanQuery: true, scanBody: false, maxBodyBytes: 8192 }
enforcement:
rejectStatus: 403 # 404 hides the middleware's existence
rejectBody: ""
escalation: ["1h", "24h", "168h", "0"] # "0" means permanent
decay: 24h # behave this long and drop a rung
tarpit: { enabled: false, delay: 3s, maxConcurrent: 50 }
notify:
timeout: 5s
webhooks: [{ url: "", method: POST, headers: {}, events: [ban, unban] }]
chat: { kind: "", url: "", events: [] } # discord | slack | ntfy | gotify
abuseipdb: { enabled: false, apiKey: "", report: true, check: false, minConfidence: 75, cacheTTL: 24h }
geo: { enabled: false, provider: ip-api, cacheTTL: 168h } # console display only
admin:
enabled: false # off by default; no default token
pathPrefix: /__scanguard
token: "" # 16+ characters, compared in constant time
trustForwardedUser: false # honour X-Forwarded-User from Authelia/Authentik/oauth2-proxy
readOnly: false
eventLogSize: 500Locked out of the console? SCANGUARD_DISABLE=1 in Traefik's environment turns
scanguard into a pass-through at startup, no config edit required.
Running WordPress?
The default signatures include the wp-* paths, so your own admin traffic would
ban you. Either allowlist yourself, or exclude them:
detectors:
signatures:
exclude: ['^/wp-admin/', '^/wp-login\.php$', '^/wp-includes/', '^/wp-content/']go test ./... # unit tests, ~0.5s
(cd tools/yaegi-smoke && go run .) # run the plugin under the real interpreter
cd examples && docker compose up -d
./scanner-sim.sh # 20 end-to-end checks against live Traefik
./streaming-check.sh # the WebSocket + SSE release gate
./rules-check.sh # the rule editor, including restart survival
./notify-check.sh # webhooks and chat, delivered for realtools/yaegi-smoke is the most important thing in this repository. Traefik
does not compile plugins, it interprets them with
Yaegi, which is pinned at v0.16.1 and exposes
only the Go 1.22 standard library. Two whole classes of bug are invisible to
go build and go test:
- Using a newer stdlib API (
slices.Sort,sync.OnceFunc,atomic.Pointer,min/max,os/exec). Compiles locally, fails at plugin load. //go:embed, which does not work under Yaegi and fails silently — the variable is empty, the tests pass, and the page is blank only inside Traefik. That is why the console is a string constant, and why the smoke test asserts the page is non-empty.
Two more interpreter traps this project hit, both invisible to go test and both
now asserted by the smoke test:
- Encoding a response as
map[string]interface{}with structs as values produced{"effective":{}}under Yaegi — the nested structs lost every field, with no encoding error. The console read that empty object, wrote it back, and wiped the ruleset. Every admin response now has a named struct type; see the note onRulesResponse. - mapstructure merges into a pre-populated slice instead of replacing it.
A default of
[400,401,403,404,405,501]plus a configured[404]yields[404,401,403,404,405,501]. List defaults therefore live indefaultBadPathStatusesand friends, applied after decoding, never inCreateConfig.
go.mod declares go 1.22 so your local toolchain refuses the newer APIs for
you. Keep the module dependency-free: anything using cgo, assembly, //go:embed
or heavy reflection fails at plugin import.
Measured on Traefik v3.7.10 with the example configuration, wrk -t4 -c50 inside
the compose network:
| req/s | avg latency | |
|---|---|---|
| Baseline route, no middleware | 8790 | 5.7 ms |
| Through scanguard | 2760 | 18.9 ms |
So roughly a third of the raw proxy throughput — which is the price of an interpreted plugin, and still five to ten times a "hundreds of requests per second" workload. Traefik's memory was 75 MB with several thousand tracked sources.
Where the time goes, from go test -bench .:
| cost | note | |
|---|---|---|
| Already-banned request | 881 ns, 4 allocs | resolve, one map lookup, write a status |
| Client-IP resolution | 208 ns, 0 allocs | with four trusted-proxy CIDRs |
| Signature match (48 patterns) | 3.2 µs | short subject, so cheap |
| User-agent match (22 patterns) | 25 µs | the dominant cost |
| Clean request, end to end | 52 µs, 3 allocs | almost entirely the two matches |
An alternation with no literal prefix must attempt a match at every position, so
cost scales with subject length — which is why matching a 90-character
user-agent costs 8× more than matching a path despite having half the patterns.
A literal pre-filter measures 3.2× faster interpreted, and is deliberately not
implemented; see the note in matcher.go for why a wrong pre-filter fails
silently in exactly the direction you cannot afford.
If you need the throughput back: detectors.userAgent.enabled: false removes
most of the cost, at the price of catching self-identifying scanners only via
their request paths.
- No Traefik dashboard tab. Not now, not later. See above.
- Single Traefik instance by default. State is process-global, so N replicas means N independent ban lists, thresholds effectively multiplied by N, and an Unblock that applies to one replica. It degrades silently. Use the Redis backend for HA — bans are shared and propagate to other replicas within one janitor cycle (~30s, measured), the way CrowdSec's stream mode works. Per-source counters stay local by design: shipping a counter increment to Redis on every request would put a network round trip on the hot path to share state nobody needs. A Redis outage therefore degrades to per-replica enforcement rather than to an outage of its own.
- Plugins load only at startup. Installing or upgrading scanguard needs a Traefik restart. The console cannot do it.
- Response-status detectors act one request late. The decision for request N can only be enforced from N+1. That is inherent to observing responses.
- Yaegi plugins have broken on Traefik patch releases. Pin your Traefik version and re-run the harness after upgrades.
- Interpreted code is unprofilable — every frame shows as
yaegi/interp.call.func9in pprof. The hot path is kept to a map lookup and one pre-compiled alternation regexp for that reason.
MIT. Default rulesets were written for this project; the design owes a debt to tomMoulard/fail2ban (MIT), CrowdSec (MIT) — whose distinct-path bucket model is the single best idea here — and maxlerebourg/crowdsec-bouncer-traefik-plugin (Apache-2.0), which proved most of these mechanisms work under Yaegi at all.