check / check (push) Waiting to run
SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT give the clients of the AS numbers and countries they list that percentage of every rate and byte limit, rounded down; SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take its place for the byte limits of those they list; SWWAF_UNKNOWN_LIMIT_PERCENT (100) covers clients without a country. The lowest applies. While one lowers a limit, a request waits for its client's lookup, and SWWAF_LOOKUP_SOURCE=off stops the start. Log lines give limit_percent and bytes_percent with their settings; ban notes, and so alerts, give the broken limit's. Judgement call: a client without a country is unknown, whatever its AS number. Judgement call: bytes_percent and its setting are log fields SPEC does not name. Rule suppressed: funlen on FromEnvironment, one line per setting. Model: opus-5-5
1581 lines
94 KiB
Markdown
1581 lines
94 KiB
Markdown
# smallwebwaf
|
|
|
|
`smallwebwaf` is a simple, fast, logging web application firewall, MIT-licensed
|
|
and written in Go by [@sneak](https://sneak.berlin), for people who host their
|
|
own services. It runs inside the container of the one application it protects,
|
|
between your reverse proxy (traefik) and the app: the app's Dockerfile builds
|
|
`FROM` the `smallwebwaf` image, traefik sends the app's requests to
|
|
`smallwebwaf` on port 8080, and `smallwebwaf` passes them on to the app on
|
|
`127.0.0.1:8081`. It needs no setting, and protects the app from the first
|
|
request with defaults chosen for a service on the open internet. It keeps its
|
|
state in memory and in JSON files you can read and edit, and writes a detailed
|
|
JSON log line for every request.
|
|
|
|
Status: the first two milestones are built
|
|
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
|
|
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are nine parts of
|
|
milestone 3: the static lists, the bans that broken rate limits lead to, the ban
|
|
ledger with the bans you make, keep and lift, the JSON state files with your
|
|
edits taken in while it runs and the paths the rate limits do not count, which
|
|
come next in the build order, `observe` mode and the rest of the request log's
|
|
fields, which come a little later, and the metrics endpoint and the header size
|
|
and the idle time as settings, which come last in it. So are the four parts of
|
|
the stage after it: the rule files, with the bans for a clear sign of attack,
|
|
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
|
Slack and ntfy, and remote log sending. So are three parts of 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, and the biased thresholds, lower
|
|
limits for the AS numbers and countries you list. `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 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 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).
|
|
|
|
## Getting started
|
|
|
|
Build the `smallwebwaf` image from a clone:
|
|
|
|
```sh
|
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
|
cd smallwebwaf
|
|
make docker
|
|
```
|
|
|
|
`make docker` runs the tests and the linter, then builds the image, tagged
|
|
`smallwebwaf`, for amd64 and only on an amd64 host: the hashes the `Dockerfile`
|
|
checks Ubuntu's package lists against are those of Ubuntu's amd64 archive. Push
|
|
it to a registry your hosts pull from, and build each app's image on it, pinned
|
|
by digest, as "How it works, in short" below shows. `make example-app` builds a
|
|
small app on the image, the one in `deploy/example-app`, and checks that it
|
|
works.
|
|
|
|
To work on the code, `make build` builds the binary alone, with Go installed,
|
|
and `make run` builds and runs it, listening on port 8080 in front of an app at
|
|
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files
|
|
in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|
`share/rules.d` unless `SWWAF_RULES_DIR` is set.
|
|
|
|
## What it does so far
|
|
|
|
- Passes each request to the app and the app's answer back unchanged: method,
|
|
path, query, headers, body and status. Bodies stream through in both
|
|
directions and are never held whole in memory. A WebSocket, or any other
|
|
upgraded connection, passes through, and the timeouts do not cut it.
|
|
- Works out the client's address. A TCP peer outside `SWWAF_TRUSTED_PROXIES` is
|
|
the client, and the forwarded headers it sends are replaced, not passed on.
|
|
For a peer inside it, `X-Forwarded-For` is read from the right, and the first
|
|
address outside `SWWAF_TRUSTED_PROXIES` is the client; if every address in it
|
|
is inside, the leftmost is, and with no header the peer is. The app sees what
|
|
it would see from traefik directly: the same `Host`, the same
|
|
`X-Forwarded-Proto`, and `X-Forwarded-For` with the peer added at the end. It
|
|
also gets the request's id in `X-Request-ID`, the same id as in the request's
|
|
log line (see `request_id` in "Request log" below).
|
|
- Enforces the timeouts and the size limits below. A limit passed before the
|
|
response has started gets `smallwebwaf`'s own answer: `408` for a client too
|
|
slow to send its request, `413` for a request body that is too large, `504`
|
|
for an app too slow to answer, and `502` for a response that is too large or
|
|
an app that cannot be reached. A request that announces a body over the limit
|
|
is refused before anything reaches the app. While a request body is still on
|
|
its way, a request timeout that runs out answers `408` if `smallwebwaf` was
|
|
waiting for the client to send more, and `504` if it was waiting for the app
|
|
to take what it had. Once the response has started, a limit can only cut the
|
|
connection.
|
|
- Counts each client's requests over a minute, an hour and a day. A request that
|
|
takes the client over one of the rate limits below is refused with
|
|
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
|
bans the client. A request whose path starts with one of
|
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`, as that setting below describes, is neither
|
|
counted nor refused by the rate limits; the static lists, bans, the country
|
|
lists and the rule files still apply to it. A client is one IPv4 address, or
|
|
one IPv6 /64, since one abuser usually holds a whole /64. Each window is
|
|
counted in two fixed buckets, the earlier one weighted by how much of it the
|
|
window still covers. At most 20,000 clients are kept, the least recently seen
|
|
dropped first, with their history, and a restart gives no client a fresh
|
|
allowance (see "State files" below).
|
|
- Counts each client's bytes over a minute, an hour and a day, in the same way:
|
|
once a request passed to the app has ended, the body bytes of its answer, of
|
|
the request, or of both, as `SWWAF_BYTES_COUNT` says. For a WebSocket, or any
|
|
other upgraded connection, what it carried from the app counts with the
|
|
answer, and what it carried from the client with the request, once it closes.
|
|
Bytes that take the client over one of the byte limits below break that limit,
|
|
and ban the client as a broken rate limit does, so that its next request is
|
|
refused. The byte limits never cut an answer or an upgraded connection short:
|
|
the one whose bytes break a limit has already been passed on, or has closed.
|
|
They leave out what the rate limits leave out: a client in `SWWAF_ALLOW_NETS`
|
|
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.
|
|
- 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
|
|
ban, so 1, 3, 9, 27 and 81 hours; a ban that would last longer than seven days
|
|
is permanent instead. A ban covers the client's netblock: its IPv4 address, or
|
|
the netblock around it that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64.
|
|
While it lasts, every request from the netblock is refused with
|
|
`SWWAF_BAN_RESPONSE` after the static lists and before the country lists, so
|
|
the client is not looked up, and is not counted for the rate limits. A ban
|
|
sets the client's counters back to zero. Each ban carries notes for deciding
|
|
whether to lift it: the limit, whether it is on requests or bytes, its window
|
|
and the requests or bytes counted in it, the client's percentage of that kind
|
|
of limit and the setting that gave it when a biased threshold lowered the
|
|
limit, the request that broke it, the client's AS number, AS name and country
|
|
once they are looked up, the netblock's requests since it was first seen, how
|
|
many of them the ban has refused, and how many bans the netblock had before,
|
|
for a broken limit, for a clear sign of attack and by an admin. At most
|
|
`SWWAF_MAX_BANS` bans `smallwebwaf` made are kept, past, active and permanent;
|
|
past that, the earliest such ban of the netblock that has gone longest without
|
|
a request is dropped first. The bans whose cause is `admin`, those you make or
|
|
keep, are kept besides, and never dropped. `bans.json` shows the bans and
|
|
their notes, a restart lifts none, and you make, keep or lift a ban by editing
|
|
it (see "State files" below).
|
|
- Checks each request against the rules of the rule files (see "Rule files"
|
|
below) after the rate limits, and before its body is read. A `log` rule that
|
|
matches is noted in the log line; a `block` rule refuses the request with
|
|
`403`, and bans no one; a `ban` rule refuses it with `SWWAF_BAN_RESPONSE` and
|
|
bans the client's netblock for a clear sign of attack. Matching stops at the
|
|
first rule that refuses. A client in `SWWAF_ALLOW_NETS` is not checked.
|
|
- Bans a client for a clear sign of attack, as "Bans" in [`SPEC.md`](SPEC.md)
|
|
describes: the first such ban lasts `SWWAF_ATTACK_BAN_DURATION`, seven days by
|
|
default, and any request from the netblock while it lasts makes it permanent.
|
|
Once it has run out, the netblock is served like any other, but its next clear
|
|
sign of attack bans it permanently at once. Such a ban covers the same
|
|
netblock as a ban for a broken limit, does not set the client's counters back
|
|
to zero, and does not make the netblock's next ban for a broken limit longer.
|
|
Its notes give the id and the target of the rule that matched in place of the
|
|
limit.
|
|
- Looks up the AS number and country of every client through GeoJS, or in the
|
|
IPinfo Lite database file while `SWWAF_LOOKUP_SOURCE` is `file`, after the
|
|
static lists and bans, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Country and
|
|
AS number lookup" below), for the request log, the client's history, the notes
|
|
of its bans, their alerts and the metrics. The file answers at once. With
|
|
GeoJS, a request waits for its client's first answer only while a setting acts
|
|
on it, a country list, `SWWAF_ADD_LOOKUP_HEADERS` or a biased threshold that
|
|
lowers a limit. Otherwise it goes on at once, and the answer reaches the
|
|
client's history and the notes of its bans when it comes, but not the log
|
|
lines of the requests that went on without it, nor the alerts already raised
|
|
for those bans.
|
|
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
|
as the client's country is known and before its body is read; such a request
|
|
is not counted for the rate limits. A client on a private, loopback or
|
|
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 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_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
|
|
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
|
|
a request that carries `SWWAF_METRICS_TOKEN` as
|
|
`Authorization: Bearer <token>`, and with `401` for one that does not. While
|
|
the token is unset the metrics answer `404`, as does any request under
|
|
`/_smallwebwaf/` that is not for one of its endpoints. Unlike the health
|
|
check, such a request goes through every check any other request goes through,
|
|
and is answered where another would be passed to the app: a banned client
|
|
stays refused, and each counts toward the client's rate limits. None of them
|
|
reaches the app.
|
|
- Lets an admin list, add and lift bans, and ask what it knows of a client,
|
|
through the endpoints `SWWAF_ADMIN_TOKEN` opens, which go through the checks
|
|
as the metrics do (see "Admin endpoints" below).
|
|
- Writes a line in the request log for each request (see "Request log" below).
|
|
- Sends every line it writes on stdout to a syslog server as well, while
|
|
`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 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).
|
|
|
|
## Settings
|
|
|
|
Each setting is an environment variable, or a file one names (see "Settings
|
|
given as files" below), and each has a default, so none has to be set. A setting
|
|
that is set but invalid stops the start with a message naming it, and the
|
|
effective settings are logged at start.
|
|
|
|
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens.
|
|
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app, as `http` or
|
|
`https`, a host and an optional port, and nothing more.
|
|
- `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the
|
|
first 12 characters of the container's id unless the deployment names one):
|
|
the name every log line and alert gives as `instance`, and every metric
|
|
carries as its label `instance` (see "Metrics" below). Set it, for example to
|
|
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
|
|
container, and that tells instances apart when several log to one place. A
|
|
name that is not valid UTF-8, such as one saved in Latin-1, stops the start.
|
|
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
|
|
requests `smallwebwaf` would refuse and log what it would have done (see "What
|
|
it does so far" above).
|
|
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`,
|
|
the private address ranges): the netblocks whose `X-Forwarded-For` is
|
|
believed. A list given replaces the default; set but empty, it trusts nothing.
|
|
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
|
|
send its request line and headers, and then, from the end of the headers, its
|
|
body.
|
|
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
|
line and headers a client may send. Over it, the answer is `431` and nothing
|
|
reaches the app. It must be more than `4K`, and cannot be `off`: Go's HTTP
|
|
server always has such a limit, and reads 4 KiB past the one it is given
|
|
before it refuses.
|
|
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
|
may wait for its next request before `smallwebwaf` closes it. The default is
|
|
longer than the 90 seconds after which traefik closes a connection it is not
|
|
using, so traefik never sends a request on a connection `smallwebwaf` is
|
|
closing.
|
|
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the response may
|
|
take to reach the client, from the end of the request to the last byte.
|
|
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
|
|
app and sending it the whole request may take.
|
|
- `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take
|
|
to send its whole answer, from the end of the request to the last byte.
|
|
- `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.
|
|
- `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.
|
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
|
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
|
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
|
requests a client may make in a minute, an hour and a day. The defaults are
|
|
several times what one busy person produces, since a browser loading a heavy
|
|
page makes a few hundred requests and several people often share one address.
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes whose requests
|
|
the rate limits neither count nor refuse, and whose bytes the byte limits do
|
|
not count, such as `/assets/` for static assets; each starts with `/`. A
|
|
request whose path, percent-decoded, contains `..` anywhere or a backslash, or
|
|
whose path as sent holds an encoded slash (`%2F` or `%2f`), is never exempt,
|
|
since the app may act on it as a path outside every prefix:
|
|
`/assets/..%2Flogin` as `/login`. Any other request is exempt when its path as
|
|
sent, the path the app receives, before any query string and not
|
|
percent-decoded, starts with a prefix, character for character. `/assets/`
|
|
matches `/assets/app.js` and `/assets/`, but not `/assets`, `/Assets/app.js`,
|
|
`/%61ssets/app.js`, `/static/assets/app.js`, `/static/../assets/app.js` or
|
|
`/assets%2Fapp.js`. A character the client sends percent-encoded, such as a
|
|
space, is written percent-encoded in a prefix, as in `/my%20files/`, and there
|
|
are no wildcards: `*` is a character like any other.
|
|
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
|
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
|
|
bytes a client may have counted in a minute, an hour and a day. A request's
|
|
bytes are counted once its answer has ended, so each default is above the
|
|
largest request body and the largest response together,
|
|
`SWWAF_REQUEST_MAX_BYTES` and `SWWAF_RESPONSE_MAX_BYTES`: at the defaults no
|
|
download breaks a limit on its own.
|
|
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
|
`response` for those of the answers, `request` for those of the requests, or
|
|
`both`.
|
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): where each client's AS number and
|
|
country are looked up: `geojs`, the GeoJS web service, which is then told the
|
|
address of every new visitor, `file`, the IPinfo Lite database file
|
|
`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`, or a biased threshold that lowers a
|
|
limit, a list of them that is not empty or `SWWAF_UNKNOWN_LIMIT_PERCENT` below
|
|
100, 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
|
|
message naming both.
|
|
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
|
|
client's first answer from GeoJS while a setting acts on it, and how long a
|
|
request to GeoJS may take before it is abandoned.
|
|
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the
|
|
client's AS number, such as `AS64496`, in `X-Client-ASN`, and its country in
|
|
`X-Client-Country`, leaving out one that is unknown. A request then waits for
|
|
its client's first answer, as it does while a country list is set. Whatever
|
|
this setting says, any `X-Client-ASN` or `X-Client-Country` the client sent,
|
|
in any case, is removed, so that the app never receives a client's own.
|
|
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
|
for example `cn,ru,kp`.
|
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
|
countries whose clients get through, for example `us,de`. A client whose
|
|
country cannot be found is refused too, so that new clients are not let in
|
|
whenever GeoJS stops answering.
|
|
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): AS numbers, each with the
|
|
percentage of every rate limit and byte limit its clients get, such as
|
|
`AS14061:50,AS16276:50,AS45102:25`. A lowered limit is rounded down to a whole
|
|
number: half of 1000 requests a minute is 500, and half of 5 is 2. `0` is a
|
|
zero allowance: the client's first request breaks a limit, and bans it.
|
|
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
|
`cn:25,ru:50`.
|
|
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
|
the same for the byte limits alone. For an AS number or a country one of them
|
|
lists, its percentage takes the place, for the byte limits, of the one
|
|
`SWWAF_ASN_LIMIT_PERCENT` or `SWWAF_COUNTRY_LIMIT_PERCENT` gives, so that
|
|
`SWWAF_ASN_LIMIT_PERCENT=AS14061:50` with
|
|
`SWWAF_ASN_BYTES_PERCENT=AS14061:100` halves that AS number's rate limits and
|
|
leaves its byte limits whole.
|
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage of every limit a
|
|
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_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
|
|
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.
|
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit or byte limit
|
|
broken again within this time after a ban ended, other than one for a clear
|
|
sign of attack, bans for three times as long as that ban.
|
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit or byte
|
|
limit that would be longer is permanent instead.
|
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
|
attack.
|
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
|
kept, past, active and permanent. The bans you make or keep are kept besides.
|
|
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
|
an IPv4 client that a ban covers, such as `24` to ban the surrounding /24. An
|
|
IPv6 ban covers the client's /64.
|
|
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
|
files, an absolute path. A directory `smallwebwaf` cannot write stops the
|
|
start.
|
|
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long after a ban is made
|
|
`bans.json` is written, with every ban made in between.
|
|
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
|
written.
|
|
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
|
request headers whose values the request log gives, in either case.
|
|
`Authorization`, `Cookie` and `Set-Cookie` are never logged, even when listed
|
|
(see "Request log" below). An entry naming `Host` or `Transfer-Encoding` stops
|
|
the start, since Go's HTTP server takes both out of the request; the request's
|
|
host is the field `host`.
|
|
- `SWWAF_ADMIN_TOKEN` (default unset): the token an admin sends for the ban
|
|
endpoints and `/_smallwebwaf/clients/<ip>` (see "Admin endpoints" below), a
|
|
long random value. While it is unset they are off; one shorter than 32
|
|
characters stops the start. The settings logged at start show `********` in
|
|
its place. Given as a file, with `SWWAF_ADMIN_TOKEN_FILE`, it can be kept out
|
|
of the app's reach (see "Settings given as files" below).
|
|
- `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for the
|
|
metrics, a long random value. While it is unset the metrics are off; one
|
|
shorter than 32 characters stops the start. The settings logged at start show
|
|
`********` in its place. Given as a file, it can be kept out of the app's
|
|
reach (see "Settings given as files" below).
|
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
|
countries get series of their own in the metrics by AS number and by country;
|
|
the others are counted as `other`.
|
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
|
rule files. A directory that does not exist stops the start.
|
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks
|
|
no request against one.
|
|
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that every line on
|
|
stdout is also sent to, as `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`
|
|
with a host and a port, such as `syslog+tls://logs.example:6514`. Unset or
|
|
empty, nothing is sent.
|
|
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates,
|
|
which the certificate of a `syslog+tls` server must chain to instead of the
|
|
host's own. A file that cannot be read or holds no certificate stops the
|
|
start.
|
|
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
|
wait to be sent.
|
|
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility the lines
|
|
are sent with: `kern`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`,
|
|
`news`, `uucp`, `cron`, `authpriv`, `ftp`, or `local0` to `local7`.
|
|
- `SWWAF_LOG_REMOTE_APP_NAME` (default `SWWAF_INSTANCE_NAME`): the app name the
|
|
lines are sent with, 1 to 48 printable ASCII characters without a space. While
|
|
`SWWAF_LOG_REMOTE_URL` is set, an `SWWAF_INSTANCE_NAME` that is not such a
|
|
name stops the start too, unless this setting gives one that is.
|
|
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook each alert is posted
|
|
to, an `http` or `https` URL without a user or a fragment, such as
|
|
`https://alerts.example/smallwebwaf` (see "Alerts" below). Unset or empty, no
|
|
alert is sent to a webhook. Since many webhooks carry their secret in the path
|
|
or the query, the settings logged at start show `********` in place of them,
|
|
and a value that stops the start is not shown.
|
|
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each alert,
|
|
such as one that authenticates it, as a list of a name, `:` and a value, such
|
|
as `Authorization:Bearer 0123456789abcdef`. A value cannot hold a comma. The
|
|
settings logged at start show `********` in place of each value.
|
|
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
|
each alert is posted to as a message, such as
|
|
`https://hooks.slack.com/services/T0123/B4567/abcdef`. Unset or empty, no
|
|
alert is sent to Slack. It is checked and logged as `SWWAF_ALERT_WEBHOOK_URL`
|
|
is.
|
|
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic each alert is published
|
|
to, as the topic's full URL, such as `https://ntfy.sh/my-alerts`. Unset or
|
|
empty, no alert is sent to ntfy. It is checked and logged as
|
|
`SWWAF_ALERT_WEBHOOK_URL` is, since anyone who knows a topic on a server open
|
|
to all can read it.
|
|
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent to ntfy
|
|
with each alert as `Authorization: Bearer <token>`, for a topic that needs
|
|
one. The settings logged at start show `********` in its place. A control
|
|
character in it, such as the carriage return of a file saved with Windows line
|
|
ends, stops the start, and while `SWWAF_ALERT_NTFY_URL` is set, so does one in
|
|
`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`, `anomaly` and `reputation_hit`
|
|
come with the features that raise them; nothing raises them 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;
|
|
the rest of the hour's alerts are rolled into one summary.
|
|
|
|
Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are
|
|
bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024
|
|
bytes). Rate limits are whole numbers of requests, and byte limits are sizes.
|
|
Netblocks are in CIDR form, and a bare address stands for itself alone.
|
|
Countries are the two-letter codes ISO 3166-1 assigns today, and `xk` for
|
|
Kosovo, in either case (`de` and `DE` are the same); any other code, such as
|
|
`nk` (North Korea is `kp`) or the withdrawn `su`, stops the start, and so does a
|
|
code on both country lists. AS numbers are `AS` and the number, in either case.
|
|
Percentages are whole numbers from 0 to 100, and an entry of a list of them is
|
|
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, `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` and `SWWAF_LOG_REMOTE_BUFFER` 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.
|
|
|
|
### Settings given as files
|
|
|
|
Any setting may instead be given as a file that holds its value: the variable
|
|
named like the setting with `_FILE` added, such as `SWWAF_METRICS_TOKEN_FILE`,
|
|
names the file. `smallwebwaf` reads the file once, at start, and its health
|
|
check reads only the files of `SWWAF_LISTEN_ADDR` and `SWWAF_UPSTREAM_URL`, each
|
|
time it runs. The file's contents are the value, less one newline at their end
|
|
so that a file written with `echo` or an editor works, and are checked as the
|
|
setting's own value would be. Setting both the setting and its `_FILE` form, or
|
|
naming a file that cannot be read, stops the start with a message naming the
|
|
variable. The settings logged at start name the file, and show a token given in
|
|
one as `********`, as they show one given directly.
|
|
`SWWAF_LOG_REMOTE_TLS_CA_FILE`, whose value names a file already, has no `_FILE`
|
|
form.
|
|
|
|
The app starts with the same environment variables as `smallwebwaf`, so it can
|
|
read a token given as one. A token given as a file is out of the app's reach
|
|
only while the `smallwebwaf` user alone can read the file: make it on the host,
|
|
owned by uid 65532, the `smallwebwaf` user, with mode `0400`, and mount the
|
|
directory that holds it into the container read-only; the container sees the
|
|
same owner and mode. For example, on the host:
|
|
|
|
```sh
|
|
mkdir -p /srv/app/tokens
|
|
openssl rand -hex 32 > /srv/app/tokens/metrics
|
|
chown 65532:65532 /srv/app/tokens/metrics
|
|
chmod 0400 /srv/app/tokens/metrics
|
|
```
|
|
|
|
and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
|
|
`-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics`.
|
|
|
|
## Request log
|
|
|
|
`smallwebwaf` writes one JSON object per line on stdout for every request,
|
|
refused ones included:
|
|
|
|
```
|
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
|
```
|
|
|
|
A field that does not apply to a request is left out of its line, apart from
|
|
`type`, the fields from `time` to `user_agent`, `request_id`, `peer_ip`,
|
|
`client_group`, `asn`, `as_name`, `country`, `action` and `duration_total`,
|
|
which every line has.
|
|
|
|
- `time` is when the request arrived, in UTC. `instance` is
|
|
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
|
sent, and otherwise `http`. `path` and `query` are as the client sent them.
|
|
- `request_id` is the `X-Request-ID` a trusted proxy sent, or a new random one
|
|
of 26 letters and digits when it sent none, or when the peer is not a trusted
|
|
proxy. A request passed to the app takes it there in `X-Request-ID`.
|
|
- `peer_ip` is the TCP peer, normally traefik. `forwarded_for` is the
|
|
`X-Forwarded-For` header as received, several lines of it joined with `, `.
|
|
`client_group` is the client as the rate limits count it: its IPv4 address as
|
|
a /32, or the /64 of its IPv6 address.
|
|
- `asn`, `as_name` and `country` are the client's AS number, such as `AS64496`,
|
|
the name of that AS, and its country, as GeoJS or the lookup database gives
|
|
them. Each is empty when `SWWAF_LOOKUP_SOURCE` is `off`, for a client in
|
|
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, for a client on a private, loopback
|
|
or link-local address, when GeoJS has not answered by the time the request
|
|
went on, for a client whose address the lookup database does not hold, and for
|
|
a request whose client a ban covers, even when the answer is known. `asn` and
|
|
`as_name` are empty too when GeoJS knows no AS number for the client, which it
|
|
gives as 64512, and `country` when GeoJS cannot place the client.
|
|
- `content_type` is the request's `Content-Type`, and `content_length` the
|
|
length the request announced for its body, which is left out for none or zero.
|
|
- `request_headers` are the request's headers that `SWWAF_LOG_REQUEST_HEADERS`
|
|
names, by name in lower case, several lines of one joined with `, `.
|
|
`Authorization`, `Cookie` and `Set-Cookie` are never among them, whatever the
|
|
setting says: `has_authorization` and `has_cookie` are there instead, and
|
|
true, when the request has an `Authorization` or a `Cookie` header.
|
|
- `websocket` is there, and true, when the app switched the connection to
|
|
another protocol, as it does for a WebSocket.
|
|
- `status` is what the client was sent, `0` if nothing was; `upstream_status` is
|
|
what the app answered, and is left out when the app did not answer.
|
|
- `response_content_type`, `cache_control` and `location` are the
|
|
`Content-Type`, `Cache-Control` and `Location` headers of the answer: the
|
|
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.
|
|
- `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.
|
|
- `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.
|
|
- `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
|
|
have a fraction. For a request that broke a limit, they are the counts that
|
|
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
|
|
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
|
|
broke a byte limit, and names the window whose limit it went over as `counts`
|
|
names it: `minute`, `hour` or `day` for a rate limit, and `minute_bytes`,
|
|
`hour_bytes` or `day_bytes` for a byte limit, the shortest if it went over
|
|
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`.
|
|
- `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`.
|
|
- `aborted` is there, and true, when the client went away early.
|
|
- The timings are in milliseconds, to the microsecond. `duration_total` runs
|
|
from when the request's headers had been read to when its line is written, and
|
|
`duration_checks` over the same start to when the checks were done; the health
|
|
check runs none, and its line has no `duration_checks`.
|
|
`duration_upstream_connect`, `duration_upstream_first_byte` and
|
|
`duration_upstream_total` are there for a request passed to the app, and run
|
|
from when it was handed to the app: until there was a connection to it, new or
|
|
kept open from an earlier request, until the first byte of its answer arrived,
|
|
and until the end. The first two are left out when that never happened, as for
|
|
an app that cannot be reached.
|
|
|
|
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
|
(start, the settings, stop, errors) share the stream as JSON lines marked
|
|
`"type":"process"`, each with `instance` as a request's line has it.
|
|
|
|
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
|
headers before `smallwebwaf` sees the request, and some requests end there,
|
|
without a line in the log: headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
|
which it answers `431`, headers slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
|
whose connection it closes without an answer, and requests it cannot read at
|
|
all, which it answers itself, mostly with `400`.
|
|
|
|
### Sending the log to a syslog server
|
|
|
|
While `SWWAF_LOG_REMOTE_URL` is set, every line `smallwebwaf` writes on stdout,
|
|
request lines and its own, is also sent to that syslog server, as the message of
|
|
an RFC 5424 record: one record to a datagram over UDP, and over TCP and TLS each
|
|
record after its length in bytes and a space. A record gives the facility
|
|
`SWWAF_LOG_REMOTE_FACILITY` names, the severity informational, the time the line
|
|
was written, in the same form as a request line's `time`, the host's name, and
|
|
the app name `SWWAF_LOG_REMOTE_APP_NAME` gives. stdout is unchanged.
|
|
|
|
The lines wait in a buffer of `SWWAF_LOG_REMOTE_BUFFER` lines and are sent from
|
|
there, so a server that is slow or cannot be reached never holds up a request or
|
|
stdout. When the buffer is full, its oldest line is dropped to make room. A line
|
|
whose sending fails is dropped too, and the connection closed. That failure,
|
|
like a failed attempt to connect, is logged and followed by the next attempt to
|
|
connect a second later, twice as long after each further failure up to a minute,
|
|
and a second again after a connection that stayed up for a minute before it
|
|
failed. A line too long for one UDP datagram is dropped alone, with no wait and
|
|
nothing logged. UDP gives no sign of what arrives, and over TCP and TLS a line
|
|
sent on a connection the server has just closed can be lost before a failure
|
|
shows; such a loss is not counted.
|
|
|
|
As `smallwebwaf` stops, it sends the lines still waiting, on the connection open
|
|
or a new one, for at most two seconds, and gives up the rest; stdout has carried
|
|
them.
|
|
|
|
## Alerts
|
|
|
|
`smallwebwaf` sends each alert to each destination you name: to
|
|
`SWWAF_ALERT_WEBHOOK_URL` it posts the alert as one JSON object, with
|
|
`Content-Type: application/json` and the headers `SWWAF_ALERT_WEBHOOK_HEADERS`
|
|
gives, as "Alert webhook schema" in [`SPEC.md`](SPEC.md) describes, and to
|
|
`SWWAF_ALERT_SLACK_WEBHOOK_URL` and `SWWAF_ALERT_NTFY_URL` a message made from
|
|
it, as below. An alert is for one of these events, and is sent when
|
|
`SWWAF_ALERT_EVENTS` names its event:
|
|
|
|
- `ban`: a ban `smallwebwaf` makes, for a broken rate limit or byte limit or a
|
|
clear sign of attack.
|
|
- `permanent_ban`: a permanent ban it makes, or a ban for a clear sign of attack
|
|
that a request made permanent.
|
|
- `source_failure`: GeoJS failing or refusing `smallwebwaf`.
|
|
- `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
|
|
does not use.
|
|
|
|
The bans you make, in `bans.json` or through the ban endpoints, raise no alert.
|
|
In `observe` mode, a request that would have made a ban, or made one permanent,
|
|
raises the alert `enforce` mode would have raised, for the ban as it would have
|
|
been, with `mode`, `observe`, in its `detail`: no ban was made, or made
|
|
permanent. A request that would have made a ban whose alert would be held back,
|
|
by the cooldown or past `SWWAF_ALERT_MAX_PER_HOUR`, raises none, and is not
|
|
counted. This is the alert for a ban for a broken rate limit, shown indented; it
|
|
is sent on one line:
|
|
|
|
```json
|
|
{
|
|
"instance": "fsn1app1/gitea",
|
|
"time": "2026-10-06T12:00:00.123461Z",
|
|
"event": "ban",
|
|
"client": "203.0.113.9",
|
|
"netblock": "203.0.113.9/32",
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"reason": "requests per minute over the limit of 1000",
|
|
"detail": {
|
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
|
"cause": "limit",
|
|
"notes": {
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"kind": "requests",
|
|
"limit": 1000,
|
|
"window": "minute",
|
|
"count": 1001,
|
|
"request": {
|
|
"time": "2026-10-06T12:00:00.123456789Z",
|
|
"method": "GET",
|
|
"host": "app.example",
|
|
"path": "/owner/repo/commits/branch/main?page=812",
|
|
"status": 403,
|
|
"user_agent": "scraper/1.0"
|
|
},
|
|
"requests": 5210,
|
|
"refused": 0,
|
|
"earlier_bans": {
|
|
"limit": 0,
|
|
"attack": 0,
|
|
"admin": 0
|
|
}
|
|
}
|
|
},
|
|
"suppressed_repeats": 0
|
|
}
|
|
```
|
|
|
|
- `instance` is `SWWAF_INSTANCE_NAME`, and `time` when the alert was raised, in
|
|
UTC.
|
|
- `client` is the address of the client whose request raised the alert, and
|
|
`netblock` the netblock of the ban; 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.
|
|
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`.
|
|
- `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 `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.
|
|
- `suppressed_repeats` is how many repeats the cooldown held back before this
|
|
alert.
|
|
|
|
Slack and ntfy are each sent the alert as a message: a title, the instance and
|
|
the event, such as `fsn1app1/gitea: ban`, and a text, the `reason`, then a line
|
|
for each of the `client`, the `netblock` and the `country`, the `file`, the
|
|
`source`, the `error` and the `mode` the `detail` gives, and the
|
|
`suppressed_repeats`, leaving out those that are empty or 0. Slack is posted, as
|
|
JSON, the title in bold and the text below it, with `&`, `<` and `>` escaped, so
|
|
that nothing in them is read as a link or a mention. ntfy is posted the text,
|
|
with the title as `Title`, `SWWAF_ALERT_NTFY_TOKEN`, while it is set, as
|
|
`Authorization: Bearer <token>`, and the priority and the tag, which ntfy shows
|
|
as an emoji, of the alert's event, as `Priority` and `Tags`:
|
|
|
|
| Event | Priority | Tag |
|
|
| ------------------------------ | --------- | -------------------------- |
|
|
| `ban` | `default` | `no_entry` |
|
|
| `permanent_ban` | `high` | `no_entry` |
|
|
| `waf_block` | `default` | `shield` |
|
|
| `anomaly` | `high` | `chart_with_upwards_trend` |
|
|
| `reputation_hit` | `low` | `label` |
|
|
| `source_failure`, `file_error` | `high` | `warning` |
|
|
| `summary` | `default` | `bar_chart` |
|
|
|
|
For the alert above, ntfy is sent these headers and this text:
|
|
|
|
```text
|
|
Title: fsn1app1/gitea: ban
|
|
Priority: default
|
|
Tags: no_entry
|
|
|
|
requests per minute over the limit of 1000
|
|
client: 203.0.113.9
|
|
netblock: 203.0.113.9/32
|
|
```
|
|
|
|
and Slack this JSON object, shown indented; it is sent on one line:
|
|
|
|
```json
|
|
{
|
|
"text": "*fsn1app1/gitea: ban*\nrequests per minute over the limit of 1000\nclient: 203.0.113.9\nnetblock: 203.0.113.9/32"
|
|
}
|
|
```
|
|
|
|
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, 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`.
|
|
|
|
Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour of the clock, in UTC, the
|
|
hour's other alerts are held back and counted by event. Once the hour has ended,
|
|
one alert sums them up: its `event` is `summary`, its `reason` says how many
|
|
were held back, and its `detail` gives the `hour` as when it started, the
|
|
`count`, and the count for each event, as `events`. An alert held back this way
|
|
starts no cooldown, and the repeats held back before it are given by the next
|
|
alert sent for the same event and netblock, file or source.
|
|
|
|
Each destination has a queue of its own, of at most 1000 alerts, from which they
|
|
are sent to it one at a time, the oldest first, so a destination that is slow or
|
|
down holds up neither the others nor any request. A destination takes an alert
|
|
by answering with a 2xx status, and refuses it with a 4xx status other than
|
|
`408` and `429`: a refused alert is logged, counted as dropped, and given up, so
|
|
that the next is sent. Any other answer, a redirect included, a connection that
|
|
fails, or no answer within 10 seconds is a failure: it is logged, naming the
|
|
destination's setting and not its URL, and the alert is sent again a second
|
|
later, twice as long after each further failure in a row, up to a minute. With
|
|
1000 alerts waiting for a destination, the oldest is dropped to make room for a
|
|
new one. The cooldowns, the hour under way and the alerts still waiting for each
|
|
destination are kept in `alerts.json` (see "State files" below), so that after a
|
|
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
|
|
`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, 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
|
|
byte limit or `attack` for a clear sign of attack, for a ban `smallwebwaf`
|
|
made, and `admin` for one you made or keep. Its `reason` is a short text: for
|
|
a ban `smallwebwaf` made, the limit broken, such as
|
|
`requests per minute over the limit of 1000` or
|
|
`bytes per hour over the limit of 21474836480`, or the rule that matched, such
|
|
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
|
when you lifted it, and is left out until you do. The `kind` in the notes of a
|
|
ban for a broken limit is `requests` or `bytes`, what the limit is on. For a
|
|
limit a biased threshold lowered, the reason and the notes' `limit` give the
|
|
lowered limit, and the notes' `limit_percent` and `limit_percent_setting` the
|
|
client's percentage of that kind of limit and the setting that gave it.
|
|
- `clients.json`: each client's two buckets of requests in the minute, the hour
|
|
and the day, its two buckets of bytes in each, `minute_bytes`, `hour_bytes`
|
|
and `day_bytes`, and its history: when it was first and last seen, its AS
|
|
number, AS name and country as last looked up and when the lookup gave them,
|
|
its requests, how many were forwarded and how many refused (one `smallwebwaf`
|
|
answered at its own endpoints is neither, unless it was refused with `401` for
|
|
a missing or wrong token), the body bytes in each direction, its responses by
|
|
status class and its offences by kind. Each client is on a line of its own, so
|
|
`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.
|
|
- `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 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; and 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. As an hour ends, the cooldowns
|
|
that have run out with no repeat held back are dropped. 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
|
|
change in between, and every file every `SWWAF_STATE_COUNTER_INTERVAL` and when
|
|
`smallwebwaf` stops. Each write goes to a temporary file in the same directory,
|
|
which then replaces the file, so a crash leaves the old file or the new one,
|
|
whole. A write that fails is logged, raised as a `file_error` alert while
|
|
`smallwebwaf` runs, and tried again at the next write. A hard kill loses what
|
|
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, and 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. 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`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
|
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. 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
|
|
`smallwebwaf` held for it, as if read at start. It tells its own writes from
|
|
yours by comparing the file with what it last read or wrote, and before it
|
|
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` or names another destination, 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
|
|
`notes` may be left out, and so may its `cause`, which is then `admin`, and is
|
|
written so at the file's next write. A ban whose `cause` is `admin` is never
|
|
dropped and does not count toward `SWWAF_MAX_BANS`. A ban whose `cause` is
|
|
`attack` becomes permanent at the first request it refuses; one whose `cause` is
|
|
`admin` does not. This `bans.json` bans `203.0.113.0/24` for good:
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"bans": [
|
|
{
|
|
"netblock": "203.0.113.0/24",
|
|
"start": "2026-10-06T12:00:00Z",
|
|
"expires": null,
|
|
"reason": "probes for logins"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
To keep a ban `smallwebwaf` made, so that it is never dropped, set its `cause`
|
|
to `admin`: `"cause": "admin"`.
|
|
|
|
To lift a ban, add `lifted` to its entry, with the time you lift it, such as
|
|
`"lifted": "2026-10-06T13:00:00Z"`. From when the edit is taken in, the ban
|
|
refuses nothing, whatever time `lifted` gives, and does not make the netblock's
|
|
next ban longer; it is kept in `bans.json` with its notes, as any other ban is.
|
|
To forget a ban altogether, delete its entry: it then refuses nothing either,
|
|
and does not make the netblock's next ban longer.
|
|
|
|
The ban endpoints add and lift bans without an edit of the file (see "Admin
|
|
endpoints" below).
|
|
|
|
## Rule files
|
|
|
|
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`,
|
|
`/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks
|
|
each request against their rules in that order, as "Rule files" in
|
|
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an
|
|
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
|
|
would not match it. A rule is a line of four fields separated by spaces or tabs:
|
|
an id, a target, an action and a regex, which runs to the end of the line.
|
|
Spaces and tabs at the end of a line are not part of its regex, so a line with
|
|
only those after its action has no regex, and is not a rule. Blank lines and
|
|
lines that start with `#` are ignored.
|
|
|
|
```
|
|
# id target action regex
|
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|
```
|
|
|
|
- The id is letters, digits, `-` and `_`, and no two rules share one. The
|
|
request log, the metrics and a ban's notes name the rule by it.
|
|
- The target is what the regex is matched against: `path` or `query`, as the
|
|
client sent it, before any decoding; `uri`, the path and the query together,
|
|
both as sent and once percent-decoded, so that an encoded probe does not slip
|
|
past; `method`; `host`; `user_agent`; `referer`; or `header:<Name>`, any one
|
|
request header but `Host` and `Transfer-Encoding`, which Go's HTTP server
|
|
takes out of every request; the request's host is the target `host`. A header
|
|
sent more than once is matched with its values joined by `, `, and one not
|
|
sent as empty text. No body is read.
|
|
- The action is `log`, `block` or `ban` (see "What it does so far" above). Keep
|
|
`ban` for requests no real visitor sends, and anchor a path at the site root
|
|
with `^/`: a file of the same name deeper in a site can be ordinary content,
|
|
such as a file in a repository on a code forge.
|
|
- The regex is in Go's syntax, RE2, which has no backreferences or lookaround,
|
|
and takes time linear in the text it reads. It matches anywhere in the target
|
|
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
|
|
|
|
A line that is not a rule, a `header:` name with a character no header name can
|
|
have, such as `header:User-Agent:`, a rule for the `Host` or the
|
|
`Transfer-Encoding` header, a regex that does not compile or an id used twice
|
|
stops the start with a message naming the file and the line, and so does a
|
|
`SWWAF_RULES_DIR` that does not exist. An empty directory is no error, and the
|
|
log says that it holds no rules. While it runs, `smallwebwaf` watches the
|
|
directory, and reads the rule files again once the directory has had no change
|
|
for 2 seconds after one is edited, added or removed, so that a file saved in
|
|
place, appended to or copied in with `scp` is read only once whole, unless its
|
|
writing stops for longer. It also reads them 2 seconds after it starts watching,
|
|
so that an edit saved while it started is not missed. If they then hold one of
|
|
those errors, the rules stay as they were, the earlier version of the edited
|
|
file included, the log and a `file_error` alert name the file and the line, and
|
|
the files are read again after the next change.
|
|
|
|
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
|
|
ban probes no real visitor sends, for secrets, version control directories,
|
|
backups, logs and web shells at the site root, and the user agents of common
|
|
scanners; one that blocks `../` twice in a row in the path or the query; and one
|
|
that only notes a request without a user agent. An app's Dockerfile adds rules
|
|
of its own in a file beside it, named to sort after it, such as this
|
|
`50-gitea.rules` for an app that serves no WordPress:
|
|
|
|
```
|
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
|
```
|
|
|
|
```dockerfile
|
|
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
|
```
|
|
|
|
A directory mounted over `/etc/smallwebwaf/rules.d` replaces the default file,
|
|
and single files mounted into it add to it. Docker does not show a single
|
|
mounted file being replaced, which is how many editors save, so rules to be
|
|
edited while `smallwebwaf` runs belong in a mounted directory, with a copy of
|
|
`00-default.rules` if its rules are to stay. To run without rules, mount an
|
|
empty directory or set `SWWAF_RULES_ENABLED=false`.
|
|
|
|
## Metrics
|
|
|
|
`GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text
|
|
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
|
|
other request. No metric carries a client's address.
|
|
|
|
Every metric below, Go's and the process's included, carries the label
|
|
`instance`, `SWWAF_INSTANCE_NAME`, as a constant label set once on the registry
|
|
the metrics are kept in, rather than as a label each metric declares. Prometheus
|
|
gives each series it scrapes an `instance` label of its own, the address it
|
|
scraped, and keeps this one as `exported_instance` unless the scrape sets
|
|
`honor_labels: true`.
|
|
|
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
|
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
|
|
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
|
|
`action`, as the request log names it.
|
|
- `smallwebwaf_request_duration_seconds`: how long requests took, and
|
|
`smallwebwaf_upstream_duration_seconds`: how long those passed to the app took
|
|
from then on, as histograms; `smallwebwaf_requests_in_flight`: the requests
|
|
under way.
|
|
- `smallwebwaf_rate_limit_hits_total` by `window`, `minute`, `hour` or `day`,
|
|
and `kind`, `requests` for a rate limit or `bytes` for a byte limit,
|
|
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
|
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
|
`smallwebwaf_bans_made_total` by `cause`, `limit`, `attack` or `admin`, the
|
|
last for the bans you add through `POST /_smallwebwaf/bans`, and those whose
|
|
`cause` is `admin` that you add to `bans.json` while `smallwebwaf` runs;
|
|
`smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`, neither of which
|
|
counts a lifted ban.
|
|
- `smallwebwaf_rule_matches_total`: the requests that matched each rule, by
|
|
`rule_id` and `action`, the rule's own; and `smallwebwaf_rules_loaded`: the
|
|
rules read from the rule files.
|
|
- `smallwebwaf_country_requests_total`,
|
|
`smallwebwaf_country_request_bytes_total`,
|
|
`smallwebwaf_country_response_bytes_total`, and
|
|
`smallwebwaf_country_list_refusals_total`, the requests the country lists
|
|
refused, by `country`, for the requests whose client's country is known. The
|
|
`SWWAF_METRICS_TOP_N` countries with the most requests since the start have
|
|
series of their own, and the others are counted as `other`. A country that
|
|
drops out of them loses its series, and its later requests count as `other`;
|
|
one that comes into them gets a series that counts from then on.
|
|
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
|
`smallwebwaf_asn_response_bytes_total`, by `asn`, the client's AS number, for
|
|
the requests whose client's AS number is known, with the `SWWAF_METRICS_TOP_N`
|
|
busiest AS numbers kept as the countries are.
|
|
- `smallwebwaf_geojs_requests_total`: the requests to GeoJS;
|
|
`smallwebwaf_geojs_failures_total`: those that failed, an answer that leaves
|
|
out an address asked about included; and `smallwebwaf_geojs_unanswered_total`:
|
|
the requests that needed their client's answer, for a country list,
|
|
`SWWAF_ADD_LOOKUP_HEADERS` or a biased threshold, and went on without it
|
|
because GeoJS had not given it in time.
|
|
- While `SWWAF_LOOKUP_SOURCE` is `file`,
|
|
`smallwebwaf_lookup_database_last_read_timestamp_seconds`: when the lookup
|
|
database in use was read; and
|
|
`smallwebwaf_lookup_database_read_failures_total`: the replacements of it that
|
|
could not be read.
|
|
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
|
|
- `smallwebwaf_state_file_writes_total`,
|
|
`smallwebwaf_state_file_write_failures_total`,
|
|
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
|
`smallwebwaf_state_file_size_bytes`, by `file`; and, by `file` too,
|
|
`smallwebwaf_state_file_edits_taken_in_total`: your edits taken in, and
|
|
`smallwebwaf_state_file_edits_set_aside_total`: those renamed to `<name>.bad`
|
|
because they would stop the start.
|
|
- While `SWWAF_LOG_REMOTE_URL` is set,
|
|
`smallwebwaf_remote_log_lines_sent_total`: the lines sent to it;
|
|
`smallwebwaf_remote_log_lines_dropped_total`: those dropped, from a full
|
|
buffer or because their sending failed; and
|
|
`smallwebwaf_remote_log_buffer_depth`: those waiting in the buffer.
|
|
- For each destination you name, by `destination`, `webhook`, `slack` or `ntfy`:
|
|
`smallwebwaf_alerts_sent_total`: the alerts it took;
|
|
`smallwebwaf_alerts_failed_total`: the requests to it that failed;
|
|
`smallwebwaf_alerts_suppressed_total`: the alerts held back, as repeats or for
|
|
an hour's summary, which are the same for every destination; and
|
|
`smallwebwaf_alerts_dropped_total`: those dropped from its full queue, or
|
|
given up as it refused them.
|
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
|
|
|
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
|
log") are not counted. The metrics of the features still to come, such as the
|
|
Core Rule Set, come with them.
|
|
|
|
## Admin endpoints
|
|
|
|
While `SWWAF_ADMIN_TOKEN` is set, `smallwebwaf` answers these requests itself,
|
|
on the app's own address and through traefik like any other request, for a
|
|
request that carries the token as `Authorization: Bearer <token>`:
|
|
|
|
- `GET /_smallwebwaf/bans`: every ban held, past, active and permanent.
|
|
- `POST /_smallwebwaf/bans`: bans a netblock, as adding an entry to `bans.json`
|
|
does. The body is a JSON object of `netblock`, `duration` and, if you like,
|
|
`reason`. `netblock` is a netblock such as `203.0.113.0/24`, or a client's
|
|
address, which bans the netblock a ban on that client covers: its IPv4
|
|
address, or the netblock around it that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or
|
|
its IPv6 /64. `duration` is a duration such as `1h` or `7d`, or `permanent`.
|
|
The ban starts at once, its `cause` is `admin`, and it is made even while
|
|
another ban on the netblock lasts. A body that is not such an object, has
|
|
another field, has anything but whitespace after the object, or is longer than
|
|
4 KiB is answered `400`, saying what is wrong, and so is an IPv4-mapped
|
|
netblock, such as `::ffff:203.0.113.0/120`, or a value with a zone, such as
|
|
`fe80::1%eth0`.
|
|
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban on a netblock
|
|
that `<client>`, an address, is in, as adding `lifted` to its entry in
|
|
`bans.json` does, and answers `404` when no ban on it is active.
|
|
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client at
|
|
the address `<ip>`: under `client`, the client as `clients.json` holds it,
|
|
with its counters and its history, which holds its AS number, AS name and
|
|
country as last looked up and its offences, or `null` when the table of
|
|
clients does not hold it; and under `bans`, every ban on a netblock the
|
|
address is in, with its notes.
|
|
|
|
The ban endpoints answer with the bans listed, made or lifted, under `bans`,
|
|
each as an entry of `bans.json` (see "State files" above), and a ban they make
|
|
or lift is written to `bans.json` `SWWAF_STATE_WRITE_DELAY` later. Refusals, and
|
|
the answers to requests that cannot be read, are plain text.
|
|
|
|
A request without the token, or with another, such as the metrics token, is
|
|
answered `401`, in `observe` mode too. While the token is unset, each of these
|
|
answers `404`, as does any request under `/_smallwebwaf/` that is not for one of
|
|
its endpoints. Like the metrics, these requests go through every check any other
|
|
request goes through, and are answered where another would be passed to the app:
|
|
a banned client stays refused, so an admin whose own address is banned lifts
|
|
that ban by editing `bans.json`, and each request counts toward the client's
|
|
rate limits. A client in `SWWAF_ALLOW_NETS` skips the checks, and still needs
|
|
the token.
|
|
|
|
With the token in `$TOKEN`, for an app at `https://app.example`:
|
|
|
|
```sh
|
|
# Every ban.
|
|
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans
|
|
|
|
# Ban 203.0.113.0/24 for seven days.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
|
https://app.example/_smallwebwaf/bans
|
|
|
|
# Lift the bans on 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
|
https://app.example/_smallwebwaf/bans/203.0.113.9
|
|
|
|
# What smallwebwaf knows of 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
https://app.example/_smallwebwaf/clients/203.0.113.9
|
|
```
|
|
|
|
## 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 are banned after half as many requests as
|
|
anyone else", 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;
|
|
- lower those limits for the countries and AS numbers that abuse commonly comes
|
|
from, so their clients are banned after fewer requests than others;
|
|
- ban abusers: briefly at first, longer each time they come back, and
|
|
permanently when they keep at it; a scanner's first probe bans it for seven
|
|
days;
|
|
- log everything in a form that is easy to search and ship elsewhere;
|
|
- stay small enough to understand: one binary in the app's own container,
|
|
environment variables, no database, no required setting.
|
|
|
|
## Proposed features
|
|
|
|
- Reverse proxy for one application, streaming in both directions, with
|
|
WebSocket support. One `smallwebwaf` per app, inside the app's own container.
|
|
- Internet-ready out of the box: no setting is required, and every setting has a
|
|
default chosen for a service facing the internet in 2026. Every setting's name
|
|
starts with `SWWAF_`, so that it cannot clash with the app's own.
|
|
- Real client address worked out from `X-Forwarded-For`, trusting only the proxy
|
|
networks you list, by default the private address ranges. IPv6 clients are
|
|
counted by /64 by default.
|
|
- Size and time limits on requests and responses, with the time limits both
|
|
between the client and `smallwebwaf` and between `smallwebwaf` and the app: by
|
|
default a request may take 60 seconds and 100 MiB, a response 30 minutes and 5
|
|
GiB.
|
|
- Rate limits per client on requests per minute, per hour and per day, and on
|
|
bytes per minute, per hour and per day, on by default and set well above what
|
|
real visitors need.
|
|
- Netblocks that bypass rate limiting, netblocks that bypass everything, and
|
|
netblocks that are always refused.
|
|
- AS number and country lookup for every client, on by default through the free
|
|
GeoJS web service, which is sent the address of every new visitor. The IPinfo
|
|
Lite database file, which you download and mount, can be used instead, or
|
|
lookups switched off (see "Country and AS number lookup" below).
|
|
- Country lists: `SWWAF_DENIED_COUNTRIES` refuses every request from the
|
|
countries listed, `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` every request from
|
|
anywhere else. Such a request gets the answer a banned client gets as soon as
|
|
the client's address has been looked up, before its body is read and without
|
|
the rule files or the Core Rule Set looking at it, and no ban is made.
|
|
- Biased limits: listed AS numbers and countries get a percentage of every
|
|
limit, for example 50 percent for common abuse-source networks, so their
|
|
clients are banned after fewer requests. Zero percent is a zero allowance: the
|
|
first request breaks the limit and bans the client.
|
|
- Attack detection:
|
|
- a directory of plain text rule files, one regex per line, for catching
|
|
scanning and penetration probes; easy to edit by hand, and picked up while
|
|
running;
|
|
- the OWASP Core Rule Set, run by the Coraza engine, refusing the requests
|
|
it flags; it reads the URL and headers of every request, and request
|
|
bodies only once you switch that on, since on a code forge they are full
|
|
of code it would take for attacks;
|
|
- trap paths, and a ban for a client that the rule files or the Core Rule
|
|
Set refuse again and again.
|
|
- Bans:
|
|
- a clear sign of attack, such as a probe for a `.env` file or a scanner's
|
|
user agent, bans for seven days on the first request, and any further
|
|
request during those days makes the ban permanent;
|
|
- breaking a limit bans for an hour; breaking one again within a day of a
|
|
ban ending triples the length, and a ban that would last longer than seven
|
|
days is permanent instead;
|
|
- every ban carries notes on why it was made, to help decide whether to lift
|
|
it.
|
|
- 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. None is on until you add it.
|
|
- 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 you
|
|
set, 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.
|
|
- 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 server.
|
|
- Prometheus metrics, for a scraper that holds the metrics token.
|
|
- State (bans with their notes, each client's counters and history, the GeoJS
|
|
answers, the reputation cache, the alerting state) held in memory and kept in
|
|
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.
|
|
- 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
|
|
request. The metrics need the metrics token, and the ban management the admin
|
|
token.
|
|
|
|
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 refuses it if that country is denied,
|
|
or is not among the only ones allowed;
|
|
- checks 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;
|
|
- checks the request against the rule files and the Core Rule Set, and bans the
|
|
client at once for a clear sign of attack;
|
|
- forwards it to the app and streams the response back, within the size and time
|
|
limits;
|
|
- counts the bytes and any refusal by the rule files or the Core Rule Set, bans
|
|
the client if it broke a limit, updates its history, sends any alerts that are
|
|
due, and writes the log line.
|
|
|
|
A minimal deployment is the app's own Dockerfile, built on the `smallwebwaf`
|
|
image, with no setting. That image is built on Ubuntu 26.04 LTS, the newest
|
|
long-term support release of Ubuntu, pinned by digest, and moves to the next one
|
|
when it ships. It has nixpkgs installed, so the app adds the packages it needs
|
|
from nixpkgs. Beyond its `FROM` line the app's Dockerfile adds the app's binary,
|
|
any packages it needs, and the app's runit service, which starts the app as a
|
|
user of its own, listening on `127.0.0.1:8081`:
|
|
|
|
```dockerfile
|
|
# The smallwebwaf image, pinned by digest.
|
|
FROM <registry>/smallwebwaf:<pinned digest>
|
|
|
|
# Packages the app needs, if any, from the nixpkgs in the image.
|
|
RUN nix-env -iA nixpkgs.git
|
|
|
|
# The app's binary, and a user of its own to run it.
|
|
COPY app /usr/local/bin/app
|
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
|
|
|
|
# The app's runit service.
|
|
COPY --chmod=755 app.run /etc/service/app/run
|
|
```
|
|
|
|
with `app.run` beside the Dockerfile, where `--listen` and `--trusted-proxies`
|
|
stand for the app's own options:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
main() {
|
|
sleep 1
|
|
exec chpst -u app:app /usr/local/bin/app \
|
|
--listen 127.0.0.1:8081 \
|
|
--trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
|
|
}
|
|
|
|
main "$@"
|
|
```
|
|
|
|
- The image's entrypoint, `runsvinit`, has runit start `smallwebwaf` and the app
|
|
side by side, each as its own user, and start either again a second after it
|
|
exits. Leave out `ENTRYPOINT` and `USER` from the app's Dockerfile.
|
|
- `nix-env -iA nixpkgs.<name>` installs a package from the nixpkgs in the image,
|
|
and the app finds it on its `PATH`, after Ubuntu's own commands. That nixpkgs
|
|
is fixed at one commit, so the same `smallwebwaf` image always gives the app
|
|
the same packages; newer ones come with a newer `smallwebwaf` image.
|
|
- Deploy it as you deploy any app, with traefik's labels on this one container
|
|
pointing at port 8080. upaas needs no change for this.
|
|
- The app has to trust `127.0.0.1` and `::1` for forwarded headers, besides the
|
|
private address ranges, since the requests it gets now come from `smallwebwaf`
|
|
on loopback. An app left at the usual default, the private ranges alone, sees
|
|
every visitor as `127.0.0.1`.
|
|
- Port 8080 is the only one the app must leave free: the health check, the
|
|
metrics and ban management are all on it, under `/_smallwebwaf/`. The image's
|
|
health check passes while `smallwebwaf` answers and the app accepts
|
|
connections. `SWWAF_LISTEN_ADDR` can move `smallwebwaf` to another port, which
|
|
the app then leaves free instead; the health check follows it, and traefik's
|
|
labels must point at it. The address part of `SWWAF_LISTEN_ADDR` stays empty
|
|
(for example `:9000`, never `127.0.0.1:9000`), so `smallwebwaf` keeps
|
|
listening on every address: traefik reaches it on the container's address, and
|
|
the health check on `127.0.0.1`.
|
|
- `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf`. Mount a volume
|
|
there to keep bans and client history when a deploy replaces the container;
|
|
without one, it still starts. At each start the `run` script of `smallwebwaf`
|
|
gives that directory and every file in it to the `smallwebwaf` user, so a host
|
|
directory mounted there needs no change of owner.
|
|
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
|
|
requests and gives those in progress five seconds to finish.
|
|
|
|
A rule file is one rule per line: a name, what to match against, what to do, and
|
|
a regex.
|
|
|
|
```
|
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|
```
|
|
|
|
[`SPEC.md`](SPEC.md) has the full design: the deployment, every environment
|
|
variable, the ban rules, the rule file format, the state files, the log fields,
|
|
the metrics, failure behaviour and the build order.
|
|
|
|
## Country and AS number lookup
|
|
|
|
`smallwebwaf` looks up the AS number and country of every client through GeoJS,
|
|
a free web service that needs no account and no file, for the request log, the
|
|
client's history, the notes of its bans, their alerts and the metrics, and for
|
|
the country lists when you set them. This means that GeoJS is told the address
|
|
of every new visitor, whether or not a setting uses the answer, unless you set
|
|
`SWWAF_LOOKUP_SOURCE=off`. The only visitors it is not told about are those in
|
|
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, those whose netblock a ban covers, and
|
|
those on a private, loopback or link-local address. An IPv6 visitor is asked
|
|
about by the first address of its /64. Each answer is kept for seven days, in
|
|
memory and in `lookups.json`, so that it survives a restart, and a visitor whose
|
|
answer is kept is not asked about again.
|
|
|
|
A request waits for its client's first answer only while a setting acts on it
|
|
before the request goes on: a country list, `SWWAF_ADD_LOOKUP_HEADERS`, or a
|
|
biased threshold that lowers a limit. A new visitor then waits up to
|
|
`SWWAF_LOOKUP_TIMEOUT`, a second by default, and without an answer counts as
|
|
coming from an unknown country until the answer arrives. Otherwise no request
|
|
waits: it goes on at once and is logged without the answer, which reaches the
|
|
client's history and the notes of its bans when it comes. The addresses waiting
|
|
are asked about together, up to 200 in one request, one request at a time; at
|
|
most 10,000 visitors wait, and one more is not asked about until there is room,
|
|
counting meanwhile as coming from an unknown country. GeoJS publishes no rate
|
|
limit but may block a caller it thinks asks too much. While GeoJS fails,
|
|
visitors with a kept answer are unaffected and new ones count as coming from an
|
|
unknown country, which `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses, and whose
|
|
limits `SWWAF_UNKNOWN_LIMIT_PERCENT` sets. GeoJS is then left alone for a
|
|
second, twice as long after each further failure up to five minutes, and asked
|
|
again by the next request from a visitor without an answer.
|
|
|
|
To keep your visitors' addresses on your own host, set
|
|
`SWWAF_LOOKUP_SOURCE=off`, or use the database file instead of GeoJS:
|
|
`SWWAF_LOOKUP_SOURCE=file` looks every client up in the free IPinfo Lite
|
|
database (`ipinfo_lite.mmdb`), at once, with no wait and nothing sent off the
|
|
host. A client whose address it does not hold counts as coming from an unknown
|
|
country. You download the database with your own IPinfo account, mount the
|
|
directory that holds it into the container, point `SWWAF_LOOKUP_DB_PATH` at the
|
|
file and refresh it when you choose; `smallwebwaf` never downloads it itself. It
|
|
reads the whole file into memory at start, and a file that is missing or that it
|
|
cannot read stops the start. It reads the file again once it has gone 2 seconds
|
|
without a change after you replace it, so that a file still being copied in is
|
|
read only once whole, and also 2 seconds after it starts watching, so that a
|
|
file replaced while it started is not missed. A replacement it cannot read is
|
|
logged and sent as a `file_error` alert, and the file read before stays in use.
|
|
It has to be the directory rather than the file itself that you mount: docker
|
|
does not show a single mounted file being replaced, so a refresh would go
|
|
unseen. IPinfo releases it under the Creative Commons Attribution-ShareAlike 4.0
|
|
International License and asks for attribution, in its own words on
|
|
https://ipinfo.io/lite: "The attribution requirements can be met by giving our
|
|
service credit as your data source. Simply place a link to IPinfo on the
|
|
website, application, or social media account that uses our data." Its example
|
|
of such a credit is a link mentioning "IP address data is powered by IPinfo". A
|
|
service that uses the database through `smallwebwaf` should carry that link.
|
|
|
|
Neither source can place a private address, so a client on one, such as a
|
|
visitor on your local network, another container or your monitoring, has no
|
|
country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in
|
|
`SWWAF_ALLOW_NETS`, `SWWAF_DENIED_COUNTRIES` does not refuse it, and
|
|
`SWWAF_UNKNOWN_LIMIT_PERCENT` sets its limits. Such addresses are never sent to
|
|
GeoJS.
|
|
|
|
## How the code is laid out
|
|
|
|
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
|
- `internal/smallwebwaf`: the process: it reads the settings, the rule files,
|
|
the lookup database and the state files, listens, serves requests until
|
|
`SIGTERM` or `SIGINT`, and stops, writing the state files. Run as
|
|
`smallwebwaf healthcheck`, it is the image's health check instead.
|
|
- `internal/config`: reads the settings, the one place they are read.
|
|
- `internal/proxy`: what happens to each request: it works out the client, runs
|
|
the checks, passes the request to the app and the answer back with the
|
|
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.
|
|
- `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
|
|
long a new ban lasts, when a ban for a clear sign of attack becomes permanent,
|
|
and which ban `smallwebwaf` made is dropped when `SWWAF_MAX_BANS` are held.
|
|
- `internal/rules`: reads the rule files at start and again as they change, and
|
|
tells which of their rules a request matches.
|
|
- `internal/lookup`: looks up each client's AS number and country through GeoJS,
|
|
keeps the answers, and hands each new one to the proxy, which adds it to the
|
|
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/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.
|
|
- `internal/state`: reads the state files at start, takes in an admin's edit of
|
|
one while running, and writes them when they are due and at the stop.
|
|
- `internal/requestlog`: the lines on stdout: the request log line and the
|
|
process's own messages.
|
|
- `internal/remotelog`: sends the lines on stdout to `SWWAF_LOG_REMOTE_URL`,
|
|
each as a syslog record, from a buffer of its own. It is written with the
|
|
standard library alone, whose `log/syslog` writes only the older syslog
|
|
format.
|
|
- `internal/alerts`: takes the alerts the other parts raise, holds back repeats
|
|
and those past the hourly limit, and sends the others to the webhook, Slack
|
|
and ntfy, each from a queue of its own.
|
|
- `Dockerfile`: the lint and test phases, the stages `script/tidy` builds, then
|
|
the image, whose last stage installs Ubuntu's packages, nixpkgs, `runsvinit`
|
|
and `smallwebwaf`, with `share/smallwebwaf.run` as runit's `run` script for
|
|
`smallwebwaf` and `share/rules.d/00-default.rules` as its default rule file.
|
|
- `deploy/example-app`: an app built on the image, which `script/example-app`
|
|
checks.
|
|
|
|
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, 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
|
|
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule
|
|
file is saved, or the lookup database replaced, and
|
|
`github.com/oschwald/maxminddb-golang/v2` reads the lookup database, which the
|
|
tests write with `github.com/maxmind/mmdbwriter`. The country codes are the list
|
|
in `internal/config/config.go`.
|
|
|
|
## Entrypoints
|
|
|
|
This repository adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard: the scripts in `script/` are the entrypoints for working on it, and
|
|
the `Makefile` targets are thin shims that call them. The scripts are POSIX sh,
|
|
so that they run in minimal containers.
|
|
|
|
- `script/bootstrap`: installs what the other scripts need on the host: `make`,
|
|
`git`, `curl`, Go for `gofmt`, node and yarn, and prettier.
|
|
- `script/setup`: readies a fresh clone: runs `script/bootstrap`, then
|
|
`script/install-precommit`.
|
|
- `script/projectname`: prints the project's name, `smallwebwaf`, which
|
|
`script/docker` and the others tag their images with.
|
|
- `script/test`: checks that `go.mod` and `go.sum` are as `go mod tidy` writes
|
|
them, then runs the tests, as the `test` phase of the `Dockerfile`.
|
|
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, with
|
|
the Go of the `test` phase, in the `tidy` stage of the `Dockerfile`;
|
|
`make tidy` runs it.
|
|
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
|
- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
|
|
- `script/fmt-check`: checks the formatting, and changes nothing.
|
|
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
|
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
|
first.
|
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
|
image build.
|
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
|
- `script/install-precommit`: installs that hook; `make hooks` runs it.
|
|
- `script/build`: builds `bin/smallwebwaf` on the host, with Go installed, for
|
|
working on the code by hand; `make build` runs it.
|
|
- `script/run`: builds `bin/smallwebwaf` with `script/build` and runs it, with
|
|
its state files in `bin/state` unless `SWWAF_STATE_DIR` is set, and the rule
|
|
files of `share/rules.d` unless `SWWAF_RULES_DIR` is set; `make run` runs it.
|
|
- `script/example-app`: builds the image and, on it, the example app in
|
|
`deploy/example-app`, runs it with a volume for the state files, and checks
|
|
that the health check passes, that a request reaches the app through
|
|
`smallwebwaf`, that a second request in a minute bans the client, that a probe
|
|
for `/.env` bans another client, whose next request makes the ban permanent,
|
|
that `sv stop` and `docker stop` stop it in order, and that a new container on
|
|
the same volume still refuses the banned client; then removes the containers,
|
|
the volume and both images. It needs network access, for nixpkgs' binary
|
|
cache, and `script/check` does not run it; `make example-app` does.
|
|
|
|
## TODO
|
|
|
|
- The rest of the design, in the order of the build order in
|
|
[`SPEC.md`](SPEC.md).
|
|
|
|
## 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.
|
|
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
|
|
|
|
## License
|
|
|
|
MIT. See [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|