Skip to content

Anhangá

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"

Contents


The name

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.


How it works

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.


Quick start

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

Install

Prerequisites

  • Go 1.23+
  • Docker (for building the image)
  • make, curl, git

Set up the toolchain

make install

Downloads 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.

Build

make build-local      # host platform, into ./bin
make build            # static linux/amd64, into ./bin
make docker-build     # container image

Configure

Rules live in rules.yaml at the repo root. A fully annotated example ships in the repo; testdata/valid/full.yaml exercises every feature.

Schema

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 methods

description 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.

Match blocks

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.

match_any vs match_all

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.

Allow carve-outs and why order matters

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.


The check command

make check

This 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 — which netip silently rewrites to 10.0.0.0/8, hiding a real mistake. check insists on the canonical form and tells you what it is.
  • Rule set names containing commas, which would break ?rules= parsing.
  • match_all with 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:

  1. make check — locally, before you commit.
  2. CI — the check-rules job. Everything else needs: it.
  3. docker build — the verify stage. The final image stage copies FROM verify, which makes it a hard dependency: a bad rules file cannot produce an image.
  4. After push — the release workflow pulls the image back down and re-runs check against it, closing the loop against the artifact that will actually run.

Lint and test

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        # benchmarks

make dev runs tidy + lint + test + check. make ci runs what CI runs.

Why -race specifically

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.

The test suite

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.


Deploy

docker stack deploy -c deploy/stack.yml anhanga

See 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).

Alternative: rules as a Swarm config

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.


Traefik wiring

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"

Why not chain one middleware per rule set?

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.

Trust your forwarded headers

⚠️ Get this wrong and every client_ip rule 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 Traefik

If 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.


Releasing

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 release

make release:

  1. Runs make check — validates rules.yaml. If it fails, nothing is tagged.
  2. Bumps version.txt to today's CalVer (YYYYMMDD.N; same day increments N, a new day resets to 1).
  3. Folds the news fragments into CHANGELOG.md (towncrier).
  4. Commits, tags v<version> (annotated), and pushes main + the tag — which triggers release.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.

What the release pipeline does

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_anhanga

Operational notes

Fail-closed behaviour

If 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.

Hot reload

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

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

Metrics

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

Logs

Structured JSON via log/slog. Every deny logs the rule set, the reason, the path, the IP, and the UA.


Performance

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 over net.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.


Layout

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

Contributing

See CONTRIBUTING.md. The short version:

make dev              # tidy, lint, test, check — run this before opening a PR

Two 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 for testdata/valid/.
  • Every user-visible change needs a news fragment in news/, e.g. news/+block-scraper.bugfix. towncrier folds them into CHANGELOG.md at release time.

Participation is governed by the Code of Conduct.


Security

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:


License

GNU General Public License v2.0 — see LICENSE.

About

Anhangá is a lightweight Traefik ForwardAuth middleware service designed for Docker Swarm environments

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages