Guardian of the forest. It neither devours nor kills — it turns back those who should not be there.
A Traefik ForwardAuth service that filters requests by path, user agent, client IP, and method, using named rule sets loaded from a YAML file. Built for Docker Swarm.
Rules live in rules.yaml. Each rule set has a name. Traefik middlewares reference rule sets by name, so one service can apply waf, another can apply waf,staging, and a third can apply nothing at all.
# rules.yaml
version: 1
rulesets:
waf:
description: "Block probes for secrets and VCS metadata"
match:
path_prefix: [/.env, /.git]
staging:
description: "Keep crawlers out of non-production sites"
match:
user_agent_contains: [googlebot, bingbot]# Traefik dynamic config
http:
middlewares:
deny-staging:
forwardAuth:
address: "http://anhanga:8080/check?rules=waf,staging"# Swarm service labels
- "traefik.http.routers.myapp.middlewares=deny-staging@file"- The name
- How it works
- Quick start
- Install
- Configure
- The
checkcommand - Lint and test
- Deploy
- Traefik wiring
- Releasing
- Operational notes
- Performance
- Contributing
- Security
- License
Anhangá is a spirit from Tupi-Guarani cosmology — in the original belief, the protector of the forest and its animals. The name comes from anho ("alone") + anga ("spirit").
What matters here is how it protects. Anhangá is not a predator. In Antônio Houaiss's description it is "a genie of the forest and protector of the fauna and flora in Tupi mythology," one who "neither devours nor kills" but instead "avenges animals victimized by insatiable hunters." It guards what is most vulnerable — nursing animals, pregnant does — and it turns hunters back with fever and confusion rather than force. It patrols as a white deer with fiery eyes: watching, and visible only when you are somewhere you should not be.
That is the shape of this service. It sits in front of things worth protecting, it is invisible to every request that has honest business there, and it stops the ones that do not. It does not retaliate, rate-limit, tarpit, or fight back. It answers 204 or it answers 403.
Jesuit missionaries later inverted the figure — recast the guardian as a demon, and José de Anchieta coined Anhangupiara, "enemy of the anhangás," as the angel opposing it. Gonçalves Dias inherited that reading and wrote Anhangá as a "cruel and merciless entity, allied with the colonizers." The demon is the colonizer's edit. The name here is used in its older and truer sense: the one who guards.
Traefik's forwardAuth middleware calls an HTTP endpoint before proxying a request. A 2xx response permits the request; anything else rejects it, and the status is forwarded to the client.
anhanga is that endpoint. Traefik sends it the original request's details as headers:
| Header | Contents |
|---|---|
X-Forwarded-Uri |
the original path and query string |
X-Forwarded-Method |
the original method |
X-Forwarded-For |
the client IP chain |
User-Agent |
passed through unchanged |
anhanga evaluates them against the rule sets named in the ?rules= query parameter, and returns 204 (allow) or the configured deny status.
Everything expensive — regex compilation, CIDR set construction, case folding — happens once at load time. The request path does no allocation.
git clone https://github.com/simplesconsultoria/anhanga
cd anhanga
make install # deps + golangci-lint
make dev # tidy, lint, test, validate rules.yaml
make run # serve on :8080 against ./rules.yaml
make probe # (in another shell) send sample requests- Go 1.23+
- Docker (for building the image)
make,curl,git
make installDownloads Go module dependencies and installs a pinned golangci-lint into $(go env GOPATH)/bin. Make sure that directory is on your PATH.
The linter version is pinned deliberately. An unpinned linter is a linter that fails your build one morning because upstream added a check overnight.
make build-local # host platform, into ./bin
make build # static linux/amd64, into ./bin
make docker-build # container imageRules live in rules.yaml at the repo root. A fully annotated example ships in the repo; testdata/valid/full.yaml exercises every feature.
version: 1 # required, must be 1
defaults:
deny_status: 403 # optional, default 403
rulesets:
<name>: # lowercase, [a-z0-9_-], no commas
description: "..." # REQUIRED
action: deny # deny (default) | allow
deny_status: 403 # optional, overrides defaults
match_all: false # optional, see below
match:
path_prefix: [] # literal prefixes
path: [] # RE2 regexes
user_agent_contains: [] # case-insensitive substrings
user_agent_regex: [] # RE2 regexes
client_ip: [] # IPs and CIDRs, v4 and v6
method: [] # HTTP methodsdescription is required on purpose. A deny rule with no stated reason is a deny rule nobody will dare to delete in two years, and you will end up with an accreting pile of rules nobody understands.
Unknown keys are a hard error. A typo like user_agent_contain (missing the trailing s) would otherwise parse cleanly into a rule set that matches nothing — a rule that looks active but silently does nothing. check catches it.
| Block | Semantics | Notes |
|---|---|---|
path_prefix |
literal strings.HasPrefix |
Prefer this. Fast, and hard to get wrong. |
path |
RE2 regex | Not auto-anchored. Write ^/admin, not /admin. |
user_agent_contains |
case-insensitive substring | Prefer this over the regex form. |
user_agent_regex |
RE2 regex | Use (?i) for case-insensitivity. |
client_ip |
IP or CIDR | IPv4 and IPv6. Bare addresses become /32 or /128. |
method |
HTTP method | Case-insensitive. Validated against a known list. |
Paths are matched with the query string stripped. A rule for /.env catches /.env?x=1, and a request to /search?q=/.env is not caught.
Within a block, entries are OR-ed: a request matches path_prefix if it matches any prefix in the list.
By default (match_all: false), a rule set matches if any populated block matches:
mixed:
description: "Block /admin, and separately block badbot everywhere"
match:
path_prefix: [/admin]
user_agent_contains: [badbot]That denies anything under /admin or anything from badbot.
With match_all: true, every populated block must match:
admin-crawlers:
description: "Bots are blocked on /admin, but allowed elsewhere"
match_all: true
match:
path_prefix: [/admin]
user_agent_contains: [bot, crawler]That denies bots only on /admin. Humans on /admin get through; bots elsewhere get through.
Empty blocks are ignored in both modes — they can neither cause nor prevent a match.
A rule set with action: allow short-circuits evaluation: if it matches, the request is permitted and no further rule sets are consulted.
internal:
description: "Internal networks bypass everything after this"
action: allow
match:
client_ip: [10.0.0.0/8]Rule sets are evaluated in the order they appear in ?rules=, not the order they appear in the file. First match wins.
?rules=internal,waf internal IP hitting /.env -> ALLOWED (carve-out fires first)
?rules=waf,internal internal IP hitting /.env -> DENIED (waf fires first)
Put allow rules first. This is the single easiest thing to get wrong. There is a test pinning the behaviour in both directions (TestEvaluate_FirstMatchWins) precisely because it is a footgun.
make checkThis is the release gate. It validates rules.yaml and exits non-zero if anything is wrong.
✓ rules.yaml is valid
7 rule set(s):
admin-crawlers deny match_all path_prefix:1 ua_contains:2
banned-ips deny match_any ip:3
internal allow match_any ip:3
no-trace deny match_any method:2
scrapers deny match_any ua_contains:1 ua_re:1
staging deny match_any ua_contains:6
waf deny match_any path_prefix:5 path_re:3
Reference these from Traefik as:
forwardAuth.address: http://anhanga:8080/check?rules=banned-ips,...
It catches, among other things:
- Unknown keys. A typo that would silently disable a rule.
- Invalid regexes.
- Regexes that match everything.
.*matches the empty string, so on a deny rule it takes the site down. Rejected. - Empty rule sets. No conditions means every request matches. On a deny rule, that is an outage. Rejected.
- Invalid CIDRs, including
10.1.2.3/8— whichnetipsilently rewrites to10.0.0.0/8, hiding a real mistake.checkinsists on the canonical form and tells you what it is. - Rule set names containing commas, which would break
?rules=parsing. match_allwith only one populated block, which does nothing and means the author has misunderstood something.- Missing descriptions.
Every problem is reported, not just the first, so one run tells you everything to fix. Output is sorted, so it does not reorder between runs.
Where it runs:
make check— locally, before you commit.- CI — the
check-rulesjob. Everything elseneeds:it. docker build— theverifystage. The final image stage copiesFROM verify, which makes it a hard dependency: a bad rules file cannot produce an image.- After push — the release workflow pulls the image back down and re-runs
checkagainst it, closing the loop against the artifact that will actually run.
make lint # golangci-lint
make vet # go vet
make test # go test ./...
make test-race # go test -race ./... <- what CI runs
make cover # coverage report -> coverage.html
make bench # benchmarksmake dev runs tidy + lint + test + check. make ci runs what CI runs.
The hot-reload path swaps an atomic.Pointer[matcher.Set] while requests are reading it. That is exactly the kind of code where a data race stays invisible until it is a production incident. CI runs -race; so should you.
| File | Covers |
|---|---|
internal/config/load_test.go |
Every validation rule, error collection, determinism |
internal/config/golden_test.go |
Walks testdata/ — valid files must parse and compile, invalid files must be rejected |
internal/matcher/matcher_test.go |
Matching semantics, match_all, short-circuit ordering, regex alternation isolation |
internal/server/server_test.go |
ForwardAuth contract, header parsing, fail-closed behaviour, hot reload |
The golden tests are the ones that scale. Adding a new failure mode to the validator means dropping a file in testdata/invalid/ — no test code to write. And a file that is supposed to be broken but quietly starts validating is exactly the regression that would otherwise ship a hole to production.
docker stack deploy -c deploy/stack.yml anhangaSee deploy/stack.yml. The points that matter:
Two replicas minimum. This service sits in the critical path of every request through the middleware. If it is unreachable, Traefik returns 500 to the client — the site goes down, not just the filter. One replica means one restart is an outage.
order: start-first. With stop-first there is a window where zero replicas are serving, and in that window every request through the middleware 500s.
Rules are baked into the image. No volume, no Swarm config. The image tag is the rules version, and docker service update --image ...:v1.0.1 is the entire deploy. This is why the whole pipeline is built around rebuilding on every rules change.
The image is FROM scratch, runs as UID 65534, and contains exactly one file plus the rules. It has no shell — the HEALTHCHECK works because the binary can probe itself (anhanga healthcheck).
If you would rather not rebuild the image, mount rules.yaml as a Swarm config and let the binary hot-reload it. It watches the file (via the containing directory, so it survives the rename-over-file writes that container runtimes do) and rejects an invalid reload while continuing to serve the previous rules.
But note: Swarm configs are immutable. "Updating" one means creating a new one and updating the service to point at it — the same ceremony as bumping an image tag, with less traceability. The commented-out blocks in stack.yml show how, if you want it.
See deploy/traefik-dynamic.yml.
Define one middleware per rule combination:
http:
middlewares:
deny-standard:
forwardAuth:
address: "http://anhanga:8080/check?rules=internal,banned-ips,waf,no-trace"
deny-staging:
forwardAuth:
address: "http://anhanga:8080/check?rules=internal,banned-ips,waf,staging,no-trace"Attach per service:
deploy:
labels:
- "traefik.http.routers.myapp.middlewares=deny-standard@file"You can:
- "traefik.http.routers.myapp.middlewares=deny-waf@file,deny-staging@file"That avoids declaring a middleware per combination. But each is a separate HTTP round trip, and more importantly it changes the semantics: each middleware evaluates independently, so an action: allow carve-out in one cannot short-circuit the deny rules in another. The internal bypass only works if internal and the deny sets are evaluated together, in one call, in the right order.
Use the combined form. Chain only when you have no allow rules and you actively want independent evaluation.
⚠️ Get this wrong and everyclient_iprule in your config becomes decorative.
anhanga reads the client address from X-Forwarded-For. You must configure Traefik's forwardedHeaders.trustedIPs, or a client can simply set the header themselves and your client_ip rules become decorative.
# Traefik STATIC config
entryPoints:
websecure:
address: ":443"
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8" # the proxies IN FRONT of TraefikIf Traefik is directly internet-facing, omit this entirely — Traefik will then ignore any client-supplied X-Forwarded-For and use the real peer address, which is what you want.
Never set forwardedHeaders.insecure: true. That is precisely the hole described above.
You release on every rules change. One command:
vim rules.yaml
# describe the change as a news fragment (see news/), e.g.:
echo "Block new scraper UA." > news/+block-scraper.bugfix
make releasemake release:
- Runs
make check— validatesrules.yaml. If it fails, nothing is tagged. - Bumps
version.txtto today's CalVer (YYYYMMDD.N; same day incrementsN, a new day resets to1). - Folds the news fragments into
CHANGELOG.md(towncrier). - Commits, tags
v<version>(annotated), and pushesmain+ the tag — which triggersrelease.yml.
It refuses to run on a dirty tree or off main, so a release never sweeps up unrelated changes.
Versions are CalVer (YYYYMMDD.N). version.txt is the single source of truth: the git tag carries a leading v (v20260712.1), while the image tag does not (20260712.1), matching version.txt and make docker-build.
tag v*
└─> gate: reuse ci.yml wholesale
├─ check-rules validate rules.yaml
│ validate every testdata/valid/*.yaml
│ assert every testdata/invalid/*.yaml is REJECTED
├─ lint golangci-lint, go.mod tidy
├─ test go test -race
└─ docker-build build image, run `check` inside it,
smoke-test the running container
└─> release (needs: gate)
├─ build multi-arch (amd64 + arm64), push to GHCR
│ tagged <CalVer>, latest, sha-<commit>
└─ pull the pushed image back, re-run `check` against it
The gate reuses ci.yml rather than reimplementing the checks. A release that runs a drifted copy of the CI checks is a release that can ship something CI would have caught.
The pipeline stops at a published image. Deploying is deliberately not this repository's job — how the new tag reaches your swarm belongs wherever your stack definitions live. Rolling it by hand is one command:
docker service update \
--image ghcr.io/simplesconsultoria/anhanga:20260712.1 \
anhanga_anhangaIf a Traefik middleware references a rule set that does not exist in rules.yaml — the drift case, where someone deletes a rule set but leaves the middleware pointing at it — anhanga returns 500, not 204.
This is deliberate. Silently allowing the request would turn that middleware into a no-op and nobody would notice until an incident. The counter anhanga_unknown_ruleset_total should always be zero; alert on it.
Same for a ?rules= parameter that is missing entirely — a typo in a Traefik label must not silently disable the filter.
The binary watches its config file and reloads on change (also on SIGHUP). A reload that fails to parse, validate, or compile is rejected, and the previous rules keep serving. A bad edit cannot take the filter down or, worse, silently empty it.
The watcher watches the containing directory, not the file. Editors and container runtimes routinely replace a file by renaming a new one over it, which destroys the inode a file-level watch is bound to — after one such write, a file-level watch goes permanently deaf.
--verbose-deny puts the matched rule set and reason into the response body. Useful in staging. Leave it off in production, where it tells an attacker exactly which rule they tripped.
GET /metrics, Prometheus text format:
| Metric | Meaning |
|---|---|
anhanga_requests_total |
checks served |
anhanga_allowed_total |
allows |
anhanga_denied_total |
denies |
anhanga_unknown_ruleset_total |
should always be 0 — alert on this |
anhanga_reloads_total |
successful reloads |
anhanga_reload_failures_total |
rejected reloads; old rules still serving |
Structured JSON via log/slog. Every deny logs the rule set, the reason, the path, the IP, and the UA.
Evaluation is allocation-free on the hot path. make bench keeps that claim honest — watch the allocs/op column.
The design choices that get it there:
- Literal prefixes are checked before regexes. Most real rules are prefixes.
- All regexes in a block compile into one alternation. One evaluation, not N. Each pattern is wrapped in its own
(?:...)group so a top-level|inside one pattern cannot bleed into its neighbours. - The UA is lowercased once per request, not once per substring check.
- CIDRs go into a
netipx.IPSet— sorted ranges, binary search — not a linear scan overnet.IPNet. - The rule set is swapped via
atomic.Pointer. Readers never lock.
In practice the overlay network hop dominates. Do not put this in front of anything without measuring, but it is not going to be your bottleneck.
cmd/anhanga/ main: serve, check, healthcheck
internal/config/ YAML schema, parsing, validation
internal/matcher/ compiled rule sets, request evaluation
internal/server/ ForwardAuth HTTP handler
testdata/valid/ configs that MUST validate
testdata/invalid/ configs that MUST be rejected
deploy/ Swarm stack, Traefik dynamic config
news/ towncrier fragments, folded into CHANGELOG.md
.github/workflows/ ci, release
rules.yaml the rules
version.txt the single source of truth for the version
See CONTRIBUTING.md. The short version:
make dev # tidy, lint, test, check — run this before opening a PRTwo conventions worth knowing before you start:
- New validation rules need no test code. Drop a file in
testdata/invalid/and the golden test picks it up. Same fortestdata/valid/. - Every user-visible change needs a news fragment in
news/, e.g.news/+block-scraper.bugfix. towncrier folds them intoCHANGELOG.mdat release time.
Participation is governed by the Code of Conduct.
anhanga sits in the request path of everything behind it, so a flaw here is a flaw in the sites it protects. Please do not open a public issue for a vulnerability — see SECURITY.md for the private reporting channel.
Two things worth re-reading before you deploy, because both turn real protection into decoration:
- Trust your forwarded headers. Without
forwardedHeaders.trustedIPsconfigured correctly in Traefik, a client can forgeX-Forwarded-Forand everyclient_iprule becomes cosmetic. - Leave
--verbose-denyoff in production. It tells an attacker exactly which rule they tripped.
GNU General Public License v2.0 — see LICENSE.