DNS blocklists asked in the background, verdicts kept (closes #104)
check / check (push) Waiting to run

Zones in SWWAF_DNSBL_ZONES are asked about each client in the background,
through SWWAF_DNSBL_RESOLVER or the host's resolver; no request waits.
Verdicts last SWWAF_REPUTATION_CACHE_TTL, kept in reputation.json.
SWWAF_REPUTATION_ACTION (limit:25) denies, limits or logs a listed
client; each zone listing it raises reputation_hit. A failed query gives
no verdict, raises source_failure, and pauses the zone a minute. A
zone's key, its first label under dq.spamhaus.net, is masked everywhere
but reputation.json. Zones compare without regard to case.

Judgement call: answers in 127.255.255.0/24 or outside 127.0.0.0/8 are failures.
Judgement call: the minute's pause after a failure; at most 1,000 queries at once.
Judgement call: one zone given with two keys stops the start as listed twice.
Rule suppressed: paralleltest on the DNSBL tests (Go's resolver shares state across synctest bubbles), funlen on the test of every logged setting.

Model: opus-5-5
This commit was merged in pull request #110.
This commit is contained in:
2026-10-07 21:09:51 +02:00
parent 2b8c98ba1f
commit 0b0f207423
18 changed files with 2330 additions and 236 deletions
+255 -140
View File
@@ -26,33 +26,36 @@ Slack and ntfy, and remote log sending. So is the stage after that: the AS
number and country of every client, looked up through GeoJS or in the IPinfo
Lite database file, the byte limits, the biased thresholds, lower limits for the
AS numbers and countries you list, and the anomaly thresholds, alerts for
unusual traffic that refuse nothing. So is the first part of the stage after
that: the blocklists you name by URL, which it fetches and keeps, and a file of
AS numbers' percentages, fetched the same way. `smallwebwaf` passes each request
to the app and the app's answer back, unchanged, within its timeouts and size
limits, works out each client's address, looks up its AS number and country
unless you switch that off, bans a client that sends too many requests or too
many bytes, not counting those for the paths you choose, with lower limits for
the clients of the AS numbers and countries you list, refuses a client that
comes from a country you refuse or from a network you refuse, refuses, limits or
only notes a client a blocklist you name lists, lets the networks you choose
through, checks each request against the rule files and bans a client whose
request is a clear sign of attack, keeps its bans, each client's counters and
history, GeoJS's answers and the last good copy of each list it fetches in JSON
files across restarts, takes in your edits of those files, such as a ban you
make, keep or lift, and of the rule files while it runs, writes a JSON log line
for every request, sends its log lines to a syslog server too if you name one,
sends an alert to a webhook, to Slack and to ntfy, each if you name one, for
each ban it makes or makes permanent, for traffic over an anomaly threshold you
set, for a client a blocklist lists, for GeoJS failing or a list it cannot
fetch, for a rule file or state file with an error and for a replacement of the
lookup database it cannot read, serves Prometheus metrics to a scraper that
holds the metrics token, lets an admin who holds the admin token list, add and
lift bans and ask what it knows of a client, and in `observe` mode passes on the
requests it would refuse, logging what it would have done with them. It comes as
the image the app's own image is built on. The rest of the design comes after
that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey of
existing tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
unusual traffic that refuse nothing. So are the first two parts of the stage
after that: the blocklists you name by URL, which it fetches and keeps, with a
file of AS numbers' percentages fetched the same way, and the DNS blocklists
(DNSBL zones), which it asks about each client in the background. `smallwebwaf`
passes each request to the app and the app's answer back, unchanged, within its
timeouts and size limits, works out each client's address, looks up its AS
number and country unless you switch that off, bans a client that sends too many
requests or too many bytes, not counting those for the paths you choose, with
lower limits for the clients of the AS numbers and countries you list, refuses a
client that comes from a country you refuse or from a network you refuse,
refuses, limits or only notes a client a blocklist or a DNSBL zone you name
lists, lets the networks you choose through, checks each request against the
rule files and bans a client whose request is a clear sign of attack, keeps its
bans, each client's counters and history, GeoJS's answers, the last good copy of
each list it fetches and the DNSBL zones' verdicts in JSON files across
restarts, takes in your edits of those files, such as a ban you make, keep or
lift, and of the rule files while it runs, writes a JSON log line for every
request, sends its log lines to a syslog server too if you name one, sends an
alert to a webhook, to Slack and to ntfy, each if you name one, for each ban it
makes or makes permanent, for traffic over an anomaly threshold you set, for a
client a blocklist or a DNSBL zone lists, for GeoJS failing, a list it cannot
fetch or a DNSBL zone that fails or refuses a query, for a rule file or state
file with an error and for a replacement of the lookup database it cannot read,
serves Prometheus metrics to a scraper that holds the metrics token, lets an
admin who holds the admin token list, add and lift bans and ask what it knows of
a client, and in `observe` mode passes on the requests it would refuse, logging
what it would have done with them. It comes as the image the app's own image is
built on. The rest of the design comes after that, in the order of the build
order in [`SPEC.md`](SPEC.md). The survey of existing tools that led to the
design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started
@@ -206,35 +209,48 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
limit, the lowest of its percentages applying, as for a biased threshold, and
with `log` nothing more is done. Whatever the action, the request's log line
names the lists, and each raises an alert.
- Checks the client's own address against the DNSBL zones `SWWAF_DNSBL_ZONES`
names, after the blocklists and before the rate limits (see "DNS blocklists"
below), by the verdicts it keeps. A zone is asked about a client in the
background, and no request waits for its answer: the request that has it
asked, and any other from the client before the answer comes, goes on as from
a client the zone does not list. With `SWWAF_REPUTATION_ACTION` at `limit:25`,
its default, a client a zone's verdict lists gets a quarter of every rate
limit and byte limit, the lowest of its percentages applying, as for a biased
threshold. With `deny`, its requests are refused with `SWWAF_BAN_RESPONSE`
before their bodies are read; they are not counted for the rate limits, and
make no ban. With `log`, nothing more is done. Whatever the action, the
request's log line names the zones, and each raises an alert. A client a
blocklist refuses is not checked.
- Checks the client's own address against the static lists, the three netblock
settings below, before anything else, its lookup included. A client in
`SWWAF_ALLOW_NETS` skips bans, the country lists, the blocklists, the rate
limits, the byte limits and the rule files, and is not looked up; the timeouts
and size limits still apply. A client in `SWWAF_DENY_NETS` is refused with
`SWWAF_BAN_RESPONSE` before its body is read, and the request is not counted
for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let through. A
client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the
rate limits, and has no bytes counted by the byte limits; the country lists,
the rule files and bans still apply to it.
`SWWAF_ALLOW_NETS` skips bans, the country lists, the blocklists, the DNSBL
zones, the rate limits, the byte limits and the rule files, and is not looked
up; the timeouts and size limits still apply. A client in `SWWAF_DENY_NETS` is
refused with `SWWAF_BAN_RESPONSE` before its body is read, and the request is
not counted for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let
through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor
refused by the rate limits, and has no bytes counted by the byte limits; the
country lists, the rule files and bans still apply to it.
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
that `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a rate limit or
a rule would refuse: it passes them to the app, and their log lines name what
`enforce` mode would have done (see `would_action` in "Request log" below).
The checks run, and requests and bytes are counted, as in `enforce` mode, with
three differences: neither a broken rate limit or byte limit nor a `ban` rule
makes a ban; a broken limit does not set the client's counters back to zero,
so each request over a rate limit is logged as one that would be refused, and
each whose bytes keep the client over a byte limit as breaking it; and a
request under a ban does not make it permanent. As in `enforce` mode, the
bytes counted are only those of the requests `enforce` mode would have passed
to the app. A ban it would have made, or made permanent, raises the alert
`enforce` mode would have raised, marked as what would have happened (see
"Alerts" below). The bans in `bans.json` are kept, and refuse requests again
when `smallwebwaf` next runs in `enforce` mode, as long as they last. The
timeouts and size limits still apply, since they protect `smallwebwaf` and the
app themselves, and a request for one of `smallwebwaf`'s own endpoints without
its token is still answered `401`. It is for trying a configuration before
enforcing it.
that `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would refuse: it passes them to the app, and
their log lines name what `enforce` mode would have done (see `would_action`
in "Request log" below). The checks run, and requests and bytes are counted,
as in `enforce` mode, with three differences: neither a broken rate limit or
byte limit nor a `ban` rule makes a ban; a broken limit does not set the
client's counters back to zero, so each request over a rate limit is logged as
one that would be refused, and each whose bytes keep the client over a byte
limit as breaking it; and a request under a ban does not make it permanent. As
in `enforce` mode, the bytes counted are only those of the requests `enforce`
mode would have passed to the app. A ban it would have made, or made
permanent, raises the alert `enforce` mode would have raised, marked as what
would have happened (see "Alerts" below). The bans in `bans.json` are kept,
and refuse requests again when `smallwebwaf` next runs in `enforce` mode, as
long as they last. The timeouts and size limits still apply, since they
protect `smallwebwaf` and the app themselves, and a request for one of
`smallwebwaf`'s own endpoints without its token is still answered `401`. It is
for trying a configuration before enforcing it.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check.
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
@@ -254,14 +270,14 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
`SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server"
below).
- Sends an alert for each ban it makes or makes permanent, for a count over an
anomaly threshold, for a client a blocklist lists, for GeoJS failing or a list
it cannot fetch, for a rule file or state file with an error, and for a
replacement of the lookup database it cannot read, holding back repeats and,
past an hourly limit, rolling the rest into one summary, to each destination
you name: as a JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as
a message to the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names,
and as a message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts"
below).
anomaly threshold, for a client a blocklist or a DNSBL zone lists, for GeoJS
failing, a list it cannot fetch or a DNSBL zone that fails or refuses a query,
for a rule file or state file with an error, and for a replacement of the
lookup database it cannot read, holding back repeats and, past an hourly
limit, rolling the rest into one summary, to each destination you name: as a
JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to
the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a
message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below).
- Counts requests and their bytes over a minute and an hour, per client, per
netblock around a client, per AS number, for the whole service and per named
netblock, and sends an `anomaly` alert for a count over the anomaly threshold
@@ -324,8 +340,8 @@ effective settings are logged at start.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
country lists, the blocklists, the rate limits, the byte limits and the rule
files, such as your monitoring or your own networks.
country lists, the blocklists, the DNSBL zones, the rate limits, the byte
limits and the rule files, such as your monitoring or your own networks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
rate limits and the byte limits do not apply to, such as a machine that talks
to the app all day.
@@ -427,10 +443,34 @@ effective settings are logged at start.
`limit:<percent>`, such as `limit:25`, gives it that percentage of every rate
limit and byte limit, and `log` does nothing more than note the lists in the
log line and raise the alert.
- `SWWAF_DNSBL_ZONES` (default empty): the DNSBL zones each client is asked
about, such as `dnsbl.dronebl.org` (see "DNS blocklists" below). A zone is a
DNS name of at most 189 characters: labels of letters, digits and hyphens, of
up to 63 characters each, neither starting nor ending with a hyphen, joined by
dots. A Spamhaus zone is the name of its keyed query service, with the key in
it, such as `<key>.xbl.dq.spamhaus.net`, and is shown with its key masked (see
"DNS blocklists" below). A zone that is not such a name stops the start, as
does one listed twice, even with its letters in another case or with another
key. Do not name a zone meant for mail (see "DNS blocklists" below).
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver the zones are asked
through, an IP address with an optional port, 53 when none is given, such as
`192.0.2.53` or `[2001:db8::53]:5353`. Unset, it is the host's, as
`/etc/resolv.conf` names it. Several zones refuse queries that come through a
public resolver.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): what is done with a client a
zone's verdict lists, as `SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`,
`limit:<percent>` or `log`. A zone's verdict is less certain than a list such
as DROP, so by default a client it lists gets a quarter of every rate limit
and byte limit.
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict on a
client is used after the zone gave it.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone may take
before it fails.
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
that is banned, breaks a rate limit, matches a `ban` rule, is in
`SWWAF_DENY_NETS`, comes from a refused country or is in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny`: `403`, `429`, or `close` to close the
`SWWAF_DENY_NETS`, comes from a refused country, is in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone while
`SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close the
connection without an answer. Behind traefik, `close` does not leave the
client unanswered: traefik answers `502`, as it does whenever its backend
drops a connection. A `block` rule always answers `403`.
@@ -576,14 +616,16 @@ listed twice in one of them stops the start. `off` switches a timeout, a size
limit, a rate limit, a byte limit, an anomaly threshold, `SWWAF_ALERT_COOLDOWN`
or `SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
`SWWAF_LOOKUP_TIMEOUT`, `SWWAF_UNKNOWN_LIMIT_PERCENT`,
`SWWAF_BLOCKLIST_REFRESH`, the ban settings, the state settings,
`SWWAF_BLOCKLIST_REFRESH`, `SWWAF_REPUTATION_CACHE_TTL`,
`SWWAF_REPUTATION_TIMEOUT`, the ban settings, the state settings,
`SWWAF_METRICS_TOP_N`, `SWWAF_LOG_REMOTE_BUFFER`, `SWWAF_ANOMALY_NET_V4_PREFIX`
and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off.
Several limits are fixed rather than settings. At most 20,000 clients are kept,
with their counters and history, and an IPv6 client is counted by its /64. At
most 100,000 answers from GeoJS are kept, for 7 days each, and at most 20,000
anomaly counters.
most 100,000 answers from GeoJS are kept, for 7 days each, at most 20,000
anomaly counters, and at most 100,000 verdicts of the DNSBL zones, with at most
1,000 queries to them under way at once.
### Settings given as files
@@ -666,8 +708,9 @@ which every line has.
app's, as passed on, or those of `smallwebwaf`'s own answer.
- `request_bytes` and `response_bytes` count body bytes.
- `action` is `forward` for a request passed to the app, `denied` for one
refused because its client is in `SWWAF_DENY_NETS`, or in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny`, `banned` for one refused because a ban
refused because its client is in `SWWAF_DENY_NETS`, in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone while
`SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a ban
covers its client or because it matched a `ban` rule, which bans its client,
`country_denied` for one refused for its client's country, `rate_limited` for
one that broke a rate limit and banned its client, `rule_blocked` for one a
@@ -676,16 +719,17 @@ which every line has.
could not be reached or its answer broke off, and `admin` for one
`smallwebwaf` answered at its own endpoint.
- `would_action` is there in `observe` mode for a request that
`SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a rate limit or a
rule would have refused in `enforce` mode, and names the action that refusal
would have had: `denied`, `banned`, `country_denied`, `rate_limited` or
`rule_blocked`. `action` then names what was done: `forward` for a request
passed to the app, and another action, such as `too_large`, for one a size or
time limit refused.
`SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would have refused in `enforce` mode, and
names the action that refusal would have had: `denied`, `banned`,
`country_denied`, `rate_limited` or `rule_blocked`. `action` then names what
was done: `forward` for a request passed to the app, and another action, such
as `too_large`, for one a size or time limit refused.
- `limit_percent` is there for a request the rate limits count whose client a
biased threshold, or `SWWAF_BLOCKLIST_ACTION` for a blocklist that lists it,
gives less than the whole of the rate limits, and gives the percentage it
gets, with `limit_percent_setting` naming the setting that gave it, such as
biased threshold, `SWWAF_BLOCKLIST_ACTION` for a blocklist that lists it, or
`SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it, gives less
than the whole of the rate limits, and gives the percentage it gets, with
`limit_percent_setting` naming the setting that gave it, such as
`SWWAF_ASN_LIMIT_PERCENT`, or `SWWAF_ASN_LIMIT_PERCENT_URL` for the file it
names. `bytes_percent` and `bytes_percent_setting` are the same for the byte
limits. Each is left out when the client gets the whole of those limits.
@@ -697,11 +741,11 @@ which every line has.
health check, one from a client in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a
ban, the country lists or a blocklist refuse, or would refuse in `observe`
mode. Its `minute_bytes`, `hour_bytes` and `day_bytes` give the client's bytes
in each window as the byte limits count them, in the same way: for a request
whose bytes they count, with its own, once it has ended; for any other, those
counted before it.
ban, the country lists, a blocklist or a DNSBL zone's verdict refuse, or would
refuse in `observe` mode. Its `minute_bytes`, `hour_bytes` and `day_bytes`
give the client's bytes in each window as the byte limits count them, in the
same way: for a request whose bytes they count, with its own, once it has
ended; for any other, those counted before it.
- `rule_ids` is there for a request that matched rules of the rule files, and
lists their ids in the order they matched, up to the one that refused it.
- `limit_hit` is there for a request that broke a rate limit, or whose bytes
@@ -711,12 +755,16 @@ which every line has.
several. `offence` is then `limit`. A request whose bytes broke a byte limit
is not refused: its `action` is what it would have been otherwise, such as
`forward`.
- `reputation` is there for a request whose client a blocklist lists, and gives
the URLs of the blocklists that list it, in the order `SWWAF_BLOCKLIST_URLS`
names them, whatever `SWWAF_BLOCKLIST_ACTION` says. It is left out for a
client the blocklists are not checked for: one in `SWWAF_ALLOW_NETS`, and one
`SWWAF_DENY_NETS`, a ban or the country lists refuse first, or would in
`observe` mode.
- `reputation` is there for a request whose client a blocklist or a DNSBL zone's
verdict lists, and gives the URLs of the blocklists that list it, in the order
`SWWAF_BLOCKLIST_URLS` names them, then the zones whose verdict lists it, in
the order `SWWAF_DNSBL_ZONES` names them, whatever `SWWAF_BLOCKLIST_ACTION`
and `SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not
come yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named.
It is left out for a client the blocklists are not checked for: one in
`SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban or the country lists
refuse first, or would in `observe` mode. The zones are not checked either for
a client a blocklist refuses, or would in `observe` mode.
- `ban_expires` is there for a request that made a ban or was refused under one,
or in `observe` mode would have been refused under one, and gives when the ban
ends, in the same form as `time`, or `permanent`.
@@ -786,11 +834,13 @@ it, as below. An alert is for one of these events, and is sent when
- `anomaly`: a count of requests or bytes over an anomaly threshold, raised by
each request that ends with the count over it, in `observe` mode as in
`enforce` mode. It refuses and bans nothing.
- `reputation_hit`: a request whose client a blocklist lists, one alert for each
blocklist that lists it, whatever `SWWAF_BLOCKLIST_ACTION` says, in `observe`
mode as in `enforce` mode.
- `source_failure`: GeoJS failing or refusing `smallwebwaf`, or a fetch of a
list failing (see "Blocklists" below).
- `reputation_hit`: a request whose client a blocklist or a DNSBL zone's verdict
lists, one alert for each blocklist and each zone that lists it, whatever
`SWWAF_BLOCKLIST_ACTION` and `SWWAF_REPUTATION_ACTION` say, in `observe` mode
as in `enforce` mode.
- `source_failure`: GeoJS failing or refusing `smallwebwaf`, a fetch of a list
failing (see "Blocklists" below), or a query to a DNSBL zone failing or
refused (see "DNS blocklists" below).
- `file_error`: a rule file edited while it runs that has an error, an edit of a
state file set aside as `<name>.bad`, a state file it could not write while
running, or a replacement of the lookup database it could not read, which it
@@ -863,7 +913,7 @@ is sent on one line:
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`;
for an `anomaly`, what was counted over which threshold, such as
`requests per minute of the netblock 203.0.113.0/24 over the threshold of 1000`;
for a `reputation_hit`, `listed by a blocklist`.
for a `reputation_hit`, `listed by a blocklist` or `listed by a DNSBL zone`.
- `detail` is what is particular to the event: for a ban, its `cause`, when it
ends as `ban_expires`, in the form the request log gives it, and its `notes`,
as `bans.json` gives them; for an `anomaly`, the `scope`, `client`, `net`,
@@ -871,11 +921,11 @@ is sent on one line:
`asn` and the `name` of the named netblock for `watch`, the `window`, `minute`
or `hour`, the `kind`, `requests` or `bytes`, the `count`, which is weighted
as the rate limits weigh theirs, and the `threshold`; for a `reputation_hit`,
the `source`, the URL of the blocklist; for `source_failure`, the `source`,
`geojs` or the URL of the list, the `error`, and for GeoJS, when it is asked
again, `asking_again_in`; for `file_error`, the `file`, which for an edit set
aside is the file it was renamed to, and the `error`, which for a file that
does not parse names where in it the error is.
the `source`, the URL of the blocklist or the zone; for `source_failure`, the
`source`, `geojs`, the URL of the list or the zone, the `error`, and for
GeoJS, when it is asked again, `asking_again_in`; for `file_error`, the
`file`, which for an edit set aside is the file it was renamed to, and the
`error`, which for a file that does not parse names where in it the error is.
- `suppressed_repeats` is how many repeats the cooldown held back before this
alert, and for a `summary`, those no other alert gives (see below).
@@ -921,11 +971,11 @@ and Slack this JSON object, shown indented; it is sent on one line:
```
An alert for the same event as the last one sent, on the same netblock, and for
a `reputation_hit` about the same blocklist, or for a `file_error` about the
same file, or for a `source_failure` about the same source, or for an `anomaly`
in the same scope, with the same netblock, AS number or name, whatever its
window and kind, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it is
held back and counted, and the next alert sent for them gives that count as
a `reputation_hit` about the same blocklist or zone, or for a `file_error` about
the same file, or for a `source_failure` about the same source, or for an
`anomaly` in the same scope, with the same netblock, AS number or name, whatever
its window and kind, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it
is held back and counted, and the next alert sent for them gives that count as
`suppressed_repeats`. As each hour of the clock, in UTC, ends, the cooldowns
that have run out are dropped, and the repeats they held back, which no alert
sent since has given, go in that hour's summary.
@@ -990,8 +1040,11 @@ with times in UTC.
indented to be read, under `lists`: its `url`, when it was last `tried`, the
fetch failed or not, and its last good copy: when that was `fetched`, and its
`lines`, as fetched, comment lines included, each on a line of its own, both
left out while no fetch of it has succeeded. As the file is read, the lists
the settings no longer name are dropped.
left out while no fetch of it has succeeded; and under `verdicts`, each
verdict of a DNSBL zone still in use (see "DNS blocklists" below): its `zone`,
the `client`'s address, whether the zone `listed` the client, and when the
zone gave it, `fetched`. As the file is read, the lists the settings no longer
name, and the verdicts of the zones they no longer name, are dropped.
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
read: under `cooldowns`, for each event and netblock, with the `source` too
for a `reputation_hit`, or event and `file` or `source`, or for an `anomaly`,
@@ -1029,24 +1082,26 @@ keeps refusing every client in its netblock until it ends, even after
until a fetch of it succeeds. A netblock whose address has bits past its length,
such as `203.0.113.9/24`, is read as the netblock it is in, `203.0.113.0/24`.
Buckets and answers whose time has passed are dropped, and so is an anomaly
counter left with no bucket. A missing file is empty state, as on a first start.
A file that does not parse, or has another `version`, stops the start with a
message naming the file, and the line and column where Go's JSON decoder gives
them; so does a state directory `smallwebwaf` cannot write. So does an entry
without a field it needs, named with the entry's place in the file: a ban's
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
client's `client`, or the `start` of a window in which it has requests or bytes;
an answer's `client`, `country`, which is `""` for a client GeoJS cannot place,
or `answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
`time`; an anomaly counter's `netblock`, unless it counts an AS number or the
whole service, its `asn`, for an AS number, its `name`, for a named netblock, or
the `start` of a window in which it has requests or bytes; a list's `url`,
`fetched` or `lines`, which is `[]` for an empty list. So does a ban whose
`cause` is not `limit`, `attack` or `admin`, alerts waiting for a destination
that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose `scope` is
not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list with a line
that would make its fetch fail. An answer's `asn` or `as_name` left out reads as
empty.
counter left with no bucket; a verdict past its time is neither used nor written
again. A missing file is empty state, as on a first start. A file that does not
parse, or has another `version`, stops the start with a message naming the file,
and the line and column where Go's JSON decoder gives them; so does a state
directory `smallwebwaf` cannot write. So does an entry without a field it needs,
named with the entry's place in the file: a ban's `netblock`, `start` or
`expires`, which is `null` for a permanent ban; a client's `client`, or the
`start` of a window in which it has requests or bytes; an answer's `client`,
`country`, which is `""` for a client GeoJS cannot place, or `answered`; a
cooldown's `event` or `sent`; an alert waiting's `event` or `time`; an anomaly
counter's `netblock`, unless it counts an AS number or the whole service, its
`asn`, for an AS number, its `name`, for a named netblock, or the `start` of a
window in which it has requests or bytes; a list's `url`, `fetched` or `lines`,
which is `[]` for an empty list; a verdict's `zone`, `client`, `listed`, which
is `false` for a client the zone does not list, or `fetched`. So does a ban
whose `cause` is not `limit`, `attack` or `admin`, alerts waiting for a
destination that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose
`scope` is not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list
with a line that would make its fetch fail. An answer's `asn` or `as_name` left
out reads as empty.
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
a state file as soon as you save it: what the file then holds replaces what
@@ -1238,6 +1293,11 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
`smallwebwaf_reputation_failures_total`: the fetches of the list that failed;
and `smallwebwaf_reputation_last_fetch_timestamp_seconds`: when the copy of it
in use was fetched, `0` while there is none.
- By `source`, each zone `SWWAF_DNSBL_ZONES` names:
`smallwebwaf_reputation_hits_total`: the requests whose client the zone's
verdict lists, a series that comes with the first;
`smallwebwaf_reputation_queries_total`: the queries made to the zone; and
`smallwebwaf_reputation_failures_total`: those that failed.
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
- `smallwebwaf_state_file_writes_total`,
`smallwebwaf_state_file_write_failures_total`,
@@ -1429,9 +1489,9 @@ goes through the candidates one by one.
readable JSON files, written regularly and at every stop, so a restart loses
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
up the change. Nothing is read from disk while serving a request. The files
for the bans, the clients, the GeoJS answers, the copies of the lists and the
alerts are built, with an edit taken in while running (see "State files"
above); the rest comes with its features.
for the bans, the clients, the GeoJS answers, the copies of the lists, the
verdicts of the DNSBL zones and the alerts are built, with an edit taken in
while running (see "State files" above); the rest comes with its features.
- Health checks, the metrics, and listing, adding and lifting bans or asking why
a given address was refused, all on the one port every request uses: under
`/_smallwebwaf/` on the app's own address, through traefik like any other
@@ -1659,6 +1719,58 @@ hour apart whatever `SWWAF_BLOCKLIST_REFRESH` is: each fetches a list again that
long after its own last try, so those first started within the same hour, with
the same refresh, keep fetching within the same hour.
## DNS blocklists
`SWWAF_DNSBL_ZONES` names DNS blocklists, DNSBL zones, which list an address by
answering a query for a name made from it. None is named by default, for the
reason none of the blocklists is. `smallwebwaf` asks each zone about a client's
own address in the background, the first time it sees the client: the request
goes on at once, as from a client the zone does not list, and so does every
request from it until the zone has answered. The name asked about is the one RFC
5782 gives: the four numbers of an IPv4 address in reverse order, so that
`192.0.2.99` is asked about in `dnsbl.dronebl.org` as
`99.2.0.192.dnsbl.dronebl.org`, or the 32 hex digits of an IPv6 address in
reverse order, each followed by a dot. An IPv6 client is asked about by its own
address, not by its /64.
A zone that answers that the name does not exist, or has no address, does not
list the client, and one that answers with an address in `127.0.0.0/8` lists it.
Any other answer gives no verdict, and is a failure: an address in
`127.255.255.0/24`, with which Spamhaus refuses a query, such as one sent
through a public resolver or one past its limit; an address outside
`127.0.0.0/8`, such as a resolver gives that answers even for names that do not
exist; an error the resolver answers with, such as a refusal; and no answer
within `SWWAF_REPUTATION_TIMEOUT`. A failure is counted, logged and raised as a
`source_failure` alert, held back as a repeat within `SWWAF_ALERT_COOLDOWN`, and
the zone is not asked again for a minute, so that a zone that refuses queries is
not asked on every request.
Each verdict, the zone listing the client or not, is kept, in memory and in
`reputation.json` (see "State files" above), so that a restart keeps it, and is
used for `SWWAF_REPUTATION_CACHE_TTL` after the zone gave it, 24 hours by
default. The client's first request after that has the zone asked again, and
until it answers, no verdict of the zone applies to the client. At most 100,000
verdicts are kept, the one fetched longest ago dropped first, and at most 1,000
queries are under way at once; past that, a zone is asked about a client at the
client's next request.
A client in `SWWAF_ALLOW_NETS` is not checked, nor is one a blocklist refuses.
`SWWAF_REPUTATION_ACTION` says what is done with one a zone's verdict lists, as
"What it does so far" above describes.
Do not name a zone meant for mail, such as one of residential and dynamic
address ranges, which ordinary visitors come from, or one that includes such a
list, as Spamhaus's `zen` does: it would refuse them, or lower their limits.
Spamhaus's zones answer through its keyed query service, named with the key in
it, such as `<key>.xbl.dq.spamhaus.net`. The key of a zone under
`dq.spamhaus.net` is its first label, and `smallwebwaf` shows `********` in its
place wherever it names the zone, as `********.xbl.dq.spamhaus.net`: in the
settings logged at start, an error that stops the start, its own messages, the
request log, the alerts and the metrics. Only `reputation.json` keeps the zone
with its key. A key in the name of any other zone is shown as given. Several
zones refuse queries that come through a public resolver; `SWWAF_DNSBL_RESOLVER`
names another resolver to ask through.
## How the code is laid out
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
@@ -1672,15 +1784,15 @@ the same refresh, keep fetching within the same hour.
standard library's `httputil.ReverseProxy` within the timeouts and size
limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
a ban, for the country lists, for a blocklist, for a rate limit, which bans
the client, for a `block` or `ban` rule, the latter banning the client, and
for an announced body over the size limit; in `observe` mode, only for the
size limit, with what it would have refused for noted in the log line. A
request under `/_smallwebwaf/` that `check` lets through is answered by
`answerAdmin` instead of reaching the app. Once the answer to a request passed
to the app has ended, `countBytes` counts its bytes for the byte limits, and
once any request but the health check has ended, `countAnomalies` counts it
for the anomaly thresholds.
a ban, for the country lists, for a blocklist, for a DNSBL zone's verdict, for
a rate limit, which bans the client, for a `block` or `ban` rule, the latter
banning the client, and for an announced body over the size limit; in
`observe` mode, only for the size limit, with what it would have refused for
noted in the log line. A request under `/_smallwebwaf/` that `check` lets
through is answered by `answerAdmin` instead of reaching the app. Once the
answer to a request passed to the app has ended, `countBytes` counts its bytes
for the byte limits, and once any request but the health check has ended,
`countAnomalies` counts it for the anomaly thresholds.
- `internal/metrics`: the metrics, counted as the other parts tell it what
happened, and served in the Prometheus text format.
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
@@ -1696,7 +1808,9 @@ the same refresh, keep fetching within the same hour.
- `internal/reputation`: fetches the blocklists and the file
`SWWAF_ASN_LIMIT_PERCENT_URL` names as they are due, keeps the last good copy
of each, and tells which blocklists list an address and what percentage the
file gives an AS number.
file gives an AS number; and asks the DNSBL zones about clients in the
background, through the standard library's resolver, keeps their verdicts, and
tells which zones' verdicts list an address.
- `internal/ratelimit`: the table of clients: counts each client's requests and
bytes, tells when they take it over a rate limit or a byte limit, and keeps
each client's history.
@@ -1724,7 +1838,8 @@ the same refresh, keep fetching within the same hour.
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
recently seen, the anomaly counters to 20,000, dropping the one counted least
recently seen, the DNSBL zones' verdicts to 100,000, dropping the one fetched
longest ago, the anomaly counters to 20,000, dropping the one counted least
recently, and the banned netblocks in the order they were last seen, from which
the ledger picks the ban to drop past `SWWAF_MAX_BANS`, and
`github.com/prometheus/client_golang` keeps the metrics and serves them, and