Add README, SPEC and tool evaluation, closes #1

Initial documents: what smallwebwaf is and why, the proposed feature list, the design spec with the rule file format and the open design questions, and the survey of existing tools.

Model: fable-5-1
This commit is contained in:
2026-09-21 07:42:45 +00:00
commit a0d2c21346
3 changed files with 1112 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
# Evaluation: existing tools for an env-var-configured protective sidecar
Date: 2026-09-21. Method: web survey of project documentation, repositories and
release pages. Nothing was installed or run. Claims marked "unverified" were not
confirmed against primary documentation.
## What was asked for
One container that sits between traefik and one application container,
configured only by environment variables, that provides:
- R1: rate limits per minute, per hour and per day
- R2: netblocks that bypass rate limiting
- R3: alerts on attacks through webhook, Slack or ntfy
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
or netblocks, crossing a threshold
- R5: temporary blocks for abusers, permanent bans for repeat offenders
- R6: one or more RBLs or IP reputation APIs
- R7: AS number lookup
- R8: thresholds biased by AS number or country (for example, listed AS
numbers get 50 percent of the normal limit)
- R9: WAF-style attack detection and prevention
- R10: runs as a plain env-var-configured sidecar between traefik and one app
## Verdict
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
number or country) or the bytes half of R4 at all, and the tools that come
closest on the rest fail R10: they need several containers, YAML or a web UI.
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
directory begins that spec. It reuses the mature parts of the surveyed field as
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
reimplementing them.
Closest existing options, and why each still falls short:
- BunkerWeb is the closest single product.
- Meets: R1 (rates in requests per second, minute, hour or day), R2
(`LIMIT_IGNORE_IP`, also by AS number and reverse DNS), R3 partly (webhook,
Slack, Discord, Matrix plugins, fired only on denied requests; ntfy only
through the generic webhook, payload format unverified), R5 partly
(`BAD_BEHAVIOR_BAN_TIME`, `0` means permanent; no escalation for repeat
offenders, the ban length is one fixed value), R6 (DNSBL plugin, external
blacklist URLs, optional CrowdSec), R7 partly (AS number used for
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
Set, or Coraza plugin), env-var settings.
- Fails: R8 (AS number and country can only allow or deny, never scale a
limit), R4 (no volume or byte threshold alerts in the free edition;
reporting is a paid feature), R5 escalation, and R10 in spirit: since
1.6 it needs a `bunkerweb` container plus a `bw-scheduler` container and
a database, or the all-in-one image that bundles nginx, scheduler, UI and
Redis in one container. It is designed to be the front door for many
sites, not a per-app sidecar. AGPL-3.0.
- Unverified: whether several rates (minute, hour, day) can be stacked on
the same URL pattern; the documented design is one rate per URL pattern.
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
- Meets: R5 fully (profiles with `duration_expr`, for example four hours
times the number of previous decisions), R3 (notification plugins for
Slack, generic HTTP, email and others; ntfy through the HTTP plugin), R6
(community blocklist, further blocklists, reputation API), R7 (alerts are
enriched with AS number and country), R9 (AppSec component with virtual
patching and ModSecurity-syntax rules), R2 (allowlists).
- Partly: R1 and R8. Detection is by leaky-bucket scenarios over logs, and
a scenario can filter on AS number or country, so a stricter bucket for
listed AS numbers is possible, but each is a hand-written YAML scenario,
it reacts after the fact by banning, and it is not an inline limiter that
answers 429.
- Fails: R10 (needs the security engine container with persistent state,
log acquisition from traefik, a bouncer such as the traefik plugin, and
YAML for acquisition, profiles, scenarios and notifications), bytes half
of R4.
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
with no new code. It gives inline limiting (one window per middleware, keyed
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
reputation. It still fails R8, the bytes half of R4, R1's three stacked
windows only by chaining three middlewares per router, and R10 entirely: the
configuration lives in traefik labels and CrowdSec YAML on each host, not in
one sidecar's environment.
## Candidate by candidate
- CrowdSec with the traefik bouncer plugin
(`maxlerebourg/crowdsec-bouncer-traefik-plugin`)
- What it is: a traefik middleware plugin that asks a CrowdSec local API
whether the client IP is banned, and can forward each request to the
CrowdSec AppSec component for a WAF verdict. Modes: query per request,
cached, streamed decision list (recommended), standalone against the
central API, or AppSec only. Supports captcha remediation.
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
- Configuration: traefik static config to load the plugin, dynamic config
or labels for the middleware, CrowdSec YAML for everything else.
- Sidecar fit: no. It lives inside traefik, plus a separate engine
container.
- Maturity: widely used (about 900 stars, listed in the traefik plugin
catalog, documented by CrowdSec itself), actively maintained. Traefik
plugins run in an interpreter inside traefik, which costs some
per-request time.
- CrowdSec generic bouncers (nginx, Caddy, firewall)
- Same engine, different enforcement point. The firewall bouncer blocks at
nftables level on the host, which is cheap and covers every service on
the host at once; worth considering fleet-wide regardless of this
project. Not a sidecar, same misses as above.
- BunkerWeb 1.6.14 (`bunkerity/bunkerweb`)
- What it is: nginx with Lua plugins, ModSecurity and the Core Rule Set,
configured by settings that are passed as env vars to its scheduler
container. Documents running behind traefik with `USE_REAL_IP` and
`REAL_IP_FROM`.
- Coverage and misses: see Verdict. Also has bot challenges (cookie,
JavaScript, captcha), country allow and deny, Tor and user-agent lists.
- Sidecar fit: partial. Env vars yes; single small container no.
- Maturity: mature, frequent releases, commercial company behind it with a
paid edition; free plugins are AGPL-3.0.
- SafeLine (`chaitin/SafeLine`)
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
semantic detection engine and a web console. About 22,000 stars.
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
challenge, authentication gate.
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
messages, syslog forwarding and the better reputation database are paid
(Lite about 10 USD per month, Pro about 100 USD per month); the free
edition is capped at 10 applications.
- Configuration: web UI backed by PostgreSQL. No env-var configuration.
- Sidecar fit: no. Seven containers (postgres, management, detector,
tengine, and three helpers); the proxy container uses host networking.
The detection engine is closed source.
- Maturity: very active, vendor-driven.
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
- What it is: a Go library that implements the ModSecurity rule language
and runs the OWASP Core Rule Set. OWASP project, actively maintained, the
successor path now that ModSecurity is in maintenance only.
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule
Set, with env vars for backend address, engine mode and rule-set
tuning).
- Covers: R9 only. `coraza-crs-docker` fits R10 well: one container, env
vars, backend address.
- Misses: R1 to R8. ModSecurity-language rules can count requests per IP
in a persistent collection, but Coraza's support for persistent
collections is limited and this is not a practical rate limiter.
- Value here: the right library to embed for R9 in a purpose-built sidecar.
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
- What it is: official images of Apache or nginx with ModSecurity and the
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
paranoia level, anomaly thresholds, many more). Current images carry
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
- Covers: R9, and R10 (single container, env vars, one backend).
- Misses: R1 to R8.
- Maturity: rule set is very actively maintained; the ModSecurity engine
itself is in maintenance under OWASP after Trustwave ended support in
2024.
- Anubis (`TecharoHQ/anubis`), 1.27 current
- What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which is likely a large share of unwanted traffic on a public
gitea.
- Covers: R10 well (one container, env vars for listener, target,
difficulty, cookies). Policy rules can match on path, user agent,
headers, IP ranges, and with the vendor's hosted data service also AS
number and country, and can weigh a request toward a harder challenge.
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
vars. AS number and country matching depend on the vendor's hosted
service. Breaks non-browser clients unless paths are exempted; for
gitea, git-over-HTTP and API paths must be allowed through by rule.
- Maturity: very active, widely deployed on code forges since 2025.
- Value here: complementary. It can be chained (traefik, then the sidecar,
then Anubis, then the app) if challenge pages are wanted.
- go-away (`git.gammaspectra.live/git/go-away`)
- What it is: a single Go reverse proxy with a rule language over request
properties, several challenge types (including ones that need no
JavaScript), built-in DNSBL check (default `dnsbl.dronebl.org`), network
range lists, and metrics by network range, AS and user agent.
- Covers: R6 partly, R7 partly (through range lists rather than a lookup
database; unverified), bot filtering.
- Misses: R1, R3, R4, R5, R8, R9. YAML policy configuration. Small
maintainer base.
- fail2ban-style traefik plugins
- `tomMoulard/fail2ban`: watches request rate or response status per IP
inside traefik and bans for a fixed time. Covers a slice of R5 (fixed
temporary ban, no escalation, state lost on traefik restart) and R2
(allowlist). Nothing else.
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
plugins (country allow or deny through an external lookup API): same
shape, each one small feature, configured in traefik, state in memory.
- Sidecar fit: no; they live inside traefik.
- Traefik built-in middlewares
- `rateLimit` (one average-and-burst window per middleware, optional Redis
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts,
reputation or AS number awareness.
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
and per-path rate limiting with one configurable window, IP and DNS
blacklists, Tor exit list fetch, country and AS number allow or deny
from MaxMind databases.
- Misses: R8 (allow or deny only), R3, R4, R5 (no documented ban state or
alerting), R1's three windows. Caddyfile configuration. One maintainer,
pre-1.0, AGPL-3.0.
- open-appsec (Check Point)
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar,
with a declarative policy file or the vendor's cloud console. Covers R9
only; rate limiting and richer features are in paid tiers. Not a sidecar
in the required sense.
- iocaine
- Serves generated garbage pages to clients the fronting proxy classifies
as scrapers. Not a limiter, WAF or ban tool; out of scope except as a
curiosity for scraper traffic.
- Pangolin
- A tunnelled access platform that bundles traefik and optionally
CrowdSec. Replaces the ingress rather than adding a sidecar; out of
scope.
## Requirement by requirement, across the field
- R1 three windows: BunkerWeb (one rate per URL pattern; stacking unverified).
Everyone else offers one window per rule or none.
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
anomalies with a custom scenario; nothing handles bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
not both by history.
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
deny), Anubis (hosted service).
- R8 biased thresholds: none.
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
first).
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
None of these covers anything beyond its one job.
## Not checked
- No tool was installed, load-tested or run against real fleet traffic.
- Licence terms of reputation data sources for this use (AbuseIPDB free tier,
Spamhaus query policy, IPinfo Lite attribution) were not read in full.
- How upaas describes a second container for a deployed service was not
examined; no repo was opened for this task.
- Paid editions (BunkerWeb PRO, SafeLine Pro, CrowdSec paid blocklists) were
assessed from public feature lists only.
- The owner's Go dependency defaults file was not found at the documented path,
so library choices in `SPEC.md` are proposals to be checked against it.
## Sources
- https://docs.bunkerweb.io/latest/features/
- https://docs.bunkerweb.io/latest/integrations/
- https://github.com/bunkerity/bunkerweb-plugins
- https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
- https://docs.crowdsec.net/u/bouncers/traefik/
- https://docs.crowdsec.net/docs/next/local_api/profiles/format/
- https://docs.crowdsec.net/u/user_guides/waf_rp_howto/
- https://www.crowdsec.net/blog/waf-traefik-crowdsec
- https://github.com/chaitin/SafeLine
- https://dev.to/carrie_luo1/differences-between-safeline-waf-free-and-safeline-waf-pro-2dak
- https://github.com/coreruleset/coraza-crs-docker
- https://github.com/coreruleset/modsecurity-crs-docker
- https://github.com/TecharoHQ/anubis
- https://git.gammaspectra.live/git/go-away
- https://github.com/fabriziosalmi/caddy-waf
- https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
- https://github.com/juitde/traefik-plugin-fail2ban
- https://docs.openappsec.io/
+152
View File
@@ -0,0 +1,152 @@
# smallwebwaf
`smallwebwaf` is a simple, fast, logging web application firewall for people who
host their own services. It is one small container that sits between your
reverse proxy (traefik) and one application: traefik points at `smallwebwaf`,
and `smallwebwaf` points at the app. It is configured with environment
variables, keeps its state in memory, and writes a detailed JSON log line for
every request.
Status: design stage. This repository currently holds the documents only; no
code has been written. The design is in [`SPEC.md`](SPEC.md), and the survey of
existing tools that led to it is in [`EVALUATION.md`](EVALUATION.md).
## Why
Small self-hosted sites now receive a great deal of traffic nobody asked for:
scrapers that ignore `robots.txt` and crawl every commit of every repository on
a public git server, vulnerability scanners walking through lists of WordPress
and `.env` paths, and credential-guessing bots. Most of it comes from a small
number of hosting networks and countries. A single-person operation has no
abuse desk and no CDN contract; it needs something small that can be put in
front of one service and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but
cannot tell a residential visitor from a rented server farm. The products that
do most of it want several containers, a database and a web console. None of
them can say "clients from these networks get half the normal allowance", which
is the most useful thing to be able to say when nearly all abuse comes from a
known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the
candidates one by one.
`smallwebwaf` is meant to fill that gap:
- protect a service from misbehaving scrapers and scanners with per-client
request and byte limits over a minute, an hour and a day;
- bias those limits against the countries and AS numbers that abuse commonly
comes from, so their clients get a configured percentage of the normal
allowance rather than an outright block;
- remember abusers, block them for a while, and ban the ones who keep coming
back;
- log everything in a form that is easy to search and ship elsewhere;
- stay small enough to understand: one binary, one container, environment
variables, no database.
## Proposed features
- Reverse proxy for one upstream application, streaming in both directions,
with WebSocket support. One `smallwebwaf` per app.
- Real client address worked out from `X-Forwarded-For`, trusting only the
proxy networks you list. IPv6 clients are counted by /64.
- Rate limits per client on requests per minute, per hour and per day, and on
bytes per minute, per hour and per day.
- Netblocks that bypass rate limiting, netblocks that bypass everything, and
netblocks that are always refused.
- AS number and country lookup for every client from a local database file.
- Biased limits: listed AS numbers and countries get a percentage of every
limit, for example 50 percent for common abuse-source networks. Zero percent
refuses outright.
- Attack detection:
- a directory of plain text rule files, one regex per line, for catching
scanning and penetration probes; easy to edit by hand;
- the OWASP Core Rule Set, run by the Coraza engine, in detect-only or
blocking mode;
- trap paths and bursts of error responses.
- Offences add up to a temporary block. Block lengths grow for repeat offenders
(for example one hour, then a day, then a week) and end in a permanent ban.
- IP reputation: downloadable blocklists, DNS blocklists, AbuseIPDB, and an
optional feed of decisions from a CrowdSec engine. Lookups happen in the
background and never delay a request.
- Alerts on attacks and bans to a generic webhook, Slack or ntfy, with a
cooldown and an hourly cap so a wide attack cannot flood the channel.
- Anomaly alerts when requests or bytes per minute or hour cross a threshold,
for a single client, its surrounding netblock, an AS number, a named netblock
or the whole service.
- Observe mode: log and alert on every decision while refusing nothing, for the
first days in front of a new service.
- Request log: one JSON object per request on stdout with the usual web log
fields, the decision taken and why, AS number and country, and timings.
Optionally also sent to a remote syslog or RELP endpoint.
- Prometheus metrics on their own port.
- State (bans, offender history, hour and day counters, reputation cache) held
in memory and saved as readable, hand-editable JSON files, written atomically.
Nothing is read from disk while serving a request.
- A small admin endpoint for health checks, listing, adding and lifting bans,
and asking why a given address was refused.
Not planned: TLS termination, routing for several apps, browser challenges
(captcha or proof of work), a web console, or defence against floods large
enough to fill the host's network link.
## How it works, in short
For each request `smallwebwaf`:
- works out who the client really is;
- lets it straight through if it is on the bypass list, refuses it if it is on
the deny list or currently banned;
- looks up its AS number and country, and any cached reputation verdict;
- picks the client's limit percentage from those;
- checks the minute, hour and day request counters against the limits, and
answers 429 if one is exceeded;
- checks the request against the rule files and the Core Rule Set;
- forwards it to the app and streams the response back;
- counts the bytes, records any offence, bans the client if it has collected
enough of them, sends any alerts that are due, and writes the log line.
A minimal deployment beside an app in docker-compose:
```yaml
services:
app:
image: example/app
networks: [internal]
waf:
image: <registry>/smallwebwaf:<pinned digest>
environment:
UPSTREAM_URL: http://app:3000
TRUSTED_PROXIES: 172.18.0.0/16
MODE: observe
RATE_LIMIT_PER_MINUTE: "120"
RATE_LIMIT_PER_HOUR: "2000"
RATE_LIMIT_PER_DAY: "10000"
ASN_LIMIT_PERCENT: AS14061:50,AS16276:50
COUNTRY_LIMIT_PERCENT: CN:25
ALERT_NTFY_URL: https://ntfy.example.invalid/alerts
volumes: [waf-state:/data]
networks: [internal, traefik]
labels:
traefik.enable: "true"
traefik.http.routers.app.rule: Host(`app.example.invalid`)
traefik.http.services.app.loadbalancer.server.port: "8080"
```
A rule file is one rule per line: a name, what to match against, what to do,
and a regex.
```
env-file path offence:3 (?i)/\.env(\.[a-z]+)?$
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
```
[`SPEC.md`](SPEC.md) has the full design: every environment variable, the rule
file format, the state files, the log fields, the metrics, failure behaviour,
the build order, and the design questions still open for the owner.
## Documents
- [`SPEC.md`](SPEC.md): the design.
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
and misses, and why none was adopted.
+695
View File
@@ -0,0 +1,695 @@
# smallwebwaf SPEC (draft): protective reverse-proxy sidecar
Status: second draft, with the owner's rulings on storage, logging and metrics
applied. Nothing has been built yet. `EVALUATION.md` beside this file explains why no existing tool was
chosen. Points that turn on an owner decision are collected under "Questions for
the owner" and are marked "(open)" where they appear.
## Purpose
One small container that sits between traefik and one application container. The
traefik router for the public hostname points at the sidecar; the sidecar
forwards to the application. The sidecar limits request and byte rates per
client, detects attacks, blocks abusers for a time and bans repeat offenders,
consults IP reputation sources, looks up the AS number and country of each
client, scales its limits for listed AS numbers and countries, and sends alerts.
Everything is configured by environment variables, apart from the attack
detection rules, which are read from a directory of hand-editable text files.
## Non-goals
- TLS termination, certificates, hostname routing: traefik's job.
- More than one upstream application per sidecar. Run one sidecar per app.
- Browser challenges (captcha, proof of work). If wanted, chain Anubis between
the sidecar and the app.
- Defence against traffic floods that saturate the host's network link. That
needs help upstream of the host.
- A web UI or a configuration file. Settings are environment variables; the
only files read are the rule files, which hold one regex per line and nothing
more elaborate.
- Sharing ban state between sidecars in the first version (open, see
Questions).
## Architecture
- One statically linked Go binary in one container, running as a non-root user,
read-only root filesystem, one writable volume for state.
- Three listeners, of which only the first is routed by traefik:
- the proxy listener;
- a metrics listener serving Prometheus metrics, which can be switched off;
- an admin listener for health and ban management.
- Components inside the process:
- Client identification: works out the real client IP from
`X-Forwarded-For`, trusting only configured proxy netblocks.
- Lookup: AS number and country from local database files, held in memory.
- Reputation: static blocklists fetched on a schedule; DNSBL and reputation
API queries made in the background and cached.
- Counters: per-client request and byte counts per minute, hour and day,
held in memory; the hour and day counts are written out so they survive a
restart.
- Attack detection: regex rules read at start from a directory of plain
text rule files (see "Rule files"), Coraza with the OWASP Core Rule Set,
and simple signals (requests for listed trap paths, bursts of error
responses from the app).
- Ban ledger: offences, active bans and ban history, held in memory and
written to disk as JSON (see "Persistent state"). No database of any kind
is used.
- Request log: one JSON object per request on stdout, optionally also sent
to a remote syslog or RELP endpoint (see "Request log").
- Metrics: Prometheus counters and gauges on their own listener (see
"Metrics endpoint").
- Alerting: a queue with de-duplication feeding webhook, Slack and ntfy
senders.
- Proxy: the standard library's `net/http/httputil.ReverseProxy`, streaming
in both directions, with WebSocket upgrade support.
- Proposed libraries (to be checked against the owner's Go dependency defaults
before any are added):
- standard library for the proxy, HTTP clients, DNS, logging (`log/slog`),
state files (`encoding/json`, `os.Rename`);
- `github.com/corazawaf/coraza/v3` and
`github.com/corazawaf/coraza-coreruleset` for attack detection;
- `github.com/oschwald/maxminddb-golang` to read `.mmdb` lookup databases;
- `github.com/prometheus/client_golang` for metrics;
- remote log sending: the standard library's `log/syslog` is frozen and
does not write the current syslog format (RFC 5424), and no widely used Go
RELP client was found, so the library for this is open (see Questions).
## Data flow for one request
Steps run in this order; the first step that produces a final answer ends
processing.
- Identify the client.
- If the TCP peer is inside `TRUSTED_PROXIES`, walk `X-Forwarded-For` from
the right and take the first address not inside `TRUSTED_PROXIES`.
Otherwise use the TCP peer address and ignore the header.
- IPv6 clients are grouped by prefix (`IPV6_GROUP_PREFIX`, default 64) for
counting and banning, because one abuser usually controls a whole /64.
- Static lists.
- In `ALLOW_NETS`: skip every check below and forward. Still counted for
anomaly alerts.
- In `DENY_NETS`: refuse.
- Ban ledger. An active ban: refuse (`BAN_RESPONSE`).
- Lookup AS number and country. Unknown is a valid result and changes nothing.
- Reputation.
- Address inside a fetched blocklist: apply `BLOCKLIST_ACTION`.
- Cached DNSBL or reputation API result: apply `REPUTATION_ACTION`.
- No cached result: queue a background query and carry on. A first request
is never delayed by a reputation query.
- Work out the client's limit percentage: the lowest of the percentages that
apply (AS number, country, reputation). 100 if none applies. 0 means refuse.
(Lowest-wins versus multiplying is open.)
- Request rate limits. Unless the client is in `RATE_LIMIT_EXEMPT_NETS`, check
the minute, hour and day counters against each configured limit times the
percentage. Over any limit: answer 429 with `Retry-After`, record an offence.
- Rule files. The request is checked against the rules loaded from
`RULES_DIR`, in file name order then line order. Each rule that matches takes
its action: record offences, refuse with 403, ban at once, or only log.
Matching stops at the first rule that refuses or bans.
- Core Rule Set inspection of the request (headers, URL, and body up to
`WAF_BODY_LIMIT`). In `block` mode a match at or over the anomaly threshold is
refused with 403 and recorded as an offence; in `detect` mode it is only
logged and alerted.
- Forward to `UPSTREAM_URL`, streaming. Add `X-Forwarded-For` and, if enabled,
`X-Client-ASN` and `X-Client-Country` for the app's own logs.
- After the response.
- Add response bytes (and request body bytes) to the byte counters. Once a
client's byte total passes a byte limit times the percentage, following
requests get 429 until the window moves on, and an offence is recorded. A
response already in progress is not cut off.
- Count 401, 403 and 404 responses from the app per client; passing
`ERROR_BURST_THRESHOLD` within a minute records an offence.
- Update anomaly counters and evaluate alert thresholds.
- Offences and bans.
- `BAN_AFTER_OFFENCES` offences within `OFFENCE_WINDOW` creates a ban.
- Ban length follows `BAN_DURATIONS` by the number of earlier bans for that
client within `BAN_HISTORY_WINDOW`: first ban the first value, second ban
the second value, and so on.
- After `PERMANENT_BAN_AFTER` bans within `BAN_HISTORY_WINDOW`, the ban has
no expiry.
- A request for a path in `TRAP_PATHS` counts as `TRAP_OFFENCE_WEIGHT`
offences at once.
## Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client,
with the previous bucket weighted by how much of it still overlaps the
sliding window. This costs a few integers per client per window and avoids
the burst at bucket boundaries that a single fixed bucket allows.
- Counters are read and updated in memory only. The hour and day counters are
written to disk on a timer and at shutdown and loaded again at start (see
"Persistent state"), so a restart does not hand every client a fresh daily
allowance. Minute counters are not written; a restart forgives at most one
minute of counting. Buckets whose time has passed are discarded on load.
- Memory is bounded by `MAX_TRACKED_CLIENTS`; when full, the least recently seen
clients with no recent offences are dropped first.
## Configuration surface
Conventions: lists are comma separated; netblocks are CIDR (a bare address means
/32 or /128); durations use Go syntax plus `d` for days (`90s`, `15m`, `24h`,
`7d`); byte sizes accept `K`, `M`, `G` suffixes; an unset limit means that
limit is off. Every variable may instead be given as `NAME_FILE` pointing at a
file holding the value, for secrets and long lists. Invalid configuration stops
the process at start with a message naming the variable. At start the effective
configuration is logged with secrets masked.
- Core
- `UPSTREAM_URL` (required): the application, for example
`http://gitea:3000`.
- `LISTEN_ADDR` (default `:8080`): proxy listener.
- `ADMIN_LISTEN_ADDR` (default `127.0.0.1:9090`): admin listener.
- `ADMIN_TOKEN`: bearer token required for ban management endpoints.
- `INSTANCE_NAME`: included in every log line, metric and alert, for example
`fsn1app1/gitea`.
- `MODE` (default `enforce`): `enforce`, or `observe` to log and alert on
every decision while refusing nothing. Meant for the first days of a
rollout.
- `TRUSTED_PROXIES` (required): netblocks whose `X-Forwarded-For` is
believed, normally the docker network traefik reaches the sidecar from.
- `IPV6_GROUP_PREFIX` (default `64`).
- `MAX_TRACKED_CLIENTS` (default `500000`).
- Persistent state
- `STATE_DIR` (default `/data`): the JSON state files and downloaded lookup
databases.
- `STATE_WRITE_DELAY` (default `2s`): after a ban or offence changes, wait
this long before writing, so a burst of changes becomes one write.
- `STATE_COUNTER_INTERVAL` (default `60s`): how often the hour and day
counters and the reputation cache are written.
- Logging
- The request log on stdout is always on and has no switch.
- `LOG_LEVEL` (default `info`): for the process's own messages (start-up,
fetch failures, state writes), which are JSON lines on stdout too, marked
`"type":"process"`. It does not filter the request log.
- `LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`):
extra request headers to record. `Authorization`, `Cookie` and
`Set-Cookie` values are never logged, only whether they were present.
- `LOG_REMOTE_URL`: when set, every log line is also sent to this endpoint.
Forms: `syslog+udp://host:514`, `syslog+tcp://host:514`,
`syslog+tls://host:6514`, `relp://host:2514`, `relp+tls://host:2514`.
- `LOG_REMOTE_TLS_CA_FILE`: optional CA certificate for the `+tls` forms.
- `LOG_REMOTE_BUFFER` (default `10000`): lines held in memory while the
endpoint is unreachable; when full the oldest are dropped and counted.
- `LOG_REMOTE_FACILITY` (default `local0`), `LOG_REMOTE_APP_NAME` (default
`INSTANCE_NAME`): syslog header fields.
- Metrics
- `METRICS_ENABLED` (default `true`).
- `METRICS_LISTEN_ADDR` (default `:9100`): its own listener, so it can be
bound to a monitoring network without exposing the admin endpoints.
- `METRICS_PATH` (default `/metrics`).
- `METRICS_TOP_N` (default `50`): how many AS numbers and countries get
their own series; the rest are summed as `other`.
- `METRICS_TOKEN`: optional bearer token; unset means no authentication,
which is the usual arrangement for a scraper on a private network.
- Static lists
- `ALLOW_NETS`: bypass everything (monitoring, the owner's own networks).
- `RATE_LIMIT_EXEMPT_NETS`: bypass request and byte limits only; attack
detection and bans still apply.
- `DENY_NETS`: always refused.
- Request rate limits, per client (R1, R2)
- `RATE_LIMIT_PER_MINUTE`, `RATE_LIMIT_PER_HOUR`, `RATE_LIMIT_PER_DAY`.
- `RATE_LIMIT_EXEMPT_PATHS`: path prefixes not counted (static assets,
health checks).
- Byte limits, per client
- `BYTES_LIMIT_PER_MINUTE`, `BYTES_LIMIT_PER_HOUR`, `BYTES_LIMIT_PER_DAY`.
- `BYTES_COUNT` (default `both`): `response`, `request` or `both`.
- Lookup of AS number and country (R7)
- `ASN_DB_PATH`, `COUNTRY_DB_PATH`: `.mmdb` files mounted into the
container; or
- `ASN_DB_URL`, `COUNTRY_DB_URL`: downloaded into `STATE_DIR` at start and
every `LOOKUP_DB_REFRESH` (default `7d`). One file may serve both.
- Known free sources in this format: IPinfo Lite (country and AS number in
one file, free account token), DB-IP Lite, MaxMind GeoLite2 (free
account). Choice is open.
- `ADD_LOOKUP_HEADERS` (default `false`): pass `X-Client-ASN` and
`X-Client-Country` to the app.
- Missing or unreadable database: start anyway, treat every client as
unknown, log a warning and send one alert.
- Biased thresholds (R8)
- `ASN_LIMIT_PERCENT`: for example `AS14061:50,AS16276:50,AS45102:25`.
Listed AS numbers get that percentage of every request and byte limit.
`0` refuses outright.
- `COUNTRY_LIMIT_PERCENT`: same form with ISO country codes, for example
`CN:25,RU:50`.
- `ASN_BYTES_PERCENT`, `COUNTRY_BYTES_PERCENT`: optional overrides applied
to byte limits only, when the byte percentage should differ from the
request percentage.
- `UNKNOWN_LIMIT_PERCENT` (default `100`): for clients the lookup cannot
place.
- `ASN_LIMIT_PERCENT_URL`: optional URL of a text file of `AS:percent`
lines, so one abuse-source list can be shared by every sidecar in the
fleet.
- Attack detection (R9)
- `RULES_DIR` (default `/etc/smallwebwaf/rules.d`): directory of rule files
read once at start; format under "Rule files". The image ships a default
file there. Mounting a directory over it replaces the defaults; mounting
single files into it adds to them.
- `RULES_ENABLED` (default `true`): `false` skips rule files entirely.
- `WAF_MODE` (default `detect`): `off`, `detect`, `block`.
- `WAF_PARANOIA_LEVEL` (default `1`), `WAF_ANOMALY_THRESHOLD` (default `5`):
the Core Rule Set's own two tuning values.
- `WAF_DISABLED_RULES`: rule ids to switch off when an app trips a false
positive.
- `WAF_EXEMPT_PATHS`: path prefixes not inspected.
- `WAF_BODY_LIMIT` (default `128K`): bodies are inspected up to this size
and streamed beyond it without buffering, so large uploads and git pushes
are not held in memory.
- `TRAP_PATHS`: paths the app never serves and only scanners ask for, for
example `/.env,/wp-login.php,/.git/config`. `TRAP_OFFENCE_WEIGHT`
(default `3`). This is the env-var short form of a `path` rule with an
`offence` action, for deployments that mount no rule files.
- `ERROR_BURST_THRESHOLD` (default off): 401, 403 and 404 responses per
client per minute that count as an offence.
- Blocks and bans (R5)
- `BAN_AFTER_OFFENCES` (default `3`), `OFFENCE_WINDOW` (default `10m`).
- `BAN_DURATIONS` (default `1h,24h,7d`).
- `PERMANENT_BAN_AFTER` (default `4`; `0` never bans permanently),
`BAN_HISTORY_WINDOW` (default `90d`).
- `BAN_RESPONSE` (default `403`): `403`, `429`, or `close` to drop the
connection without an answer.
- `BAN_SCOPE_V4_PREFIX` (default `32`): widen to for example `24` to ban the
surrounding netblock.
- Reputation (R6)
- `BLOCKLIST_URLS`: text files of addresses and netblocks, for example
Spamhaus DROP, FireHOL level 1, the Tor exit list. Refreshed every
`BLOCKLIST_REFRESH` (default `6h`); the last good copy is kept on failure.
- `BLOCKLIST_ACTION` (default `limit:25`): `deny`, `limit:<percent>`, or
`log`.
- `DNSBL_ZONES`: for example `dnsbl.dronebl.org`. Zones meant for mail
(lists of residential ranges) will block ordinary visitors and should not
be used; Spamhaus zones need their keyed query service, given as a zone
name containing the key.
- `DNSBL_RESOLVER`: optional resolver address, since public resolvers are
refused by several list operators.
- `ABUSEIPDB_KEY`, `ABUSEIPDB_MIN_SCORE` (default `75`),
`ABUSEIPDB_DAILY_BUDGET` (default `900`; the free tier allows 1000 checks
a day). Only clients that have already committed one offence are queried,
so the budget is spent on suspects.
- `CROWDSEC_LAPI_URL`, `CROWDSEC_LAPI_KEY`: optional. If a CrowdSec engine
exists on the host, pull its decision list on a schedule and treat listed
addresses as banned. This is how a fleet-wide blocklist can arrive
without the sidecar depending on CrowdSec.
- `REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>`, or
`log`, for DNSBL and API hits.
- `REPUTATION_CACHE_TTL` (default `24h`), `REPUTATION_TIMEOUT` (default
`2s`).
- Alerting (R3)
- `ALERT_WEBHOOK_URL`: JSON POST; schema below.
`ALERT_WEBHOOK_HEADERS`: optional `Name:value` pairs for authentication.
- `ALERT_SLACK_WEBHOOK_URL`: Slack incoming webhook, formatted message.
- `ALERT_NTFY_URL` (full topic URL), `ALERT_NTFY_TOKEN`: title, priority
and tags set from the event type.
- `ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure`):
which event types are sent.
- `ALERT_COOLDOWN` (default `15m`): the same event type for the same client
or netblock is not repeated within this time; a count of suppressed
repeats is included in the next one.
- `ALERT_MAX_PER_HOUR` (default `60`): beyond this, alerts are rolled into
one summary per hour so a wide attack cannot flood the channel.
- Anomaly thresholds, alert only, nothing is blocked (R4)
- Per client: `ANOMALY_CLIENT_REQUESTS_PER_MINUTE`,
`ANOMALY_CLIENT_REQUESTS_PER_HOUR`, `ANOMALY_CLIENT_BYTES_PER_MINUTE`,
`ANOMALY_CLIENT_BYTES_PER_HOUR`.
- Per surrounding netblock (`ANOMALY_NET_V4_PREFIX` default `24`,
`ANOMALY_NET_V6_PREFIX` default `48`): `ANOMALY_NET_REQUESTS_PER_MINUTE`,
`..._PER_HOUR`, `ANOMALY_NET_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Per AS number: `ANOMALY_ASN_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`ANOMALY_ASN_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Whole service: `ANOMALY_TOTAL_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`ANOMALY_TOTAL_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Named netblocks: `WATCH_NETS`, for example
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`, with
`WATCH_REQUESTS_PER_MINUTE`, `..._PER_HOUR`, `WATCH_BYTES_PER_MINUTE`,
`..._PER_HOUR` applied to each named block as a whole.
- Anomaly counting includes clients in `ALLOW_NETS` and
`RATE_LIMIT_EXEMPT_NETS`, since an exempt client misbehaving is worth
knowing about.
## Rule files
A required feature: at start the sidecar reads every `*.rules` file in
`RULES_DIR` and checks each request against them. The files are plain text meant
to be edited by hand, so that a new scanning pattern seen in the request log can
be turned into a rule in one line.
- One rule per line, four fields separated by spaces or tabs; the fourth field
runs to the end of the line:
```
<id> <target> <action> <regex>
```
- Blank lines and lines starting with `#` are ignored.
- `id`: a short name of letters, digits, `-` and `_`, unique across all files.
It appears in the request log, metrics, alerts and offence history.
- `target`: what the regex is matched against.
- `path`: the URL path as received, before any decoding.
- `query`: the raw query string.
- `uri`: path and query together, both as received and once
percent-decoded, so an encoded probe cannot slip past.
- `method`, `host`, `user_agent`, `referer`.
- `header:<Name>`: any one request header.
- Request bodies are not available to rule files; body inspection is the
Core Rule Set's job.
- `action`:
- `log`: note the match in the request log and do nothing else.
- `offence` or `offence:<n>`: record one or `n` offences and forward the
request. Enough offences within `OFFENCE_WINDOW` creates a ban.
- `block` or `block:<n>`: refuse with 403 and record one or `n` offences.
- `ban`: refuse and ban the client at once, at whatever length its ban
history calls for.
- `regex`: Go regular expression syntax (RE2). It has no backreferences or
lookaround, and in exchange matching time is linear in the input, so no rule
can be made to stall the proxy. `(?i)` at the front makes a rule
case-insensitive. A rule matches if the regex matches anywhere in the target;
anchor with `^` and `$` when that is not wanted.
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
rules in line order.
- Rules are compiled once at start and held in memory. A line that does not
parse, a regex that does not compile, or a duplicate id stops the process
with a message naming the file and line. A missing or empty directory is not
an error: the log says that no rules were loaded. Changing rules means
restarting the container; reloading while running is given up for now.
- In `MODE=observe` every action is logged as what would have happened and
nothing is refused.
- Clients in `ALLOW_NETS` are not checked.
Example file:
```
# 00-default.rules: probes no real visitor sends
# id target action regex
env-file path offence:3 (?i)/\.env(\.[a-z]+)?$
git-dir path offence:3 ^/\.git/(config|HEAD|index)$
wp-probe path offence:3 (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
php-shell path block:3 (?i)/(shell|c99|r57|wso|alfa)\.php$
path-traversal uri block (\.\./){2,}
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
empty-agent user_agent log ^$
```
The image ships one default file of this kind. It is kept short and limited to
patterns that are wrong for every app; anything app-specific (for example
blocking `/wp-login.php` is harmless in front of gitea and fatal in front of
WordPress) belongs in a file the deployer mounts.
## Persistent state
All state lives in memory. No database is used. What must survive a restart is
written to `STATE_DIR` as JSON files; the files are read once at start and never
during a request. The whole state is expected to stay under 10 MiB, and reading
or writing even ten times that as JSON takes well under a second, so whole-file
rewrites are cheap enough to need nothing cleverer.
- Files, each holding one kind of state:
- `bans.json`: active and past bans. Per entry: client or netblock, start,
expiry (`null` for permanent), reason, which ban this is for that client
(first, second, third), source (`local`, `admin`, `crowdsec`), and when
it was lifted, if it was.
- `offences.json`: offender history. Per entry: client, time, kind (`rate`,
`bytes`, `waf`, `trap`, `error_burst`), short detail. Entries older than
`BAN_HISTORY_WINDOW` are dropped at each write.
- `counters.json`: the hour and day request and byte counters per client.
- `reputation.json`: cached DNSBL and reputation API verdicts with the time
each was fetched.
- Format: indented JSON with a top-level `version` number, entries sorted by
client address, times in RFC 3339 UTC, durations and sizes as plain numbers
with the unit in the field name. The aim is that a person can open
`bans.json` in an editor, find an address, and remove or add an entry.
- Writing:
- serialise from a snapshot taken under the lock, so requests are not held
up while the file is written;
- write to a temporary file in the same directory, sync it, rename it over
the real name, sync the directory. A crash at any point leaves either the
old complete file or the new complete file, never a partial one;
- `bans.json` and `offences.json` are written `STATE_WRITE_DELAY` after a
change; `counters.json` and `reputation.json` every
`STATE_COUNTER_INTERVAL`; all four on orderly shutdown (SIGTERM).
- Reading:
- at start only. Active bans go into an in-memory prefix lookup; everything
else into maps. Expired counters and cache entries are discarded.
- a missing file means empty state and is normal on first run.
- a file that does not parse, or has an unknown `version`, stops the
process with a message naming the file and position. Starting with empty
bans would silently forgive every repeat offender, and since writes are
atomic a broken file can only come from a hand edit, which the editor
should hear about. `counters.json` and `reputation.json` are the
exception: if unreadable they are logged, set aside with a `.bad` suffix,
and the process starts with them empty.
- Hand edits are made with the container stopped; a running process will
overwrite the file at its next write. Changes to a running process go through
the admin endpoints. Reloading files into a running process is given up for
now.
- `STATE_DIR` not writable at start: the process exits. A write that fails
while running: state stays correct in memory, the failure is logged, counted
in metrics, alerted once per cooldown, and retried at the next write.
- What a hard kill can lose: bans and offences from the last
`STATE_WRITE_DELAY`, counters from the last `STATE_COUNTER_INTERVAL`. Losing
the volume loses ban history, not service.
## Request log
One JSON object per line on stdout for every request, including refused ones.
stdout is always on. When `LOG_REMOTE_URL` is set the same lines are also sent
to the remote endpoint, so a deployment can stop depending on docker's log
handling while `docker logs` keeps working.
- Standard web log fields: `time` (RFC 3339 with milliseconds), `instance`,
`client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`,
`status`, `request_bytes`, `response_bytes`, `referer`, `user_agent`.
- Request detail: `request_id` (generated if traefik did not supply one, and
passed to the app), `peer_ip` (the TCP peer, normally traefik),
`forwarded_for` (the header as received), `client_group` (the /64 or
configured prefix used for counting), `asn`, `as_name`, `country`,
`content_type`, `content_length`, the headers named in
`LOG_REQUEST_HEADERS`, `has_authorization` and `has_cookie` as booleans,
`websocket` when the connection was upgraded.
- Response detail: `response_content_type`, `upstream_status` (differs from
`status` when the sidecar answered itself), `cache_control`, `location` on
redirects, `aborted` when the client went away early.
- Decision: `action` (`forward`, `rate_limited`, `bytes_limited`, `banned`,
`denied`, `waf_blocked`, `upstream_error`), `would_action` in `observe` mode,
`limit_percent` and which rule set it, `counts` (the client's minute, hour
and day request and byte totals after this request), `limit_hit` (which
window), `rule_ids` (rule file rules that matched), `waf_rule_ids`,
`waf_score`, `reputation` (sources that listed the
client), `offence` when one was recorded, `ban_expires`.
- Timings in milliseconds: `duration_total`, `duration_checks` (everything the
sidecar did before forwarding), `duration_waf`, `duration_upstream_connect`,
`duration_upstream_first_byte`, `duration_upstream_total`.
- Bodies are never logged. Query strings are logged as received; an app that
carries secrets in query strings needs that fixed in the app.
- The process's own messages share the stream as JSON lines with
`"type":"process"`; request lines carry `"type":"request"`.
- Remote sending:
- syslog forms send each line as the message of an RFC 5424 record, with
octet-counted framing on TCP and TLS;
- RELP forms send the same record and wait for the receiver's
acknowledgement, so lines are not lost across a receiver restart. This is
the form to use with rsyslog;
- sending happens on its own goroutine from a bounded buffer
(`LOG_REMOTE_BUFFER`). An unreachable or slow endpoint never delays a
request and never stops stdout; it reconnects with backoff, drops the
oldest lines when the buffer is full, and counts the drops in metrics.
UDP gives no delivery signal at all and is offered only for
compatibility.
## Metrics endpoint
Prometheus text format on `METRICS_LISTEN_ADDR` at `METRICS_PATH`, on by default,
switched off with `METRICS_ENABLED=false`. It has its own listener so it can be
reached by a scraper without exposing ban management.
- Traffic: requests and bytes in and out, by status class and `action`;
request duration and upstream duration histograms; requests in flight.
- Limits and bans: limit hits by window and kind (requests or bytes), offences
by kind, bans created by ordinal, permanent bans, active bans (gauge).
- Attack detection: rule file matches by rule id and action, and the number of
rules loaded; Core Rule Set matches by mode and rule id (label limited to the
rules that actually fired).
- Lookup and reputation: requests and bytes by AS number and by country, limited
to the `METRICS_TOP_N` (default `50`) busiest of each with the rest summed as
`other`, so the label set stays bounded; reputation queries, hits, failures
and remaining daily budget by source; age of each blocklist and lookup
database.
- Housekeeping: tracked clients (gauge), state file writes, write failures,
last successful write time and size per file; alerts sent, failed and
suppressed by destination; remote log lines sent, dropped and buffer depth;
the standard Go runtime and process metrics.
- No metric carries a client IP address as a label; per-address questions are
answered by the request log and `GET /clients/<ip>`.
## Admin listener
- `GET /healthz`: for the container health check.
- `GET /bans`, `POST /bans` (client or netblock, duration, reason),
`DELETE /bans/<client>`: need `ADMIN_TOKEN`.
- `GET /clients/<ip>`: current counters, lookup result, reputation, offences;
for answering "why was this address refused".
## Alert webhook schema
One JSON object per alert:
- `instance`, `time`, `event` (one of the `ALERT_EVENTS` values)
- `client`, `netblock`, `asn`, `as_name`, `country`
- `reason`: short human-readable sentence
- `detail`: event-specific fields, for example `window`, `count`, `limit`,
`limit_percent`, `rule_ids`, `path`, `ban_ordinal`, `ban_expires`
- `suppressed_repeats`: number of identical alerts held back by the cooldown
## Deployment as a sidecar
docker-compose shape, using gitea as the example; upaas deployments follow the
same shape if upaas can run a second container for a service (open):
```yaml
services:
gitea:
image: gitea/gitea:1
# no traefik labels, no published ports
networks: [internal]
gitea-guard:
image: <registry>/<image>:<pinned digest>
read_only: true
user: "65532:65532"
environment:
UPSTREAM_URL: http://gitea:3000
TRUSTED_PROXIES: 172.18.0.0/16
INSTANCE_NAME: fsn1app1/gitea
MODE: observe
RATE_LIMIT_PER_MINUTE: "120"
RATE_LIMIT_PER_HOUR: "2000"
RATE_LIMIT_PER_DAY: "10000"
BYTES_LIMIT_PER_HOUR: 2G
ASN_DB_URL: https://example.invalid/asn-country.mmdb
ASN_LIMIT_PERCENT: AS14061:50,AS16276:50
COUNTRY_LIMIT_PERCENT: CN:25
WAF_MODE: detect
TRAP_PATHS: /.env,/wp-login.php,/.git/config
BLOCKLIST_URLS: https://www.spamhaus.org/drop/drop.txt
ALERT_NTFY_URL: https://ntfy.example.invalid/fleet-alerts
LOG_REMOTE_URL: relp://logs.example.invalid:2514
METRICS_LISTEN_ADDR: :9100
volumes:
- gitea-guard-state:/data
- ./50-gitea.rules:/etc/smallwebwaf/rules.d/50-gitea.rules:ro
networks: [internal, traefik]
labels:
traefik.enable: "true"
traefik.http.routers.gitea.rule: Host(`git.example.invalid`)
traefik.http.services.gitea.loadbalancer.server.port: "8080"
```
- The application container leaves the traefik network, so the sidecar cannot
be bypassed.
- Traefik routes only port 8080. The metrics port is reached by the scraper
over a docker network it shares with the sidecar; the admin port stays on
loopback inside the container and is used through `docker exec`.
- The state volume holds a few small JSON files; it needs no backup beyond
whatever the host already does.
- SSH access to gitea does not pass through the sidecar and is not protected
by it.
- Rollout per service: start in `MODE=observe` with `WAF_MODE=detect`, read a
few days of logs and alerts, set limits and rule exclusions from what real
traffic looks like, then switch to `enforce` and `block`.
- Notes specific to gitea: clones and pushes over HTTP are large and long, so
byte limits must be sized for a legitimate clone, the proxy sets no overall
response timeout, and `WAF_BODY_LIMIT` keeps pack uploads out of memory.
Archive download and blame or history pages are what scrapers hammer; request
limits do most of the work there.
## Failure behaviour
- Lookup database missing: clients are unknown, service continues, one alert.
- Reputation source down or over quota: no verdict, service continues, one
`source_failure` alert per cooldown.
- Alert destination down: retried with backoff from a bounded queue, oldest
dropped first, drops counted in metrics.
- Upstream down: 502 from the sidecar, not counted as client offences.
- Attack detection engine error on a request: request is forwarded, error
logged and counted.
- Remote log endpoint down: stdout continues, lines are buffered then dropped
oldest first, drops counted in metrics.
- State file write fails while running: memory stays authoritative, logged,
counted, alerted, retried.
- In short: the only things that stop the process are invalid configuration,
a rule file that does not parse, an unwritable `STATE_DIR`, and an unparseable `bans.json` or `offences.json`
at start. A broken helper never takes the protected service down.
## Risks the design has to handle
- Forged `X-Forwarded-For`: handled by `TRUSTED_PROXIES` being required and by
walking the header from the right.
- Many clients behind one address (mobile carriers, offices, Tor): rate limits
punish them collectively; `RATE_LIMIT_EXEMPT_NETS` and sensible day limits
are the remedy, and bans on such addresses should stay short.
- IPv6 address rotation inside a /64: handled by grouping.
- Widely distributed scrapers using thousands of addresses at low rates each:
per-client limits do not see them. The AS number and netblock anomaly
alerts reveal them, and `ASN_LIMIT_PERCENT` at a low value or `0` is the
response. A per-AS-number or per-country total limit is open.
- Core Rule Set false positives against real apps (gitea's editor, API
payloads): `detect` by default, exclusions by rule id and path.
- Slow-request attacks: header read timeout and idle timeout on the listener.
- Alert floods: cooldown and hourly cap.
## Build order
- First: proxy, client identification, static lists, three-window request
limits, exemptions, `observe` mode, the full request log on stdout, the
metrics endpoint, health.
- Second: rule files, ban ledger with escalation and permanent bans, the JSON
state files,
admin endpoints, alerting to all three destinations, remote log sending.
- Third: AS number and country lookup, biased thresholds, byte limits, anomaly
thresholds.
- Fourth: blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed.
- Fifth: attack detection with Coraza and the Core Rule Set, trap paths, error
bursts.
- Each stage is usable on its own; the first three already cover the traffic
problem the fleet has today.
## Questions for the owner
- Combining percentages. A client in a listed AS number (50 percent) and a
listed country (25 percent): should it get the lowest (25 percent of the
normal limit) or the product (12.5 percent)? Lowest is easier to predict when
reading a config; product punishes overlap harder. Recommendation: lowest.
- Meaning of "certain countries getting a percentage rate or byte limit". Read
here as: each client from that country gets that percentage of the normal
per-client limits. The other reading is a cap on the country as a whole, for
example all of one country together may use at most 20 percent of the
service's hourly byte budget. The second needs a configured total budget and
means one heavy client can lock out its compatriots. Recommendation: build
the per-client reading first; add whole-country and whole-AS-number caps only
if distributed scrapers make it necessary.
- Sharing bans across the fleet. As drafted, each sidecar remembers only its
own bans, so an abuser banned at gitea starts fresh at the next service.
Options: keep it per sidecar; or run one CrowdSec engine per host and have
every sidecar read its decision list (already supported above) and also
report its own bans to it. Recommendation: per sidecar for the first version,
decide on CrowdSec after seeing real ban volumes.
- Lookup database source. IPinfo Lite (one file with country and AS number,
free account, attribution required), DB-IP Lite (no account, attribution
required), or MaxMind GeoLite2 (free account, licence terms on
redistribution). Recommendation: IPinfo Lite, fetched once centrally and
served to the fleet from an internal URL so sidecars hold no account token.
- upaas. Whether upaas can deploy a second container beside an app and move
the traefik labels onto it was not examined. If it cannot, that is a feature
request for upaas, not something this sidecar can solve.
- Remote log library. Sending syslog in the current format (RFC 5424) is not
covered by Go's standard library, whose `log/syslog` package is frozen and
writes the older format, and no widely used Go RELP client was found. The
choices are a small third-party package for each, or writing both inside this
project; the syslog record is a few dozen lines, a RELP client with
acknowledgement handling is a few hundred. Recommendation: write the syslog
sender in-project, ship it first, and decide on RELP after looking at the
available packages; say if RELP must be in the first release.
- Ban response. `403` tells the abuser they were noticed; `close` wastes less
and tells them nothing. Recommendation: `403` by default for debuggability,
`close` available.