check / check (push) Failing after 2m47s
The program is now sww, built from cmd/sww, with two modes: sww rproxy and sww discriminator. servewaf becomes discriminator everywhere it names that daemon: the package (internal/discriminator), its types, functions, files, the discriminator_line log field, and its docs and tests. smallwebwaf is left only where sneak's directive allows it: the documents' product name, the git.eeqj.de/sneak/smallwebwaf URLs, and the Go module path sneak.berlin/go/smallwebwaf. Everywhere else a name containing it takes the sww form: the package (internal/sww), metric names (sww_*), the X-Sww-* headers, the /_sww/ admin endpoints, the OS user, and the state and rules directories. The SWWAF_ environment variables take the SWW_ form. No logic changes. make check passes. Model: deepseek-v4-pro
726 lines
59 KiB
Markdown
726 lines
59 KiB
Markdown
# smallwebwaf
|
|
|
|
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by
|
|
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
|
|
requests of scrapers, scanners and other abusers before they reach an app.
|
|
|
|
## What it does
|
|
|
|
A web application firewall reads each request on its way to a web app and
|
|
refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
|
|
shipped in one image, with two commands that do this together:
|
|
|
|
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
|
it. It asks `discriminator` about each request, passes the allowed ones to the
|
|
app, and passes the app's answers back. It keeps nothing and never touches
|
|
disk.
|
|
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
|
counters and each client's history. One `discriminator` serves many sites: a
|
|
client banned through one site is refused on all of them, and its requests on
|
|
all of them count toward one set of limits.
|
|
|
|
The site keeps answering while `discriminator` is down, slow or restarting:
|
|
without an answer in time, `rproxy` passes the request to the app.
|
|
|
|
It protects against:
|
|
|
|
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
|
requests and on bytes per minute, per hour and per day. The defaults leave
|
|
room for a person reading a site for hours. They catch a scraper that keeps up
|
|
one request every 2 seconds. A client over a limit is refused and banned: for
|
|
a while at first, for three times as long at each repeat, and permanently when
|
|
it keeps at it.
|
|
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
|
default rule file bans such a client at its first probe, and its next request
|
|
during the ban makes the ban permanent. Your own rules, or a list of trap
|
|
paths, ban probes for other apps, such as a WordPress login, the same way.
|
|
- Attacks carried in a request's URL or headers, such as SQL injection and path
|
|
traversal. The OWASP Core Rule Set, a published set of rules that recognise
|
|
common web attacks, runs on every request through Coraza, a Go engine. A
|
|
request the rules take for an attack is refused.
|
|
- Abuse from networks and countries you do not want. You can:
|
|
- refuse whole netblocks and countries;
|
|
- give the clients of chosen networks and countries a fraction of every
|
|
limit, so that they are banned sooner;
|
|
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high
|
|
AbuseIPDB score;
|
|
- ban clients named by your own CrowdSec engine.
|
|
- Slow and oversized requests. Every request and every answer has a time limit
|
|
and a size limit.
|
|
|
|
For the operator, it:
|
|
|
|
- logs each request as JSON lines, saying what it did and why;
|
|
- sends alerts to a webhook, Slack or ntfy;
|
|
- serves Prometheus metrics;
|
|
- keeps its bans, counters and history in JSON files you can read and edit, so
|
|
that a restart loses nothing and a ban can be lifted with an editor.
|
|
|
|
Not built, and not planned:
|
|
|
|
- TLS and routing by host name. Both stay with traefik, the reverse proxy in
|
|
front of `rproxy`.
|
|
- Browser challenges such as captchas.
|
|
- A web console, or a configuration file.
|
|
- Defence against floods large enough to fill the host's network link.
|
|
|
|
## Getting started
|
|
|
|
### The image
|
|
|
|
No image is published. Build it from a clone. `make docker` runs the tests and
|
|
the linter, then builds the image, tagged `smallwebwaf`.
|
|
|
|
```sh
|
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
|
cd smallwebwaf
|
|
make docker
|
|
```
|
|
|
|
Push the image to a registry your hosts pull from, and name it there by digest.
|
|
|
|
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
|
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
|
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
|
the app accepts connections, whether or not `discriminator` answers; in
|
|
`discriminator`'s container, once `discriminator` answers.
|
|
|
|
### The usual setup: three containers
|
|
|
|
The usual setup is three containers on one private network:
|
|
|
|
- the app;
|
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
|
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
|
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
|
`/var/lib/sww` for its state files.
|
|
|
|
Point traefik's labels at port 8080 of `rproxy`
|
|
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
|
of the app or of `discriminator`. Anything on that network can reach the app
|
|
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
|
only what you trust. Each further app adds its own container and its own
|
|
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
|
setup" example below shows this shape.
|
|
|
|
### `rproxy` in the app's container
|
|
|
|
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
|
runs beside the app in one container. `discriminator` still runs in a container
|
|
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
|
by digest, and adds:
|
|
|
|
- the app's binary;
|
|
- the packages the app needs, from the nixpkgs in the image;
|
|
- a user for the app;
|
|
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default
|
|
of `SWRP_UPSTREAM_URL`.
|
|
|
|
```dockerfile
|
|
# The smallwebwaf image, pinned by digest.
|
|
FROM <registry>/smallwebwaf@sha256:<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
|
|
```
|
|
|
|
`app.run` sits beside the `Dockerfile`. `--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 "$@"
|
|
```
|
|
|
|
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
|
|
entrypoint starts `rproxy` and the app side by side, each as its own user. Run
|
|
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
|
|
app, and the "`rproxy` beside the app" example below runs one.
|
|
|
|
The app sees the headers traefik sent, with traefik's address added to
|
|
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
|
|
`rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
|
|
on the private network, which the usual default of a trusted-proxy setting
|
|
covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
|
|
ranges leave out, so the app's own setting must name them too, as `app.run`
|
|
does.
|
|
|
|
### Checking that it works
|
|
|
|
- `docker inspect --format '{{.State.Health.Status}}' <container>` says
|
|
`healthy` for each container of `smallwebwaf`.
|
|
- A log line with `"msg":"starting"` gives the version, the address and every
|
|
setting in effect, with secrets masked.
|
|
- A request to the site through traefik gets the app's answer.
|
|
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
|
the request have `"action":"forward"`.
|
|
- To see a refusal without banning yourself, run `discriminator` with
|
|
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
|
`discriminator`'s log lines have `"would_action":"banned"` and
|
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
|
"Clients and bans" below describes.
|
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
|
`discriminator`'s network:
|
|
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
|
the whole exchange (see "Entrypoints").
|
|
|
|
## Configuration
|
|
|
|
Every setting is an environment variable, read once at start; changing one means
|
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
|
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
|
default, chosen for a service on the open internet. A setting that needs an
|
|
account, a key, a token or an alert destination stays off until you give one.
|
|
|
|
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
|
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
|
contents are the value, less one newline at the end. Setting both forms stops
|
|
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
|
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
|
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
|
one, since `rproxy` never touches disk.
|
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
|
1024: `512K`, `100M`, `5G`.
|
|
- A count is a whole number of requests, such as `1000`.
|
|
- A time in milliseconds is a whole number above zero, such as `2000`.
|
|
- A list is comma separated. A list set to an empty value is an empty list, and
|
|
replaces the default.
|
|
- A netblock is an address and a prefix length, such as `10.0.0.0/8`; a bare
|
|
address stands for itself alone. A country is a two-letter code in either
|
|
case, such as `de`. An AS number is `AS` and the number, such as `AS64496`. A
|
|
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
|
- `off` switches a limit, a threshold or a timeout off where the table says a
|
|
setting takes it.
|
|
- An invalid setting stops the process at start with one log line naming the
|
|
variable and what is wrong with the value. The settings in effect are logged
|
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
|
|
|
Every setting the code reads, with its default:
|
|
|
|
| Variable | Default | What it does |
|
|
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
|
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
|
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
|
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
|
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
|
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
|
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
|
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
|
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
|
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
|
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
|
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
|
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
|
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
|
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
|
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
|
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
|
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
|
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
|
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
|
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
|
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
|
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
|
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
|
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
|
|
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
|
full, and the limits that are fixed rather than settings.
|
|
|
|
## docker-compose examples
|
|
|
|
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
|
`make docker` gives the image; on another host, use the name you pushed it
|
|
under, by digest. Docker Compose puts the services on a private network of its
|
|
own, where each reaches the others by service name. Each example publishes
|
|
`rproxy`'s port on the host, for trying it out.
|
|
|
|
### Smallest setup
|
|
|
|
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
|
state files, every setting at its default.
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
# Your app, which listens on port 8081.
|
|
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
|
|
|
rproxy:
|
|
image: smallwebwaf
|
|
environment:
|
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
|
SWRP_UPSTREAM_URL: http://app:8081
|
|
ports:
|
|
- "8080:8080"
|
|
|
|
discriminator:
|
|
image: smallwebwaf
|
|
environment:
|
|
SWW_COMMAND: discriminator
|
|
volumes:
|
|
- discriminator-state:/var/lib/sww
|
|
|
|
volumes:
|
|
discriminator-state:
|
|
```
|
|
|
|
### Custom per-address limits
|
|
|
|
For a site with no API:
|
|
|
|
- tighter limits for everyone;
|
|
- no limits for the office and the automation host;
|
|
- no checks for monitoring;
|
|
- a refused netblock;
|
|
- a smaller share of the limits for two hosting networks and one country;
|
|
- trap paths;
|
|
- `429` for a refused client.
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
|
|
|
rproxy:
|
|
image: smallwebwaf
|
|
environment:
|
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
|
SWRP_UPSTREAM_URL: http://app:8081
|
|
ports:
|
|
- "8080:8080"
|
|
|
|
discriminator:
|
|
image: smallwebwaf
|
|
environment:
|
|
SWW_COMMAND: discriminator
|
|
# Monitoring skips every check.
|
|
SWW_ALLOW_NETS: 192.0.2.10
|
|
# The office and the automation host are never rate limited.
|
|
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
|
# A netblock that sends nothing wanted.
|
|
SWW_DENY_NETS: 198.51.100.128/25
|
|
# Tighter limits for everyone else: a small static site.
|
|
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
|
# Hosting networks and one country get a share of those limits.
|
|
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
|
SWW_COUNTRY_LIMIT_PERCENT: cn:25
|
|
# Paths only scanners ask for; one request bans the client.
|
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
|
# Answer a refused client with 429 rather than 403.
|
|
SWW_BAN_RESPONSE: "429"
|
|
SWW_LIMIT_BAN_DURATION: 30m
|
|
volumes:
|
|
- discriminator-state:/var/lib/sww
|
|
|
|
volumes:
|
|
discriminator-state:
|
|
```
|
|
|
|
### `rproxy` beside the app
|
|
|
|
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
|
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
|
container". `rproxy` answers `503` to every request while `discriminator` is
|
|
down, and `discriminator`:
|
|
|
|
- observes before it enforces;
|
|
- looks addresses up in a local database rather than GeoJS;
|
|
- sends alerts to ntfy;
|
|
- reads its ntfy token from a file.
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
|
build: ./app
|
|
environment:
|
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
|
# Answer 503 rather than pass a request on while discriminator is down.
|
|
SWRP_DEFAULT_DISPOSITION: deny
|
|
ports:
|
|
- "8080:8080"
|
|
|
|
discriminator:
|
|
image: smallwebwaf
|
|
environment:
|
|
SWW_COMMAND: discriminator
|
|
SWW_INSTANCE_NAME: fsn1/discriminator
|
|
# Refuse nothing yet; log what enforce mode would have done.
|
|
SWW_MODE: observe
|
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
|
SWW_LOOKUP_SOURCE: file
|
|
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
|
# Alerts to an ntfy topic, with its token read from a file.
|
|
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
|
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
|
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
|
volumes:
|
|
- discriminator-state:/var/lib/sww
|
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
|
- /srv/sww/tokens:/etc/sww/tokens:ro
|
|
|
|
volumes:
|
|
discriminator-state:
|
|
```
|
|
|
|
The database is mounted by its directory, not by file, so that a replaced
|
|
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
|
|
`0400`. This one holds an access token the ntfy server issued, which
|
|
`ntfy token add <user>` prints there:
|
|
|
|
```sh
|
|
mkdir -p /srv/sww/tokens
|
|
# <token> is the access token that ntfy printed.
|
|
echo '<token>' > /srv/sww/tokens/ntfy
|
|
chown 65532:65532 /srv/sww/tokens/ntfy
|
|
chmod 0400 /srv/sww/tokens/ntfy
|
|
```
|
|
|
|
## Design
|
|
|
|
How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
|
|
|
### What happens to a request
|
|
|
|
A request goes through these steps, in this order. Once `discriminator` refuses
|
|
a request, the steps after that are skipped, apart from the report, and nothing
|
|
of the request reaches the app.
|
|
|
|
`rproxy` takes the first two:
|
|
|
|
1. The health check, `GET /_sww/healthz`, is answered at once.
|
|
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
|
the visitor sent it, with the first 64 KiB of its body.
|
|
|
|
`discriminator` takes the checks:
|
|
|
|
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
|
4. The bans.
|
|
5. The lookup of the client's AS number and country.
|
|
6. The country lists.
|
|
7. The reputation sources.
|
|
8. The client's limit percentage, from its AS number, its country or a
|
|
reputation hit.
|
|
9. The rate limits.
|
|
10. The trap paths.
|
|
11. The rule files.
|
|
12. The header limits, and then the Core Rule Set.
|
|
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
|
prefix.
|
|
|
|
`rproxy` takes the rest, with `discriminator` counting at the end:
|
|
|
|
14. The request is passed to the app, streaming.
|
|
15. After the answer, `rproxy` sends `discriminator` a report with the status
|
|
and the bytes each way. For an allowed request, `discriminator` counts the
|
|
bytes toward the byte limits and adds the request to the client's history.
|
|
|
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
|
request over the header limits of step 12 is answered `431`, and bans nobody
|
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
|
status for a size or time limit, such as `413` for a body too large.
|
|
|
|
### Clients and bans
|
|
|
|
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
|
since one abuser usually controls a whole /64.
|
|
- Each limit is counted over a sliding window with a few integers per client.
|
|
The table of clients and the table of bans each have a bound
|
|
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
|
lifts it.
|
|
- Every ban carries notes: the client's AS number and country, the limit it
|
|
broke or the rule it matched, the request that caused it, and its earlier
|
|
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
|
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
|
|
|
### Attack detection
|
|
|
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
|
front of a code forge, bodies carry what people write and publish, which the
|
|
rules take for attacks.
|
|
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
|
requests to a code forge through, such as git over HTTP and sign-in through
|
|
Git Credential Manager. On gitea's own paths, its searches, file names and
|
|
branch names get through too. No setting undoes the changes; "Attack
|
|
detection" in [`SPEC.md`](SPEC.md) lists them.
|
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
|
fields or paths.
|
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
|
while `discriminator` runs. The image ships one file of probes no real visitor
|
|
sends (see [`docs/rules.md`](docs/rules.md)).
|
|
|
|
### State files
|
|
|
|
`discriminator` keeps everything in memory, and the five JSON files in
|
|
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
|
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
|
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
|
notices a file edited on disk and takes the edit in; an edit that does not parse
|
|
is set aside as `<name>.bad`, and never stops the running process (see
|
|
[`docs/state-files.md`](docs/state-files.md)).
|
|
|
|
### The image and its processes
|
|
|
|
- The image is built on Ubuntu 26.04 LTS, pinned by digest. Its Ubuntu packages
|
|
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
|
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
|
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
|
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
|
and starts it again when it exits. The `run` script of `discriminator` gives
|
|
the state directory to the `sww` user at each start, so a volume needs no
|
|
change of owner.
|
|
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
|
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
|
collector, at its own address on the private network; nothing routes the
|
|
internet there.
|
|
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
|
in the app's container, the app must keep answering for five seconds after
|
|
`SIGTERM` for that to help them.
|
|
- Only faults found at start stop a process: an invalid setting, a rule file or
|
|
state file that does not parse, a lookup database that cannot be read, or an
|
|
address it cannot listen on. Once running, a failing lookup service,
|
|
reputation source or alert destination never takes `discriminator` down.
|
|
|
|
### Packages
|
|
|
|
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
|
- `internal/sww`: the process, as the command it is given, and the health check.
|
|
- `internal/config`: reads the settings, the one place they are read.
|
|
- `internal/rproxy`: the reverse proxy in front of one app.
|
|
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
|
answers its own endpoints.
|
|
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
|
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
|
`internal/alerts`: one check or source each.
|
|
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
|
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
|
metrics.
|
|
|
|
## Rationale
|
|
|
|
Small self-hosted sites 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;
|
|
- scanners walking through lists of WordPress and `.env` paths;
|
|
- 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 its services 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 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".
|
|
[`EVALUATION.md`](EVALUATION.md) goes through the candidates one by one.
|
|
|
|
## 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.
|
|
The tests and the linter run in Docker, as phases of the `Dockerfile`, and never
|
|
on the host.
|
|
|
|
- `script/bootstrap`: installs on the host what the other scripts need, with
|
|
`make bootstrap`:
|
|
- `make`, `git` and `curl`;
|
|
- Go, for `gofmt`;
|
|
- node, yarn and prettier.
|
|
- `script/setup`: readies a fresh clone: `script/bootstrap`, then
|
|
`script/install-precommit`. `make setup`.
|
|
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
|
|
images with.
|
|
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
|
|
tests, as the `test` phase of the `Dockerfile`. `make test`.
|
|
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
|
|
the `tidy` stage of the `Dockerfile`. `make tidy`.
|
|
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
|
`make lint`.
|
|
- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
|
|
`make fmt`.
|
|
- `script/fmt-check`: checks the formatting and changes nothing.
|
|
`make fmt-check`.
|
|
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
|
|
`make check`.
|
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
|
first. `make docker`.
|
|
- `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`.
|
|
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
|
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
|
host, with the settings in your environment. `make run`. Unless your settings
|
|
say otherwise:
|
|
- `rproxy` listens on port 8080, in front of an app at
|
|
`http://127.0.0.1:8081`;
|
|
- `discriminator` listens on port 8090;
|
|
- its state files go in `bin/state`;
|
|
- its rule files are those of `share/rules.d`.
|
|
- `script/example-app`: builds the image and the example app
|
|
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
|
`discriminator` in another, and checks them. Then it removes the containers,
|
|
the network, the volumes and the images. Needs network access.
|
|
`make example-app`. It checks:
|
|
- the health checks;
|
|
- a request passed to the app;
|
|
- a ban for a broken limit, and a ban for a probe made permanent;
|
|
- that only `discriminator`'s container changes the state directory's owner;
|
|
- with `discriminator` stopped, the app still answering through `rproxy`;
|
|
- with `discriminator` started again, bans applying again;
|
|
- orderly stops with `sv stop` and `docker stop`;
|
|
- bans surviving a new container on the same volume;
|
|
- a setting given as a file.
|
|
|
|
## TODO
|
|
|
|
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending
|
|
the log over RELP.
|
|
|
|
## Documents
|
|
|
|
- [`SPEC.md`](SPEC.md): how `smallwebwaf` behaves, and why.
|
|
- [`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.
|
|
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
|
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
|
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
|
`/_sww/`, with examples.
|
|
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
|
gets.
|
|
- [`docs/metrics.md`](docs/metrics.md): the metrics.
|
|
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
|
holds.
|
|
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
|
|
|
## License
|
|
|
|
MIT. See [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|