Blocklists and an AS percentage file fetched by URL (closes #29)
check / check (push) Waiting to run

SWWAF_BLOCKLIST_URLS names lists of addresses and netblocks, fetched every
SWWAF_BLOCKLIST_REFRESH (24h, never under 1h); an IPv4-mapped line stands
for its IPv4 address or netblock. reputation.json keeps each list's last
try, failed or not, even one cut off by a stop, which a restart waits on
as a running instance does, and its last good copy, whole, used while a
fetch fails. SWWAF_BLOCKLIST_ACTION denies, limits or only logs a listed
client; the log line names the lists, each raises reputation_hit, and a
failed fetch raises source_failure. SWWAF_ASN_LIMIT_PERCENT_URL is fetched
the same way and counts as SWWAF_ASN_LIMIT_PERCENT does, the lower winning.

Judgement call: a failed fetch is retried after the refresh, not sooner.
Not done: ban notes do not name the lists yet.

Model: opus-5-5
This commit was merged in pull request #108.
This commit is contained in:
2026-10-07 19:09:41 +02:00
parent 82e20e0cb5
commit 2b8c98ba1f
19 changed files with 2588 additions and 322 deletions
+303 -173
View File
@@ -26,29 +26,33 @@ 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. `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, lets the networks you choose
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, and GeoJS's answers 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 GeoJS failing, 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).
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).
## Getting started
@@ -124,18 +128,20 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
or `SWWAF_RATE_LIMIT_EXEMPT_NETS`, and a request for a path
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts.
- Gives the clients of the AS numbers and countries the biased thresholds list,
`SWWAF_ASN_LIMIT_PERCENT` and `SWWAF_COUNTRY_LIMIT_PERCENT`, the percentage
they give of every rate limit and byte limit, so that the same rules ban them
after fewer requests, and, while `SWWAF_UNKNOWN_LIMIT_PERCENT` is below 100,
every client without a country that percentage. A client to which several
apply gets the lowest. `SWWAF_ASN_BYTES_PERCENT` and
`SWWAF_COUNTRY_BYTES_PERCENT` give the AS numbers and countries they list a
percentage of the byte limits in place of the other two. Each client is
counted on its own, against its own lowered limits: no budget is shared by a
whole AS number or country, which one abuser could use up and so lock out
everyone else there. The log line of each request the rate limits count gives
its client's percentages below 100 and the settings that gave them, and so do
the notes of a ban for a lowered limit, and its alert.
`SWWAF_ASN_LIMIT_PERCENT`, the file `SWWAF_ASN_LIMIT_PERCENT_URL` names and
`SWWAF_COUNTRY_LIMIT_PERCENT`, the percentage they give of every rate limit
and byte limit, so that the same rules ban them after fewer requests, and,
while `SWWAF_UNKNOWN_LIMIT_PERCENT` is below 100, every client without a
country that percentage. A client to which several apply gets the lowest. For
the byte limits, `SWWAF_ASN_BYTES_PERCENT` gives an AS number it lists a
percentage in place of those `SWWAF_ASN_LIMIT_PERCENT` and the file give it,
and `SWWAF_COUNTRY_BYTES_PERCENT` gives a country it lists one in place of the
one `SWWAF_COUNTRY_LIMIT_PERCENT` gives it. Each client is counted on its own,
against its own lowered limits: no budget is shared by a whole AS number or
country, which one abuser could use up and so lock out everyone else there.
The log line of each request the rate limits count gives its client's
percentages below 100 and the settings that gave them, and so do the notes of
a ban for a lowered limit, and its alert.
- Bans a client that breaks a rate limit or a byte limit, as "Bans" in
[`SPEC.md`](SPEC.md) describes: the first ban lasts an hour, and a limit
broken again within a day of a ban ending bans for three times as long as that
@@ -191,34 +197,43 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
link-local address has no country and is never looked up:
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is in
`SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
- Checks the client's own address against the blocklists `SWWAF_BLOCKLIST_URLS`
names, after the country lists and before the rate limits (see "Blocklists"
below). With `SWWAF_BLOCKLIST_ACTION` at `deny`, its default, a request from a
client a blocklist lists is refused with `SWWAF_BAN_RESPONSE` before its body
is read; it is not counted for the rate limits, and makes no ban. With
`limit:<percent>` the client gets that percentage of every rate limit and byte
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 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 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_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.
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
that `SWWAF_DENY_NETS`, a ban, the country lists, 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
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.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check.
@@ -239,13 +254,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 GeoJS failing, 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 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).
- 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
@@ -308,8 +324,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 rate limits, the byte limits and the rule files, such as
your monitoring or your own networks.
country lists, the blocklists, 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.
@@ -349,9 +365,10 @@ effective settings are logged at start.
`SWWAF_LOOKUP_DB_PATH` names, or `off`, which looks up no client and sends no
address to GeoJS. With `off`, a country list that is not empty,
`SWWAF_ADD_LOOKUP_HEADERS` set to `true`, a biased threshold that lowers a
limit, a list of them that is not empty or `SWWAF_UNKNOWN_LIMIT_PERCENT` below
100, or an anomaly threshold per AS number that is not `off`, stops the start,
with a message naming it and `SWWAF_LOOKUP_SOURCE`.
limit, a list of them that is not empty, `SWWAF_ASN_LIMIT_PERCENT_URL` set or
`SWWAF_UNKNOWN_LIMIT_PERCENT` below 100, or an anomaly threshold per AS number
that is not `off`, stops the start, with a message naming it and
`SWWAF_LOOKUP_SOURCE`.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `file` without it, or it with
any other `SWWAF_LOOKUP_SOURCE`, the default included, stops the start, with a
@@ -389,11 +406,33 @@ effective settings are logged at start.
client without a country gets: one the lookup cannot place, one on a private,
loopback or link-local address, which is never looked up, and one whose answer
from GeoJS has not come in time.
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
file of AS numbers, each with its percentage of every rate limit and byte
limit, one such as `AS14061:50` to a line, whose percentages count as those of
`SWWAF_ASN_LIMIT_PERCENT` do, so that one list can serve several instances.
For an AS number both give a percentage, the lower applies, and an AS number
the file lists twice gets the lower of its two. It is fetched and kept as a
blocklist is (see "Blocklists" below). A URL `SWWAF_BLOCKLIST_URLS` names too
stops the start.
- `SWWAF_BLOCKLIST_URLS` (default empty): the blocklists, as `http` or `https`
URLs without a user or a fragment, such as
`https://www.spamhaus.org/drop/drop.txt` (see "Blocklists" below). A URL
listed twice stops the start.
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long after a list was last
fetched, or a fetch of it failed, it is fetched again, for the blocklists and
`SWWAF_ASN_LIMIT_PERCENT_URL`. Less than `1h` stops the start: the Spamhaus
lists may be fetched no more than once an hour.
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): what is done with a client a
blocklist lists: `deny` refuses its requests with `SWWAF_BAN_RESPONSE`,
`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_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` or comes from a refused country: `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
`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
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`.
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
limit or byte limit.
@@ -486,8 +525,8 @@ effective settings are logged at start.
`SWWAF_INSTANCE_NAME`, which ntfy is sent in the title.
- `SWWAF_ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
the events alerts are sent for. `waf_block` and `reputation_hit` come with the
features that raise them; nothing raises them yet.
the events alerts are sent for. `waf_block` comes with the Core Rule Set;
nothing raises it yet.
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long a repeat of an alert is held
back (see "Alerts" below).
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour;
@@ -536,9 +575,10 @@ an AS number or a country, `:` and a percentage; an AS number or a country
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`, 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.
`SWWAF_LOOKUP_TIMEOUT`, `SWWAF_UNKNOWN_LIMIT_PERCENT`,
`SWWAF_BLOCKLIST_REFRESH`, 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
@@ -626,26 +666,29 @@ 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`, `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 `block` rule refused, `too_large` for a request or
response over its size limit, `timed_out` for one that ran out of time,
`upstream_error` when the app could not be reached or its answer broke off,
and `admin` for one `smallwebwaf` answered at its own endpoint.
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
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
`block` rule refused, `too_large` for a request or response over its size
limit, `timed_out` for one that ran out of time, `upstream_error` when the app
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 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 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 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`. `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.
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
`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.
- `counts` gives the client's requests in the minute, the hour and the day as
the rate limits count them, this request included: in each window, those in
the bucket under way and a share of those in the bucket before, so a count can
@@ -653,11 +696,11 @@ which every line has.
broke it. It is left out for a request the rate limits do not count: the
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
or the country lists 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
`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.
- `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.
@@ -668,6 +711,12 @@ 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.
- `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`.
@@ -737,7 +786,11 @@ 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.
- `source_failure`: GeoJS failing or refusing `smallwebwaf`.
- `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).
- `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
@@ -800,25 +853,29 @@ is sent on one line:
- `client` is the address of the client whose request raised the alert, and
`netblock` the netblock of the ban, or for an `anomaly`, the netblock counted:
the client's own, the netblock around it or a named netblock, and none for an
AS number or the whole service; both are empty for `source_failure` and
AS number or the whole service, or for a `reputation_hit`, the client's own,
as `client_group` gives it; both are empty for `source_failure` and
`file_error`. `asn`, `as_name` and `country` are, for a ban, the client's as
the ban's notes give them when the alert is raised: empty, as in this alert,
when GeoJS had not answered about the client by then; for an `anomaly`, the
client's as the lookup gave them by the time its request ended.
client's as the lookup gave them by the time its request ended; for a
`reputation_hit`, the client's as its request's log line gives them.
- `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`.
`requests per minute of the netblock 203.0.113.0/24 over the threshold of 1000`;
for a `reputation_hit`, `listed by a blocklist`.
- `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`,
`asn`, `total` or `watch`, as the settings name them, the `asn` counted for
`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 `source_failure`,
the `source`, `geojs`, the `error`, and when GeoJS 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.
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.
- `suppressed_repeats` is how many repeats the cooldown held back before this
alert, and for a `summary`, those no other alert gives (see below).
@@ -863,14 +920,15 @@ 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, 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.
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
`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.
Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the hour's other alerts are
held back and counted by event. An alert held back this way starts no cooldown.
@@ -897,11 +955,12 @@ restart the alerts waiting are sent, and the cooldowns go on.
## State files
`smallwebwaf` keeps its state in memory and a copy of it in four JSON files in
`smallwebwaf` keeps its state in memory and a copy of it in five JSON files in
`SWWAF_STATE_DIR`, `/var/lib/smallwebwaf` by default, as "Persistent state" in
[`SPEC.md`](SPEC.md) describes. Each has a top-level `version`, 1, and lists its
entries by client address, but for the alerts waiting, and the anomaly counters,
which are listed by scope first, with times in UTC.
entries by client address, but for the alerts waiting, the anomaly counters,
which are listed by scope first, and the copies of the lists, listed by URL,
with times in UTC.
- `bans.json`: every ban with its notes, indented to be read. A permanent ban's
`expires` is `null`. A ban's `cause` is `limit` for a broken rate limit or
@@ -927,25 +986,32 @@ which are listed by scope first, with times in UTC.
`grep` shows everything about one.
- `lookups.json`: GeoJS's answers, one to a line, each with the client's AS
number, AS name and country, when GeoJS gave it and when it was last used.
- `reputation.json`: each list fetched from a URL (see "Blocklists" below),
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.
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
read: under `cooldowns`, for each event and netblock, or event and `file` or
`source`, or for an `anomaly`, its `scope` with its `netblock`, `asn` or
`name`, or event alone, when the last alert was sent, `sent`, and the repeats
held back since, `suppressed_repeats`; under `hour`, the hour under way, from
its `start`, the alerts `sent` in it and those `held_back` for its summary, by
event; under `waiting`, for each destination you name, `webhook`, `slack` or
`ntfy`, the alerts still waiting to be sent to it, the oldest first, each as
the webhook is sent it; and under `anomaly_counters`, each anomaly counter:
its `scope`, as an `anomaly` alert names it, with the `netblock` of a client,
of a netblock around a client or of a named netblock, the `asn` of an AS
number and the `name` of a named netblock, and its two buckets of requests in
the minute and the hour, `minute` and `hour`, and of bytes, `minute_bytes` and
`hour_bytes`, each left out while it is empty. As an hour ends, the cooldowns
that have run out are dropped, and the hour's summary gives the repeats they
held back. As the file is read, the alerts waiting for a destination you no
longer name are dropped. A file whose `waiting` is a list, as it was before
alerts went to Slack and ntfy too, stops the start: put the list under
`"webhook"`, or remove the file.
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`,
its `scope` with its `netblock`, `asn` or `name`, or event alone, when the
last alert was sent, `sent`, and the repeats held back since,
`suppressed_repeats`; under `hour`, the hour under way, from its `start`, the
alerts `sent` in it and those `held_back` for its summary, by event; under
`waiting`, for each destination you name, `webhook`, `slack` or `ntfy`, the
alerts still waiting to be sent to it, the oldest first, each as the webhook
is sent it; and under `anomaly_counters`, each anomaly counter: its `scope`,
as an `anomaly` alert names it, with the `netblock` of a client, of a netblock
around a client or of a named netblock, the `asn` of an AS number and the
`name` of a named netblock, and its two buckets of requests in the minute and
the hour, `minute` and `hour`, and of bytes, `minute_bytes` and `hour_bytes`,
each left out while it is empty. As an hour ends, the cooldowns that have run
out are dropped, and the hour's summary gives the repeats they held back. As
the file is read, the alerts waiting for a destination you no longer name are
dropped. A file whose `waiting` is a list, as it was before alerts went to
Slack and ntfy too, stops the start: put the list under `"webhook"`, or remove
the file.
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
through `DELETE /_smallwebwaf/bans/<client>`, or made permanent, with every such
@@ -957,27 +1023,30 @@ whole. A write that fails is logged, raised as a `file_error` alert while
changed since the last write.
At start the files are read back: each client keeps its counts, so a restart
gives it no fresh allowance, each anomaly counter keeps its counts, and each ban
gives it no fresh allowance, each anomaly counter keeps its counts, each ban
keeps refusing every client in its netblock until it ends, even after
`SWWAF_BAN_SCOPE_V4_PREFIX` has changed. 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
`SWWAF_BAN_SCOPE_V4_PREFIX` has changed, and the copy of each list stays in use
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. So does a ban whose
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`, and an anomaly counter whose `scope`
is not `client`, `net`, `asn`, `total` or `watch`. An answer's `asn` or
`as_name` left out reads as empty.
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
@@ -987,14 +1056,14 @@ writes a file it takes in any edit made since, so your edit is not overwritten;
a change `smallwebwaf` made after you opened the file, such as a new ban, is
lost when you save over it. An edit that would stop the start, because it does
not parse, has another `version`, leaves out a field an entry needs, gives a ban
another `cause`, names another destination or gives an anomaly counter another
`scope`, does not stop the running `smallwebwaf`: it keeps what it holds, and at
the file's next write renames your file to `<name>.bad`, such as
`bans.json.bad`, writes the file again from memory, logs the file and where the
error is, and raises a `file_error` alert for it. It waits for that write
because an editor's file can be read before the editor has finished writing it.
Mend the `.bad` file and move it back. A file you remove is written again at its
next write.
another `cause`, names another destination, gives an anomaly counter another
`scope` or gives a list's copy a line that would make its fetch fail, does not
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
file again from memory, logs the file and where the error is, and raises a
`file_error` alert for it. It waits for that write because an editor's file can
be read before the editor has finished writing it. Mend the `.bad` file and move
it back. A file you remove is written again at its next write.
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
and its `expires`, `null` for a ban that never ends; its `reason` and its
@@ -1163,6 +1232,12 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
database in use was read; and
`smallwebwaf_lookup_database_read_failures_total`: the replacements of it that
could not be read.
- By `source`, the URL of each list `SWWAF_BLOCKLIST_URLS` or
`SWWAF_ASN_LIMIT_PERCENT_URL` names: `smallwebwaf_reputation_hits_total`: the
requests whose client the blocklist lists, a series that comes with the first;
`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.
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
- `smallwebwaf_state_file_writes_total`,
`smallwebwaf_state_file_write_failures_total`,
@@ -1354,9 +1429,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 and the alerts are built, with an
edit taken in while running (see "State files" above); the others come with
their features.
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.
- 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
@@ -1376,7 +1451,7 @@ For each request `smallwebwaf`:
the deny list or currently banned;
- looks up its AS number and country, and refuses it if that country is denied,
or is not among the only ones allowed;
- checks for a cached reputation verdict;
- checks it against the blocklists, and for a cached reputation verdict;
- picks the client's limit percentage from those;
- checks the minute, hour and day request counters against the limits, and bans
the client if it breaks one;
@@ -1533,6 +1608,57 @@ country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in
`SWWAF_UNKNOWN_LIMIT_PERCENT` sets its limits. Such addresses are never sent to
GeoJS.
## Blocklists
`SWWAF_BLOCKLIST_URLS` names blocklists: text files of addresses and netblocks,
one to a line, written as the Spamhaus DROP list,
`https://www.spamhaus.org/drop/drop.txt`, is. Anything after a `;` or a `#` on a
line is left out, and so is a line left blank. A bare address stands for itself
alone, as in the settings. An IPv4-mapped address or netblock, such as
`::ffff:192.0.2.0/120`, is read as the IPv4 one it stands for, here
`192.0.2.0/24`, since a client's IPv4 address is checked as IPv4; a mapped
netblock shorter than `/96` stands for none, and is not a netblock. None is
named by default: a list judges a client by what others saw it do, while the
defaults judge it by what it does to your service.
`smallwebwaf` fetches each list, and the file `SWWAF_ASN_LIMIT_PERCENT_URL`
names, `SWWAF_BLOCKLIST_REFRESH` after it last fetched it or tried to, 24 hours
by default and never less than one, one list after another. Since
`reputation.json` keeps when each list was last tried, the fetch failed or not,
even one cut off as `smallwebwaf` stopped, whose request the server may have
had, at start `smallwebwaf` fetches at once only a list it has never tried, and
one it last tried that long ago; any other waits its turn, so that restarts do
not fetch a list more often. A fetch fails when the server answers other than
`200`, when it does not finish within a minute, when the list is longer than 16
MiB, or when a line of it is not an address or a netblock, or for
`SWWAF_ASN_LIMIT_PERCENT_URL`, not an AS number, `:` and a percentage. The copy
fetched before then stays in use, and the failure is counted, logged and raised
as a `source_failure` alert; a fetch cut off as `smallwebwaf` stops is not a
failure. The last good copy of each list is kept whole, comment lines included,
in `reputation.json` (see "State files" above), so that a restart keeps it in
use too. Each list is named by its URL, in the request log, the alerts and the
metrics, so keep a secret out of it.
A client in `SWWAF_ALLOW_NETS` is not checked. Any other is checked by its own
address after the country lists, and `SWWAF_BLOCKLIST_ACTION` says what is done
with one a list lists, as "What it does so far" above describes. `deny` suits
lists of networks that send nothing legitimate, such as DROP; `limit:<percent>`
suits lists of addresses shared with ordinary visitors, such as those of Tor's
exits.
The Spamhaus DROP list is The Spamhaus Project's, https://www.spamhaus.org. Its
terms, on its DROP page, ask that a product using it credit The Spamhaus Project
and keep the list's date and copyright lines with the data, which the copy in
`reputation.json` does; a service that uses DROP through `smallwebwaf` uses data
from The Spamhaus Project, and should say so. They also ask that it be fetched
automatically no more than once an hour, once a day being more than enough in
most cases, and Spamhaus may block an address that fetches it more often. Each
`smallwebwaf` fetches its own copy, on its own schedule, so on a host where
several apps run it, sharing one address, their fetches can come less than an
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.
## How the code is laid out
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
@@ -1546,15 +1672,15 @@ GeoJS.
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 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 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
@@ -1567,6 +1693,10 @@ GeoJS.
client's history and to the notes of its bans; or in the lookup database,
which it reads again when the file is replaced. `internal/lookup/lookuptest`
writes lookup databases for the tests.
- `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.
- `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.